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:
parent
fb5b02bec9
commit
33a1ac1ca4
12 changed files with 119 additions and 26 deletions
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -23,7 +23,7 @@ function ImageUploaderField({ onChange, allowedMimeTypes: mimeTypes, ...rest }:
|
|||
<div>
|
||||
<ImageUploader
|
||||
allowedMimeTypes={allowedMimeTypes}
|
||||
onCompleted={({ url }) => {
|
||||
onUploadComplete={({ url }) => {
|
||||
onChange(url);
|
||||
}}
|
||||
onUploadErrorChange={setUploadError}
|
||||
|
|
|
@ -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,
|
||||
]
|
||||
);
|
||||
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -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}
|
||||
/>
|
|
@ -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;
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in a new issue