diff --git a/.changeset/curly-cycles-rush.md b/.changeset/curly-cycles-rush.md new file mode 100644 index 0000000..8007fd1 --- /dev/null +++ b/.changeset/curly-cycles-rush.md @@ -0,0 +1,5 @@ +--- +"rescript-relay-router": minor +--- + +Allow mapping path params to type coercable from string. diff --git a/examples/client-rendering/src/routes/Root__Todos__ByStatusDecoded__Child_route_renderer.res b/examples/client-rendering/src/routes/Root__Todos__ByStatusDecoded__Child_route_renderer.res new file mode 100644 index 0000000..b8fd3ea --- /dev/null +++ b/examples/client-rendering/src/routes/Root__Todos__ByStatusDecoded__Child_route_renderer.res @@ -0,0 +1,8 @@ +let renderer = Routes.Root.Todos.ByStatusDecoded.Child.Route.makeRenderer( + ~prepare=_props => { + () + }, + ~render=_props => { + React.null + }, +) diff --git a/examples/client-rendering/src/routes/Root__Todos__ByStatusDecoded_route_renderer.res b/examples/client-rendering/src/routes/Root__Todos__ByStatusDecoded_route_renderer.res new file mode 100644 index 0000000..d0bf333 --- /dev/null +++ b/examples/client-rendering/src/routes/Root__Todos__ByStatusDecoded_route_renderer.res @@ -0,0 +1,8 @@ +let renderer = Routes.Root.Todos.ByStatusDecoded.Route.makeRenderer( + ~prepare=_props => { + () + }, + ~render=_props => { + React.null + }, +) diff --git a/examples/client-rendering/src/routes/__generated__/RouteDeclarations.res b/examples/client-rendering/src/routes/__generated__/RouteDeclarations.res index 6b73438..eb3f595 100644 --- a/examples/client-rendering/src/routes/__generated__/RouteDeclarations.res +++ b/examples/client-rendering/src/routes/__generated__/RouteDeclarations.res @@ -4,7 +4,7 @@ open RelayRouter__Internal__DeclarationsSupport external unsafe_toPrepareProps: 'any => prepareProps = "%identity" let loadedRouteRenderers: Belt.HashMap.String.t = Belt.HashMap.String.make( - ~hintSize=4, + ~hintSize=6, ) let make = (~prepareDisposeTimeout=5 * 60 * 1000): array => { @@ -218,6 +218,146 @@ let make = (~prepareDisposeTimeout=5 * 60 * 1000): array (() => Js.import(Root__Todos__ByStatusDecoded_route_renderer.renderer))->Obj.magic->doLoadRouteRenderer(~routeName, ~loadedRouteRenderers) + let makePrepareProps = (. + ~environment: RescriptRelay.Environment.t, + ~pathParams: Js.Dict.t, + ~queryParams: RelayRouter.Bindings.QueryParams.t, + ~location: RelayRouter.History.location, + ): prepareProps => { + let prepareProps: Route__Root__Todos__ByStatusDecoded_route.Internal.prepareProps = { + environment: environment, + + location: location, + childParams: Obj.magic(pathParams), + byStatusDecoded: pathParams->Js.Dict.unsafeGet("byStatusDecoded")->((byStatusDecodedRawAsString: string) => (byStatusDecodedRawAsString :> TodoStatusPathParam.t)), + statuses: queryParams->RelayRouter.Bindings.QueryParams.getArrayParamByKey("statuses")->Belt.Option.map(value => value->Belt.Array.keepMap(value => value->Js.Global.decodeURIComponent->TodoStatus.parse)), + } + prepareProps->unsafe_toPrepareProps + } + + { + path: ":byStatusDecoded:TodoStatusPathParam.t", + name: routeName, + chunk: "Root__Todos__ByStatusDecoded_route_renderer", + loadRouteRenderer, + preloadCode: ( + ~environment: RescriptRelay.Environment.t, + ~pathParams: Js.Dict.t, + ~queryParams: RelayRouter.Bindings.QueryParams.t, + ~location: RelayRouter.History.location, + ) => preloadCode( + ~loadedRouteRenderers, + ~routeName, + ~loadRouteRenderer, + ~environment, + ~location, + ~makePrepareProps, + ~pathParams, + ~queryParams, + ), + prepare: ( + ~environment: RescriptRelay.Environment.t, + ~pathParams: Js.Dict.t, + ~queryParams: RelayRouter.Bindings.QueryParams.t, + ~location: RelayRouter.History.location, + ~intent: RelayRouter.Types.prepareIntent, + ) => prepareRoute( + ~environment, + ~pathParams, + ~queryParams, + ~location, + ~getPrepared, + ~loadRouteRenderer, + ~makePrepareProps, + ~makeRouteKey=( + ~pathParams: Js.Dict.t, + ~queryParams: RelayRouter.Bindings.QueryParams.t + ): string => { + + "Root__Todos__ByStatusDecoded:" + ++ pathParams->Js.Dict.get("byStatusDecoded")->Option.getOr("") + ++ queryParams->RelayRouter.Bindings.QueryParams.getParamByKey("statuses")->Option.getOr("") + } + + , + ~routeName, + ~intent + ), + children: [ { + let routeName = "Root__Todos__ByStatusDecoded__Child" + let loadRouteRenderer = () => (() => Js.import(Root__Todos__ByStatusDecoded__Child_route_renderer.renderer))->Obj.magic->doLoadRouteRenderer(~routeName, ~loadedRouteRenderers) + let makePrepareProps = (. + ~environment: RescriptRelay.Environment.t, + ~pathParams: Js.Dict.t, + ~queryParams: RelayRouter.Bindings.QueryParams.t, + ~location: RelayRouter.History.location, + ): prepareProps => { + let prepareProps: Route__Root__Todos__ByStatusDecoded__Child_route.Internal.prepareProps = { + environment: environment, + + location: location, + byStatusDecoded: pathParams->Js.Dict.unsafeGet("byStatusDecoded")->((byStatusDecodedRawAsString: string) => (byStatusDecodedRawAsString :> TodoStatusPathParam.t)), + statuses: queryParams->RelayRouter.Bindings.QueryParams.getArrayParamByKey("statuses")->Belt.Option.map(value => value->Belt.Array.keepMap(value => value->Js.Global.decodeURIComponent->TodoStatus.parse)), + } + prepareProps->unsafe_toPrepareProps + } + + { + path: "", + name: routeName, + chunk: "Root__Todos__ByStatusDecoded__Child_route_renderer", + loadRouteRenderer, + preloadCode: ( + ~environment: RescriptRelay.Environment.t, + ~pathParams: Js.Dict.t, + ~queryParams: RelayRouter.Bindings.QueryParams.t, + ~location: RelayRouter.History.location, + ) => preloadCode( + ~loadedRouteRenderers, + ~routeName, + ~loadRouteRenderer, + ~environment, + ~location, + ~makePrepareProps, + ~pathParams, + ~queryParams, + ), + prepare: ( + ~environment: RescriptRelay.Environment.t, + ~pathParams: Js.Dict.t, + ~queryParams: RelayRouter.Bindings.QueryParams.t, + ~location: RelayRouter.History.location, + ~intent: RelayRouter.Types.prepareIntent, + ) => prepareRoute( + ~environment, + ~pathParams, + ~queryParams, + ~location, + ~getPrepared, + ~loadRouteRenderer, + ~makePrepareProps, + ~makeRouteKey=( + ~pathParams: Js.Dict.t, + ~queryParams: RelayRouter.Bindings.QueryParams.t + ): string => { + + "Root__Todos__ByStatusDecoded__Child:" + ++ pathParams->Js.Dict.get("byStatusDecoded")->Option.getOr("") + ++ queryParams->RelayRouter.Bindings.QueryParams.getParamByKey("statuses")->Option.getOr("") + } + + , + ~routeName, + ~intent + ), + children: [], + } + }], + } + }, { let routeName = "Root__Todos__Single" let loadRouteRenderer = () => (() => Js.import(Root__Todos__Single_route_renderer.renderer))->Obj.magic->doLoadRouteRenderer(~routeName, ~loadedRouteRenderers) diff --git a/examples/client-rendering/src/routes/__generated__/Route__Root__Todos__ByStatusDecoded__Child_route.res b/examples/client-rendering/src/routes/__generated__/Route__Root__Todos__ByStatusDecoded__Child_route.res new file mode 100644 index 0000000..d1bf49b --- /dev/null +++ b/examples/client-rendering/src/routes/__generated__/Route__Root__Todos__ByStatusDecoded__Child_route.res @@ -0,0 +1,186 @@ +// @generated +// This file is autogenerated from `todoRoutes.json`, do not edit manually. +@live +type pathParams = { + byStatusDecoded: TodoStatusPathParam.t, +} + +type queryParams = { + statuses: option>, +} + +module Internal = { + @live + type prepareProps = { + environment: RescriptRelay.Environment.t, + location: RelayRouter.History.location, + ...pathParams, + ...queryParams, + } + + @live + type renderProps<'prepared> = { + childRoutes: React.element, + prepared: 'prepared, + environment: RescriptRelay.Environment.t, + location: RelayRouter.History.location, + ...pathParams, + ...queryParams, + } + + @live + type renderers<'prepared> = { + prepare: prepareProps => 'prepared, + prepareCode: option<(. prepareProps) => array>, + render: renderProps<'prepared> => React.element, + } + @live + let makePrepareProps = (. + ~environment: RescriptRelay.Environment.t, + ~pathParams: Js.Dict.t, + ~queryParams: RelayRouter.Bindings.QueryParams.t, + ~location: RelayRouter.History.location, + ): prepareProps => { + { + environment: environment, + + location: location, + byStatusDecoded: pathParams->Js.Dict.unsafeGet("byStatusDecoded")->((byStatusDecodedRawAsString: string) => (byStatusDecodedRawAsString :> TodoStatusPathParam.t)), + statuses: queryParams->RelayRouter.Bindings.QueryParams.getArrayParamByKey("statuses")->Belt.Option.map(value => value->Belt.Array.keepMap(value => value->Js.Global.decodeURIComponent->TodoStatus.parse)), + } + } + +} + +@live +let parseQueryParams = (search: string): queryParams => { + open RelayRouter.Bindings + let queryParams = QueryParams.parse(search) + { + statuses: queryParams->QueryParams.getArrayParamByKey("statuses")->Belt.Option.map(value => value->Belt.Array.keepMap(value => value->Js.Global.decodeURIComponent->TodoStatus.parse)), + + } +} + +@live +let applyQueryParams = ( + queryParams: RelayRouter__Bindings.QueryParams.t, + ~newParams: queryParams, +) => { + open RelayRouter__Bindings + + + queryParams->QueryParams.setParamArrayOpt(~key="statuses", ~value=newParams.statuses->Belt.Option.map(statuses => statuses->Belt.Array.map(statuses => statuses->TodoStatus.serialize->Js.Global.encodeURIComponent))) +} + +@live +type useQueryParamsReturn = { + queryParams: queryParams, + setParams: ( + ~setter: queryParams => queryParams, + ~onAfterParamsSet: queryParams => unit=?, + ~navigationMode_: RelayRouter.Types.setQueryParamsMode=?, + ~removeNotControlledParams: bool=?, + ~shallow: bool=?, + ) => unit +} + +@live +let useQueryParams = (): useQueryParamsReturn => { + let internalSetQueryParams = RelayRouter__Internal.useSetQueryParams() + let {search} = RelayRouter.Utils.useLocation() + let currentQueryParams = React.useMemo(() => { + search->parseQueryParams + }, [search]) + + let setParams = ( + ~setter, + ~onAfterParamsSet=?, + ~navigationMode_=RelayRouter.Types.Push, + ~removeNotControlledParams=true, + ~shallow=true, + ) => { + let newParams = setter(currentQueryParams) + + switch onAfterParamsSet { + | None => () + | Some(onAfterParamsSet) => onAfterParamsSet(newParams) + } + + internalSetQueryParams({ + applyQueryParams: applyQueryParams(~newParams, ...), + currentSearch: search, + navigationMode_: navigationMode_, + removeNotControlledParams: removeNotControlledParams, + shallow: shallow, + }) + } + + { + queryParams: currentQueryParams, + setParams: React.useMemo( + () => setParams, + (search, currentQueryParams), + ), + } +} + +@inline +let routePattern = "/todos/:byStatusDecoded" + +@live +let makeLink = (~byStatusDecoded: TodoStatusPathParam.t, ~statuses: option>=?) => { + open RelayRouter.Bindings + let queryParams = QueryParams.make() + switch statuses { + | None => () + | Some(statuses) => queryParams->QueryParams.setParamArray(~key="statuses", ~value=statuses->Belt.Array.map(value => value->TodoStatus.serialize->Js.Global.encodeURIComponent)) + } + RelayRouter.Bindings.generatePath(routePattern, Js.Dict.fromArray([("byStatusDecoded", (byStatusDecoded :> string)->Js.Global.encodeURIComponent)])) ++ queryParams->QueryParams.toString +} +@live +let makeLinkFromQueryParams = (~byStatusDecoded: TodoStatusPathParam.t, queryParams: queryParams) => { + makeLink(~byStatusDecoded, ~statuses=?queryParams.statuses, ) +} + +@live +let useMakeLinkWithPreservedPath = () => { + let location = RelayRouter.Utils.useLocation() + React.useMemo(() => { + (makeNewQueryParams: queryParams => queryParams) => { + let newQueryParams = location.search->parseQueryParams->makeNewQueryParams + open RelayRouter.Bindings + let queryParams = location.search->QueryParams.parse + queryParams->applyQueryParams(~newParams=newQueryParams) + location.pathname ++ queryParams->QueryParams.toString + } + }, [location.search]) +} + + +@live +let isRouteActive = (~exact: bool=false, {pathname}: RelayRouter.History.location): bool => { + RelayRouter.Internal.matchPathWithOptions({"path": routePattern, "end": exact}, pathname)->Belt.Option.isSome +} + +@live +let useIsRouteActive = (~exact=false) => { + let location = RelayRouter.Utils.useLocation() + React.useMemo(() => location->isRouteActive(~exact), (location, exact)) +} + +@live +let usePathParams = (): option => { + let {pathname} = RelayRouter.Utils.useLocation() + switch RelayRouter.Internal.matchPath(routePattern, pathname) { + | Some({params}) => Some(Obj.magic(params)) + | None => None + } +} + +@obj +external makeRenderer: ( + ~prepare: Internal.prepareProps => 'prepared, + ~prepareCode: Internal.prepareProps => array=?, + ~render: Internal.renderProps<'prepared> => React.element, +) => Internal.renderers<'prepared> = "" \ No newline at end of file diff --git a/examples/client-rendering/src/routes/__generated__/Route__Root__Todos__ByStatusDecoded_route.res b/examples/client-rendering/src/routes/__generated__/Route__Root__Todos__ByStatusDecoded_route.res new file mode 100644 index 0000000..34fb823 --- /dev/null +++ b/examples/client-rendering/src/routes/__generated__/Route__Root__Todos__ByStatusDecoded_route.res @@ -0,0 +1,214 @@ +// @generated +// This file is autogenerated from `todoRoutes.json`, do not edit manually. +@live +type pathParams = { + byStatusDecoded: TodoStatusPathParam.t, +} + +type queryParams = { + statuses: option>, +} + +module Internal = { + @live + type childPathParams = { + byStatusDecoded: option, + } + + @live + type prepareProps = { + environment: RescriptRelay.Environment.t, + location: RelayRouter.History.location, + ...pathParams, + ...queryParams, + childParams: childPathParams, + } + + @live + type renderProps<'prepared> = { + childRoutes: React.element, + prepared: 'prepared, + environment: RescriptRelay.Environment.t, + location: RelayRouter.History.location, + ...pathParams, + ...queryParams, + childParams: childPathParams, + } + + @live + type renderers<'prepared> = { + prepare: prepareProps => 'prepared, + prepareCode: option<(. prepareProps) => array>, + render: renderProps<'prepared> => React.element, + } + @live + let makePrepareProps = (. + ~environment: RescriptRelay.Environment.t, + ~pathParams: Js.Dict.t, + ~queryParams: RelayRouter.Bindings.QueryParams.t, + ~location: RelayRouter.History.location, + ): prepareProps => { + { + environment: environment, + + location: location, + childParams: Obj.magic(pathParams), + byStatusDecoded: pathParams->Js.Dict.unsafeGet("byStatusDecoded")->((byStatusDecodedRawAsString: string) => (byStatusDecodedRawAsString :> TodoStatusPathParam.t)), + statuses: queryParams->RelayRouter.Bindings.QueryParams.getArrayParamByKey("statuses")->Belt.Option.map(value => value->Belt.Array.keepMap(value => value->Js.Global.decodeURIComponent->TodoStatus.parse)), + } + } + +} + +@live +let parseQueryParams = (search: string): queryParams => { + open RelayRouter.Bindings + let queryParams = QueryParams.parse(search) + { + statuses: queryParams->QueryParams.getArrayParamByKey("statuses")->Belt.Option.map(value => value->Belt.Array.keepMap(value => value->Js.Global.decodeURIComponent->TodoStatus.parse)), + + } +} + +@live +let applyQueryParams = ( + queryParams: RelayRouter__Bindings.QueryParams.t, + ~newParams: queryParams, +) => { + open RelayRouter__Bindings + + + queryParams->QueryParams.setParamArrayOpt(~key="statuses", ~value=newParams.statuses->Belt.Option.map(statuses => statuses->Belt.Array.map(statuses => statuses->TodoStatus.serialize->Js.Global.encodeURIComponent))) +} + +@live +type useQueryParamsReturn = { + queryParams: queryParams, + setParams: ( + ~setter: queryParams => queryParams, + ~onAfterParamsSet: queryParams => unit=?, + ~navigationMode_: RelayRouter.Types.setQueryParamsMode=?, + ~removeNotControlledParams: bool=?, + ~shallow: bool=?, + ) => unit +} + +@live +let useQueryParams = (): useQueryParamsReturn => { + let internalSetQueryParams = RelayRouter__Internal.useSetQueryParams() + let {search} = RelayRouter.Utils.useLocation() + let currentQueryParams = React.useMemo(() => { + search->parseQueryParams + }, [search]) + + let setParams = ( + ~setter, + ~onAfterParamsSet=?, + ~navigationMode_=RelayRouter.Types.Push, + ~removeNotControlledParams=true, + ~shallow=true, + ) => { + let newParams = setter(currentQueryParams) + + switch onAfterParamsSet { + | None => () + | Some(onAfterParamsSet) => onAfterParamsSet(newParams) + } + + internalSetQueryParams({ + applyQueryParams: applyQueryParams(~newParams, ...), + currentSearch: search, + navigationMode_: navigationMode_, + removeNotControlledParams: removeNotControlledParams, + shallow: shallow, + }) + } + + { + queryParams: currentQueryParams, + setParams: React.useMemo( + () => setParams, + (search, currentQueryParams), + ), + } +} + +@inline +let routePattern = "/todos/:byStatusDecoded" + +@live +let makeLink = (~byStatusDecoded: TodoStatusPathParam.t, ~statuses: option>=?) => { + open RelayRouter.Bindings + let queryParams = QueryParams.make() + switch statuses { + | None => () + | Some(statuses) => queryParams->QueryParams.setParamArray(~key="statuses", ~value=statuses->Belt.Array.map(value => value->TodoStatus.serialize->Js.Global.encodeURIComponent)) + } + RelayRouter.Bindings.generatePath(routePattern, Js.Dict.fromArray([("byStatusDecoded", (byStatusDecoded :> string)->Js.Global.encodeURIComponent)])) ++ queryParams->QueryParams.toString +} +@live +let makeLinkFromQueryParams = (~byStatusDecoded: TodoStatusPathParam.t, queryParams: queryParams) => { + makeLink(~byStatusDecoded, ~statuses=?queryParams.statuses, ) +} + +@live +let useMakeLinkWithPreservedPath = () => { + let location = RelayRouter.Utils.useLocation() + React.useMemo(() => { + (makeNewQueryParams: queryParams => queryParams) => { + let newQueryParams = location.search->parseQueryParams->makeNewQueryParams + open RelayRouter.Bindings + let queryParams = location.search->QueryParams.parse + queryParams->applyQueryParams(~newParams=newQueryParams) + location.pathname ++ queryParams->QueryParams.toString + } + }, [location.search]) +} + + +@live +let isRouteActive = (~exact: bool=false, {pathname}: RelayRouter.History.location): bool => { + RelayRouter.Internal.matchPathWithOptions({"path": routePattern, "end": exact}, pathname)->Belt.Option.isSome +} + +@live +let useIsRouteActive = (~exact=false) => { + let location = RelayRouter.Utils.useLocation() + React.useMemo(() => location->isRouteActive(~exact), (location, exact)) +} +@live +type subRoute = [#Child] + +@live +let getActiveSubRoute = (location: RelayRouter.History.location): option<[#Child]> => { + let {pathname} = location + if RelayRouter.Internal.matchPath("/todos/:byStatusDecoded", pathname)->Belt.Option.isSome { + Some(#Child) + } else { + None + } +} + +@live +let useActiveSubRoute = (): option<[#Child]> => { + let location = RelayRouter.Utils.useLocation() + React.useMemo(() => { + getActiveSubRoute(location) + }, [location]) +} + +@live +let usePathParams = (): option => { + let {pathname} = RelayRouter.Utils.useLocation() + switch RelayRouter.Internal.matchPath(routePattern, pathname) { + | Some({params}) => Some(Obj.magic(params)) + | None => None + } +} + +@obj +external makeRenderer: ( + ~prepare: Internal.prepareProps => 'prepared, + ~prepareCode: Internal.prepareProps => array=?, + ~render: Internal.renderProps<'prepared> => React.element, +) => Internal.renderers<'prepared> = "" \ No newline at end of file diff --git a/examples/client-rendering/src/routes/__generated__/Route__Root__Todos_route.res b/examples/client-rendering/src/routes/__generated__/Route__Root__Todos_route.res index 5eac3ce..f7dc52b 100644 --- a/examples/client-rendering/src/routes/__generated__/Route__Root__Todos_route.res +++ b/examples/client-rendering/src/routes/__generated__/Route__Root__Todos_route.res @@ -7,7 +7,8 @@ type queryParams = { module Internal = { @live type childPathParams = { - byStatus: option<[#"completed" | #"notCompleted"]>, + byStatus: option<[#completed | #notCompleted]>, + byStatusDecoded: option, todoId: option, } @@ -170,13 +171,15 @@ let useIsRouteActive = (~exact=false) => { React.useMemo(() => location->isRouteActive(~exact), (location, exact)) } @live -type subRoute = [#ByStatus | #Single] +type subRoute = [#ByStatus | #ByStatusDecoded | #Single] @live -let getActiveSubRoute = (location: RelayRouter.History.location): option<[#ByStatus | #Single]> => { +let getActiveSubRoute = (location: RelayRouter.History.location): option<[#ByStatus | #ByStatusDecoded | #Single]> => { let {pathname} = location if RelayRouter.Internal.matchPath("/todos/:byStatus(completed|notCompleted)", pathname)->Belt.Option.isSome { Some(#ByStatus) + } else if RelayRouter.Internal.matchPath("/todos/:byStatusDecoded", pathname)->Belt.Option.isSome { + Some(#ByStatusDecoded) } else if RelayRouter.Internal.matchPath("/todos/:todoId", pathname)->Belt.Option.isSome { Some(#Single) } else { @@ -185,7 +188,7 @@ let getActiveSubRoute = (location: RelayRouter.History.location): option<[#BySta } @live -let useActiveSubRoute = (): option<[#ByStatus | #Single]> => { +let useActiveSubRoute = (): option<[#ByStatus | #ByStatusDecoded | #Single]> => { let location = RelayRouter.Utils.useLocation() React.useMemo(() => { getActiveSubRoute(location) diff --git a/examples/client-rendering/src/routes/__generated__/Route__Root_route.res b/examples/client-rendering/src/routes/__generated__/Route__Root_route.res index f02781a..48e83dd 100644 --- a/examples/client-rendering/src/routes/__generated__/Route__Root_route.res +++ b/examples/client-rendering/src/routes/__generated__/Route__Root_route.res @@ -3,7 +3,8 @@ module Internal = { @live type childPathParams = { - byStatus: option<[#"completed" | #"notCompleted"]>, + byStatus: option<[#completed | #notCompleted]>, + byStatusDecoded: option, todoId: option, } diff --git a/examples/client-rendering/src/routes/__generated__/Routes.res b/examples/client-rendering/src/routes/__generated__/Routes.res index cbba2c6..64d0c84 100644 --- a/examples/client-rendering/src/routes/__generated__/Routes.res +++ b/examples/client-rendering/src/routes/__generated__/Routes.res @@ -1,16 +1,30 @@ // @generated // This file is autogenerated, do not edit manually +/** [See route renderer](./Root_route_renderer.res)*/ module Root = { module Route = Route__Root_route + /** [See route renderer](./Root__Todos_route_renderer.res)*/ module Todos = { module Route = Route__Root__Todos_route + /** [See route renderer](./Root__Todos__ByStatus_route_renderer.res)*/ module ByStatus = { module Route = Route__Root__Todos__ByStatus_route } + /** [See route renderer](./Root__Todos__ByStatusDecoded_route_renderer.res)*/ + module ByStatusDecoded = { + module Route = Route__Root__Todos__ByStatusDecoded_route + + /** [See route renderer](./Root__Todos__ByStatusDecoded__Child_route_renderer.res)*/ + module Child = { + module Route = Route__Root__Todos__ByStatusDecoded__Child_route + + } + } + /** [See route renderer](./Root__Todos__Single_route_renderer.res)*/ module Single = { module Route = Route__Root__Todos__Single_route diff --git a/examples/client-rendering/src/routes/todoRoutes.json b/examples/client-rendering/src/routes/todoRoutes.json index 1e4c1be..43dba3a 100644 --- a/examples/client-rendering/src/routes/todoRoutes.json +++ b/examples/client-rendering/src/routes/todoRoutes.json @@ -8,6 +8,16 @@ "name": "ByStatus", "children": [] }, + { + "path": ":byStatusDecoded:TodoStatusPathParam.t", + "name": "ByStatusDecoded", + "children": [ + { + "path": "", + "name": "Child" + } + ] + }, { "path": ":todoId?showMore=bool", "name": "Single", diff --git a/examples/client-rendering/src/utils/TodoStatusPathParam.res b/examples/client-rendering/src/utils/TodoStatusPathParam.res new file mode 100644 index 0000000..48f91d3 --- /dev/null +++ b/examples/client-rendering/src/utils/TodoStatusPathParam.res @@ -0,0 +1 @@ +@unboxed type t = Completed | NotCompleted | Other(string) diff --git a/examples/express/src/routes/__generated__/Route__Root__Todos_route.res b/examples/express/src/routes/__generated__/Route__Root__Todos_route.res index 5eac3ce..f41242c 100644 --- a/examples/express/src/routes/__generated__/Route__Root__Todos_route.res +++ b/examples/express/src/routes/__generated__/Route__Root__Todos_route.res @@ -7,7 +7,7 @@ type queryParams = { module Internal = { @live type childPathParams = { - byStatus: option<[#"completed" | #"notCompleted"]>, + byStatus: option<[#completed | #notCompleted]>, todoId: option, } diff --git a/examples/express/src/routes/__generated__/Route__Root_route.res b/examples/express/src/routes/__generated__/Route__Root_route.res index f02781a..8dcb15a 100644 --- a/examples/express/src/routes/__generated__/Route__Root_route.res +++ b/examples/express/src/routes/__generated__/Route__Root_route.res @@ -3,7 +3,7 @@ module Internal = { @live type childPathParams = { - byStatus: option<[#"completed" | #"notCompleted"]>, + byStatus: option<[#completed | #notCompleted]>, todoId: option, } diff --git a/examples/express/src/routes/__generated__/Routes.res b/examples/express/src/routes/__generated__/Routes.res index cbba2c6..f267ac0 100644 --- a/examples/express/src/routes/__generated__/Routes.res +++ b/examples/express/src/routes/__generated__/Routes.res @@ -1,16 +1,20 @@ // @generated // This file is autogenerated, do not edit manually +/** [See route renderer](./Root_route_renderer.res)*/ module Root = { module Route = Route__Root_route + /** [See route renderer](./Root__Todos_route_renderer.res)*/ module Todos = { module Route = Route__Root__Todos_route + /** [See route renderer](./Root__Todos__ByStatus_route_renderer.res)*/ module ByStatus = { module Route = Route__Root__Todos__ByStatus_route } + /** [See route renderer](./Root__Todos__Single_route_renderer.res)*/ module Single = { module Route = Route__Root__Todos__Single_route diff --git a/packages/rescript-relay-router/cli/RescriptRelayRouterCli__Codegen.res b/packages/rescript-relay-router/cli/RescriptRelayRouterCli__Codegen.res index 558a9e8..965e54b 100644 --- a/packages/rescript-relay-router/cli/RescriptRelayRouterCli__Codegen.res +++ b/packages/rescript-relay-router/cli/RescriptRelayRouterCli__Codegen.res @@ -308,6 +308,31 @@ type routeParamFields = { allRecordFields: array, } +let getRecordStructureToDecodePathParams = (p: printableRoute, ~paramName="params") => { + let str = ref("") + + switch p.params { + | [] => str.contents + | _ => + p.params->Array.forEach(p => { + str := + str.contents ++ + ` ${Utils.printablePathParamToParamName( + p, + )}: ${paramName}->Js.Dict.unsafeGet("${Utils.printablePathParamToParamName( + p, + )}")${switch p { + | PrintableRegularPathParam({text, pathToCustomModuleWithTypeT}) => + `->((${text}RawAsString: string) => (${text}RawAsString :> ${pathToCustomModuleWithTypeT}))` + | PrintablePathParamWithMatchBranches(_) => `->Obj.magic` + | _ => "" + }},\n` + }) + + str.contents + } +} + let getRouteParamRecordFields = (route: printableRoute) => { let pathParamsRecordFields = [] let queryParamsRecordFields = [] @@ -354,14 +379,10 @@ let rec findChildrenPathParams = (route: printableRoute, ~pathParams=Dict.make() child.params->Array.forEach(param => { pathParams->Dict.set( switch param { - | PrintableRegularPathParam(name) - | PrintablePathParamWithMatchBranches(name, _) => name - }, - switch param { - | PrintableRegularPathParam(_) => "string" - | PrintablePathParamWithMatchBranches(_, values) => - `[${values->Array.map(v => `#"${v}"`)->Array.join(" | ")}]` + | PrintableRegularPathParam({text}) + | PrintablePathParamWithMatchBranches({text}) => text }, + param, ) }) let _ = findChildrenPathParams(child, ~pathParams) @@ -417,18 +438,8 @@ let getMakePrepareProps = (route: printableRoute, ~returnMode) => { str.contents = str.contents ++ " childParams: Obj.magic(pathParams),\n" } - params->Array.forEach(param => { - str.contents = - str.contents ++ - ` ${Param(Utils.printablePathParamToParamName(param)) - ->SafeParam.makeSafeParamName(~params) - ->SafeParam.getSafeKey}: pathParams->Js.Dict.unsafeGet("${Utils.printablePathParamToParamName( - param, - )}")${switch param { - | PrintablePathParamWithMatchBranches(_) => "->Obj.magic" - | _ => "" - }},\n` - }) + str.contents = + str.contents ++ getRecordStructureToDecodePathParams(route, ~paramName="pathParams") if hasQueryParams { route.queryParams @@ -539,7 +550,7 @@ let getPrepareTypeDefinitions = (route: printableRoute) => { | childPathParams => str := str.contents ++ " @live\n type childPathParams = {\n" childPathParams->Array.forEach(((key, typ)) => { - str := str.contents ++ ` ${key}: option<${typ}>,\n` + str := str.contents ++ ` ${key}: option<${typ->Utils.printablePathParamToTypeStr}>,\n` }) str := str.contents ++ " }\n\n" recordFields->Array.push(KeyValue("childParams", "childPathParams")) diff --git a/packages/rescript-relay-router/cli/RescriptRelayRouterCli__Parser.res b/packages/rescript-relay-router/cli/RescriptRelayRouterCli__Parser.res index 31ed856..e712d87 100644 --- a/packages/rescript-relay-router/cli/RescriptRelayRouterCli__Parser.res +++ b/packages/rescript-relay-router/cli/RescriptRelayRouterCli__Parser.res @@ -163,7 +163,7 @@ let dummyPos: range = { module Path = { let withoutQueryParams = path => path->String.split("?")->Array.get(0)->Option.getOr("") - type inContext = ParamName | MatchBranches + type inContext = ParamName | ParamCustomType | MatchBranches @live type findingPathParamsContext = { @@ -171,6 +171,7 @@ module Path = { endChar: option, paramName: string, inContext: inContext, + currentParamCustomType: string, currentMatchParam: string, matchBranches: array, } @@ -187,8 +188,8 @@ module Path = { let addParamIfNotAlreadyPresent = (~currentCtx, ~paramLoc) => { switch parentContext.seenPathParams->List.find(param => { let textNode = switch param.seenAtPosition { - | PathParam(textNode) => textNode - | PathParamWithMatchBranches(textNode, _) => textNode + | PathParam({text}) => text + | PathParamWithMatchBranches({text}) => text } textNode.text == currentCtx.paramName }) { @@ -208,9 +209,14 @@ module Path = { } foundPathParams->Array.push( if currentCtx.matchBranches->Array.length > 0 { - PathParamWithMatchBranches(textNode, currentCtx.matchBranches) + PathParamWithMatchBranches({text: textNode, matchArms: currentCtx.matchBranches}) + } else if currentCtx.currentParamCustomType->String.length > 0 { + PathParam({ + text: textNode, + pathToCustomModuleWithTypeT: currentCtx.currentParamCustomType, + }) } else { - PathParam(textNode) + PathParam({text: textNode}) }, ) | Some(alreadySeenPathParam) => @@ -254,6 +260,7 @@ module Path = { endChar: None, inContext: ParamName, currentMatchParam: "", + currentParamCustomType: "", matchBranches: [], }) | (Some(currentCtx), "/") => @@ -301,6 +308,13 @@ module Path = { ...currentCtx, inContext: MatchBranches, }) + | (Some({inContext: ParamName, paramName} as currentCtx), ":") + if paramName->String.length > 0 => + currentContext := + Some({ + ...currentCtx, + inContext: ParamCustomType, + }) | (Some({inContext: ParamName} as currentCtx), char) => currentContext := Some({ @@ -324,7 +338,34 @@ module Path = { | false => ctx.addDecodeError( ~loc=charLoc, - ~message=`"${char}" is not a valid character in a path parameter. Path parameters can contain letters, digits, and underscores.`, + ~message=`"${char}" is not a valid character in a path parameter. Path parameters can contain letters, digits, dots and underscores.`, + ) + } + } + | (Some({inContext: ParamCustomType} as currentCtx), char) => + currentContext := + Some({ + ...currentCtx, + currentParamCustomType: currentCtx.currentParamCustomType ++ char, + }) + + switch currentCtx.paramName->String.length { + | 0 => + switch %re(`/[A-Z]/`)->RegExp.test(char) { + | true => () + | false => + ctx.addDecodeError( + ~loc=charLoc, + ~message=`Path parameter type references must refer to a module, and therefore must start with an uppercase letter.`, + ) + } + | _ => + switch %re(`/[A-Za-z0-9_\.]/`)->RegExp.test(char) { + | true => () + | false => + ctx.addDecodeError( + ~loc=charLoc, + ~message=`"${char}" is not a valid character in a path parameter. Path parameters can contain letters, digits, dots and underscores.`, ) } } @@ -362,6 +403,22 @@ module Path = { // If there's an open context when there's no more chars, it means the param goes to the end of the line. switch currentContext.contents { | None => () + | Some({currentParamCustomType, startChar}) + if currentParamCustomType->String.length > 0 && + !(currentParamCustomType->String.endsWith(".t")) => + ctx.addDecodeError( + ~loc={ + start: { + line: lineNum, + column: startChar, + }, + end_: { + line: lineNum, + column: startCharIdx + pathWithoutQueryParams->String.length, + }, + }, + ~message=`Custom path parameters type annotations must refer to a type t in a module, hence end with ".t".`, + ) | Some(currentCtx) => let paramLoc = { start: { diff --git a/packages/rescript-relay-router/cli/RescriptRelayRouterCli__Types.res b/packages/rescript-relay-router/cli/RescriptRelayRouterCli__Types.res index d0945ba..a4aa715 100644 --- a/packages/rescript-relay-router/cli/RescriptRelayRouterCli__Types.res +++ b/packages/rescript-relay-router/cli/RescriptRelayRouterCli__Types.res @@ -34,10 +34,17 @@ type queryParamNode = { queryParam: (range, queryParam), } -type pathParam = PathParam(textNode) | PathParamWithMatchBranches(textNode, array) +type pathParam = + | PathParam({text: textNode, pathToCustomModuleWithTypeT?: string}) + | PathParamWithMatchBranches({ + text: textNode, + /** The paths to match for this route. */ + matchArms: array, + }) type printablePathParam = - PrintableRegularPathParam(string) | PrintablePathParamWithMatchBranches(string, array) + | PrintableRegularPathParam({text: string, pathToCustomModuleWithTypeT?: string}) + | PrintablePathParamWithMatchBranches({text: string, matchArms: array}) module RouteName: { type t @@ -88,7 +95,20 @@ module RoutePath: { ->List.fromArray ->List.reverse ->List.concat(currentRoutePath.currentRoutePath) - ->List.filter(urlPart => urlPart != ""), + ->List.filter(urlPart => urlPart != "") + ->List.map(p => { + // Handle path params with explicit type annotations + if p->String.includesFrom(":", 1) { + ":" ++ + p + ->String.sliceToEnd(~start=1) + ->String.split(":") + ->Array.get(0) + ->Option.getOr("") + } else { + p + } + }), } } diff --git a/packages/rescript-relay-router/cli/RescriptRelayRouterCli__Utils.res b/packages/rescript-relay-router/cli/RescriptRelayRouterCli__Utils.res index 5917ef7..898a0ad 100644 --- a/packages/rescript-relay-router/cli/RescriptRelayRouterCli__Utils.res +++ b/packages/rescript-relay-router/cli/RescriptRelayRouterCli__Utils.res @@ -143,15 +143,16 @@ let toRendererFileName = rendererName => rendererName ++ "_route_renderer.res" let printablePathParamToTypeStr = p => switch p { - | PrintableRegularPathParam(_) => "string" - | PrintablePathParamWithMatchBranches(_, matchBranches) => - `[${matchBranches->Array.map(b => `#${b}`)->Array.join(" | ")}]` + | PrintableRegularPathParam({?pathToCustomModuleWithTypeT}) => + pathToCustomModuleWithTypeT->Option.getOr("string") + | PrintablePathParamWithMatchBranches({matchArms}) => + `[${matchArms->Array.map(b => `#${b}`)->Array.join(" | ")}]` } let printablePathParamToParamName = p => switch p { - | PrintableRegularPathParam(name) => name - | PrintablePathParamWithMatchBranches(name, _) => name + | PrintableRegularPathParam({text}) => text + | PrintablePathParamWithMatchBranches({text}) => text } let rec rawRouteToMatchable = (route: printableRoute): routeForCliMatching => { @@ -191,9 +192,10 @@ and parsedToPrintable = (routeEntry: routeEntry): printableRoute => { path: routeEntry.routePath, params: routeEntry.pathParams->Array.map(p => switch p { - | PathParam({text}) => PrintableRegularPathParam(text) - | PathParamWithMatchBranches({text}, matchBranches) => - PrintablePathParamWithMatchBranches(text, matchBranches) + | PathParam({text, ?pathToCustomModuleWithTypeT}) => + PrintableRegularPathParam({text: text.text, ?pathToCustomModuleWithTypeT}) + | PathParamWithMatchBranches({text, matchArms}) => + PrintablePathParamWithMatchBranches({text: text.text, matchArms}) } ), children: routeEntry.children->Option.getOr([])->routeChildrenToPrintable, @@ -269,6 +271,9 @@ let rec printNestedRouteModules = (route: printableRoute, ~indentation): string let str = ref("") let strEnd = ref("") + str->printIndentation(indentation) + str->add(`/** [See route renderer](./${route.name->RouteName.getRouteRendererFileName})*/\n`) + str->printIndentation(indentation) str->add(`module ${moduleName} = {\n`) str->printIndentation(indentation + 1) diff --git a/packages/rescript-relay-router/test/RescriptRelayRouterCli__Parser_test.test.res b/packages/rescript-relay-router/test/RescriptRelayRouterCli__Parser_test.test.res index 25081ef..b73051b 100644 --- a/packages/rescript-relay-router/test/RescriptRelayRouterCli__Parser_test.test.res +++ b/packages/rescript-relay-router/test/RescriptRelayRouterCli__Parser_test.test.res @@ -61,8 +61,9 @@ describe("Parsing", () => { pathParamsParent->Array.map( p => switch p { - | PathParam({text}) => text - | PathParamWithMatchBranches({text}, _) => text + | PathParam({text}) + | PathParamWithMatchBranches({text}) => + text.text }, ), )->Expect.toEqual(["slug"]) @@ -71,8 +72,9 @@ describe("Parsing", () => { pathParamsChild->Array.map( p => switch p { - | PathParam({text}) => text - | PathParamWithMatchBranches({text}, _) => text + | PathParam({text}) + | PathParamWithMatchBranches({text}) => + text.text }, ), )->Expect.toEqual(["memberId", "slug"])