Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/console/src/components/ImageInputs/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -17,6 +18,7 @@ type Props = {
function SubmitFormChangesActionBar({
isOpen,
isSubmitting,
isSubmitDisabled = false,
confirmText = 'general.save_changes',
onSubmit,
onDiscard,
Expand All @@ -34,6 +36,7 @@ function SubmitFormChangesActionBar({
}}
/>
<Button
disabled={isSubmitDisabled}
isLoading={isSubmitting}
type="primary"
size="medium"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
/**
Expand All @@ -46,7 +47,8 @@ function FileUploader<T extends Record<string, unknown> = UserAssets>({
defaultApiInstanceTimeout,
allowedMimeTypes,
actionDescription,
onCompleted,
onUploadStart,
onUploadComplete,
onUploadErrorChange,
className,
apiInstance,
Expand Down Expand Up @@ -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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ function ImageUploaderField({ onChange, allowedMimeTypes: mimeTypes, ...rest }:
<div>
<ImageUploader
allowedMimeTypes={allowedMimeTypes}
onCompleted={({ url }) => {
onUploadComplete={({ url }) => {
onChange(url);
}}
onUploadErrorChange={setUploadError}
Expand Down
4 changes: 4 additions & 0 deletions packages/console/src/hooks/use-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
hideErrorToast?: boolean | LogtoErrorCode[];
resourceIndicator: string;
timeout?: number;
signal?: AbortSignal;
};

const useGlobalRequestErrorHandler = (toastDisabledErrorCodes?: LogtoErrorCode[]) => {
Expand All @@ -60,7 +61,7 @@

// This is what will happen when the user still has the legacy refresh token without
// organization scope. We should sign them out and redirect to the sign in page.
// TODO: This is a temporary solution to prevent the user from getting stuck in Console,

Check warning on line 64 in packages/console/src/hooks/use-api.ts

View workflow job for this annotation

GitHub Actions / ESLint Report Analysis

packages/console/src/hooks/use-api.ts#L64

[no-warning-comments] Unexpected 'todo' comment: 'TODO: This is a temporary solution to...'.
// which can be removed after all legacy refresh tokens are expired, i.e. after Jan 10th,
// 2024.
if (response.status === 403 && data.message === 'Insufficient permissions.') {
Expand Down Expand Up @@ -112,6 +113,7 @@
hideErrorToast,
resourceIndicator,
timeout = requestTimeout,
signal,
}: StaticApiProps): KyInstance => {
const { isAuthenticated, getAccessToken, getOrganizationToken } = useLogto();
const { i18n } = useTranslation(undefined, { keyPrefix: 'admin_console' });
Expand All @@ -128,6 +130,7 @@
ky.create({
prefixUrl,
timeout,
signal,
hooks: {
beforeError: conditionalArray(
!disableGlobalErrorHandling &&
Expand Down Expand Up @@ -159,6 +162,7 @@
getAccessToken,
i18n.language,
timeout,
signal,
]
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ function LogosUploader({ isDarkModeEnabled }: Props) {
: 'enterprise_sso_details.branding_logo_context'
)}
allowedMimeTypes={allowedMimeTypes}
onCompleted={({ url }) => {
onUploadComplete={({ url }) => {
onChange(url);
}}
onUploadErrorChange={setUploadLogoError}
Expand All @@ -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}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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>();

Expand Down Expand Up @@ -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}>
Expand Down Expand Up @@ -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>
Expand All @@ -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}
/>
</>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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) => {
Expand All @@ -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}
/>
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
19 changes: 11 additions & 8 deletions packages/console/src/pages/SignInExperience/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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>
);
}

Expand Down
Loading