F: Improve navigation API. Implement async versions

This commit is contained in:
Ivan 2025-02-26 16:28:29 +03:00
parent 8ed6aefc16
commit e211898cc0
29 changed files with 125 additions and 93 deletions

View File

@ -18,12 +18,14 @@ export function Navigation() {
const size = useWindowSize(); const size = useWindowSize();
const noNavigationAnimation = useAppLayoutStore(state => state.noNavigationAnimation); const noNavigationAnimation = useAppLayoutStore(state => state.noNavigationAnimation);
const navigateHome = (event: React.MouseEvent<Element>) => router.push(urls.home, event.ctrlKey || event.metaKey); const navigateHome = (event: React.MouseEvent<Element>) =>
router.push({ path: urls.home, newTab: event.ctrlKey || event.metaKey });
const navigateLibrary = (event: React.MouseEvent<Element>) => const navigateLibrary = (event: React.MouseEvent<Element>) =>
router.push(urls.library, event.ctrlKey || event.metaKey); router.push({ path: urls.library, newTab: event.ctrlKey || event.metaKey });
const navigateHelp = (event: React.MouseEvent<Element>) => router.push(urls.manuals, event.ctrlKey || event.metaKey); const navigateHelp = (event: React.MouseEvent<Element>) =>
router.push({ path: urls.manuals, newTab: event.ctrlKey || event.metaKey });
const navigateCreateNew = (event: React.MouseEvent<Element>) => const navigateCreateNew = (event: React.MouseEvent<Element>) =>
router.push(urls.create_schema, event.ctrlKey || event.metaKey); router.push({ path: urls.create_schema, newTab: event.ctrlKey || event.metaKey });
return ( return (
<nav <nav

View File

@ -3,11 +3,19 @@
import { createContext, useContext, useEffect, useState } from 'react'; import { createContext, useContext, useEffect, useState } from 'react';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
export interface NavigationProps {
path: string;
newTab?: boolean;
force?: boolean;
}
interface INavigationContext { interface INavigationContext {
push: (path: string, newTab?: boolean) => void; push: (props: NavigationProps) => void;
replace: (path: string) => void; pushAsync: (props: NavigationProps) => void | Promise<void>;
back: () => void; replace: (props: Omit<NavigationProps, 'newTab'>) => void;
forward: () => void; replaceAsync: (props: Omit<NavigationProps, 'newTab'>) => void | Promise<void>;
back: (force?: boolean) => void;
forward: (force?: boolean) => void;
canBack: () => boolean; canBack: () => boolean;
@ -37,33 +45,47 @@ export const NavigationState = ({ children }: React.PropsWithChildren) => {
return !!window.history && window.history?.length !== 0; return !!window.history && window.history?.length !== 0;
} }
function push(path: string, newTab?: boolean) { function push(props: NavigationProps) {
if (newTab) { if (props.newTab) {
window.open(`${path}`, '_blank'); window.open(`${props.path}`, '_blank');
return; } else if (props.force || validate()) {
}
if (validate()) {
Promise.resolve(router(path, { viewTransition: true })).catch(console.error);
setIsBlocked(false); setIsBlocked(false);
Promise.resolve(router(props.path, { viewTransition: true })).catch(console.error);
} }
} }
function replace(path: string) { function pushAsync(props: NavigationProps): void | Promise<void> {
if (validate()) { if (props.newTab) {
Promise.resolve(router(path, { replace: true, viewTransition: true })).catch(console.error); window.open(`${props.path}`, '_blank');
} else if (props.force || validate()) {
setIsBlocked(false); setIsBlocked(false);
return router(props.path, { viewTransition: true });
} }
} }
function back() { function replace(props: Omit<NavigationProps, 'newTab'>) {
if (validate()) { if (props.force || validate()) {
setIsBlocked(false);
Promise.resolve(router(props.path, { replace: true, viewTransition: true })).catch(console.error);
}
}
function replaceAsync(props: Omit<NavigationProps, 'newTab'>): void | Promise<void> {
if (props.force || validate()) {
setIsBlocked(false);
return router(props.path, { replace: true, viewTransition: true });
}
}
function back(force?: boolean) {
if (force || validate()) {
Promise.resolve(router(-1)).catch(console.error); Promise.resolve(router(-1)).catch(console.error);
setIsBlocked(false); setIsBlocked(false);
} }
} }
function forward() { function forward(force?: boolean) {
if (validate()) { if (force || validate()) {
Promise.resolve(router(1)).catch(console.error); Promise.resolve(router(1)).catch(console.error);
setIsBlocked(false); setIsBlocked(false);
} }
@ -73,7 +95,9 @@ export const NavigationState = ({ children }: React.PropsWithChildren) => {
<NavigationContext <NavigationContext
value={{ value={{
push, push,
pushAsync,
replace, replace,
replaceAsync,
back, back,
forward, forward,
canBack, canBack,

View File

@ -41,32 +41,32 @@ export function UserDropdown({ isOpen, hideDropdown }: UserDropdownProps) {
function navigateProfile(event: React.MouseEvent<Element>) { function navigateProfile(event: React.MouseEvent<Element>) {
hideDropdown(); hideDropdown();
router.push(urls.profile, event.ctrlKey || event.metaKey); router.push({ path: urls.profile, newTab: event.ctrlKey || event.metaKey });
} }
function logoutAndRedirect() { function logoutAndRedirect() {
hideDropdown(); hideDropdown();
void logout().then(() => router.push(urls.login)); void logout().then(() => router.push({ path: urls.login, force: true }));
} }
function gotoAdmin() { function gotoAdmin() {
hideDropdown(); hideDropdown();
void logout().then(() => router.push(urls.admin, true)); void logout().then(() => router.push({ path: urls.admin, force: true, newTab: true }));
} }
function gotoIcons(event: React.MouseEvent<Element>) { function gotoIcons(event: React.MouseEvent<Element>) {
hideDropdown(); hideDropdown();
router.push(urls.icons, event.ctrlKey || event.metaKey); router.push({ path: urls.icons, newTab: event.ctrlKey || event.metaKey });
} }
function gotoRestApi() { function gotoRestApi() {
hideDropdown(); hideDropdown();
router.push(urls.rest_api, true); router.push({ path: urls.rest_api, newTab: true });
} }
function gotoDatabaseSchema(event: React.MouseEvent<Element>) { function gotoDatabaseSchema(event: React.MouseEvent<Element>) {
hideDropdown(); hideDropdown();
router.push(urls.database_schema, event.ctrlKey || event.metaKey); router.push({ path: urls.database_schema, newTab: event.ctrlKey || event.metaKey });
} }
function handleToggleDarkMode() { function handleToggleDarkMode() {

View File

@ -15,7 +15,7 @@ export function UserMenu() {
return ( return (
<div ref={menu.ref} className='h-full w-[4rem] flex items-center justify-center'> <div ref={menu.ref} className='h-full w-[4rem] flex items-center justify-center'>
<Suspense fallback={<Loader circular scale={1.5} />}> <Suspense fallback={<Loader circular scale={1.5} />}>
<UserButton onLogin={() => router.push(urls.login)} onClickUser={menu.toggle} /> <UserButton onLogin={() => router.push({ path: urls.login, force: true })} onClickUser={menu.toggle} />
</Suspense> </Suspense>
<UserDropdown isOpen={menu.isOpen} hideDropdown={() => menu.hide()} /> <UserDropdown isOpen={menu.isOpen} hideDropdown={() => menu.hide()} />
</div> </div>

View File

@ -11,7 +11,7 @@ export function ExpectedAnonymous() {
const router = useConceptNavigation(); const router = useConceptNavigation();
function logoutAndRedirect() { function logoutAndRedirect() {
void logout().then(() => router.push(urls.login)); void logout().then(() => router.push({ path: urls.login, force: true }));
} }
return ( return (

View File

@ -41,7 +41,7 @@ export function LoginPage() {
if (router.canBack()) { if (router.canBack()) {
router.back(); router.back();
} else { } else {
router.push(urls.library); router.push({ path: urls.library, force: true });
} }
}); });
} }

View File

@ -33,8 +33,8 @@ export function Component() {
password: newPassword, password: newPassword,
token: token token: token
}).then(() => { }).then(() => {
router.replace(urls.home); router.replace({ path: urls.home });
router.push(urls.login); router.push({ path: urls.login });
}); });
} }
} }

View File

@ -4,7 +4,6 @@ import { urls, useConceptNavigation } from '@/app';
import { useQueryStrings } from '@/hooks/useQueryStrings'; import { useQueryStrings } from '@/hooks/useQueryStrings';
import { useMainHeight } from '@/stores/appLayout'; import { useMainHeight } from '@/stores/appLayout';
import { PARAMETER } from '@/utils/constants';
import { HelpTopic } from '../../models/helpTopic'; import { HelpTopic } from '../../models/helpTopic';
@ -19,13 +18,11 @@ export function ManualsPage() {
const mainHeight = useMainHeight(); const mainHeight = useMainHeight();
function onSelectTopic(newTopic: HelpTopic) { function onSelectTopic(newTopic: HelpTopic) {
router.push(urls.help_topic(newTopic)); router.push({ path: urls.help_topic(newTopic) });
} }
if (!Object.values(HelpTopic).includes(activeTopic)) { if (!Object.values(HelpTopic).includes(activeTopic)) {
setTimeout(() => { router.push({ path: urls.page404, force: true });
router.push(urls.page404);
}, PARAMETER.refreshTimeout);
return null; return null;
} }

View File

@ -6,9 +6,9 @@ export function HomePage() {
const { isAnonymous } = useAuthSuspense(); const { isAnonymous } = useAuthSuspense();
if (isAnonymous) { if (isAnonymous) {
router.replace(urls.manuals); router.replace({ path: urls.manuals });
} else { } else {
router.replace(urls.library); router.replace({ path: urls.library });
} }
return null; return null;

View File

@ -111,9 +111,9 @@ export const libraryApi = {
} }
}), }),
deleteItem: (target: number) => deleteItem: (data: { target: number; beforeInvalidate?: () => void | Promise<void> }) =>
axiosDelete({ axiosDelete({
endpoint: `/api/library/${target}`, endpoint: `/api/library/${data.target}`,
request: { request: {
successMessage: infoMsg.itemDestroyed successMessage: infoMsg.itemDestroyed
} }

View File

@ -10,22 +10,23 @@ export const useDeleteItem = () => {
const mutation = useMutation({ const mutation = useMutation({
mutationKey: [KEYS.global_mutation, libraryApi.baseKey, 'delete-item'], mutationKey: [KEYS.global_mutation, libraryApi.baseKey, 'delete-item'],
mutationFn: libraryApi.deleteItem, mutationFn: libraryApi.deleteItem,
onSuccess: (_, variables) => { onSuccess: async (_, variables) => {
client.invalidateQueries({ queryKey: libraryApi.libraryListKey }).catch(console.error); await client.invalidateQueries({ queryKey: libraryApi.libraryListKey });
await Promise.resolve(variables.beforeInvalidate?.());
setTimeout( setTimeout(
() => () =>
void Promise.allSettled([ void Promise.allSettled([
client.invalidateQueries({ queryKey: [KEYS.oss] }), client.invalidateQueries({ queryKey: [KEYS.oss] }),
client.resetQueries({ queryKey: KEYS.composite.rsItem({ itemID: variables }) }), client.resetQueries({ queryKey: KEYS.composite.rsItem({ itemID: variables.target }) }),
client.resetQueries({ queryKey: KEYS.composite.ossItem({ itemID: variables }) }) client.resetQueries({ queryKey: KEYS.composite.ossItem({ itemID: variables.target }) })
]).catch(console.error), ]),
PARAMETER.navigationDuration PARAMETER.refreshTimeout
); );
}, },
onError: () => client.invalidateQueries() onError: () => client.invalidateQueries()
}); });
return { return {
deleteItem: (target: number) => mutation.mutateAsync(target), deleteItem: (data: { target: number; beforeInvalidate?: () => void | Promise<void> }) => mutation.mutateAsync(data),
isPending: mutation.isPending isPending: mutation.isPending
}; };
}; };

View File

@ -64,7 +64,7 @@ export function EditorLibraryItem({ schema, isAttachedToOSS }: EditorLibraryItem
function handleOpenLibrary(event: React.MouseEvent<Element>) { function handleOpenLibrary(event: React.MouseEvent<Element>) {
setGlobalLocation(schema.location); setGlobalLocation(schema.location);
router.push(urls.library, event.ctrlKey || event.metaKey); router.push({ path: urls.library, newTab: event.ctrlKey || event.metaKey });
} }
function handleEditLocation() { function handleEditLocation() {

View File

@ -38,7 +38,7 @@ export function MenuRole({ isOwned, isEditor }: MenuRoleProps) {
hideTitle={accessMenu.isOpen} hideTitle={accessMenu.isOpen}
className='h-full pr-2' className='h-full pr-2'
icon={<IconAlert size='1.25rem' className='icon-red' />} icon={<IconAlert size='1.25rem' className='icon-red' />}
onClick={() => router.push(urls.login)} onClick={() => router.push({ path: urls.login })}
/> />
); );
} }

View File

@ -60,7 +60,7 @@ export function DlgCloneLibraryItem() {
}); });
function onSubmit(data: ICloneLibraryItemDTO) { function onSubmit(data: ICloneLibraryItemDTO) {
return cloneItem(data).then(newSchema => router.push(urls.schema(newSchema.id))); return cloneItem(data).then(newSchema => router.pushAsync({ path: urls.schema(newSchema.id), force: true }));
} }
return ( return (

View File

@ -69,7 +69,7 @@ export function FormCreateItem() {
if (router.canBack()) { if (router.canBack()) {
router.back(); router.back();
} else { } else {
router.push(urls.library); router.push({ path: urls.library });
} }
} }
@ -95,9 +95,9 @@ export function FormCreateItem() {
return createItem(data).then(newItem => { return createItem(data).then(newItem => {
setSearchLocation(data.location); setSearchLocation(data.location);
if (newItem.item_type == LibraryItemType.RSFORM) { if (newItem.item_type == LibraryItemType.RSFORM) {
router.push(urls.schema(newItem.id)); router.push({ path: urls.schema(newItem.id), force: true });
} else { } else {
router.push(urls.oss(newItem.id)); router.push({ path: urls.oss(newItem.id), force: true });
} }
}); });
} }

View File

@ -44,9 +44,9 @@ export function TableLibraryItems({ items }: TableLibraryItemsProps) {
return; return;
} }
if (item.item_type === LibraryItemType.RSFORM) { if (item.item_type === LibraryItemType.RSFORM) {
router.push(urls.schema(item.id), event.ctrlKey || event.metaKey); router.push({ path: urls.schema(item.id), newTab: event.ctrlKey || event.metaKey });
} else if (item.item_type === LibraryItemType.OSS) { } else if (item.item_type === LibraryItemType.OSS) {
router.push(urls.oss(item.id), event.ctrlKey || event.metaKey); router.push({ path: urls.oss(item.id), newTab: event.ctrlKey || event.metaKey });
} }
} }

View File

@ -151,7 +151,7 @@ export function NodeContextMenu({ isOpen, operation, cursorX, cursorY, onHide }:
void inputCreate({ void inputCreate({
itemID: schema.id, itemID: schema.id,
data: { target: operation.id, positions: getPositions() } data: { target: operation.id, positions: getPositions() }
}).then(new_schema => router.push(urls.schema(new_schema.id))); }).then(new_schema => router.push({ path: urls.schema(new_schema.id), force: true }));
} }
function handleRelocateConstituents() { function handleRelocateConstituents() {

View File

@ -37,7 +37,7 @@ export function MenuMain() {
} }
function handleCreateNew() { function handleCreateNew() {
router.push(urls.create_schema); router.push({ path: urls.create_schema });
} }
function handleShowQR() { function handleShowQR() {
@ -91,7 +91,7 @@ export function MenuMain() {
<DropdownButton <DropdownButton
text='Библиотека' text='Библиотека'
icon={<IconLibrary size='1rem' className='icon-primary' />} icon={<IconLibrary size='1rem' className='icon-primary' />}
onClick={() => router.push(urls.library)} onClick={() => router.push({ path: urls.library })}
/> />
</Dropdown> </Dropdown>
</div> </div>

View File

@ -92,7 +92,7 @@ export const OssEditState = ({ itemID, children }: React.PropsWithChildren<OssEd
id: schema.id, id: schema.id,
tab: tab tab: tab
}); });
router.push(url); router.push({ path: url });
} }
function navigateOperationSchema(target: number) { function navigateOperationSchema(target: number) {
@ -100,18 +100,21 @@ export const OssEditState = ({ itemID, children }: React.PropsWithChildren<OssEd
if (!node?.result) { if (!node?.result) {
return; return;
} }
router.push(urls.schema_props({ id: node.result, tab: RSTabID.CST_LIST })); router.push({ path: urls.schema_props({ id: node.result, tab: RSTabID.CST_LIST }) });
} }
function deleteSchema() { function deleteSchema() {
if (!window.confirm(promptText.deleteOSS)) { if (!window.confirm(promptText.deleteOSS)) {
return; return;
} }
void deleteItem(schema.id).then(() => { void deleteItem({
target: schema.id,
beforeInvalidate: () => {
if (searchLocation === schema.location) { if (searchLocation === schema.location) {
setSearchLocation(''); setSearchLocation('');
} }
router.push(urls.library); return router.pushAsync({ path: urls.library, force: true });
}
}); });
} }

View File

@ -37,7 +37,7 @@ export function OssPage() {
useEffect(() => setIsModified(false), [setIsModified]); useEffect(() => setIsModified(false), [setIsModified]);
if (!urlData.id) { if (!urlData.id) {
router.replace(urls.page404); router.replace({ path: urls.page404, force: true });
return null; return null;
} }

View File

@ -43,7 +43,7 @@ export function ToolbarRSFormCard({ schema, onSubmit, isMutable, deleteSchema }:
return ( return (
<MiniSelectorOSS <MiniSelectorOSS
items={rsSchema.oss} items={rsSchema.oss}
onSelect={(event, value) => router.push(urls.oss(value.id), event.ctrlKey || event.metaKey)} onSelect={(event, value) => router.push({ path: urls.oss(value.id), newTab: event.ctrlKey || event.metaKey })}
/> />
); );
})(); })();

