0
Fork 0
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:
Xiao Yijun 2022-07-05 17:09:00 +08:00 committed by GitHub
parent 98995cabcf
commit 098367e1a3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 370 additions and 316 deletions

View file

@ -48,6 +48,7 @@
"dayjs": "^1.10.5", "dayjs": "^1.10.5",
"dnd-core": "^16.0.0", "dnd-core": "^16.0.0",
"eslint": "^8.10.0", "eslint": "^8.10.0",
"history": "^5.3.0",
"i18next": "^21.6.12", "i18next": "^21.6.12",
"i18next-browser-languagedetector": "^6.1.3", "i18next-browser-languagedetector": "^6.1.3",
"ky": "^0.31.0", "ky": "^0.31.0",

View file

@ -19,6 +19,7 @@ export type ConfirmModalProps = {
isOpen: boolean; isOpen: boolean;
onCancel: () => void; onCancel: () => void;
onConfirm: () => void; onConfirm: () => void;
onClose?: () => void;
}; };
const ConfirmModal = ({ const ConfirmModal = ({
@ -31,6 +32,7 @@ const ConfirmModal = ({
isOpen, isOpen,
onCancel, onCancel,
onConfirm, onConfirm,
onClose = onCancel,
}: ConfirmModalProps) => { }: ConfirmModalProps) => {
return ( return (
<ReactModal <ReactModal
@ -47,7 +49,7 @@ const ConfirmModal = ({
</> </>
} }
className={classNames(styles.content, className)} className={classNames(styles.content, className)}
onClose={onCancel} onClose={onClose}
> >
{children} {children}
</ModalLayout> </ModalLayout>

View file

@ -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;

View file

@ -20,6 +20,7 @@ import FormField from '@/components/FormField';
import LinkButton from '@/components/LinkButton'; import LinkButton from '@/components/LinkButton';
import TabNav, { TabNavItem } from '@/components/TabNav'; import TabNav, { TabNavItem } from '@/components/TabNav';
import TextInput from '@/components/TextInput'; import TextInput from '@/components/TextInput';
import UnsavedChangesAlertModal from '@/components/UnsavedChangesAlertModal';
import useApi, { RequestError } from '@/hooks/use-api'; import useApi, { RequestError } from '@/hooks/use-api';
import { useTheme } from '@/hooks/use-theme'; import { useTheme } from '@/hooks/use-theme';
import Back from '@/icons/Back'; import Back from '@/icons/Back';
@ -52,7 +53,7 @@ const ApiResourceDetails = () => {
handleSubmit, handleSubmit,
register, register,
reset, reset,
formState: { isSubmitting, errors }, formState: { isDirty, isSubmitting, errors },
} = useForm<FormData>({ } = useForm<FormData>({
defaultValues: data, defaultValues: data,
}); });
@ -179,6 +180,7 @@ const ApiResourceDetails = () => {
</Card> </Card>
</> </>
)} )}
<UnsavedChangesAlertModal hasUnsavedChanges={isDirty} />
</div> </div>
); );
}; };

View file

@ -1,24 +1,43 @@
import { SnakeCaseOidcConfig } from '@logto/schemas'; import { Application, SnakeCaseOidcConfig } from '@logto/schemas';
import React from 'react'; import React, { useEffect } from 'react';
import { useFormContext } from 'react-hook-form';
import CopyToClipboard from '@/components/CopyToClipboard'; import CopyToClipboard from '@/components/CopyToClipboard';
import FormField from '@/components/FormField'; import FormField from '@/components/FormField';
import UnsavedChangesAlertModal from '@/components/UnsavedChangesAlertModal';
import * as styles from '../index.module.scss'; import * as styles from '../index.module.scss';
type Props = { type Props = {
oidcConfig: SnakeCaseOidcConfig; 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 ( return (
<FormField title="admin_console.application_details.token_endpoint"> <>
<CopyToClipboard <FormField title="admin_console.application_details.token_endpoint">
className={styles.textField} <CopyToClipboard
value={oidcConfig.token_endpoint} className={styles.textField}
variant="border" value={oidcConfig.token_endpoint}
/> variant="border"
</FormField> />
</FormField>
<UnsavedChangesAlertModal hasUnsavedChanges={isDirty} />
</>
); );
}; };

View file

