0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-01-27 21:39:16 -05:00

fix(console): reset form state when the submit handler throws (#4029)

This commit is contained in:
Xiao Yijun 2023-06-13 11:28:41 +08:00 committed by GitHub
parent 9a9d1d2ed4
commit 6726bcaa0f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 412 additions and 329 deletions

View file

@ -15,6 +15,7 @@ import TextInput from '@/components/TextInput';
import type { RequestError } from '@/hooks/use-api';
import useApi from '@/hooks/use-api';
import type { GuideForm } from '@/types/guide';
import { trySubmitSafe } from '@/utils/form';
import { uriValidator } from '@/utils/validator';
import * as styles from './index.module.scss';
@ -42,7 +43,7 @@ function UriInputField({ appId, name, title, isSingle = false }: Props) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const api = useApi();
const onSubmit = async (value: string[]) => {
const onSubmit = trySubmitSafe(async (value: string[]) => {
const updatedApp = await api
.patch(`api/applications/${appId}`, {
json: {
@ -57,7 +58,7 @@ function UriInputField({ appId, name, title, isSingle = false }: Props) {
// Reset form to set 'isDirty' to false
reset(getValues());
};
});
const onKeyPress = (event: KeyboardEvent<HTMLInputElement>, value: string[]) => {
if (event.key === 'Enter') {

View file

@ -13,6 +13,7 @@ import PageMeta from '@/components/PageMeta';
import TextInput from '@/components/TextInput';
import useUserOnboardingData from '@/onboarding/hooks/use-user-onboarding-data';
import * as pageLayout from '@/onboarding/scss/layout.module.scss';
import { trySubmitSafe } from '@/utils/form';
import { CardSelector, MultiCardSelector } from '../../../components/CardSelector';
import ActionBar from '../../components/ActionBar';
@ -41,9 +42,11 @@ function About() {
reset(questionnaire);
}, [questionnaire, reset]);
const onSubmit = handleSubmit(async (formData) => {
await update({ questionnaire: formData });
});
const onSubmit = handleSubmit(
trySubmitSafe(async (formData) => {
await update({ questionnaire: formData });
})
);
const onNext = async () => {
await onSubmit();

View file

@ -25,6 +25,7 @@ import * as pageLayout from '@/onboarding/scss/layout.module.scss';
import type { OnboardingSieConfig } from '@/onboarding/types';
import { Authentication, OnboardingPage } from '@/onboarding/types';
import { getOnboardingPage } from '@/onboarding/utils';
import { trySubmitSafe } from '@/utils/form';
import { buildUrl } from '@/utils/url';
import { uriValidator } from '@/utils/validator';
@ -85,21 +86,22 @@ function SignInExperience() {
}
}, [onboardingSieConfig, signInExperience]);
const submit = (onSuccess: () => void) => async (formData: OnboardingSieConfig) => {
if (!signInExperience) {
return;
}
const submit = (onSuccess: () => void) =>
trySubmitSafe(async (formData: OnboardingSieConfig) => {
if (!signInExperience) {
return;
}
const updatedData = await api
.patch(buildUrl('api/sign-in-exp', { removeUnusedDemoSocialConnector: '1' }), {
json: parser.onboardSieConfigToSignInExperience(formData, signInExperience),
})
.json<SignInExperienceType>();
const updatedData = await api
.patch(buildUrl('api/sign-in-exp', { removeUnusedDemoSocialConnector: '1' }), {
json: parser.onboardSieConfigToSignInExperience(formData, signInExperience),
})
.json<SignInExperienceType>();
void mutate(updatedData);
void mutate(updatedData);
onSuccess();
};
onSuccess();
});
if (isLoading) {
return <Skeleton />;

View file

@ -14,6 +14,7 @@ import PageMeta from '@/components/PageMeta';
import ActionBar from '@/onboarding/components/ActionBar';
import useUserOnboardingData from '@/onboarding/hooks/use-user-onboarding-data';
import * as pageLayout from '@/onboarding/scss/layout.module.scss';
import { trySubmitSafe } from '@/utils/form';
import type { Questionnaire } from '../../types';
import { OnboardingPage } from '../../types';
@ -42,9 +43,11 @@ function Welcome() {
reset(questionnaire);
}, [questionnaire, reset]);
const onSubmit = handleSubmit(async (formData) => {
await update({ questionnaire: formData });
});
const onSubmit = handleSubmit(
trySubmitSafe(async (formData) => {
await update({ questionnaire: formData });
})
);
const onNext = async () => {
await onSubmit();

View file

@ -10,6 +10,7 @@ import ModalLayout from '@/components/ModalLayout';
import TextInput from '@/components/TextInput';
import useApi from '@/hooks/use-api';
import * as modalStyles from '@/scss/modal.module.scss';
import { trySubmitSafe } from '@/utils/form';
type Props = {
resourceId: string;
@ -28,17 +29,19 @@ function CreatePermissionModal({ resourceId, onClose }: Props) {
const api = useApi();
const onSubmit = handleSubmit(async (formData) => {
if (isSubmitting) {
return;
}
const onSubmit = handleSubmit(
trySubmitSafe(async (formData) => {
if (isSubmitting) {
return;
}
const createdScope = await api
.post(`api/resources/${resourceId}/scopes`, { json: formData })
.json<Scope>();
const createdScope = await api
.post(`api/resources/${resourceId}/scopes`, { json: formData })
.json<Scope>();
onClose(createdScope);
});
onClose(createdScope);
})
);
return (
<ReactModal

View file

@ -13,6 +13,7 @@ import TextLink from '@/components/TextLink';
import UnsavedChangesAlertModal from '@/components/UnsavedChangesAlertModal';
import useApi from '@/hooks/use-api';
import useDocumentationUrl from '@/hooks/use-documentation-url';
import { trySubmitSafe } from '@/utils/form';
import type { ApiResourceDetailsOutletContext } from '../types';
@ -34,24 +35,26 @@ function ApiResourceSettings() {
const api = useApi();
const onSubmit = handleSubmit(async ({ isDefault, ...rest }) => {
if (isSubmitting) {
return;
}
const onSubmit = handleSubmit(
trySubmitSafe(async ({ isDefault, ...rest }) => {
if (isSubmitting) {
return;
}
const [data] = await Promise.all([
api.patch(`api/resources/${resource.id}`, { json: rest }).json<Resource>(),
api
.patch(`api/resources/${resource.id}/is-default`, { json: { isDefault } })
.json<Resource>(),
]);
const [data] = await Promise.all([
api.patch(`api/resources/${resource.id}`, { json: rest }).json<Resource>(),
api
.patch(`api/resources/${resource.id}/is-default`, { json: { isDefault } })
.json<Resource>(),
]);
// We cannot ensure the order of API requests, manually combine the results
const updatedApiResource = { ...data, isDefault };
reset(updatedApiResource);
onResourceUpdated(updatedApiResource);
toast.success(t('general.saved'));
});
// We cannot ensure the order of API requests, manually combine the results
const updatedApiResource = { ...data, isDefault };
reset(updatedApiResource);
onResourceUpdated(updatedApiResource);
toast.success(t('general.saved'));
})
);
return (
<>

View file

@ -8,6 +8,7 @@ import ModalLayout from '@/components/ModalLayout';
import TextInput from '@/components/TextInput';
import TextLink from '@/components/TextLink';
import useApi from '@/hooks/use-api';
import { trySubmitSafe } from '@/utils/form';
type FormData = {
name: string;
@ -28,14 +29,16 @@ function CreateForm({ onClose }: Props) {
const api = useApi();
const onSubmit = handleSubmit(async (data) => {
if (isSubmitting) {
return;
}
const onSubmit = handleSubmit(
trySubmitSafe(async (data) => {
if (isSubmitting) {
return;
}
const createdApiResource = await api.post('api/resources', { json: data }).json<Resource>();
onClose?.(createdApiResource);
});
const createdApiResource = await api.post('api/resources', { json: data }).json<Resource>();
onClose?.(createdApiResource);
})
);
return (
<ModalLayout

View file

@ -27,6 +27,7 @@ import type { RequestError } from '@/hooks/use-api';
import useApi from '@/hooks/use-api';
import useDocumentationUrl from '@/hooks/use-documentation-url';
import { applicationTypeI18nKey } from '@/types/applications';
import { trySubmitSafe } from '@/utils/form';
import Guide from '../Applications/components/Guide';
import GuideModal from '../Applications/components/Guide/GuideModal';
@ -79,34 +80,36 @@ function ApplicationDetails() {
reset(data);
}, [data, reset]);
const onSubmit = handleSubmit(async (formData) => {
if (!data || isSubmitting) {
return;
}
const onSubmit = handleSubmit(
trySubmitSafe(async (formData) => {
if (!data || isSubmitting) {
return;
}
await api
.patch(`api/applications/${data.id}`, {
json: {
...formData,
oidcClientMetadata: {
...formData.oidcClientMetadata,
redirectUris: mapToUriFormatArrays(formData.oidcClientMetadata.redirectUris),
postLogoutRedirectUris: mapToUriFormatArrays(
formData.oidcClientMetadata.postLogoutRedirectUris
),
await api
.patch(`api/applications/${data.id}`, {
json: {
...formData,
oidcClientMetadata: {
...formData.oidcClientMetadata,
redirectUris: mapToUriFormatArrays(formData.oidcClientMetadata.redirectUris),
postLogoutRedirectUris: mapToUriFormatArrays(
formData.oidcClientMetadata.postLogoutRedirectUris
),
},
customClientMetadata: {
...formData.customClientMetadata,
corsAllowedOrigins: mapToUriOriginFormatArrays(
formData.customClientMetadata.corsAllowedOrigins
),
},
},
customClientMetadata: {
...formData.customClientMetadata,
corsAllowedOrigins: mapToUriOriginFormatArrays(
formData.customClientMetadata.corsAllowedOrigins
),
},
},
})
.json<Application>();
void mutate();
toast.success(t('general.saved'));
});
})
.json<Application>();
void mutate();
toast.success(t('general.saved'));
})
);
const onDelete = async () => {
if (!data || isDeleting) {

View file

@ -14,6 +14,7 @@ import useApi from '@/hooks/use-api';
import useConfigs from '@/hooks/use-configs';
import * as modalStyles from '@/scss/modal.module.scss';
import { applicationTypeI18nKey } from '@/types/applications';
import { trySubmitSafe } from '@/utils/form';
import TypeDescription from '../TypeDescription';
@ -48,20 +49,22 @@ function CreateForm({ isOpen, onClose }: Props) {
return null;
}
const onSubmit = handleSubmit(async (data) => {
if (isSubmitting) {
return;
}
const onSubmit = handleSubmit(
trySubmitSafe(async (data) => {
if (isSubmitting) {
return;
}
const createdApp = await api.post('api/applications', { json: data }).json<Application>();
void updateConfigs({
applicationCreated: true,
...conditional(
createdApp.type === ApplicationType.MachineToMachine && { m2mApplicationCreated: true }
),
});
onClose?.(createdApp);
});
const createdApp = await api.post('api/applications', { json: data }).json<Application>();
void updateConfigs({
applicationCreated: true,
...conditional(
createdApp.type === ApplicationType.MachineToMachine && { m2mApplicationCreated: true }
),
});
onClose?.(createdApp);
})
);
return (
<Modal

View file

@ -18,6 +18,7 @@ import { useConnectorFormConfigParser } from '@/pages/Connectors/components/Conn
import { initFormData } from '@/pages/Connectors/components/ConnectorForm/utils';
import type { ConnectorFormType } from '@/pages/Connectors/types';
import { SyncProfileMode } from '@/pages/Connectors/types';
import { trySubmitSafe } from '@/utils/form';
import SenderTester from './SenderTester';
@ -71,33 +72,35 @@ function ConnectorContent({ isDeleted, connectorData, onConnectorUpdated }: Prop
const configParser = useConnectorFormConfigParser();
const onSubmit = handleSubmit(async (data) => {
const { formItems, isStandard, id } = connectorData;
const config = configParser(data, formItems);
const { syncProfile, name, logo, logoDark, target } = data;
const onSubmit = handleSubmit(
trySubmitSafe(async (data) => {
const { formItems, isStandard, id } = connectorData;
const config = configParser(data, formItems);
const { syncProfile, name, logo, logoDark, target } = data;
const payload = isSocialConnector
? {
config,
syncProfile: syncProfile === SyncProfileMode.EachSignIn,
}
: { config };
const standardConnectorPayload = {
...payload,
metadata: { name: { en: name }, logo, logoDark, target },
};
// Should not update `target` for neither passwordless connectors nor non-standard social connectors.
const body = isStandard ? standardConnectorPayload : { ...payload, target: undefined };
const payload = isSocialConnector
? {
config,
syncProfile: syncProfile === SyncProfileMode.EachSignIn,
}
: { config };
const standardConnectorPayload = {
...payload,
metadata: { name: { en: name }, logo, logoDark, target },
};
// Should not update `target` for neither passwordless connectors nor non-standard social connectors.
const body = isStandard ? standardConnectorPayload : { ...payload, target: undefined };
const updatedConnector = await api
.patch(`api/connectors/${id}`, {
json: body,
})
.json<ConnectorResponse>();
const updatedConnector = await api
.patch(`api/connectors/${id}`, {
json: body,
})
.json<ConnectorResponse>();
onConnectorUpdated(updatedConnector);
toast.success(t('general.saved'));
});
onConnectorUpdated(updatedConnector);
toast.success(t('general.saved'));
})
);
return (
<FormProvider {...methods}>

View file

@ -11,6 +11,7 @@ import TextInput from '@/components/TextInput';
import { Tooltip } from '@/components/Tip';
import useApi from '@/hooks/use-api';
import { onKeyDownHandler } from '@/utils/a11y';
import { trySubmitSafe } from '@/utils/form';
import { parsePhoneNumber } from '@/utils/phone';
import * as styles from './index.module.scss';
@ -54,17 +55,19 @@ function SenderTester({ connectorFactoryId, connectorType, className, parse }: P
};
}, [showTooltip]);
const onSubmit = handleSubmit(async (formData) => {
const { sendTo } = formData;
const onSubmit = handleSubmit(
trySubmitSafe(async (formData) => {
const { sendTo } = formData;
const data = {
config: parse(),
...(isSms ? { phone: parsePhoneNumber(sendTo) } : { email: sendTo }),
};
const data = {
config: parse(),
...(isSms ? { phone: parsePhoneNumber(sendTo) } : { email: sendTo }),
};
await api.post(`api/connectors/${connectorFactoryId}/test`, { json: data }).json();
setShowTooltip(true);
});
await api.post(`api/connectors/${connectorFactoryId}/test`, { json: data }).json();
setShowTooltip(true);
})
);
return (
<div className={className}>

View file

@ -24,6 +24,7 @@ import useApi from '@/hooks/use-api';
import useConfigs from '@/hooks/use-configs';
import SenderTester from '@/pages/ConnectorDetails/components/SenderTester';
import * as modalStyles from '@/scss/modal.module.scss';
import { trySubmitSafe } from '@/utils/form';
import type { ConnectorFormType } from '../../types';
import { SyncProfileMode } from '../../types';
@ -88,70 +89,72 @@ function Guide({ connector, onClose }: Props) {
const { title, content } = splitMarkdownByTitle(readme);
const connectorName = conditional(isLanguageTag(language) && name[language]) ?? name.en;
const onSubmit = handleSubmit(async (data) => {
if (isSubmitting) {
return;
}
// Recover error state
setConflictConnectorName(undefined);
const config = configParser(data, formItems);
const { syncProfile, name, logo, logoDark, target } = data;
const basePayload = {
config,
connectorId,
id: conditional(connectorType === ConnectorType.Social && callbackConnectorId.current),
metadata: conditional(
isStandard && {
logo,
logoDark,
target,
name: { en: name },
}
),
};
const payload = isSocialConnector
? { ...basePayload, syncProfile: syncProfile === SyncProfileMode.EachSignIn }
: basePayload;
try {
const createdConnector = await api
.post('api/connectors', {
json: payload,
})
.json<ConnectorResponse>();
await updateConfigs({ passwordlessConfigured: true });
onClose();
toast.success(t('general.saved'));
navigate(
`/connectors/${isSocialConnector ? ConnectorsTabs.Social : ConnectorsTabs.Passwordless}/${
createdConnector.id
}`
);
} catch (error: unknown) {
if (error instanceof HTTPError) {
const { response } = error;
const metadata = await response.json<
RequestErrorBody<{ connectorName: Record<string, string> }>
>();
if (metadata.code === targetErrorCode) {
setConflictConnectorName(metadata.data.connectorName);
setError('target', {}, { shouldFocus: true });
return;
}
const onSubmit = handleSubmit(
trySubmitSafe(async (data) => {
if (isSubmitting) {
return;
}
throw error;
}
});
// Recover error state
setConflictConnectorName(undefined);
const config = configParser(data, formItems);
const { syncProfile, name, logo, logoDark, target } = data;
const basePayload = {
config,
connectorId,
id: conditional(connectorType === ConnectorType.Social && callbackConnectorId.current),
metadata: conditional(
isStandard && {
logo,
logoDark,
target,
name: { en: name },
}
),
};
const payload = isSocialConnector
? { ...basePayload, syncProfile: syncProfile === SyncProfileMode.EachSignIn }
: basePayload;
try {
const createdConnector = await api
.post('api/connectors', {
json: payload,
})
.json<ConnectorResponse>();
await updateConfigs({ passwordlessConfigured: true });
onClose();
toast.success(t('general.saved'));
navigate(
`/connectors/${isSocialConnector ? ConnectorsTabs.Social : ConnectorsTabs.Passwordless}/${
createdConnector.id
}`
);
} catch (error: unknown) {
if (error instanceof HTTPError) {
const { response } = error;
const metadata = await response.json<
RequestErrorBody<{ connectorName: Record<string, string> }>
>();
if (metadata.code === targetErrorCode) {
setConflictConnectorName(metadata.data.connectorName);
setError('target', {}, { shouldFocus: true });
return;
}
}
throw error;
}
})
);
return (
<Modal

View file

@ -7,6 +7,7 @@ import Button from '@/components/Button';
import TextInput from '@/components/TextInput';
import { adminTenantEndpoint, meApi } from '@/consts';
import { useStaticApi } from '@/hooks/use-api';
import { trySubmitSafe } from '@/utils/form';
import MainFlowLikeModal from '../../components/MainFlowLikeModal';
import { parseLocationState } from '../../utils';
@ -36,11 +37,13 @@ function LinkEmailModal() {
const onSubmit = () => {
clearErrors();
void handleSubmit(async ({ email }) => {
await api.post(`me/verification-codes`, { json: { email } });
reset();
navigate('../verification-code', { state: { email, action: 'changeEmail' } });
})();
void handleSubmit(
trySubmitSafe(async ({ email }) => {
await api.post(`me/verification-codes`, { json: { email } });
reset();
navigate('../verification-code', { state: { email, action: 'changeEmail' } });
})
)();
};
const { email: currentEmail } = parseLocationState(state);

View file

@ -10,6 +10,7 @@ import FormField from '@/components/FormField';
import TextInput from '@/components/TextInput';
import UnsavedChangesAlertModal from '@/components/UnsavedChangesAlertModal';
import useApi from '@/hooks/use-api';
import { trySubmitSafe } from '@/utils/form';
import type { RoleDetailsOutletContext } from '../types';
@ -27,16 +28,18 @@ function RoleSettings() {
const api = useApi();
const onSubmit = handleSubmit(async (formData) => {
if (isSubmitting) {
return;
}
const onSubmit = handleSubmit(
trySubmitSafe(async (formData) => {
if (isSubmitting) {
return;
}
const updatedRole = await api.patch(`api/roles/${role.id}`, { json: formData }).json<Role>();
reset(updatedRole);
onRoleUpdated(updatedRole);
toast.success(t('general.saved'));
});
const updatedRole = await api.patch(`api/roles/${role.id}`, { json: formData }).json<Role>();
reset(updatedRole);
onRoleUpdated(updatedRole);
toast.success(t('general.saved'));
})
);
return (
<>

View file

@ -11,6 +11,7 @@ import RoleScopesTransfer from '@/components/RoleScopesTransfer';
import TextInput from '@/components/TextInput';
import useApi from '@/hooks/use-api';
import useConfigs from '@/hooks/use-configs';
import { trySubmitSafe } from '@/utils/form';
export type Props = {
onClose: (createdRole?: Role) => void;
@ -36,21 +37,23 @@ function CreateRoleForm({ onClose }: Props) {
const api = useApi();
const { updateConfigs } = useConfigs();
const onSubmit = handleSubmit(async ({ name, description, scopes }) => {
if (isSubmitting) {
return;
}
const onSubmit = handleSubmit(
trySubmitSafe(async ({ name, description, scopes }) => {
if (isSubmitting) {
return;
}
const payload: CreateRolePayload = {
name,
description,
scopeIds: conditional(scopes.length > 0 && scopes.map(({ id }) => id)),
};
const payload: CreateRolePayload = {
name,
description,
scopeIds: conditional(scopes.length > 0 && scopes.map(({ id }) => id)),
};
const createdRole = await api.post('api/roles', { json: payload }).json<Role>();
await updateConfigs({ roleCreated: true });
onClose(createdRole);
});
const createdRole = await api.post('api/roles', { json: payload }).json<Role>();
await updateConfigs({ roleCreated: true });
onClose(createdRole);
})
);
return (
<ModalLayout

View file

@ -15,6 +15,7 @@ import useApi from '@/hooks/use-api';
import useConfigs from '@/hooks/use-configs';
import useUserPreferences from '@/hooks/use-user-preferences';
import * as modalStyles from '@/scss/modal.module.scss';
import { trySubmitSafe } from '@/utils/form';
import usePreviewConfigs from '../../hooks/use-preview-configs';
import BrandingForm from '../../tabs/Branding/BrandingForm';
@ -59,20 +60,22 @@ function GuideModal({ isOpen, onClose }: Props) {
await updatePreferences({ experienceNoticeConfirmed: true });
};
const onSubmit = handleSubmit(async (formData) => {
if (!data || isSubmitting) {
return;
}
const onSubmit = handleSubmit(
trySubmitSafe(async (formData) => {
if (!data || isSubmitting) {
return;
}
await Promise.all([
api.patch('api/sign-in-exp', {
json: signInExperienceParser.toRemoteModel(formData),
}),
updateConfigs({ signInExperienceCustomized: true }),
]);
await Promise.all([
api.patch('api/sign-in-exp', {
json: signInExperienceParser.toRemoteModel(formData),
}),
updateConfigs({ signInExperienceCustomized: true }),
]);
onClose();
});
onClose();
})
);
const onSkip = async () => {
setIsLoading(true);

View file

@ -23,6 +23,7 @@ import useApi from '@/hooks/use-api';
import useConfigs from '@/hooks/use-configs';
import useUiLanguages from '@/hooks/use-ui-languages';
import useUserAssetsService from '@/hooks/use-user-assets-service';
import { trySubmitSafe } from '@/utils/form';
import Preview from './components/Preview';
import SignUpAndSignInChangePreview from './components/SignUpAndSignInChangePreview';
@ -133,22 +134,24 @@ function SignInExperience() {
}
};
const onSubmit = handleSubmit(async (formData: SignInExperienceForm) => {
if (!data || isSaving) {
return;
}
const onSubmit = handleSubmit(
trySubmitSafe(async (formData: SignInExperienceForm) => {
if (!data || isSaving) {
return;
}
const formatted = signInExperienceParser.toRemoteModel(formData);
const formatted = signInExperienceParser.toRemoteModel(formData);
// Sign-in methods changed, need to show confirm modal first.
if (!hasSignUpAndSignInConfigChanged(data, formatted)) {
setDataToCompare(formatted);
// Sign-in methods changed, need to show confirm modal first.
if (!hasSignUpAndSignInConfigChanged(data, formatted)) {
setDataToCompare(formatted);
return;
}
return;
}
await saveData();
});
await saveData();
})
);
if (isLoading) {
return <Skeleton />;

View file

@ -28,6 +28,7 @@ import {
flattenTranslation,
} from '@/pages/SignInExperience/utils/language';
import type { CustomPhraseResponse } from '@/types/custom-phrase';
import { trySubmitSafe } from '@/utils/form';
import * as styles from './LanguageDetails.module.scss';
import { LanguageEditorContext } from './use-language-editor-context';
@ -134,11 +135,13 @@ function LanguageDetails() {
setSelectedLanguage(languages.find((languageTag) => languageTag !== selectedLanguage) ?? 'en');
}, [api, globalMutate, isDefaultLanguage, languages, selectedLanguage, setSelectedLanguage]);
const onSubmit = handleSubmit(async (formData: Translation) => {
const updatedCustomPhrase = await upsertCustomPhrase(selectedLanguage, formData);
void mutate(updatedCustomPhrase);
toast.success(t('general.saved'));
});
const onSubmit = handleSubmit(
trySubmitSafe(async (formData: Translation) => {
const updatedCustomPhrase = await upsertCustomPhrase(selectedLanguage, formData);
void mutate(updatedCustomPhrase);
toast.success(t('general.saved'));
})
);
useEffect(() => {
reset(defaultFormValues);

View file

@ -6,6 +6,7 @@ import Button from '@/components/Button';
import TextInput from '@/components/TextInput';
import useApi from '@/hooks/use-api';
import { onKeyDownHandler } from '@/utils/a11y';
import { trySubmitSafe } from '@/utils/form';
import * as styles from './index.module.scss';
@ -36,10 +37,12 @@ function AddDomainForm({ onCustomDomainAdded }: Props) {
const api = useApi();
const onSubmit = handleSubmit(async (formData) => {
const createdDomain = await api.post('api/domains', { json: formData }).json<Domain>();
onCustomDomainAdded(createdDomain);
});
const onSubmit = handleSubmit(
trySubmitSafe(async (formData) => {
const createdDomain = await api.post('api/domains', { json: formData }).json<Domain>();
onCustomDomainAdded(createdDomain);
})
);
return (
<div className={styles.addDomain}>

View file

@ -16,6 +16,7 @@ import UnsavedChangesAlertModal from '@/components/UnsavedChangesAlertModal';
import useApi from '@/hooks/use-api';
import { useConfirmModal } from '@/hooks/use-confirm-modal';
import useDocumentationUrl from '@/hooks/use-documentation-url';
import { trySubmitSafe } from '@/utils/form';
import { safeParseJsonObject } from '@/utils/json';
import { parsePhoneNumber } from '@/utils/phone';
import { uriValidator } from '@/utils/validator';
@ -47,47 +48,45 @@ function UserSettings() {
const api = useApi();
const onSubmit = handleSubmit(async (formData) => {
if (isSubmitting) {
return;
}
const { identities, id: userId } = user;
const { customData: inputCustomData, username, primaryEmail, primaryPhone } = formData;
if (!username && !primaryEmail && !primaryPhone && Object.keys(identities).length === 0) {
const [result] = await show({
ModalContent: t('user_details.warning_no_sign_in_identifier'),
type: 'confirm',
});
if (!result) {
const onSubmit = handleSubmit(
trySubmitSafe(async (formData) => {
if (isSubmitting) {
return;
}
}
const { identities, id: userId } = user;
const { customData: inputCustomData, username, primaryEmail, primaryPhone } = formData;
const parseResult = safeParseJsonObject(inputCustomData);
if (!username && !primaryEmail && !primaryPhone && Object.keys(identities).length === 0) {
const [result] = await show({
ModalContent: t('user_details.warning_no_sign_in_identifier'),
type: 'confirm',
});
if (!parseResult.success) {
toast.error(t('user_details.custom_data_invalid'));
if (!result) {
return;
}
}
return;
}
const parseResult = safeParseJsonObject(inputCustomData);
const payload: Partial<User> = {
...formData,
primaryPhone: parsePhoneNumber(primaryPhone),
customData: parseResult.data,
};
if (!parseResult.success) {
toast.error(t('user_details.custom_data_invalid'));
return;
}
const payload: Partial<User> = {
...formData,
primaryPhone: parsePhoneNumber(primaryPhone),
customData: parseResult.data,
};
try {
const updatedUser = await api.patch(`api/users/${userId}`, { json: payload }).json<User>();
reset(userDetailsParser.toLocalForm(updatedUser));
onUserUpdated(updatedUser);
toast.success(t('general.saved'));
} catch {
// Do nothing since we only show error toasts, which is handled in the useApi hook
}
});
})
);
return (
<>

View file

@ -14,6 +14,7 @@ import TextInput from '@/components/TextInput';
import UserAccountInformation from '@/components/UserAccountInformation';
import useApi from '@/hooks/use-api';
import * as modalStyles from '@/scss/modal.module.scss';
import { trySubmitSafe } from '@/utils/form';
import { generateRandomPassword } from '@/utils/password';
import { parsePhoneNumber } from '@/utils/phone';
@ -62,46 +63,48 @@ function CreateForm({ onClose, onCreate }: Props) {
}
};
const onSubmit = handleSubmit(async (data) => {
if (isSubmitting) {
return;
}
const onSubmit = handleSubmit(
trySubmitSafe(async (data) => {
if (isSubmitting) {
return;
}
setMissingIdentifierError(undefined);
setMissingIdentifierError(undefined);
if (!hasIdentifier()) {
setMissingIdentifierError(t('users.error_missing_identifier'));
return;
}
if (!hasIdentifier()) {
setMissingIdentifierError(t('users.error_missing_identifier'));
return;
}
const password = generateRandomPassword();
const password = generateRandomPassword();
const { primaryPhone } = data;
const { primaryPhone } = data;
const userData = {
...data,
password,
...conditional(primaryPhone && { primaryPhone: parsePhoneNumber(primaryPhone) }),
};
// Filter out empty values
const payload = Object.fromEntries(
Object.entries(userData).filter(([, value]) => Boolean(value))
);
try {
const createdUser = await api.post('api/users', { json: payload }).json<User>();
setCreatedUserInfo({
user: createdUser,
const userData = {
...data,
password,
});
...conditional(primaryPhone && { primaryPhone: parsePhoneNumber(primaryPhone) }),
};
onCreate();
} catch {
// Do nothing since we only show error toasts, which is handled in the useApi hook
}
});
// Filter out empty values
const payload = Object.fromEntries(
Object.entries(userData).filter(([, value]) => Boolean(value))
);
try {
const createdUser = await api.post('api/users', { json: payload }).json<User>();
setCreatedUserInfo({
user: createdUser,
password,
});
onCreate();
} catch {
// Do nothing since we only show error toasts, which is handled in the useApi hook
}
})
);
return createdUserInfo ? (
<UserAccountInformation

View file

@ -9,6 +9,7 @@ import FormCard from '@/components/FormCard';
import UnsavedChangesAlertModal from '@/components/UnsavedChangesAlertModal';
import useApi from '@/hooks/use-api';
import BasicWebhookForm from '@/pages/Webhooks/components/BasicWebhookForm';
import { trySubmitSafe } from '@/utils/form';
import { type WebhookDetailsFormType, type WebhookDetailsOutletContext } from '../types';
import { webhookDetailsParser } from '../utils';
@ -32,14 +33,16 @@ function WebhookSettings() {
formState: { isSubmitting, isDirty },
} = formMethods;
const onSubmit = handleSubmit(async (formData) => {
const updatedHook = await api
.patch(`api/hooks/${hook.id}`, { json: webhookDetailsParser.toRemoteModel(formData) })
.json<Hook>();
reset(webhookDetailsParser.toLocalForm(updatedHook));
onHookUpdated(updatedHook);
toast.success(t('general.saved'));
});
const onSubmit = handleSubmit(
trySubmitSafe(async (formData) => {
const updatedHook = await api
.patch(`api/hooks/${hook.id}`, { json: webhookDetailsParser.toRemoteModel(formData) })
.json<Hook>();
reset(webhookDetailsParser.toLocalForm(updatedHook));
onHookUpdated(updatedHook);
toast.success(t('general.saved'));
})
);
return (
<>

View file

@ -4,6 +4,7 @@ import { FormProvider, useForm } from 'react-hook-form';
import Button from '@/components/Button';
import ModalLayout from '@/components/ModalLayout';
import useApi from '@/hooks/use-api';
import { trySubmitSafe } from '@/utils/form';
import { type BasicWebhookFormType } from '../../types';
import BasicWebhookForm from '../BasicWebhookForm';
@ -28,19 +29,21 @@ function CreateForm({ onClose }: Props) {
const api = useApi();
const onSubmit = handleSubmit(async (data) => {
const { name, events, url } = data;
const payload: CreateHookPayload = {
name,
events,
config: {
url,
},
};
const onSubmit = handleSubmit(
trySubmitSafe(async (data) => {
const { name, events, url } = data;
const payload: CreateHookPayload = {
name,
events,
config: {
url,
},
};
const created = await api.post('api/hooks', { json: payload }).json<Hook>();
onClose(created);
});
const created = await api.post('api/hooks', { json: payload }).json<Hook>();
onClose(created);
})
);
return (
<ModalLayout

View file

@ -0,0 +1,21 @@
import { HTTPError } from 'ky';
import { type FieldValues, type SubmitHandler } from 'react-hook-form';
/**
* After upgrading the react-hook-form to v7.42.0, the `isSubmitting` flag does not recover when the submit handler throws.
* So we need to catch the error and do nothing if the error is an HTTPError and the status code is not 401 to prevent the `isSubmitting` flag from being stuck.
* Reference: https://github.com/orgs/react-hook-form/discussions/10103#discussioncomment-5927542
*/
export const trySubmitSafe =
<T extends FieldValues>(handler: SubmitHandler<T>) =>
async (formData: T, event?: React.BaseSyntheticEvent) => {
try {
await handler(formData, event);
} catch (error) {
if (error instanceof HTTPError && error.response.status !== 401) {
return;
}
throw error;
}
};