View File

@ -69,13 +69,13 @@ export function ToolbarConstituenta({
function viewPredecessor(target: number) { function viewPredecessor(target: number) {
void findPredecessor(target).then(reference => void findPredecessor(target).then(reference =>
router.push( router.push({
urls.schema_props({ path: urls.schema_props({
id: reference.schema, id: reference.schema,
active: reference.id, active: reference.id,
tab: RSTabID.CST_EDIT tab: RSTabID.CST_EDIT
}) })
) })
); );
} }

View File

@ -61,7 +61,7 @@ export function FormRSForm() {
}, [isDirty, setIsModified]); }, [isDirty, setIsModified]);
function handleSelectVersion(version: CurrentVersion) { function handleSelectVersion(version: CurrentVersion) {
router.push(urls.schema(schema.id, version === 'latest' ? undefined : version)); router.push({ path: urls.schema(schema.id, version === 'latest' ? undefined : version) });
} }
function onSubmit(data: IUpdateLibraryItemDTO) { function onSubmit(data: IUpdateLibraryItemDTO) {

View File

@ -112,7 +112,7 @@ export function MenuEditSchema() {
hideTitle={editMenu.isOpen} hideTitle={editMenu.isOpen}
className='h-full px-2' className='h-full px-2'
icon={<IconArchive size='1.25rem' className='icon-primary' />} icon={<IconArchive size='1.25rem' className='icon-primary' />}
onClick={event => router.push(urls.schema(schema.id), event.ctrlKey || event.metaKey)} onClick={event => router.push({ path: urls.schema(schema.id), newTab: event.ctrlKey || event.metaKey })}
/> />
); );
} }

