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

View File

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

View File

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

View File

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

View File

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

View File

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