@ -1,5 +1,5 @@
import { Application, ApplicationType, SnakeCaseOidcConfig } from '@logto/schemas'; import { Application, ApplicationType, SnakeCaseOidcConfig } from '@logto/schemas';
import React from 'react'; import React, { useEffect } from 'react';
import { Controller, useFormContext } from 'react-hook-form'; import { Controller, useFormContext } from 'react-hook-form';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@ -9,6 +9,7 @@ import MultiTextInput from '@/components/MultiTextInput';
import { MultiTextInputRule } from '@/components/MultiTextInput/types'; import { MultiTextInputRule } from '@/components/MultiTextInput/types';
import { createValidatorForRhf, convertRhfErrorMessage } from '@/components/MultiTextInput/utils'; import { createValidatorForRhf, convertRhfErrorMessage } from '@/components/MultiTextInput/utils';
import TextInput from '@/components/TextInput'; import TextInput from '@/components/TextInput';
import UnsavedChangesAlertModal from '@/components/UnsavedChangesAlertModal';
import { uriOriginValidator, uriValidator } from '@/utilities/validator'; import { uriOriginValidator, uriValidator } from '@/utilities/validator';
import * as styles from '../index.module.scss'; import * as styles from '../index.module.scss';
@ -16,15 +17,25 @@ import * as styles from '../index.module.scss';
type Props = { type Props = {
applicationType: ApplicationType; applicationType: ApplicationType;
oidcConfig: SnakeCaseOidcConfig; oidcConfig: SnakeCaseOidcConfig;
defaultData: Application;
}; };
const Settings = ({ applicationType, oidcConfig }: Props) => { const Settings = ({ applicationType, oidcConfig, defaultData }: Props) => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { const {
control, control,
register, register,
formState: { errors }, reset,
formState: { errors, isDirty },
} = useFormContext<Application>(); } = useFormContext<Application>();
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
useEffect(() => {
reset(defaultData);
return () => {
reset(defaultData);
};
}, [reset, defaultData]);
const uriPatternRules: MultiTextInputRule = { const uriPatternRules: MultiTextInputRule = {
pattern: { pattern: {
@ -141,6 +152,7 @@ const Settings = ({ applicationType, oidcConfig }: Props) => {
)} )}
/> />
</FormField> </FormField>
<UnsavedChangesAlertModal hasUnsavedChanges={isDirty} />
</> </>
); );
}; };

View file

