F: Improve navigation API. Implement async versions

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

View File

@ -18,12 +18,14 @@ export function Navigation() {
const size = useWindowSize();
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>) =>
router.push(urls.library, event.ctrlKey || event.metaKey);
const navigateHelp = (event: React.MouseEvent<Element>) => router.push(urls.manuals, event.ctrlKey || event.metaKey);
router.push({ path: urls.library, newTab: 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>) =>
router.push(urls.create_schema, event.ctrlKey || event.metaKey);
router.push({ path: urls.create_schema, newTab: event.ctrlKey || event.metaKey });
return (
<nav

View File

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

View File

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

View File

@ -15,7 +15,7 @@ export function UserMenu() {
return (
<div ref={menu.ref} className='h-full w-[4rem] flex items-center justify-center'>
<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>
<UserDropdown isOpen={menu.isOpen} hideDropdown={() => menu.hide()} />
</div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -38,7 +38,7 @@ export function MenuRole({ isOwned, isEditor }: MenuRoleProps) {
hideTitle={accessMenu.isOpen}
className='h-full pr-2'
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) {
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 (

View File

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

View File

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

View File

@ -92,7 +92,7 @@ export const OssEditState = ({ itemID, children }: React.PropsWithChildren<OssEd
id: schema.id,
tab: tab
});
router.push(url);
router.push({ path: url });
}
function navigateOperationSchema(target: number) {
@ -100,18 +100,21 @@ export const OssEditState = ({ itemID, children }: React.PropsWithChildren<OssEd
if (!node?.result) {
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() {
if (!window.confirm(promptText.deleteOSS)) {
return;
}
void deleteItem(schema.id).then(() => {
void deleteItem({
target: schema.id,
beforeInvalidate: () => {
if (searchLocation === schema.location) {
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]);
if (!urlData.id) {
router.replace(urls.page404);
router.replace({ path: urls.page404, force: true });
return null;
}

View File

@ -43,7 +43,7 @@ export function ToolbarRSFormCard({ schema, onSubmit, isMutable, deleteSchema }:
return (
<MiniSelectorOSS
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) {
void findPredecessor(target).then(reference =>
router.push(
urls.schema_props({
router.push({
path: urls.schema_props({
id: reference.schema,
active: reference.id,
tab: RSTabID.CST_EDIT
})
)
})
);
}

View File

@ -61,7 +61,7 @@ export function FormRSForm() {
}, [isDirty, setIsModified]);
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) {

View File

@ -112,7 +112,7 @@ export function MenuEditSchema() {
hideTitle={editMenu.isOpen}
className='h-full px-2'
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
text='Создать новую схему'
icon={<IconNewItem size='1rem' className='icon-primary' />}
onClick={() => router.push(urls.create_schema)}
onClick={() => router.push({ path: urls.create_schema })}
/>
) : null}
{schema.oss.length > 0 ? (
<DropdownButton
text='Перейти к ОСС'
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}
<DropdownButton
text='Библиотека'
icon={<IconLibrary size='1rem' className='icon-primary' />}
onClick={() => router.push(urls.library)}
onClick={() => router.push({ path: urls.library })}
/>
</Dropdown>
</div>

View File

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

View File

@ -44,12 +44,14 @@ export function FormSignup() {
if (router.canBack()) {
router.back();
} else {
router.push(urls.library);
router.push({ path: urls.library });
}
}
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 (

View File

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