View File

@ -175,20 +175,20 @@ export function MenuMain() {
<DropdownButton <DropdownButton
text='Создать новую схему' text='Создать новую схему'
icon={<IconNewItem size='1rem' className='icon-primary' />} icon={<IconNewItem size='1rem' className='icon-primary' />}
onClick={() => router.push(urls.create_schema)} onClick={() => router.push({ path: urls.create_schema })}
/> />
) : null} ) : null}
{schema.oss.length > 0 ? ( {schema.oss.length > 0 ? (
<DropdownButton <DropdownButton
text='Перейти к ОСС' text='Перейти к ОСС'
icon={<IconOSS size='1rem' className='icon-primary' />} icon={<IconOSS size='1rem' className='icon-primary' />}
onClick={() => router.push(urls.oss(schema.oss[0].id))} onClick={() => router.push({ path: urls.oss(schema.oss[0].id) })}
/> />
) : null} ) : null}
<DropdownButton <DropdownButton
text='Библиотека' text='Библиотека'
icon={<IconLibrary size='1rem' className='icon-primary' />} icon={<IconLibrary size='1rem' className='icon-primary' />}
onClick={() => router.push(urls.library)} onClick={() => router.push({ path: urls.library })}
/> />
</Dropdown> </Dropdown>
</div> </div>