@ -39,7 +39,7 @@ const mapToUriOriginFormatArrays = (value?: string[]) =>
const ApplicationDetails = () => { const ApplicationDetails = () => {
const { id } = useParams(); const { id } = useParams();
const location = useLocation(); const { pathname } = useLocation();
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { data, error, mutate } = useSWR<Application, RequestError>( const { data, error, mutate } = useSWR<Application, RequestError>(
id && `/api/applications/${id}` id && `/api/applications/${id}`
@ -101,7 +101,7 @@ const ApplicationDetails = () => {
setIsReadmeOpen(false); setIsReadmeOpen(false);
}; };
const isAdvancedSettings = location.pathname.includes('advanced-settings'); const isAdvancedSettings = pathname.includes('advanced-settings');
return ( return (
<div className={detailsStyles.container}> <div className={detailsStyles.container}>
@ -177,10 +177,15 @@ const ApplicationDetails = () => {
<FormProvider {...formMethods}> <FormProvider {...formMethods}>
<form className={classNames(styles.form, detailsStyles.body)} onSubmit={onSubmit}> <form className={classNames(styles.form, detailsStyles.body)} onSubmit={onSubmit}>
<div className={styles.fields}> <div className={styles.fields}>
{isAdvancedSettings ? ( {isAdvancedSettings && (
<AdvancedSettings oidcConfig={oidcConfig} /> <AdvancedSettings oidcConfig={oidcConfig} defaultData={data} />
) : ( )}
<Settings applicationType={data.type} oidcConfig={oidcConfig} /> {!isAdvancedSettings && (
<Settings
applicationType={data.type}
oidcConfig={oidcConfig}
defaultData={data}
/>
)} )}
</div> </div>
<div className={detailsStyles.footer}> <div className={detailsStyles.footer}>

View file

@ -1,6 +1,6 @@
import { SignInExperience as SignInExperienceType } from '@logto/schemas'; import { SignInExperience as SignInExperienceType } from '@logto/schemas';
import classNames from 'classnames'; 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 { FormProvider, useForm } from 'react-hook-form';
import { toast } from 'react-hot-toast'; import { toast } from 'react-hot-toast';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@ -16,17 +16,15 @@ import useApi, { RequestError } from '@/hooks/use-api';
import useSettings from '@/hooks/use-settings'; import useSettings from '@/hooks/use-settings';
import * as detailsStyles from '@/scss/details.module.scss'; 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 Preview from './components/Preview';
import SignInMethodsChangePreview from './components/SignInMethodsChangePreview'; import SignInMethodsChangePreview from './components/SignInMethodsChangePreview';
import SignInMethodsForm from './components/SignInMethodsForm';
import Skeleton from './components/Skeleton'; import Skeleton from './components/Skeleton';
import TermsForm from './components/TermsForm';
import Welcome from './components/Welcome'; import Welcome from './components/Welcome';
import usePreviewConfigs from './hooks'; import usePreviewConfigs from './hooks';
import * as styles from './index.module.scss'; 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 { SignInExperienceForm } from './types';
import { compareSignInMethods, signInExperienceParser } from './utilities'; import { compareSignInMethods, signInExperienceParser } from './utilities';
@ -50,11 +48,19 @@ const SignInExperience = () => {
const previewConfigs = usePreviewConfigs(formData, isDirty, data); const previewConfigs = usePreviewConfigs(formData, isDirty, data);
useEffect(() => { const defaultFormData = useMemo(() => {
if (data && !isDirty) { if (!data) {
reset(signInExperienceParser.toLocalForm(data)); return;
} }
}, [data, reset, isDirty]);
return signInExperienceParser.toLocalForm(data);
}, [data]);
useEffect(() => {
if (defaultFormData && !isDirty) {
reset(defaultFormData);
}
}, [reset, isDirty, defaultFormData]);
const saveData = async () => { const saveData = async () => {
const updatedData = await api const updatedData = await api
@ -113,22 +119,18 @@ const SignInExperience = () => {
</TabNavItem> </TabNavItem>
</TabNav> </TabNav>
{!data && error && <div>{`error occurred: ${error.body?.message ?? error.message}`}</div>} {!data && error && <div>{`error occurred: ${error.body?.message ?? error.message}`}</div>}
{data && ( {data && defaultFormData && (
<FormProvider {...methods}> <FormProvider {...methods}>
<form className={styles.formWrapper} onSubmit={onSubmit}> <form className={styles.formWrapper} onSubmit={onSubmit}>
<div className={classNames(detailsStyles.body, styles.form)}> <div className={classNames(detailsStyles.body, styles.form)}>
{tab === 'branding' && ( {tab === 'branding' && (
<> <BrandingTab defaultData={defaultFormData} isDataDirty={isDirty} />
<ColorForm /> )}
<BrandingForm /> {tab === 'methods' && (
</> <SignInMethodsTab defaultData={defaultFormData} isDataDirty={isDirty} />
)} )}
{tab === 'methods' && <SignInMethodsForm />}
{tab === 'others' && ( {tab === 'others' && (
<> <OthersTab defaultData={defaultFormData} isDataDirty={isDirty} />
<TermsForm />
<LanguagesForm />
</>
)} )}
</div> </div>
<div className={detailsStyles.footer}> <div className={detailsStyles.footer}>

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -39,6 +39,10 @@ const translation = {
add_another: '+ Add Another', add_another: '+ Add Another',
deletion_confirmation: 'Are you sure you want to delete this {{title}}?', deletion_confirmation: 'Are you sure you want to delete this {{title}}?',
settings_nav: 'Settings', 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: { errors: {
something_went_wrong: 'Oops! Something went wrong.', something_went_wrong: 'Oops! Something went wrong.',

View file

@ -41,6 +41,9 @@ const translation = {
add_another: '+ 新增', add_another: '+ 新增',
deletion_confirmation: '你确定要删除这个 {{title}} 吗?', deletion_confirmation: '你确定要删除这个 {{title}} 吗?',
settings_nav: '设置', settings_nav: '设置',
unsaved_changes_warning: '还有未保存的变更, 确定要离开吗?',
leave_page: '离开此页',
stay_on_page: '留在此页',
}, },
errors: { errors: {
something_went_wrong: '哎呀,出错了!', something_went_wrong: '哎呀,出错了!',

File diff suppressed because it is too large Load diff