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:
parent
924b69848b
commit
e2c739aa11
10 changed files with 146 additions and 34 deletions
|
@ -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();
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
.hide {
|
||||
display: none;
|
||||
}
|
|
@ -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;
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -129,6 +129,7 @@ const SignInMethodEditBox = () => {
|
|||
}}
|
||||
onDelete={() => {
|
||||
remove(index);
|
||||
revalidate();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
@ -147,6 +148,7 @@ const SignInMethodEditBox = () => {
|
|||
verificationCode: getSignInMethodVerificationCodeCheckState(identifier),
|
||||
isPasswordPrimary: true,
|
||||
});
|
||||
revalidate();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
Loading…
Add table
Reference in a new issue