0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -05:00

refactor(console): block page navigation when uploading custom ui assets (#6342)

This commit is contained in:
Charles Zhao 2024-07-26 17:58:09 +08:00 committed by GitHub
parent fb5b02bec9
commit 33a1ac1ca4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 119 additions and 26 deletions

View file

@ -137,7 +137,7 @@ function ImageInputs<FormContext extends FieldValues>({
actionDescription={t(`sign_in_exp.branding.with_${field.theme}`, {
value: t(`sign_in_exp.branding_uploads.${field.type}.title`),
})}
onCompleted={({ url }) => {
onUploadComplete={({ url }) => {
onChange(url);
}}
// Noop fallback should not be necessary, but for TypeScript to be happy

View file

@ -8,6 +8,7 @@ import styles from './index.module.scss';
type Props = {
readonly isOpen: boolean;
readonly isSubmitting: boolean;
readonly isSubmitDisabled?: boolean;
readonly onSubmit: () => Promise<void>;
readonly onDiscard: () => void;
readonly confirmText?: AdminConsoleKey;
@ -17,6 +18,7 @@ type Props = {
function SubmitFormChangesActionBar({
isOpen,
isSubmitting,
isSubmitDisabled = false,
confirmText = 'general.save_changes',
onSubmit,
onDiscard,
@ -34,6 +36,7 @@ function SubmitFormChangesActionBar({
}}
/>
<Button
disabled={isSubmitDisabled}
isLoading={isSubmitting}
type="primary"
size="medium"

View file

@ -25,7 +25,8 @@ export type Props<T extends Record<string, unknown> = UserAssets> = {
readonly defaultApiInstanceTimeout?: number;
readonly allowedMimeTypes: AllowedUploadMimeType[];
readonly actionDescription?: string;
readonly onCompleted: (response: T) => void;
readonly onUploadStart?: (file: File) => void;
readonly onUploadComplete?: (response: T) => void;
readonly onUploadErrorChange: (errorMessage?: string, files?: File[]) => void;
readonly className?: string;
/**
@ -46,7 +47,8 @@ function FileUploader<T extends Record<string, unknown> = UserAssets>({
defaultApiInstanceTimeout,
allowedMimeTypes,
actionDescription,
onCompleted,
onUploadStart,
onUploadComplete,
onUploadErrorChange,
className,
apiInstance,
@ -116,17 +118,21 @@ function FileUploader<T extends Record<string, unknown> = UserAssets>({
try {
setIsUploading(true);
onUploadStart?.(acceptedFile);
const uploadApi = apiInstance ?? api;
const response = await uploadApi.post(uploadUrl, { body: formData }).json<T>();
onCompleted(response);
} catch {
onUploadComplete?.(response);
} catch (error: unknown) {
if (error instanceof Error && error.name === 'AbortError') {
return;
}
setUploadError(t('components.uploader.error_upload'));
} finally {
setIsUploading(false);
}
},
[api, apiInstance, allowedMimeTypes, maxSize, onCompleted, t, uploadUrl]
[api, apiInstance, allowedMimeTypes, maxSize, onUploadComplete, onUploadStart, t, uploadUrl]
);
const { getRootProps, getInputProps, isDragActive } = useDropzone({

View file

@ -23,7 +23,7 @@ function ImageUploaderField({ onChange, allowedMimeTypes: mimeTypes, ...rest }:
<div>
<ImageUploader
allowedMimeTypes={allowedMimeTypes}
onCompleted={({ url }) => {
onUploadComplete={({ url }) => {
onChange(url);
}}
onUploadErrorChange={setUploadError}

View file

@ -41,6 +41,7 @@ export type StaticApiProps = {
hideErrorToast?: boolean | LogtoErrorCode[];
resourceIndicator: string;
timeout?: number;
signal?: AbortSignal;
};
const useGlobalRequestErrorHandler = (toastDisabledErrorCodes?: LogtoErrorCode[]) => {
@ -112,6 +113,7 @@ export const useStaticApi = ({
hideErrorToast,
resourceIndicator,
timeout = requestTimeout,
signal,
}: StaticApiProps): KyInstance => {
const { isAuthenticated, getAccessToken, getOrganizationToken } = useLogto();
const { i18n } = useTranslation(undefined, { keyPrefix: 'admin_console' });
@ -128,6 +130,7 @@ export const useStaticApi = ({
ky.create({
prefixUrl,
timeout,
signal,
hooks: {
beforeError: conditionalArray(
!disableGlobalErrorHandling &&
@ -159,6 +162,7 @@ export const useStaticApi = ({
getAccessToken,
i18n.language,
timeout,
signal,
]
);

View file

@ -44,7 +44,7 @@ function LogosUploader({ isDarkModeEnabled }: Props) {
: 'enterprise_sso_details.branding_logo_context'
)}
allowedMimeTypes={allowedMimeTypes}
onCompleted={({ url }) => {
onUploadComplete={({ url }) => {
onChange(url);
}}
onUploadErrorChange={setUploadLogoError}
@ -67,7 +67,7 @@ function LogosUploader({ isDarkModeEnabled }: Props) {
value={value ?? ''}
actionDescription={t('enterprise_sso_details.branding_dark_logo_context')}
allowedMimeTypes={allowedMimeTypes}
onCompleted={({ url }) => {
onUploadComplete={({ url }) => {
onChange(url);
}}
onUploadErrorChange={setUploadDarkLogoError}

View file

@ -3,7 +3,6 @@ import { useContext } from 'react';
import { Controller, useFormContext } from 'react-hook-form';
import { Trans, useTranslation } from 'react-i18next';
import CustomUiAssetsUploader from '@/components/CustomUiAssetsUploader';
import InlineUpsell from '@/components/InlineUpsell';
import { isDevFeaturesEnabled, isCloud } from '@/consts/env';
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
@ -12,6 +11,7 @@ import CodeEditor from '@/ds-components/CodeEditor';
import FormField from '@/ds-components/FormField';
import TextLink from '@/ds-components/TextLink';
import useDocumentationUrl from '@/hooks/use-documentation-url';
import CustomUiAssetsUploader from '@/pages/SignInExperience/components/CustomUiAssetsUploader';
import type { SignInExperienceForm } from '../../../types';
import FormSectionTitle from '../../components/FormSectionTitle';

View file

@ -1,6 +1,6 @@
import { type SignInExperience } from '@logto/schemas';
import classNames from 'classnames';
import { useState } from 'react';
import { useCallback, useContext, useState } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { toast } from 'react-hot-toast';
import { useTranslation } from 'react-i18next';
@ -16,6 +16,7 @@ import useTenantPathname from '@/hooks/use-tenant-pathname';
import { trySubmitSafe } from '@/utils/form';
import Preview from '../components/Preview';
import { SignInExperienceContext } from '../contexts/SignInExperienceContextProvider';
import usePreviewConfigs from '../hooks/use-preview-configs';
import { SignInExperienceTab } from '../types';
import { type SignInExperienceForm } from '../types';
@ -48,6 +49,7 @@ function PageContent({ data, onSignInExperienceUpdated }: Props) {
const { updateConfigs } = useConfigs();
const { getPathname } = useTenantPathname();
const { isUploading, cancelUpload } = useContext(SignInExperienceContext);
const [dataToCompare, setDataToCompare] = useState<SignInExperience>();
@ -106,6 +108,13 @@ function PageContent({ data, onSignInExperienceUpdated }: Props) {
})
);
const onDiscard = useCallback(() => {
reset();
if (isUploading && cancelUpload) {
cancelUpload();
}
}, [isUploading, cancelUpload, reset]);
return (
<>
<TabNav className={styles.tabs}>
@ -143,9 +152,10 @@ function PageContent({ data, onSignInExperienceUpdated }: Props) {
)}
</div>
<SubmitFormChangesActionBar
isOpen={isDirty}
isOpen={isDirty || isUploading}
isSubmitDisabled={isUploading}
isSubmitting={isSaving}
onDiscard={reset}
onDiscard={onDiscard}
onSubmit={onSubmit}
/>
</div>
@ -162,8 +172,9 @@ function PageContent({ data, onSignInExperienceUpdated }: Props) {
{dataToCompare && <SignUpAndSignInChangePreview before={data} after={dataToCompare} />}
</ConfirmModal>
<UnsavedChangesAlertModal
hasUnsavedChanges={isDirty}
hasUnsavedChanges={isDirty || isUploading}
parentPath={getPathname('/sign-in-experience')}
onConfirm={onDiscard}
/>
</>
);

View file

@ -1,15 +1,17 @@
import { type CustomUiAssets, maxUploadFileSize, type AllowedUploadMimeType } from '@logto/schemas';
import { type Nullable } from '@silverhand/essentials';
import { format } from 'date-fns/fp';
import { useCallback, useState } from 'react';
import { useCallback, useContext, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import DeleteIcon from '@/assets/icons/delete.svg?react';
import FileIcon from '@/components/FileIcon';
import IconButton from '@/ds-components/IconButton';
import FileUploader from '@/ds-components/Uploader/FileUploader';
import useApi from '@/hooks/use-api';
import { formatBytes } from '@/utils/uploader';
import FileIcon from '../FileIcon';
import { SignInExperienceContext } from '../../contexts/SignInExperienceContextProvider';
import styles from './index.module.scss';
@ -28,7 +30,19 @@ function CustomUiAssetsUploader({ disabled, value, onChange }: Props) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const [file, setFile] = useState<File>();
const [error, setError] = useState<string>();
const [abortController, setAbortController] = useState(new AbortController());
const showUploader = !value?.id && !file && !error;
const { setIsUploading, setCancelUpload } = useContext(SignInExperienceContext);
const api = useApi({ timeout: requestTimeout, signal: abortController.signal });
useEffect(() => {
setCancelUpload(() => {
abortController.abort();
setIsUploading(false);
setAbortController(new AbortController());
});
}, [abortController, setCancelUpload, setIsUploading]);
const onComplete = useCallback(
(id: string) => {
@ -53,13 +67,17 @@ function CustomUiAssetsUploader({ disabled, value, onChange }: Props) {
if (showUploader) {
return (
<FileUploader<{ customUiAssetId: string }>
defaultApiInstanceTimeout={requestTimeout}
apiInstance={api}
disabled={disabled}
allowedMimeTypes={allowedMimeTypes}
maxSize={maxUploadFileSize}
uploadUrl="api/sign-in-exp/default/custom-ui-assets"
onCompleted={({ customUiAssetId }) => {
onUploadStart={() => {
setIsUploading(true);
}}
onUploadComplete={({ customUiAssetId }) => {
onComplete(customUiAssetId);
setIsUploading(false);
}}
onUploadErrorChange={onErrorChange}
/>

View file

@ -0,0 +1,48 @@
import { noop } from '@silverhand/essentials';
import { createContext, useMemo, useRef, useState } from 'react';
type SignInExperienceContextType = {
isUploading: boolean;
cancelUpload?: () => void;
setIsUploading: (value: boolean) => void;
setCancelUpload: (cancelFunction?: () => void) => void;
};
type Props = {
readonly children?: React.ReactNode;
};
export const SignInExperienceContext = createContext<SignInExperienceContextType>({
isUploading: false,
cancelUpload: noop,
setIsUploading: noop,
setCancelUpload: noop,
});
function SignInExperienceContextProvider({ children }: Props) {
const [isUploading, setIsUploading] = useState(false);
const cancelUploadRef = useRef<() => void>();
const handleSetCancelUpload = (cancelFunction?: () => void) => {
// eslint-disable-next-line @silverhand/fp/no-mutation
cancelUploadRef.current = cancelFunction;
};
const contextValue = useMemo(
() => ({
isUploading,
cancelUpload: cancelUploadRef.current,
setIsUploading,
setCancelUpload: handleSetCancelUpload,
}),
[isUploading]
);
return (
<SignInExperienceContext.Provider value={contextValue}>
{children}
</SignInExperienceContext.Provider>
);
}
export default SignInExperienceContextProvider;

View file

@ -13,6 +13,7 @@ import useUserAssetsService from '@/hooks/use-user-assets-service';
import PageContent from './PageContent';
import Skeleton from './Skeleton';
import Welcome from './Welcome';
import SignInExperienceContextProvider from './contexts/SignInExperienceContextProvider';
import styles from './index.module.scss';
type PageWrapperProps = {
@ -21,14 +22,16 @@ type PageWrapperProps = {
function PageWrapper({ children }: PageWrapperProps) {
return (
<div className={styles.container}>
<CardTitle
title="sign_in_exp.title"
subtitle="sign_in_exp.description"
className={styles.cardTitle}
/>
{children}
</div>
<SignInExperienceContextProvider>
<div className={styles.container}>
<CardTitle
title="sign_in_exp.title"
subtitle="sign_in_exp.description"
className={styles.cardTitle}
/>
{children}
</div>
</SignInExperienceContextProvider>
);
}