View File

@ -135,11 +135,11 @@ export const RSEditState = ({
} }
function navigateVersion(versionID?: number) { function navigateVersion(versionID?: number) {
router.push(urls.schema(schema.id, versionID)); router.push({ path: urls.schema(schema.id, versionID) });
} }
function navigateOss(ossID: number, newTab?: boolean) { function navigateOss(ossID: number, newTab?: boolean) {
router.push(urls.oss(ossID), newTab); router.push({ path: urls.oss(ossID), newTab: newTab });
} }
function navigateRSForm({ tab, activeID }: { tab: RSTabID; activeID?: number }) { function navigateRSForm({ tab, activeID }: { tab: RSTabID; activeID?: number }) {
@ -152,15 +152,15 @@ export const RSEditState = ({
const url = urls.schema_props(data); const url = urls.schema_props(data);
if (activeID) { if (activeID) {
if (tab === activeTab && tab !== RSTabID.CST_EDIT) { if (tab === activeTab && tab !== RSTabID.CST_EDIT) {
router.replace(url); router.replace({ path: url });
} else { } else {
router.push(url); router.push({ path: url });
} }
} else if (tab !== activeTab && tab === RSTabID.CST_EDIT && schema.items.length > 0) { } else if (tab !== activeTab && tab === RSTabID.CST_EDIT && schema.items.length > 0) {
data.active = schema.items[0].id; data.active = schema.items[0].id;
router.replace(urls.schema_props(data)); router.replace({ path: urls.schema_props(data) });
} else { } else {
router.push(url); router.push({ path: url });
} }
} }
@ -175,14 +175,17 @@ export const RSEditState = ({
return; return;
} }
const ossID = schema.oss.length > 0 ? schema.oss[0].id : null; const ossID = schema.oss.length > 0 ? schema.oss[0].id : null;
void deleteItem(schema.id).then(() => { void deleteItem({
target: schema.id,
beforeInvalidate: () => {
if (ossID) { if (ossID) {
router.push(urls.oss(ossID)); return router.pushAsync({ path: urls.oss(ossID), force: true });
} else { } else {
if (searchLocation === schema.location) { if (searchLocation === schema.location) {
setSearchLocation(''); setSearchLocation('');
} }
router.push(urls.library); return router.pushAsync({ path: urls.library, force: true });
}
} }
}); });
} }

View File

@ -48,7 +48,7 @@ export function RSFormPage() {
useEffect(() => setIsModified(false), [setIsModified]); useEffect(() => setIsModified(false), [setIsModified]);
if (!urlData.id) { if (!urlData.id) {
router.replace(urls.page404); router.replace({ path: urls.page404, force: true });
return null; return null;
} }
return ( return (

View File

@ -44,12 +44,14 @@ export function FormSignup() {
if (router.canBack()) { if (router.canBack()) {
router.back(); router.back();
} else { } else {
router.push(urls.library); router.push({ path: urls.library });
} }
} }
function onSubmit(data: IUserSignupDTO) { function onSubmit(data: IUserSignupDTO) {
return signup(data).then(createdUser => router.push(urls.login_hint(createdUser.username))); return signup(data).then(createdUser =>
router.pushAsync({ path: urls.login_hint(createdUser.username), force: true })
);
} }
return ( return (

View File

@ -32,7 +32,7 @@ export function EditorPassword() {
} }
function onSubmit(data: IChangePasswordDTO) { function onSubmit(data: IChangePasswordDTO) {
return changePassword(data).then(() => router.push(urls.login)); return changePassword(data).then(() => router.pushAsync({ path: urls.login, force: true }));
} }
return ( return (