mirror of
https://github.com/logto-io/logto.git
synced 2024-12-16 20:26:19 -05:00
feat(console): unsaved changes alert (#1409)
This commit is contained in:
parent
98995cabcf
commit
098367e1a3
14 changed files with 370 additions and 316 deletions
|
@ -48,6 +48,7 @@
|
|||
"dayjs": "^1.10.5",
|
||||
"dnd-core": "^16.0.0",
|
||||
"eslint": "^8.10.0",
|
||||
"history": "^5.3.0",
|
||||
"i18next": "^21.6.12",
|
||||
"i18next-browser-languagedetector": "^6.1.3",
|
||||
"ky": "^0.31.0",
|
||||
|
|
|
@ -19,6 +19,7 @@ export type ConfirmModalProps = {
|
|||
isOpen: boolean;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
onClose?: () => void;
|
||||
};
|
||||
|
||||
const ConfirmModal = ({
|
||||
|
@ -31,6 +32,7 @@ const ConfirmModal = ({
|
|||
isOpen,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
onClose = onCancel,
|
||||
}: ConfirmModalProps) => {
|
||||
return (
|
||||
<ReactModal
|
||||
|
@ -47,7 +49,7 @@ const ConfirmModal = ({
|
|||
</>
|
||||
}
|
||||
className={classNames(styles.content, className)}
|
||||
onClose={onCancel}
|
||||
onClose={onClose}
|
||||
>
|
||||
{children}
|
||||
</ModalLayout>
|
||||
|
|
|
@ -0,0 +1,83 @@
|
|||
import type { Blocker, Transition } from 'history';
|
||||
import React, { useCallback, useContext, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { UNSAFE_NavigationContext, Navigator } from 'react-router-dom';
|
||||
|
||||
import ConfirmModal from '../ConfirmModal';
|
||||
|
||||
type BlockerNavigator = Navigator & {
|
||||
location: Location;
|
||||
block(blocker: Blocker): () => void;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
hasUnsavedChanges: boolean;
|
||||
};
|
||||
|
||||
const UnsavedChangesAlertModal = ({ hasUnsavedChanges }: Props) => {
|
||||
const { navigator } = useContext(UNSAFE_NavigationContext);
|
||||
|
||||
const [displayAlert, setDisplayAlert] = useState(false);
|
||||
const [transition, setTransition] = useState<Transition>();
|
||||
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasUnsavedChanges) {
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
block,
|
||||
location: { pathname },
|
||||
} = navigator as BlockerNavigator;
|
||||
|
||||
const unblock = block((transition) => {
|
||||
const {
|
||||
location: { pathname: targetPathname },
|
||||
} = transition;
|
||||
|
||||
// Note: We don't want to show the alert if the user is navigating to the same page.
|
||||
if (targetPathname === pathname) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDisplayAlert(true);
|
||||
|
||||
setTransition({
|
||||
...transition,
|
||||
retry() {
|
||||
unblock();
|
||||
transition.retry();
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return unblock;
|
||||
}, [navigator, hasUnsavedChanges]);
|
||||
|
||||
const leavePage = useCallback(() => {
|
||||
transition?.retry();
|
||||
setDisplayAlert(false);
|
||||
}, [transition]);
|
||||
|
||||
const stayOnPage = useCallback(() => {
|
||||
setDisplayAlert(false);
|
||||
}, [setDisplayAlert]);
|
||||
|
||||
return (
|
||||
<ConfirmModal
|
||||
isOpen={displayAlert}
|
||||
confirmButtonType="primary"
|
||||
confirmButtonText="admin_console.general.stay_on_page"
|
||||
cancelButtonText="admin_console.general.leave_page"
|
||||
onCancel={leavePage}
|
||||
onConfirm={stayOnPage}
|
||||
onClose={stayOnPage}
|
||||
>
|
||||
{t('general.unsaved_changes_warning')}
|
||||
</ConfirmModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default UnsavedChangesAlertModal;
|
|
@ -20,6 +20,7 @@ import FormField from '@/components/FormField';
|
|||
import LinkButton from '@/components/LinkButton';
|
||||
import TabNav, { TabNavItem } from '@/components/TabNav';
|
||||
import TextInput from '@/components/TextInput';
|
||||
import UnsavedChangesAlertModal from '@/components/UnsavedChangesAlertModal';
|
||||
import useApi, { RequestError } from '@/hooks/use-api';
|
||||
import { useTheme } from '@/hooks/use-theme';
|
||||
import Back from '@/icons/Back';
|
||||
|
@ -52,7 +53,7 @@ const ApiResourceDetails = () => {
|
|||
handleSubmit,
|
||||
register,
|
||||
reset,
|
||||
formState: { isSubmitting, errors },
|
||||
formState: { isDirty, isSubmitting, errors },
|
||||
} = useForm<FormData>({
|
||||
defaultValues: data,
|
||||
});
|
||||
|
@ -179,6 +180,7 @@ const ApiResourceDetails = () => {
|
|||
</Card>
|
||||
</>
|
||||
)}
|
||||
<UnsavedChangesAlertModal hasUnsavedChanges={isDirty} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,17 +1,34 @@
|
|||
import { SnakeCaseOidcConfig } from '@logto/schemas';
|
||||
import React from 'react';
|
||||
import { Application, SnakeCaseOidcConfig } from '@logto/schemas';
|
||||
import React, { useEffect } from 'react';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
|
||||
import CopyToClipboard from '@/components/CopyToClipboard';
|
||||
import FormField from '@/components/FormField';
|
||||
import UnsavedChangesAlertModal from '@/components/UnsavedChangesAlertModal';
|
||||
|
||||
import * as styles from '../index.module.scss';
|
||||
|
||||
type Props = {
|
||||
oidcConfig: SnakeCaseOidcConfig;
|
||||
defaultData: Application;
|
||||
};
|
||||
|
||||
const AdvancedSettings = ({ oidcConfig }: Props) => {
|
||||
const AdvancedSettings = ({ oidcConfig, defaultData }: Props) => {
|
||||
const {
|
||||
reset,
|
||||
formState: { isDirty },
|
||||
} = useFormContext<Application>();
|
||||
|
||||
useEffect(() => {
|
||||
reset(defaultData);
|
||||
|
||||
return () => {
|
||||
reset(defaultData);
|
||||
};
|
||||
}, [reset, defaultData]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormField title="admin_console.application_details.token_endpoint">
|
||||
<CopyToClipboard
|
||||
className={styles.textField}
|
||||
|
@ -19,6 +36,8 @@ const AdvancedSettings = ({ oidcConfig }: Props) => {
|
|||
variant="border"
|
||||
/>
|
||||
</FormField>
|
||||
<UnsavedChangesAlertModal hasUnsavedChanges={isDirty} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { Application, ApplicationType, SnakeCaseOidcConfig } from '@logto/schemas';
|
||||
import React from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
import { Controller, useFormContext } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
|
@ -9,6 +9,7 @@ import MultiTextInput from '@/components/MultiTextInput';
|
|||
import { MultiTextInputRule } from '@/components/MultiTextInput/types';
|
||||
import { createValidatorForRhf, convertRhfErrorMessage } from '@/components/MultiTextInput/utils';
|
||||
import TextInput from '@/components/TextInput';
|
||||
import UnsavedChangesAlertModal from '@/components/UnsavedChangesAlertModal';
|
||||
import { uriOriginValidator, uriValidator } from '@/utilities/validator';
|
||||
|
||||
import * as styles from '../index.module.scss';
|
||||
|
@ -16,15 +17,25 @@ import * as styles from '../index.module.scss';
|
|||
type Props = {
|
||||
applicationType: ApplicationType;
|
||||
oidcConfig: SnakeCaseOidcConfig;
|
||||
defaultData: Application;
|
||||
};
|
||||
|
||||
const Settings = ({ applicationType, oidcConfig }: Props) => {
|
||||
const Settings = ({ applicationType, oidcConfig, defaultData }: Props) => {
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
const {
|
||||
control,
|
||||
register,
|
||||
formState: { errors },
|
||||
reset,
|
||||
formState: { errors, isDirty },
|
||||
} = useFormContext<Application>();
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
|
||||
useEffect(() => {
|
||||
reset(defaultData);
|
||||
|
||||
return () => {
|
||||
reset(defaultData);
|
||||
};
|
||||
}, [reset, defaultData]);
|
||||
|
||||
const uriPatternRules: MultiTextInputRule = {
|
||||
pattern: {
|
||||
|
@ -141,6 +152,7 @@ const Settings = ({ applicationType, oidcConfig }: Props) => {
|
|||
)}
|
||||
/>
|
||||
</FormField>
|
||||
<UnsavedChangesAlertModal hasUnsavedChanges={isDirty} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -39,7 +39,7 @@ const mapToUriOriginFormatArrays = (value?: string[]) =>
|
|||
|
||||
const ApplicationDetails = () => {
|
||||
const { id } = useParams();
|
||||
const location = useLocation();
|
||||
const { pathname } = useLocation();
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
const { data, error, mutate } = useSWR<Application, RequestError>(
|
||||
id && `/api/applications/${id}`
|
||||
|
@ -101,7 +101,7 @@ const ApplicationDetails = () => {
|
|||
setIsReadmeOpen(false);
|
||||
};
|
||||
|
||||
const isAdvancedSettings = location.pathname.includes('advanced-settings');
|
||||
const isAdvancedSettings = pathname.includes('advanced-settings');
|
||||
|
||||
return (
|
||||
<div className={detailsStyles.container}>
|
||||
|
@ -177,10 +177,15 @@ const ApplicationDetails = () => {
|
|||
<FormProvider {...formMethods}>
|
||||
<form className={classNames(styles.form, detailsStyles.body)} onSubmit={onSubmit}>
|
||||
<div className={styles.fields}>
|
||||
{isAdvancedSettings ? (
|
||||
<AdvancedSettings oidcConfig={oidcConfig} />
|
||||
) : (
|
||||
<Settings applicationType={data.type} oidcConfig={oidcConfig} />
|
||||
{isAdvancedSettings && (
|
||||
<AdvancedSettings oidcConfig={oidcConfig} defaultData={data} />
|
||||
)}
|
||||
{!isAdvancedSettings && (
|
||||
<Settings
|
||||
applicationType={data.type}
|
||||
oidcConfig={oidcConfig}
|
||||
defaultData={data}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className={detailsStyles.footer}>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { SignInExperience as SignInExperienceType } from '@logto/schemas';
|
||||
import classNames from 'classnames';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
@ -16,17 +16,15 @@ import useApi, { RequestError } from '@/hooks/use-api';
|
|||
import useSettings from '@/hooks/use-settings';
|
||||
import * as detailsStyles from '@/scss/details.module.scss';
|
||||
|
||||
import BrandingForm from './components/BrandingForm';
|
||||
import ColorForm from './components/ColorForm';
|
||||
import LanguagesForm from './components/LanguagesForm';
|
||||
import Preview from './components/Preview';
|
||||
import SignInMethodsChangePreview from './components/SignInMethodsChangePreview';
|
||||
import SignInMethodsForm from './components/SignInMethodsForm';
|
||||
import Skeleton from './components/Skeleton';
|
||||
import TermsForm from './components/TermsForm';
|
||||
import Welcome from './components/Welcome';
|
||||
import usePreviewConfigs from './hooks';
|
||||
import * as styles from './index.module.scss';
|
||||
import BrandingTab from './tabs/BrandingTab';
|
||||
import OthersTab from './tabs/OthersTab';
|
||||
import SignInMethodsTab from './tabs/SignInMethodsTab';
|
||||
import { SignInExperienceForm } from './types';
|
||||
import { compareSignInMethods, signInExperienceParser } from './utilities';
|
||||
|
||||
|
@ -50,11 +48,19 @@ const SignInExperience = () => {
|
|||
|
||||
const previewConfigs = usePreviewConfigs(formData, isDirty, data);
|
||||
|
||||
useEffect(() => {
|
||||
if (data && !isDirty) {
|
||||
reset(signInExperienceParser.toLocalForm(data));
|
||||
const defaultFormData = useMemo(() => {
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
}, [data, reset, isDirty]);
|
||||
|
||||
return signInExperienceParser.toLocalForm(data);
|
||||
}, [data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (defaultFormData && !isDirty) {
|
||||
reset(defaultFormData);
|
||||
}
|
||||
}, [reset, isDirty, defaultFormData]);
|
||||
|
||||
const saveData = async () => {
|
||||
const updatedData = await api
|
||||
|
@ -113,22 +119,18 @@ const SignInExperience = () => {
|
|||
</TabNavItem>
|
||||
</TabNav>
|
||||
{!data && error && <div>{`error occurred: ${error.body?.message ?? error.message}`}</div>}
|
||||
{data && (
|
||||
{data && defaultFormData && (
|
||||
<FormProvider {...methods}>
|
||||
<form className={styles.formWrapper} onSubmit={onSubmit}>
|
||||
<div className={classNames(detailsStyles.body, styles.form)}>
|
||||
{tab === 'branding' && (
|
||||
<>
|
||||
<ColorForm />
|
||||
<BrandingForm />
|
||||
</>
|
||||
<BrandingTab defaultData={defaultFormData} isDataDirty={isDirty} />
|
||||
)}
|
||||
{tab === 'methods' && (
|
||||
<SignInMethodsTab defaultData={defaultFormData} isDataDirty={isDirty} />
|
||||
)}
|
||||
{tab === 'methods' && <SignInMethodsForm />}
|
||||
{tab === 'others' && (
|
||||
<>
|
||||
<TermsForm />
|
||||
<LanguagesForm />
|
||||
</>
|
||||
<OthersTab defaultData={defaultFormData} isDataDirty={isDirty} />
|
||||
)}
|
||||
</div>
|
||||
<div className={detailsStyles.footer}>
|
||||
|
|
|
@ -0,0 +1,35 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
|
||||
import UnsavedChangesAlertModal from '@/components/UnsavedChangesAlertModal';
|
||||
|
||||
import BrandingForm from '../components/BrandingForm';
|
||||
import ColorForm from '../components/ColorForm';
|
||||
import { SignInExperienceForm } from '../types';
|
||||
|
||||
type Props = {
|
||||
defaultData: SignInExperienceForm;
|
||||
isDataDirty: boolean;
|
||||
};
|
||||
|
||||
const BrandingTab = ({ defaultData, isDataDirty }: Props) => {
|
||||
const { reset } = useFormContext<SignInExperienceForm>();
|
||||
|
||||
useEffect(() => {
|
||||
reset(defaultData);
|
||||
|
||||
return () => {
|
||||
reset(defaultData);
|
||||
};
|
||||
}, [reset, defaultData]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ColorForm />
|
||||
<BrandingForm />
|
||||
<UnsavedChangesAlertModal hasUnsavedChanges={isDataDirty} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default BrandingTab;
|
|
@ -0,0 +1,35 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
|
||||
import UnsavedChangesAlertModal from '@/components/UnsavedChangesAlertModal';
|
||||
|
||||
import LanguagesForm from '../components/LanguagesForm';
|
||||
import TermsForm from '../components/TermsForm';
|
||||
import { SignInExperienceForm } from '../types';
|
||||
|
||||
type Props = {
|
||||
defaultData: SignInExperienceForm;
|
||||
isDataDirty: boolean;
|
||||
};
|
||||
|
||||
const OthersTab = ({ defaultData, isDataDirty }: Props) => {
|
||||
const { reset } = useFormContext<SignInExperienceForm>();
|
||||
|
||||
useEffect(() => {
|
||||
reset(defaultData);
|
||||
|
||||
return () => {
|
||||
reset(defaultData);
|
||||
};
|
||||
}, [reset, defaultData]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<TermsForm />
|
||||
<LanguagesForm />
|
||||
<UnsavedChangesAlertModal hasUnsavedChanges={isDataDirty} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default OthersTab;
|
|
@ -0,0 +1,33 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
|
||||
import UnsavedChangesAlertModal from '@/components/UnsavedChangesAlertModal';
|
||||
|
||||
import SignInMethodsForm from '../components/SignInMethodsForm';
|
||||
import { SignInExperienceForm } from '../types';
|
||||
|
||||
type Props = {
|
||||
defaultData: SignInExperienceForm;
|
||||
isDataDirty: boolean;
|
||||
};
|
||||
|
||||
const SignInMethodsTab = ({ defaultData, isDataDirty }: Props) => {
|
||||
const { reset } = useFormContext<SignInExperienceForm>();
|
||||
|
||||
useEffect(() => {
|
||||
reset(defaultData);
|
||||
|
||||
return () => {
|
||||
reset(defaultData);
|
||||
};
|
||||
}, [reset, defaultData]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SignInMethodsForm />
|
||||
<UnsavedChangesAlertModal hasUnsavedChanges={isDataDirty} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SignInMethodsTab;
|
|
@ -39,6 +39,10 @@ const translation = {
|
|||
add_another: '+ Add Another',
|
||||
deletion_confirmation: 'Are you sure you want to delete this {{title}}?',
|
||||
settings_nav: 'Settings',
|
||||
unsaved_changes_warning:
|
||||
'You have made some changes. Are you sure you want to leave this page?',
|
||||
leave_page: 'Leave Page',
|
||||
stay_on_page: 'Stay on Page',
|
||||
},
|
||||
errors: {
|
||||
something_went_wrong: 'Oops! Something went wrong.',
|
||||
|
|
|
@ -41,6 +41,9 @@ const translation = {
|
|||
add_another: '+ 新增',
|
||||
deletion_confirmation: '你确定要删除这个 {{title}} 吗?',
|
||||
settings_nav: '设置',
|
||||
unsaved_changes_warning: '还有未保存的变更, 确定要离开吗?',
|
||||
leave_page: '离开此页',
|
||||
stay_on_page: '留在此页',
|
||||
},
|
||||
errors: {
|
||||
something_went_wrong: '哎呀,出错了!',
|
||||
|
|
366
pnpm-lock.yaml
366
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue