From 1d1a9ef5720d0e3e682718235c495fbbaae1e112 Mon Sep 17 00:00:00 2001 From: N Date: Fri, 27 Sep 2024 17:58:09 +0300 Subject: [PATCH] MA-19439: Unification of overhead variations with support for To and parameter navigation --- .../vk-mini-apps-router-example/package.json | 2 +- .../src/panels/Alternative.tsx | 4 +- .../src/panels/Persik.tsx | 4 +- .../vk-mini-apps-router-example/src/routes.ts | 4 +- src/components/RouterLink.tsx | 11 +-- src/hooks/useHref.ts | 18 +++-- src/hooks/useLinkClickHandler.ts | 13 +++- src/services/DefaultRouteNavigator.ts | 68 +++++++++++++------ src/services/RouteNavigator.type.ts | 20 ++---- src/utils/utils.ts | 38 +++++++++-- 10 files changed, 125 insertions(+), 57 deletions(-) diff --git a/examples/vk-mini-apps-router-example/package.json b/examples/vk-mini-apps-router-example/package.json index 627e2a6d..53ca5ff5 100644 --- a/examples/vk-mini-apps-router-example/package.json +++ b/examples/vk-mini-apps-router-example/package.json @@ -12,7 +12,7 @@ "@types/react-dom": "^18.2.7", "@vkontakte/icons": "^2.77.0", "@vkontakte/vk-bridge": "latest", - "@vkontakte/vk-mini-apps-router": "1.4.2", + "@vkontakte/vk-mini-apps-router": "1.5.0", "@vkontakte/vkui": "^6.0.0", "react": "^18.2.0", "react-dom": "^18.2.0", diff --git a/examples/vk-mini-apps-router-example/src/panels/Alternative.tsx b/examples/vk-mini-apps-router-example/src/panels/Alternative.tsx index a69e3eb2..4ddf90e8 100644 --- a/examples/vk-mini-apps-router-example/src/panels/Alternative.tsx +++ b/examples/vk-mini-apps-router-example/src/panels/Alternative.tsx @@ -4,7 +4,7 @@ import { Panel, PanelHeader, Header, Button, ButtonGroup, Group, Tabs, TabsItem, import bridge from '@vkontakte/vk-bridge'; import { GoFunctionProp, NavProp } from '../types'; import { useEnableSwipeBack, useActiveVkuiLocation, RouterLink } from '@vkontakte/vk-mini-apps-router'; -import { ALTERNATIVE_PANEL_TABS, HOME_PANEL_MODALS, PERSIK_PANEL_MODALS } from '../routes'; +import { ALTERNATIVE_PANEL_TABS, HOME_PANEL_MODALS, PERSIK_PANEL_MODALS, routes } from '../routes'; import { AppMap } from '../appMap/AppMap'; export const Alternative = ({ nav, go }: NavProp & GoFunctionProp) => { @@ -58,7 +58,7 @@ export const Alternative = ({ nav, go }: NavProp & GoFunctionProp) => { - Можно использовать ссылки (на Персика) + Можно использовать ссылки (на Персика) } diff --git a/examples/vk-mini-apps-router-example/src/panels/Persik.tsx b/examples/vk-mini-apps-router-example/src/panels/Persik.tsx index aa949554..0dda3a18 100644 --- a/examples/vk-mini-apps-router-example/src/panels/Persik.tsx +++ b/examples/vk-mini-apps-router-example/src/panels/Persik.tsx @@ -70,11 +70,11 @@ export const Persik = (props: NavProp) => { {emotion !== 'sad' && } diff --git a/examples/vk-mini-apps-router-example/src/routes.ts b/examples/vk-mini-apps-router-example/src/routes.ts index d8a4dd87..ba8fc19d 100644 --- a/examples/vk-mini-apps-router-example/src/routes.ts +++ b/examples/vk-mini-apps-router-example/src/routes.ts @@ -1,5 +1,4 @@ import { - createHashParamRouter, createModal, createPanel, createRoot, @@ -7,6 +6,7 @@ import { createView, RoutesConfig, RouteLeaf, + createHashRouter } from '@vkontakte/vk-mini-apps-router'; export const DEFAULT_ROOT = 'default_root'; @@ -131,7 +131,7 @@ export const hierarchy: RouteLeaf[] = [ }, ]; - export const router = createHashParamRouter(routes.getRoutes()); + export const router = createHashRouter(routes.getRoutes()); // export const router = createHashRouter([ // { diff --git a/src/components/RouterLink.tsx b/src/components/RouterLink.tsx index ecce7dc4..c2be01f7 100644 --- a/src/components/RouterLink.tsx +++ b/src/components/RouterLink.tsx @@ -1,6 +1,6 @@ import { Link } from '@vkontakte/vkui'; import { useHref } from '../hooks/useHref'; -import { RelativeRoutingType, To } from '@remix-run/router'; +import { Params, RelativeRoutingType, To } from '@remix-run/router'; import { AnchorHTMLAttributes, CSSProperties, @@ -9,12 +9,13 @@ import { MouseEvent as ReactMouseEvent, } from 'react'; import { useLinkClickHandler } from '../hooks/useLinkClickHandler'; +import { Page, PageWithParams } from '../page-types/common'; export interface LinkProps extends Omit, 'href'> { + to: To | Page | PageWithParams; reloadDocument?: boolean; replace?: boolean; relative?: RelativeRoutingType; - to: To; } export interface RouterLinkProps extends Omit { @@ -23,6 +24,7 @@ export interface RouterLinkProps extends Omit(function ( - { to, relative, replace, target, reloadDocument, onClick, ...rest }: RouterLinkProps, + { to, relative, replace, target, reloadDocument, params, onClick, ...rest }: RouterLinkProps, ref, ) { // Rendered into for absolute URLs @@ -59,12 +61,13 @@ export const RouterLink = forwardRef(functio } } - const href = useHref(to, { relative }); + const href = useHref(to, { relative, params }); const internalOnClick = useLinkClickHandler(to, { replace, target, relative, + params, }); function handleClick(event: ReactMouseEvent) { diff --git a/src/hooks/useHref.ts b/src/hooks/useHref.ts index da015b3f..9bf38ed8 100644 --- a/src/hooks/useHref.ts +++ b/src/hooks/useHref.ts @@ -1,18 +1,28 @@ -import { Location, RelativeRoutingType, To } from '@remix-run/router'; +import { Location, RelativeRoutingType, To, Params } from '@remix-run/router'; import { RouterContext } from '../contexts'; import { useContext } from 'react'; import { useResolvedPath } from './useResolvedPath'; import { getHrefWithoutHash } from '../utils/getHrefWithoutHash'; -import { invariant } from '../utils/utils'; +import { getPathFromTo, invariant } from '../utils/utils'; +import { Page, PageWithParams } from '../page-types/common'; -export function useHref(to: To, { relative }: { relative?: RelativeRoutingType } = {}): string { +export function useHref( + to: To | Page | PageWithParams, + { relative, params }: { relative?: RelativeRoutingType; params?: Params } = {}, +): string { const routeContext = useContext(RouterContext); invariant( routeContext, 'You can not use useHref hook outside of RouteContext. Make sure calling it inside RouterProvider.', ); - const { hash, pathname, search } = useResolvedPath(to, { relative }); + const path = getPathFromTo({ + to, + params, + defaultPathname: routeContext.router.state.location.pathname, + }); + + const { hash, pathname, search } = useResolvedPath(path, { relative }); const hrefWithoutHash = getHrefWithoutHash(); const href = routeContext.router.createHref({ pathname, search, hash } as Location); diff --git a/src/hooks/useLinkClickHandler.ts b/src/hooks/useLinkClickHandler.ts index 1d0654a9..606d8fc9 100644 --- a/src/hooks/useLinkClickHandler.ts +++ b/src/hooks/useLinkClickHandler.ts @@ -1,7 +1,9 @@ -import { createPath, RelativeRoutingType, To } from '@remix-run/router'; +import { createPath, Params, RelativeRoutingType, To } from '@remix-run/router'; import { HTMLAttributeAnchorTarget, MouseEvent as ReactMouseEvent, useCallback } from 'react'; import { useLocation, useRouteNavigator } from './hooks'; import { useResolvedPath } from './useResolvedPath'; +import { Page, PageWithParams } from '../page-types/common'; +import { getPathFromTo } from '../utils'; type LimitedMouseEvent = Pick; @@ -18,22 +20,27 @@ export function shouldProcessLinkClick(event: LimitedMouseEvent, target?: string } export function useLinkClickHandler( - to: To, + to: To | Page | PageWithParams, { target, replace: replaceProp, preventScrollReset, relative, + params, }: { target?: HTMLAttributeAnchorTarget; replace?: boolean; preventScrollReset?: boolean; relative?: RelativeRoutingType; + params?: Params; } = {}, ): (event: ReactMouseEvent) => void { const navigator = useRouteNavigator(); const location = useLocation(); - const path = useResolvedPath(to, { relative }); + + const path = useResolvedPath(getPathFromTo({ to, params, defaultPathname: location.pathname }), { + relative, + }); return useCallback( (event: ReactMouseEvent) => { diff --git a/src/services/DefaultRouteNavigator.ts b/src/services/DefaultRouteNavigator.ts index db739f17..231b698b 100644 --- a/src/services/DefaultRouteNavigator.ts +++ b/src/services/DefaultRouteNavigator.ts @@ -1,12 +1,18 @@ -import { BlockerFunction, Params, Router, RouterNavigateOptions } from '@remix-run/router'; -import { createKey, fillParamsIntoPath, isModalShown, isPopoutShown } from '../utils/utils'; +import { BlockerFunction, Params, Router, RouterNavigateOptions, To } from '@remix-run/router'; +import { + createKey, + getParamKeys, + getPathFromTo, + isModalShown, + isPopoutShown, +} from '../utils/utils'; import { NAVIGATION_BLOCKER_KEY, STATE_KEY_BLOCK_FORWARD_NAVIGATION, STATE_KEY_SHOW_MODAL, STATE_KEY_SHOW_POPOUT, } from '../const'; -import { hasNavigationOptionsKeys, NavigationOptions, RouteNavigator } from './RouteNavigator.type'; +import { NavigationOptions, RouteNavigator } from './RouteNavigator.type'; import { buildPanelPathFromModalMatch } from '../utils/buildPanelPathFromModalMatch'; import { InternalRouteConfig, ModalWithRoot } from '../type'; import { Page, PageWithParams } from '../page-types/common'; @@ -30,28 +36,26 @@ export class DefaultRouteNavigator implements RouteNavigator { } public async push( - to: string | Page | PageWithParams, + to: To | Page | PageWithParams, paramsOrOptions: Params | NavigationOptions = {}, options: NavigationOptions = {}, ): Promise { - const paramsAreOptions = hasNavigationOptionsKeys(paramsOrOptions); - const preparedOptions: NavigationOptions = paramsAreOptions ? paramsOrOptions : options; + const { preparedOptions, preparedParams } = this.parseParams(to, paramsOrOptions, options); const fullOptions = { ...preparedOptions, replace: Boolean(this.router.state.location.state?.[STATE_KEY_BLOCK_FORWARD_NAVIGATION]), }; - const preparedParams: Params = paramsAreOptions ? {} : (paramsOrOptions as Params); + await this.navigate(to, fullOptions, preparedParams); } public async replace( - to: string | Page | PageWithParams, + to: To | Page | PageWithParams, paramsOrOptions: Params | NavigationOptions = {}, options: NavigationOptions = {}, ): Promise { - const paramsAreOptions = hasNavigationOptionsKeys(paramsOrOptions); - const preparedOptions: NavigationOptions = paramsAreOptions ? paramsOrOptions : options; - const preparedParams: Params = paramsAreOptions ? {} : (paramsOrOptions as Params); + const { preparedOptions, preparedParams } = this.parseParams(to, paramsOrOptions, options); + await this.navigate(to, { ...preparedOptions, replace: true }, preparedParams); } @@ -159,21 +163,45 @@ Make sure this route exists or use hideModal with pushPanel set to false.`); } private async navigate( - to: string | Page | PageWithParams, + to: To | Page | PageWithParams, opts?: RouterNavigateOptions & NavigationOptions, params: Params = {}, ): Promise { - // prettier-ignore - let path = typeof to === 'string' - ? to - : to.hasParams - ? fillParamsIntoPath(to.path, params) - : to.path; - - if (opts?.keepSearchParams) { + let path = getPathFromTo({ to, params, defaultPathname: this.router.state.location.pathname }); + const newUrl = new URL(path, window.location.origin); + + if (opts?.keepSearchParams && !newUrl.search) { path += this.router.state.location.search; } await this.router.navigate(path, opts); } + + private validateOptions({ state, keepSearchParams }: NavigationOptions = {}) { + const invalidState = state && typeof state !== 'object'; + const invalidKeepSearchParams = keepSearchParams && typeof keepSearchParams !== 'boolean'; + + if (invalidState || invalidKeepSearchParams) { + console.warn('Invalid navigate options type'); + return {}; + } + return { state, keepSearchParams }; + } + + private parseParams( + to: To | Page | PageWithParams, + paramsOrOptions: Params | NavigationOptions = {}, + options: NavigationOptions = {}, + ) { + const path = typeof to === 'object' ? ('path' in to ? to.path : to.pathname || '') : to; + + if (getParamKeys(path).length) { + return { + preparedParams: paramsOrOptions as Params, + preparedOptions: this.validateOptions(options), + }; + } + + return { preparedParams: {}, preparedOptions: this.validateOptions(paramsOrOptions) }; + } } diff --git a/src/services/RouteNavigator.type.ts b/src/services/RouteNavigator.type.ts index f47e3364..938c15ea 100644 --- a/src/services/RouteNavigator.type.ts +++ b/src/services/RouteNavigator.type.ts @@ -1,33 +1,25 @@ import { Page, PageWithParams } from '../page-types/common'; -import { BlockerFunction, Params } from '@remix-run/router'; +import { BlockerFunction, Params, To } from '@remix-run/router'; export interface NavigationOptions { keepSearchParams?: boolean; - state?: Object; -} - -export function hasNavigationOptionsKeys(object: T): boolean { - const base: Required = { - keepSearchParams: true, - state: {}, - }; - return Object.keys(object).some((key) => key in base); + state?: Record; } export interface RouteNavigator { push( - to: PageWithParams, + to: To | PageWithParams, params: Params, options?: NavigationOptions, ): Promise; - push(to: string | Page, options?: NavigationOptions): Promise; + push(to: To | Page, options?: NavigationOptions): Promise; replace( - to: PageWithParams, + to: To | PageWithParams, params: Params, options?: NavigationOptions, ): Promise; - replace(to: string | Page, options?: NavigationOptions): Promise; + replace(to: To | Page, options?: NavigationOptions): Promise; back(to?: number): Promise; diff --git a/src/utils/utils.ts b/src/utils/utils.ts index 757310c1..d08ed796 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -1,19 +1,25 @@ -import { AgnosticRouteMatch, Location, Params, RouterState } from '@remix-run/router'; +import { + AgnosticRouteMatch, + createPath, + Location, + Params, + RouterState, + To, +} from '@remix-run/router'; import { RouteContextObject } from '../contexts'; import { PageInternal } from '../type'; import { STATE_KEY_SHOW_MODAL, STATE_KEY_SHOW_POPOUT } from '../const'; +import { Page, PageWithParams } from '../page-types/common'; export function getParamKeys(path: string | undefined): string[] { return path?.match(/\/:[^\/]+/g)?.map((param) => param.replace('/', '')) ?? []; } -export function fillParamsIntoPath(path: string, params: Params): string { +export function fillParamsIntoPath(path: string, params?: Params): string { const parameters = getParamKeys(path); const paramInjector = (acc: string, param: string): string => { const paramName = param.replace(':', ''); - if (!params[paramName]) { - throw new Error(`Missing parameter ${paramName} while building route ${path}`); - } + invariant(params?.[paramName], `Missing parameter ${paramName} while building route ${path}`); return acc.replace(param, params[paramName] as string); }; return parameters.reduce(paramInjector, path); @@ -72,3 +78,25 @@ export function invariant(value: any, message?: string) { throw new Error(message); } } + +export function getPathFromTo({ + to, + params, + defaultPathname = '', +}: { + to: To | Page | PageWithParams; + params?: Params; + defaultPathname?: string; +}) { + const isToObj = typeof to === 'object' && !('path' in to); + const path = typeof to === 'string' ? to : isToObj ? to.pathname || defaultPathname : to.path; + const hasParams = getParamKeys(path).length > 0; + + if (hasParams) { + const filledPath = fillParamsIntoPath(path, params); + + return isToObj ? createPath({ ...to, pathname: filledPath }) : filledPath; + } + + return isToObj ? createPath({ ...to, pathname: path }) : path; +}