Skip to content

Commit

Permalink
Merge pull request #437 from VKCOM/pavelnikitin/feature/modal-close-n…
Browse files Browse the repository at this point in the history
…o-history/MA-19935

MA-19935: Implement history-less modal close
  • Loading branch information
pasha-nikitin-2003 authored and pavel-nikitin-2022 committed Nov 7, 2024
2 parents 52395f4 + 1c05b42 commit e0a2e77
Show file tree
Hide file tree
Showing 13 changed files with 143 additions and 50 deletions.
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
v1.7.0
-
- Добавили хук useGetHistoryManager для доступа к интерфейсу истории переходов.

v1.6.0
-
- Расширили список параметров в методе hideModal, поддержали акрытие модального окна без добавления записи в history.

v1.5.0
-
- Расширили способы навигации, добавили возможность передавать hash и параметры поиска. Добавили дополнительную валидацию параметров.
Expand Down
12 changes: 10 additions & 2 deletions examples/vk-mini-apps-router-example/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import {
usePopout,
useRouteNavigator,
useGetPanelForView,
useFirstPageCheck,
useGetHistoryManager
} from '@vkontakte/vk-mini-apps-router';

import { Home } from './panels/Home';
Expand Down Expand Up @@ -37,8 +39,10 @@ import { OnboardingThree } from './onboarding/OnboardingThree';

function App() {
const [fetchedUser, setUser] = useState<any>(null);
const isFirstPage = useFirstPageCheck();
const routerPopout = usePopout();
const routeNavigator = useRouteNavigator();
const historyManager = useGetHistoryManager();
const {
root: activeRoot = DEFAULT_ROOT,
view: activeView = DEFAULT_VIEW,
Expand All @@ -58,12 +62,16 @@ function App() {
fetchData();
}, []);

const goToFirstPage = () => {
routeNavigator.go(-historyManager.getCurrentPosition());
};

const go = (path: string) => {
routeNavigator.push(path);
};

const modal = (
<ModalRoot activeModal={activeModal} onClose={() => routeNavigator.hideModal()}>
<ModalRoot activeModal={activeModal} onClose={() => routeNavigator.hideModal(false, {replace: isFirstPage})}>
<PersikModal nav={PERSIK_PANEL_MODALS.PERSIK}></PersikModal>
<BlockerModal nav={HOME_PANEL_MODALS.BLOCKER} />
<UserModal nav={HOME_PANEL_MODALS.USER} fetchedUser={fetchedUser}></UserModal>
Expand All @@ -85,7 +93,7 @@ function App() {
activePanel={defaultActivePanel || DEFAULT_VIEW_PANELS.HOME}
onSwipeBack={() => routeNavigator.back()}
>
<Home nav={DEFAULT_VIEW_PANELS.HOME} fetchedUser={fetchedUser} go={go} />
<Home nav={DEFAULT_VIEW_PANELS.HOME} fetchedUser={fetchedUser} go={go} goToFirstPage={goToFirstPage} />
<Persik nav={DEFAULT_VIEW_PANELS.PERSIK} />
<Blocker nav={DEFAULT_VIEW_PANELS.BLOCKER} />
</View>
Expand Down
9 changes: 6 additions & 3 deletions examples/vk-mini-apps-router-example/src/panels/Home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<Panel nav={nav}>
Expand Down Expand Up @@ -75,6 +75,9 @@ export const Home = ({ nav, go, fetchedUser }: HomeProps) => {
<Button stretched size="l" mode="secondary" onClick={() => go('/blocker')}>
Страница выхода с подтверждением
</Button>
<Button stretched size="l" mode="secondary" onClick={goToFirstPage}>
На самую первую страницу
</Button>
</ButtonGroup>
</ButtonGroup>
</Group>
Expand Down
4 changes: 4 additions & 0 deletions examples/vk-mini-apps-router-example/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
20 changes: 20 additions & 0 deletions src/hooks/useGetHistoryManager.ts
Original file line number Diff line number Diff line change
@@ -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;
}
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
43 changes: 26 additions & 17 deletions src/services/DefaultRouteNavigator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
getPathFromTo,
isModalShown,
isPopoutShown,
invariant,
} from '../utils/utils';
import {
NAVIGATION_BLOCKER_KEY,
Expand Down Expand Up @@ -96,24 +97,31 @@ export class DefaultRouteNavigator implements RouteNavigator {
});
}

public async hideModal(pushPanel = false): Promise<void> {
public async hideModal(pushPanel = false, options?: { replace: boolean }): Promise<void> {
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<void> {
Expand Down Expand Up @@ -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 () => {
Expand Down
14 changes: 14 additions & 0 deletions src/services/HistoryManager.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
10 changes: 8 additions & 2 deletions src/services/RouteNavigator.type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { BlockerFunction, Params } from '@remix-run/router';

export interface NavigationOptions {
keepSearchParams?: boolean;
state?: Record<string, unknown>;
state?: Record<string, unknown> | null;
}

type NavigationPath = {
Expand Down Expand Up @@ -55,8 +55,14 @@ export interface RouteNavigator {
* В случае, если модальное окно было открыто через навигацию, можно закрыть окно шагом назад
* или навигацией вперед на родительскую панель.<br>
* По умолчанию false.
*
* @param options - Необязательный параметр для дополнительных настроек навигации.
*
* @param options.replace - Если true, текущее модальное окно будет закрыто с заменой
* текущей записи истории на родительскую панель. По умолчанию false, что означает закрытие модального окна
* с добавлением новой записи в историю или возвратом на один шаг назад.
*/
hideModal(pushPanel?: boolean): Promise<void>;
hideModal(pushPanel?: boolean, options?: { replace: boolean }): Promise<void>;

showPopout(popout: JSX.Element): Promise<void>;

Expand Down
58 changes: 36 additions & 22 deletions src/services/ViewHistory.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
Expand All @@ -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;
Expand All @@ -45,13 +48,18 @@ export class ViewHistory {
.slice(0, rightLimit > -1 ? rightLimit : reversedClone.length)
.filter((item) => !item.modal && !item.popout)
.reverse();

return historyCopy.map(({ panel }) => panel);
}

get position(): number {
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;
Expand All @@ -67,36 +75,42 @@ export class ViewHistory {
this.history = [];
}

private push(record: ViewNavigationRecord): void {
private push(state: RouterState, match: AgnosticRouteMatch<string, PageInternal>): 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<string, PageInternal>): 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,
);
}

private hasKey(key: string): boolean {
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<string, PageInternal>,
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,
};
Expand Down
11 changes: 8 additions & 3 deletions src/services/ViewNavigationRecord.type.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
export type ViewNavigationRecord = {
export interface ViewNavigationRecord {
position: number;
locationKey: string;
path: string;
state: Record<string, unknown> | null;
view: string;
panel: string;
locationKey: string;
root?: string;
tab?: string;
modal?: string;
popout?: string;
};
}
1 change: 1 addition & 0 deletions src/services/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ export * from './RouteNavigator.type';
export * from './TransactionExecutor';
export * from './ViewHistory';
export * from './ViewNavigationRecord.type';
export * from './HistoryManager';

0 comments on commit e0a2e77

Please sign in to comment.