diff --git a/CHANGELOG.md b/CHANGELOG.md index dac65ea..3181f36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +v1.7.0 +- +- Добавили хук useGetHistoryManager для доступа к интерфейсу истории переходов. + +v1.6.0 +- +- Расширили список параметров в методе hideModal, поддержали акрытие модального окна без добавления записи в history. + v1.5.0 - - Расширили способы навигации, добавили возможность передавать hash и параметры поиска. Добавили дополнительную валидацию параметров. diff --git a/examples/vk-mini-apps-router-example/src/App.tsx b/examples/vk-mini-apps-router-example/src/App.tsx index b2d0eaa..86168ed 100644 --- a/examples/vk-mini-apps-router-example/src/App.tsx +++ b/examples/vk-mini-apps-router-example/src/App.tsx @@ -8,6 +8,8 @@ import { usePopout, useRouteNavigator, useGetPanelForView, + useFirstPageCheck, + useGetHistoryManager } from '@vkontakte/vk-mini-apps-router'; import { Home } from './panels/Home'; @@ -37,8 +39,10 @@ import { OnboardingThree } from './onboarding/OnboardingThree'; function App() { const [fetchedUser, setUser] = useState(null); + const isFirstPage = useFirstPageCheck(); const routerPopout = usePopout(); const routeNavigator = useRouteNavigator(); + const historyManager = useGetHistoryManager(); const { root: activeRoot = DEFAULT_ROOT, view: activeView = DEFAULT_VIEW, @@ -58,12 +62,16 @@ function App() { fetchData(); }, []); + const goToFirstPage = () => { + routeNavigator.go(-historyManager.getCurrentPosition()); + }; + const go = (path: string) => { routeNavigator.push(path); }; const modal = ( - routeNavigator.hideModal()}> + routeNavigator.hideModal(false, {replace: isFirstPage})}> @@ -85,7 +93,7 @@ function App() { activePanel={defaultActivePanel || DEFAULT_VIEW_PANELS.HOME} onSwipeBack={() => routeNavigator.back()} > - + diff --git a/examples/vk-mini-apps-router-example/src/panels/Home.tsx b/examples/vk-mini-apps-router-example/src/panels/Home.tsx index cd2837a..d158f10 100644 --- a/examples/vk-mini-apps-router-example/src/panels/Home.tsx +++ b/examples/vk-mini-apps-router-example/src/panels/Home.tsx @@ -10,16 +10,16 @@ import { ButtonGroup, Avatar, } from '@vkontakte/vkui'; -import { GoFunctionProp, NavProp, UserInfo } from '../types'; +import { GoFunctionProp, goToFirstPageProp, NavProp, UserInfo } from '../types'; import { useEnableSwipeBack } from '@vkontakte/vk-mini-apps-router'; import { AppMap } from '../appMap/AppMap'; type HomeProps = NavProp & GoFunctionProp & { fetchedUser: UserInfo; - }; + } & goToFirstPageProp; -export const Home = ({ nav, go, fetchedUser }: HomeProps) => { +export const Home = ({ nav, go, goToFirstPage, fetchedUser }: HomeProps) => { useEnableSwipeBack(); return ( @@ -75,6 +75,9 @@ export const Home = ({ nav, go, fetchedUser }: HomeProps) => { + diff --git a/examples/vk-mini-apps-router-example/src/types.ts b/examples/vk-mini-apps-router-example/src/types.ts index 7710ca2..045b1a8 100644 --- a/examples/vk-mini-apps-router-example/src/types.ts +++ b/examples/vk-mini-apps-router-example/src/types.ts @@ -6,6 +6,10 @@ export type GoFunctionProp = { go: (path: string) => void, }; +export type goToFirstPageProp = { + goToFirstPage: () => void, +}; + export type UserInfo = { photo_200?: string, first_name?: string, diff --git a/package.json b/package.json index 5a70db0..eebf1d5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@vkontakte/vk-mini-apps-router", - "version": "1.4.6", + "version": "1.6.0", "description": "React-роутер для мини-приложений ВКонтакте, построенных на VKUI", "main": "./dist/index.js", "typings": "./dist/index.d.ts", diff --git a/src/hooks/useGetHistoryManager.ts b/src/hooks/useGetHistoryManager.ts new file mode 100644 index 0000000..c4c3f60 --- /dev/null +++ b/src/hooks/useGetHistoryManager.ts @@ -0,0 +1,20 @@ +import { useContext, useEffect, useState } from 'react'; +import { RouterContext } from '../contexts'; +import { invariant } from '../utils/utils'; +import { HistoryManager } from '../services'; + +export function useGetHistoryManager(): HistoryManager { + const { viewHistory, router } = useContext(RouterContext); + const [historyManager, setHistoryManager] = useState(new HistoryManager(viewHistory, router)); + + invariant( + viewHistory, + 'You can not use useGetHistoryManager hook outside of RouteContext. Make sure calling it inside RouterProvider.', + ); + + useEffect(() => { + setHistoryManager(new HistoryManager(viewHistory, router)); + }, [viewHistory, router]); + + return historyManager; +} diff --git a/src/index.ts b/src/index.ts index af3528c..c9b64a4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,6 +12,7 @@ export { useGetPanelForView } from './hooks/useGetPanelForView'; export { useSearchParams } from './hooks/useSearchParams'; export type { SetURLSearchParams } from './hooks/useSearchParams'; export { useMetaParams } from './hooks/useMetaParams'; +export { useGetHistoryManager } from './hooks/useGetHistoryManager'; export { useFirstPageCheck } from './hooks/useFirstPageCheck'; export { useActiveVkuiLocation } from './hooks/useActiveVkuiLocation'; export { useEnableSwipeBack } from './hooks/useEnableSwipeBack'; diff --git a/src/services/DefaultRouteNavigator.ts b/src/services/DefaultRouteNavigator.ts index 0484017..d9138a9 100644 --- a/src/services/DefaultRouteNavigator.ts +++ b/src/services/DefaultRouteNavigator.ts @@ -6,6 +6,7 @@ import { getPathFromTo, isModalShown, isPopoutShown, + invariant, } from '../utils/utils'; import { NAVIGATION_BLOCKER_KEY, @@ -96,24 +97,31 @@ export class DefaultRouteNavigator implements RouteNavigator { }); } - public async hideModal(pushPanel = false): Promise { + public async hideModal(pushPanel = false, options?: { replace: boolean }): Promise { if ((!pushPanel && !this.viewHistory.isFirstPage) || isModalShown(this.router.state.location)) { - await this.router.navigate(-1); - } else { - const modalMatch = this.router.state.matches.find((match) => 'modal' in match.route); - if (modalMatch) { - const route = modalMatch.route as ModalWithRoot & InternalRouteConfig; - const path = buildPanelPathFromModalMatch(modalMatch, this.router); - if (!path) { - const rootMessage = route.root ? `root: ${route.root} ` : ''; - throw new Error(`There is no route registered for panel with ${rootMessage}, view: ${route.view}, panel: ${route.panel}. -Make sure this route exists or use hideModal with pushPanel set to false.`); - } - await this.navigate(path, { keepSearchParams: true }); - } else { - await TransactionExecutor.doNext(); - } + return await this.router.navigate(-1); } + const modalMatch = this.router.state.matches.find((match) => 'modal' in match.route); + + if (modalMatch) { + const route = modalMatch.route as ModalWithRoot & InternalRouteConfig; + const path = buildPanelPathFromModalMatch(modalMatch, this.router); + + invariant( + path, + `There is no route registered for panel with ${ + route.root ? `root: ${route.root} ` : '' + }, view: ${route.view}, panel: ${ + route.panel + }. Make sure this route exists or use hideModal with pushPanel set to false.`, + ); + + return await this.navigate(path, { + keepSearchParams: true, + replace: Boolean(options?.replace), + }); + } + await TransactionExecutor.doNext(); } public async showPopout(popout: JSX.Element): Promise { @@ -151,10 +159,11 @@ Make sure this route exists or use hideModal with pushPanel set to false.`); public block(blocker: BlockerFunction) { const key = (++this.blockerId).toString(); - this.blockers.set(key, blocker); const onLeave: BlockerFunction = (data) => { return Array.from(this.blockers.values()).some((fn) => fn(data)); }; + + this.blockers.set(key, blocker); this.router.getBlocker(NAVIGATION_BLOCKER_KEY, onLeave); return () => { diff --git a/src/services/HistoryManager.ts b/src/services/HistoryManager.ts new file mode 100644 index 0000000..7b1cda1 --- /dev/null +++ b/src/services/HistoryManager.ts @@ -0,0 +1,14 @@ +import { Router } from '@remix-run/router'; +import { ViewHistory } from '.'; + +export class HistoryManager { + public constructor(private readonly viewHistory: ViewHistory, private readonly router: Router) {} + + public getCurrentPosition() { + return this.viewHistory.position; + } + + public getHistory() { + return this.viewHistory.historyStack; + } +} diff --git a/src/services/RouteNavigator.type.ts b/src/services/RouteNavigator.type.ts index 3217f9b..656d5cc 100644 --- a/src/services/RouteNavigator.type.ts +++ b/src/services/RouteNavigator.type.ts @@ -3,7 +3,7 @@ import { BlockerFunction, Params } from '@remix-run/router'; export interface NavigationOptions { keepSearchParams?: boolean; - state?: Record; + state?: Record | null; } type NavigationPath = { @@ -55,8 +55,14 @@ export interface RouteNavigator { * В случае, если модальное окно было открыто через навигацию, можно закрыть окно шагом назад * или навигацией вперед на родительскую панель.
* По умолчанию false. + * + * @param options - Необязательный параметр для дополнительных настроек навигации. + * + * @param options.replace - Если true, текущее модальное окно будет закрыто с заменой + * текущей записи истории на родительскую панель. По умолчанию false, что означает закрытие модального окна + * с добавлением новой записи в историю или возвратом на один шаг назад. */ - hideModal(pushPanel?: boolean): Promise; + hideModal(pushPanel?: boolean, options?: { replace: boolean }): Promise; showPopout(popout: JSX.Element): Promise; diff --git a/src/services/ViewHistory.ts b/src/services/ViewHistory.ts index b923842..8a1e2fd 100644 --- a/src/services/ViewHistory.ts +++ b/src/services/ViewHistory.ts @@ -1,31 +1,34 @@ -import { Action, RouterState } from '@remix-run/router'; +import { Action, AgnosticRouteMatch, RouterState } from '@remix-run/router'; import { getRouteContext } from '../utils/utils'; import { STATE_KEY_SHOW_POPOUT } from '../const'; import { ViewNavigationRecord } from './ViewNavigationRecord.type'; +import { PageInternal } from '../type'; export class ViewHistory { private history: ViewNavigationRecord[] = []; private positionInternal = -1; updateNavigation(state: RouterState): void { - const record = this.getViewRecordFromState(state); - if (!record) { + const { match } = getRouteContext(state); + + if (!match) { return; } + switch (state.historyAction) { case Action.Push: - this.push(record); + this.push(state, match); break; case Action.Pop: - if (this.hasKey(record.locationKey)) { - this.pop(record); + if (this.hasKey(state.location.key)) { + this.pop(state); } else { // В случае, если пользователь введет в адресную строку новый хэш, мы поймаем POP событие с новой локацией. - this.push(record); + this.push(state, match); } break; case Action.Replace: - this.replace(record); + this.replace(state, match); break; } } @@ -35,7 +38,7 @@ export class ViewHistory { } get panelsHistory(): string[] { - if (this.positionInternal < 0) { + if (this.history.length < 0) { return []; } const currentView = this.history[this.positionInternal].view; @@ -45,6 +48,7 @@ export class ViewHistory { .slice(0, rightLimit > -1 ? rightLimit : reversedClone.length) .filter((item) => !item.modal && !item.popout) .reverse(); + return historyCopy.map(({ panel }) => panel); } @@ -52,6 +56,10 @@ export class ViewHistory { return this.positionInternal; } + get historyStack(): ViewNavigationRecord[] { + return [...this.history]; + } + isPopForward(historyAction: Action, key: string): boolean { const newPosition = this.history.findIndex(({ locationKey }) => locationKey === key); return historyAction === Action.Pop && newPosition > this.position; @@ -67,19 +75,19 @@ export class ViewHistory { this.history = []; } - private push(record: ViewNavigationRecord): void { + private push(state: RouterState, match: AgnosticRouteMatch): void { this.history = this.history.slice(0, this.positionInternal + 1); - this.history.push(record); - this.positionInternal = this.history.length - 1; + this.positionInternal = this.history.length; + this.history.push(this.createViewRecord(state, match, this.position)); } - private replace(record: ViewNavigationRecord): void { - this.history[this.positionInternal] = record; + private replace(state: RouterState, match: AgnosticRouteMatch): void { + this.history[this.position] = this.createViewRecord(state, match, this.position); } - private pop(record: ViewNavigationRecord): void { + private pop(state: RouterState): void { this.positionInternal = this.history.findIndex( - ({ locationKey }) => locationKey === record.locationKey, + ({ locationKey }) => locationKey === state.location.key, ); } @@ -87,16 +95,22 @@ export class ViewHistory { return Boolean(this.history.find(({ locationKey }) => locationKey === key)); } - private getViewRecordFromState(state: RouterState): ViewNavigationRecord | undefined { - const context = getRouteContext(state); - if (!context.match) { - return undefined; - } - const { route } = context.match; + private createViewRecord( + state: RouterState, + match: AgnosticRouteMatch, + position: number, + ): ViewNavigationRecord { + const { route } = match; + return { + position, + path: state.location.pathname, + state: state.location.state, + root: 'root' in route ? route.root : undefined, view: route.view, panel: route.panel, modal: 'modal' in route ? route.modal : undefined, + tab: route.tab, popout: state.location.state?.[STATE_KEY_SHOW_POPOUT], locationKey: state.location.key, }; diff --git a/src/services/ViewNavigationRecord.type.ts b/src/services/ViewNavigationRecord.type.ts index f3f1d2e..f4d3964 100644 --- a/src/services/ViewNavigationRecord.type.ts +++ b/src/services/ViewNavigationRecord.type.ts @@ -1,7 +1,12 @@ -export type ViewNavigationRecord = { +export interface ViewNavigationRecord { + position: number; + locationKey: string; + path: string; + state: Record | null; view: string; panel: string; - locationKey: string; + root?: string; + tab?: string; modal?: string; popout?: string; -}; +} diff --git a/src/services/index.ts b/src/services/index.ts index b48a794..99ced99 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -7,3 +7,4 @@ export * from './RouteNavigator.type'; export * from './TransactionExecutor'; export * from './ViewHistory'; export * from './ViewNavigationRecord.type'; +export * from './HistoryManager';