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",
"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",

View file

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

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 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>
);
};

View file

@ -1,24 +1,43 @@
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}
value={oidcConfig.token_endpoint}
variant="border"
/>
</FormField>
<>
<FormField title="admin_console.application_details.token_endpoint">
<CopyToClipboard
className={styles.textField}
value={oidcConfig.token_endpoint}
variant="border"
/>
</FormField>
<UnsavedChangesAlertModal hasUnsavedChanges={isDirty} />
</>
);
};

View file

@ -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} />
</>
);
};

View file

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

View file

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

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',
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.',

View file

@ -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: '哎呀,出错了!',

File diff suppressed because it is too large Load diff