feat(settings): add loading state to save buttons (#11639)

Introduce a loading state to SaveButton and SaveAndCancelButtons
components to enhance user feedback during save operations. Update
SettingsNewObject to manage the loading state while submitting the form.

Fix https://github.com/twentyhq/core-team-issues/issues/572

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Antoine Moreaux
2025-06-23 22:49:38 +02:00
committed by GitHub
parent d248e536f3
commit 4eb859256c
6 changed files with 45 additions and 27 deletions

View File

@ -11,6 +11,7 @@ const StyledContainer = styled.div`
type SaveAndCancelButtonsProps = {
onSave?: () => void;
isLoading?: boolean;
onCancel?: () => void;
isSaveDisabled?: boolean;
isCancelDisabled?: boolean;
@ -18,6 +19,7 @@ type SaveAndCancelButtonsProps = {
export const SaveAndCancelButtons = ({
onSave,
isLoading,
onCancel,
isSaveDisabled,
isCancelDisabled,
@ -25,7 +27,11 @@ export const SaveAndCancelButtons = ({
return (
<StyledContainer>
<CancelButton onCancel={onCancel} disabled={isCancelDisabled} />
<SaveButton onSave={onSave} disabled={isSaveDisabled} />
<SaveButton
onSave={onSave}
disabled={isSaveDisabled}
isLoading={isLoading}
/>
</StyledContainer>
);
};

View File

@ -1,13 +1,18 @@
import { t } from '@lingui/core/macro';
import { Button } from 'twenty-ui/input';
import { IconDeviceFloppy } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
type SaveButtonProps = {
onSave?: () => void;
disabled?: boolean;
isLoading?: boolean;
};
export const SaveButton = ({ onSave, disabled }: SaveButtonProps) => {
export const SaveButton = ({
onSave,
disabled,
isLoading,
}: SaveButtonProps) => {
return (
<Button
title={t`Save`}
@ -18,6 +23,7 @@ export const SaveButton = ({ onSave, disabled }: SaveButtonProps) => {
onClick={onSave}
type="submit"
Icon={IconDeviceFloppy}
isLoading={isLoading}
/>
);
};

View File

@ -19,12 +19,13 @@ import { useNavigateSettings } from '~/hooks/useNavigateSettings';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
import { H2Title } from 'twenty-ui/display';
import { Section } from 'twenty-ui/layout';
import { useState } from 'react';
export const SettingsNewObject = () => {
const { t } = useLingui();
const navigate = useNavigateSettings();
const { enqueueSnackBar } = useSnackBar();
const [isLoading, setIsLoading] = useState(false);
const { createOneObjectMetadataItem } = useCreateOneObjectMetadataItem();
const formConfig = useForm<SettingsDataModelObjectAboutFormValues>({
@ -43,6 +44,7 @@ export const SettingsNewObject = () => {
formValues: SettingsDataModelObjectAboutFormValues,
) => {
try {
setIsLoading(true);
const { data: response } = await createOneObjectMetadataItem(formValues);
navigate(
@ -57,6 +59,8 @@ export const SettingsNewObject = () => {
enqueueSnackBar((error as Error).message, {
variant: SnackBarVariant.Error,
});
} finally {
setIsLoading(false);
}
};
@ -79,6 +83,7 @@ export const SettingsNewObject = () => {
actionButton={
<SaveAndCancelButtons
isSaveDisabled={!canSave}
isLoading={isLoading}
isCancelDisabled={isSubmitting}
onCancel={() => navigate(SettingsPath.Objects)}
onSave={formConfig.handleSubmit(handleSave)}

View File

@ -1,7 +1,7 @@
import { zodResolver } from '@hookform/resolvers/zod';
import omit from 'lodash.omit';
import pick from 'lodash.pick';
import { useEffect } from 'react';
import { useEffect, useState } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { useParams } from 'react-router-dom';
import { z } from 'zod';
@ -59,8 +59,14 @@ export const SettingsObjectFieldEdit = () => {
const { deactivateMetadataField, activateMetadataField } =
useFieldMetadataItem();
const [newNameDuringSave, setNewNameDuringSave] = useState<string | null>(
null,
);
const fieldMetadataItem = objectMetadataItem?.fields.find(
(fieldMetadataItem) => fieldMetadataItem.name === fieldName,
(fieldMetadataItem) =>
fieldMetadataItem.name === fieldName ||
fieldMetadataItem.name === newNameDuringSave,
);
const getRelationMetadata = useGetRelationMetadata();
@ -100,6 +106,7 @@ export const SettingsObjectFieldEdit = () => {
formValues: SettingsDataModelFieldEditFormValues,
) => {
const { dirtyFields } = formConfig.formState;
setNewNameDuringSave(formValues.name);
try {
if (
@ -129,15 +136,15 @@ export const SettingsObjectFieldEdit = () => {
Object.keys(otherDirtyFields),
);
navigateSettings(SettingsPath.ObjectDetail, {
objectNamePlural,
});
await updateOneFieldMetadataItem({
objectMetadataId: objectMetadataItem.id,
fieldMetadataIdToUpdate: fieldMetadataItem.id,
updatePayload: formattedInput,
});
navigateSettings(SettingsPath.ObjectDetail, {
objectNamePlural,
});
}
} catch (error) {
enqueueSnackBar((error as Error).message, {
@ -187,6 +194,7 @@ export const SettingsObjectFieldEdit = () => {
]}
actionButton={
<SaveAndCancelButtons
isLoading={isSubmitting}
isSaveDisabled={!canSave}
isCancelDisabled={isSubmitting}
onCancel={() =>

View File

@ -31,7 +31,6 @@ import { FieldMetadataType } from '~/generated-metadata/graphql';
import { useNavigateApp } from '~/hooks/useNavigateApp';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
import { DEFAULT_ICONS_BY_FIELD_TYPE } from '~/pages/settings/data-model/constants/DefaultIconsByFieldType';
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
type SettingsDataModelNewFieldFormValues = z.infer<
@ -85,8 +84,7 @@ export const SettingsObjectNewFieldConfigure = () => {
);
}, [fieldType, formConfig]);
const [, setObjectViews] = useState<View[]>([]);
const [, setRelationObjectViews] = useState<View[]>([]);
const [isSaving, setIsSaving] = useState(false);
useFindManyRecords<View>({
objectNameSingular: CoreObjectNameSingular.View,
@ -94,10 +92,6 @@ export const SettingsObjectNewFieldConfigure = () => {
type: { eq: ViewType.Table },
objectMetadataId: { eq: activeObjectMetadataItem?.id },
},
onCompleted: async (views) => {
if (isUndefinedOrNull(views)) return;
setObjectViews(views);
},
});
const relationObjectMetadataId = formConfig.watch(
@ -111,10 +105,6 @@ export const SettingsObjectNewFieldConfigure = () => {
type: { eq: ViewType.Table },
objectMetadataId: { eq: relationObjectMetadataId },
},
onCompleted: async (views) => {
if (isUndefinedOrNull(views)) return;
setRelationObjectViews(views);
},
});
useEffect(() => {
@ -132,10 +122,7 @@ export const SettingsObjectNewFieldConfigure = () => {
formValues: SettingsDataModelNewFieldFormValues,
) => {
try {
navigate(SettingsPath.ObjectDetail, {
objectNamePlural,
});
setIsSaving(true);
if (
formValues.type === FieldMetadataType.RELATION &&
'relation' in formValues
@ -163,7 +150,12 @@ export const SettingsObjectNewFieldConfigure = () => {
await apolloClient.refetchQueries({
include: ['FindManyViews', 'CombinedFindManyRecords'],
});
navigate(SettingsPath.ObjectDetail, {
objectNamePlural,
});
setIsSaving(false);
} catch (error) {
setIsSaving(false);
const isDuplicateFieldNameInObject = (error as Error).message.includes(
'duplicate key value violates unique constraint "IndexOnNameObjectMetadataIdAndWorkspaceIdUnique"',
);
@ -206,6 +198,7 @@ export const SettingsObjectNewFieldConfigure = () => {
]}
actionButton={
<SaveAndCancelButtons
isLoading={isSaving}
isSaveDisabled={!canSave}
isCancelDisabled={isSubmitting}
onCancel={() =>

View File

@ -15,7 +15,7 @@ const StyledLoaderContainer = styled.div<{
border-radius: ${({ theme }) => theme.border.radius.pill};
border: 1px solid
${({ color, theme }) =>
color ? theme.tag.text[color] : theme.font.color.tertiary};
color ? theme.tag.text[color] : `var(--tw-button-color)`};
overflow: hidden;
`;
@ -23,7 +23,7 @@ const StyledLoader = styled(motion.div)<{
color?: ThemeColor;
}>`
background-color: ${({ color, theme }) =>
color ? theme.tag.text[color] : theme.font.color.tertiary};
color ? theme.tag.text[color] : `var(--tw-button-color)`};
border-radius: ${({ theme }) => theme.border.radius.pill};
height: 8px;
width: 8px;