Skip to content

Commit

Permalink
feat(console): unsaved changes alert (#1409)
Browse files Browse the repository at this point in the history
  • Loading branch information
xiaoyijun committed Jul 5, 2022
1 parent 98995ca commit 098367e
Show file tree
Hide file tree
Showing 14 changed files with 369 additions and 315 deletions.
1 change: 1 addition & 0 deletions packages/console/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 3 additions & 1 deletion packages/console/src/components/ConfirmModal/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export type ConfirmModalProps = {
isOpen: boolean;
onCancel: () => void;
onConfirm: () => void;
onClose?: () => void;
};

const ConfirmModal = ({
Expand All @@ -31,6 +32,7 @@ const ConfirmModal = ({
isOpen,
onCancel,
onConfirm,
onClose = onCancel,
}: ConfirmModalProps) => {
return (
<ReactModal
Expand All @@ -47,7 +49,7 @@ const ConfirmModal = ({
</>
}
className={classNames(styles.content, className)}
onClose={onCancel}
onClose={onClose}
>
{children}
</ModalLayout>
Expand Down
83 changes: 83 additions & 0 deletions packages/console/src/components/UnsavedChangesAlertModal/index.tsx
Original file line number Diff line number Diff line change
@@ -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;
4 changes: 3 additions & 1 deletion packages/console/src/pages/ApiResourceDetails/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -52,7 +53,7 @@ const ApiResourceDetails = () => {
handleSubmit,
register,
reset,
formState: { isSubmitting, errors },
formState: { isDirty, isSubmitting, errors },
} = useForm<FormData>({
defaultValues: data,
});
Expand Down Expand Up @@ -179,6 +180,7 @@ const ApiResourceDetails = () => {
</Card>
</>
)}
<UnsavedChangesAlertModal hasUnsavedChanges={isDirty} />
</div>
);
};
Expand Down
Original file line number Diff line number Diff line change
@@ -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} />
</>
);
};

Expand Down
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -9,22 +9,33 @@ 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';

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: {
Expand Down Expand Up @@ -141,6 +152,7 @@ const Settings = ({ applicationType, oidcConfig }: Props) => {
)}
/>
</FormField>
<UnsavedChangesAlertModal hasUnsavedChanges={isDirty} />
</>
);
};
Expand Down
17 changes: 11 additions & 6 deletions packages/console/src/pages/ApplicationDetails/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}`
Expand Down Expand Up @@ -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}>
Expand Down Expand Up @@ -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}>
Expand Down
40 changes: 21 additions & 19 deletions packages/console/src/pages/SignInExperience/index.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';

Expand All @@ -50,11 +48,19 @@ const SignInExperience = () => {

const previewConfigs = usePreviewConfigs(formData, isDirty, data);

const defaultFormData = useMemo(() => {
if (!data) {
return;
}

return signInExperienceParser.toLocalForm(data);
}, [data]);

useEffect(() => {
if (data && !isDirty) {
reset(signInExperienceParser.toLocalForm(data));
if (defaultFormData && !isDirty) {
reset(defaultFormData);
}
}, [data, reset, isDirty]);
}, [reset, isDirty, defaultFormData]);

const saveData = async () => {
const updatedData = await api
Expand Down Expand Up @@ -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}>
Expand Down

0 comments on commit 098367e

Please sign in to comment.