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:
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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)}
|
||||
|
||||
@ -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={() =>
|
||||
|
||||
@ -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={() =>
|
||||
|
||||
@ -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;
|
||||
|
||||
Reference in New Issue
Block a user