0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-02-03 21:48:55 -05:00

feat(console): sie cross tabs saving (#2507)

This commit is contained in:
Xiao Yijun 2022-11-25 17:15:22 +08:00 committed by GitHub
parent 924b69848b
commit e2c739aa11
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 146 additions and 34 deletions

View file

@ -20,11 +20,19 @@ type BlockerNavigator = Navigator & {
type Props = {
hasUnsavedChanges: boolean;
parentPath?: string;
};
const UnsavedChangesAlertModal = ({ hasUnsavedChanges }: Props) => {
const UnsavedChangesAlertModal = ({ hasUnsavedChanges, parentPath }: Props) => {
const { navigator } = useContext(UNSAFE_NavigationContext);
/**
* Props `block` and `location` are removed from `Navigator` type in react-router, for the same reason as above.
* So we have to define our own type `BlockerNavigator` to acquire these props that actually exist in `navigator` object.
*/
// eslint-disable-next-line no-restricted-syntax
const { block, location } = navigator as BlockerNavigator;
const [displayAlert, setDisplayAlert] = useState(false);
const [transition, setTransition] = useState<Transition>();
@ -35,12 +43,6 @@ const UnsavedChangesAlertModal = ({ hasUnsavedChanges }: Props) => {
return;
}
/**
* Props `block` and `location` are removed from `Navigator` type in react-router, for the same reason as above.
* So we have to define our own type `BlockerNavigator` to acquire these props that actually exist in `navigator` object.
*/
// eslint-disable-next-line no-restricted-syntax
const { block, location } = navigator as BlockerNavigator;
const { pathname } = location;
const unblock = block((transition) => {
@ -53,6 +55,13 @@ const UnsavedChangesAlertModal = ({ hasUnsavedChanges }: Props) => {
return;
}
if (parentPath && targetPathname.startsWith(parentPath)) {
unblock();
transition.retry();
return;
}
setDisplayAlert(true);
setTransition({
@ -65,7 +74,7 @@ const UnsavedChangesAlertModal = ({ hasUnsavedChanges }: Props) => {
});
return unblock;
}, [navigator, hasUnsavedChanges]);
}, [navigator, hasUnsavedChanges, location, block, parentPath]);
const leavePage = useCallback(() => {
transition?.retry();

View file

@ -0,0 +1,3 @@
.hide {
display: none;
}

View file

@ -0,0 +1,16 @@
import classNames from 'classnames';
import type { ReactNode } from 'react';
import * as styles from './index.module.scss';
type Props = {
isActive: boolean;
className?: string;
children: ReactNode;
};
const TabWrapper = ({ isActive, className, children }: Props) => {
return <div className={classNames(!isActive && styles.hide, className)}>{children}</div>;
};
export default TabWrapper;

View file

@ -1,7 +1,6 @@
import type { SignInExperience as SignInExperienceType } from '@logto/schemas';
import classNames from 'classnames';
import { nanoid } from 'nanoid';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { toast } from 'react-hot-toast';
import { useTranslation } from 'react-i18next';
@ -28,7 +27,13 @@ import Branding from './tabs/Branding';
import Others from './tabs/Others';
import SignUpAndSignIn from './tabs/SignUpAndSignIn';
import type { SignInExperienceForm } from './types';
import { compareSignUpAndSignInConfigs, signInExperienceParser } from './utilities';
import {
compareSignUpAndSignInConfigs,
getBrandingErrorCount,
getOthersErrorCount,
getSignUpAndSignInErrorCount,
signInExperienceParser,
} from './utilities';
const SignInExperience = () => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
@ -38,14 +43,13 @@ const SignInExperience = () => {
const { error: languageError, isLoading: isLoadingLanguages } = useUiLanguages();
const [dataToCompare, setDataToCompare] = useState<SignInExperienceType>();
const { current: formId } = useRef(nanoid());
const methods = useForm<SignInExperienceForm>();
const {
reset,
handleSubmit,
getValues,
watch,
formState: { isSubmitting, isDirty },
formState: { isSubmitting, isDirty, errors },
} = methods;
const api = useApi();
const formData = watch();
@ -64,7 +68,7 @@ const SignInExperience = () => {
if (defaultFormData) {
reset(defaultFormData);
}
}, [reset, defaultFormData, tab]);
}, [reset, defaultFormData]);
const saveData = async () => {
const updatedData = await api
@ -126,26 +130,29 @@ const SignInExperience = () => {
className={styles.cardTitle}
/>
<TabNav className={styles.tabs}>
<TabNavItem href="/sign-in-experience/branding">
<TabNavItem href="/sign-in-experience/branding" errorCount={getBrandingErrorCount(errors)}>
{t('sign_in_exp.tabs.branding')}
</TabNavItem>
<TabNavItem href="/sign-in-experience/sign-up-and-sign-in">
<TabNavItem
href="/sign-in-experience/sign-up-and-sign-in"
errorCount={getSignUpAndSignInErrorCount(errors, formData)}
>
{t('sign_in_exp.tabs.sign_up_and_sign_in')}
</TabNavItem>
<TabNavItem href="/sign-in-experience/others">{t('sign_in_exp.tabs.others')}</TabNavItem>
<TabNavItem href="/sign-in-experience/others" errorCount={getOthersErrorCount(errors)}>
{t('sign_in_exp.tabs.others')}
</TabNavItem>
</TabNav>
{!data && error && <div>{`error occurred: ${error.body?.message ?? error.message}`}</div>}
{data && defaultFormData && (
<div className={styles.content}>
<div className={styles.contentTop}>
<FormProvider {...methods}>
<form
id={formId}
className={classNames(styles.form, isDirty && styles.withSubmitActionBar)}
>
{tab === 'branding' && <Branding />}
{tab === 'sign-up-and-sign-in' && <SignUpAndSignIn />}
{tab === 'others' && <Others />}
<form className={classNames(styles.form, isDirty && styles.withSubmitActionBar)}>
{/* Todo: LOG-4766 Add Constants To Guard Router Path */}
<Branding isActive={tab === 'branding'} />
<SignUpAndSignIn isActive={tab === 'sign-up-and-sign-in'} />
<Others isActive={tab === 'others'} />
</form>
</FormProvider>
{formData.id && (
@ -173,7 +180,10 @@ const SignInExperience = () => {
{dataToCompare && <SignUpAndSignInChangePreview before={data} after={dataToCompare} />}
</ConfirmModal>
)}
<UnsavedChangesAlertModal hasUnsavedChanges={isDirty} />
<UnsavedChangesAlertModal
hasUnsavedChanges={isDirty}
parentPath="/console/sign-in-experience"
/>
</div>
);
};

View file

@ -1,11 +1,17 @@
import TabWrapper from '../../components/TabWrapper';
import * as styles from '../index.module.scss';
import BrandingForm from './BrandingForm';
import ColorForm from './ColorForm';
const Branding = () => (
<>
type Props = {
isActive: boolean;
};
const Branding = ({ isActive }: Props) => (
<TabWrapper isActive={isActive} className={styles.tabContent}>
<ColorForm />
<BrandingForm />
</>
</TabWrapper>
);
export default Branding;

View file

@ -1,13 +1,19 @@
import TabWrapper from '../../components/TabWrapper';
import * as styles from '../index.module.scss';
import AuthenticationForm from './AuthenticationForm';
import LanguagesForm from './LanguagesForm';
import TermsForm from './TermsForm';
const Others = () => (
<>
type Props = {
isActive: boolean;
};
const Others = ({ isActive }: Props) => (
<TabWrapper isActive={isActive} className={styles.tabContent}>
<TermsForm />
<LanguagesForm isManageLanguageVisible />
<AuthenticationForm />
</>
</TabWrapper>
);
export default Others;

View file

@ -129,6 +129,7 @@ const SignInMethodEditBox = () => {
}}
onDelete={() => {
remove(index);
revalidate();
}}
/>
)}
@ -147,6 +148,7 @@ const SignInMethodEditBox = () => {
verificationCode: getSignInMethodVerificationCodeCheckState(identifier),
isPasswordPrimary: true,
});
revalidate();
}}
/>
</div>

View file

@ -1,13 +1,19 @@
import TabWrapper from '../../components/TabWrapper';
import * as styles from '../index.module.scss';
import SignInForm from './SignInForm';
import SignUpForm from './SignUpForm';
import SocialSignInForm from './SocialSignInForm';
const SignUpAndSignIn = () => (
<>
type Props = {
isActive: boolean;
};
const SignUpAndSignIn = ({ isActive }: Props) => (
<TabWrapper isActive={isActive} className={styles.tabContent}>
<SignUpForm />
<SignInForm />
<SocialSignInForm />
</>
</TabWrapper>
);
export default SignUpAndSignIn;

View file

@ -1,5 +1,11 @@
@use '@/scss/underscore' as _;
.tabContent {
> :not(:first-child) {
margin-top: _.unit(3);
}
}
.title {
@include _.subhead-cap;
color: var(--color-neutral-variant-60);

View file

@ -2,6 +2,7 @@ import en from '@logto/phrases-ui/lib/locales/en';
import type { SignInExperience, Translation } from '@logto/schemas';
import { SignUpIdentifier, SignInMode } from '@logto/schemas';
import { conditional } from '@silverhand/essentials';
import type { DeepRequired, FieldErrorsImpl } from 'react-hook-form';
import {
isSignInMethodsDifferent,
@ -84,3 +85,50 @@ const emptyTranslation = (translation: Translation): Translation =>
}, {});
export const createEmptyUiTranslation = () => emptyTranslation(en.translation);
export const getBrandingErrorCount = (
errors: FieldErrorsImpl<DeepRequired<SignInExperienceForm>>
) => {
const { color, branding } = errors;
const colorFormErrorCount = color ? Object.keys(color).length : 0;
const brandingFormErrorCount = branding ? Object.keys(branding).length : 0;
return colorFormErrorCount + brandingFormErrorCount;
};
export const getSignUpAndSignInErrorCount = (
errors: FieldErrorsImpl<DeepRequired<SignInExperienceForm>>,
formData: SignInExperienceForm
) => {
const signUpIdentifier = formData.signUp?.identifier;
/**
* Note: we treat the `emailOrSms` sign-up identifier as 2 errors when it's invalid.
*/
const signUpIdentifierRelatedErrorCount = signUpIdentifier
? signUpIdentifier === SignUpIdentifier.EmailOrSms
? 2
: 1
: 0;
const { signUp, signIn } = errors;
const signUpErrorCount = signUp?.identifier ? signUpIdentifierRelatedErrorCount : 0;
const signInMethodErrors = signIn?.methods;
const signInMethodErrorCount = Array.isArray(signInMethodErrors)
? signInMethodErrors.filter(Boolean).length
: 0;
return signUpErrorCount + signInMethodErrorCount;
};
export const getOthersErrorCount = (
errors: FieldErrorsImpl<DeepRequired<SignInExperienceForm>>
) => {
const { termsOfUse } = errors;
const termsOfUseErrorCount = termsOfUse ? Object.keys(termsOfUse).length : 0;
return termsOfUseErrorCount;
};