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