Add support for indexes on composite fields and unicity constraint on indexes This pull request includes several changes across multiple files to improve error handling, enforce unique constraints, and update database migrations. The most important changes include updating error messages for snack bars, adding a new command to enforce unique constraints, and updating database migrations to include new fields and constraints. ### Error Handling Improvements: * [`packages/twenty-front/src/modules/error-handler/components/PromiseRejectionEffect.tsx`](diffhunk://#diff-e7dc05ced8e4730430f5c7fcd0c75b3aa723da438c26e0bef8130b614427dd9aL23-R23): Updated error messages in `enqueueSnackBar` to use `error.message` directly. * [`packages/twenty-front/src/modules/object-metadata/hooks/useFindManyObjectMetadataItems.ts`](diffhunk://#diff-74c126d6bc7a5ed6b63be994d298df6669058034bfbc367b11045f9f31a3abe6L44-R46): Simplified error messages in `enqueueSnackBar`. * [`packages/twenty-front/src/modules/object-record/hooks/useFindDuplicateRecords.ts`](diffhunk://#diff-af23a1d99639a66c251f87473e63e2b7bceaa4ee4f70fedfa0fcffe5c7d79181L56-R58): Simplified error messages in `enqueueSnackBar`. * [`packages/twenty-front/src/modules/object-record/hooks/useHandleFindManyRecordsError.ts`](diffhunk://#diff-da04296cbe280202a1eaf6b1244a30490d4f400411bee139651172c59719088eL22-R24): Simplified error messages in `enqueueSnackBar`. ### New Command for Unique Constraints: * [`packages/twenty-server/src/database/commands/upgrade-version/0-31/0-31-enforce-unique-constraints.command.ts`](diffhunk://#diff-8337096c8c80dd2619a5ba691ae5145101f8ae0368a75192a050047e8c6ab7cbR1-R159): Added a new command to enforce unique constraints on company domain names and person emails. * [`packages/twenty-server/src/database/commands/upgrade-version/0-31/0-31-upgrade-version.command.ts`](diffhunk://#diff-20215e9981a53c7566e9cbff96715685125878f5bcb84fe461a7440f2e68f6fcR13-R14): Integrated the new `EnforceUniqueConstraintsCommand` into the upgrade process. [[1]](diffhunk://#diff-20215e9981a53c7566e9cbff96715685125878f5bcb84fe461a7440f2e68f6fcR13-R14) [[2]](diffhunk://#diff-20215e9981a53c7566e9cbff96715685125878f5bcb84fe461a7440f2e68f6fcR31) [[3]](diffhunk://#diff-20215e9981a53c7566e9cbff96715685125878f5bcb84fe461a7440f2e68f6fcR64-R68) * [`packages/twenty-server/src/database/commands/upgrade-version/0-31/0-31-upgrade-version.module.ts`](diffhunk://#diff-da52814efc674c25ed55645f8ee2561013641a407f88423e705dd6c77b405527R7): Registered the new `EnforceUniqueConstraintsCommand` in the module. [[1]](diffhunk://#diff-da52814efc674c25ed55645f8ee2561013641a407f88423e705dd6c77b405527R7) [[2]](diffhunk://#diff-da52814efc674c25ed55645f8ee2561013641a407f88423e705dd6c77b405527R24) ### Database Migrations: * [`packages/twenty-server/src/database/typeorm/metadata/migrations/1726757368824-migrationDebt.ts`](diffhunk://#diff-c450aeae7bc0ef4416a0ade2dc613ca3f688629f35d2a32f90a09c3f494febdcR1-R53): Added a migration to update the `relationMetadata_ondeleteaction_enum` and set default values. * [`packages/twenty-server/src/database/typeorm/metadata/migrations/1726757368825-addIsUniqueToIndexMetadata.ts`](diffhunk://#diff-8f1e14bd7f6835ec2c3bb39bcc51e3c318a3008d576a981e682f4c985e746fbfR1-R19): Added a migration to include the `isUnique` field in `indexMetadata`. * [`packages/twenty-server/src/database/typeorm/metadata/migrations/1726762935841-addCompostiveColumnToIndexFieldMetadata.ts`](diffhunk://#diff-7c96b7276c7722d41ff31de23b2de4d6e09adfdc74815356ba63bc96a2669440R1-R19): Added a migration to include the `compositeColumn` field in `indexFieldMetadata`. * [`packages/twenty-server/src/database/typeorm/metadata/migrations/1726766871572-addWhereToIndexMetadata.ts`](diffhunk://#diff-26651295a975eb50e672dce0e4e274e861f66feb1b68105eee5a04df32796190R1-R14): Added a migration to include the `indexWhereClause` field in `indexMetadata`. ### GraphQL Exception Handling: * [`packages/twenty-server/src/engine/api/graphql/workspace-query-runner/utils/workspace-query-runner-graphql-api-exception-handler.util.ts`](diffhunk://#diff-58445eb362dc89e31107777d39b592d7842d2ab09a223012ccd055da325270a8R1-R4): Enhanced exception handling for `QueryFailedError` to provide more specific error messages for unique constraint violations. [[1]](diffhunk://#diff-58445eb362dc89e31107777d39b592d7842d2ab09a223012ccd055da325270a8R1-R4) [[2]](diffhunk://#diff-58445eb362dc89e31107777d39b592d7842d2ab09a223012ccd055da325270a8R23-R59) * [`packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/create-many-resolver.factory.ts`](diffhunk://#diff-233d58ab2333586dd45e46e33d4f07e04a4b8adde4a11a48e25d86985e5a7943L58-R58): Updated the `workspaceQueryRunnerGraphqlApiExceptionHandler` call to include context. * [`packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/create-one-resolver.factory.ts`](diffhunk://#diff-68b803f0762c407f5d2d1f5f8d389655a60654a2dd2394a81318655dcd44dc43L58-R58): Updated the `workspaceQueryRunnerGraphqlApiExceptionHandler` call to include context. --------- Co-authored-by: Charles Bochet <charles@twenty.com>
279 lines
10 KiB
TypeScript
279 lines
10 KiB
TypeScript
import { useApolloClient } from '@apollo/client';
|
|
import { zodResolver } from '@hookform/resolvers/zod';
|
|
import omit from 'lodash.omit';
|
|
import pick from 'lodash.pick';
|
|
import { useEffect } from 'react';
|
|
import { FormProvider, useForm } from 'react-hook-form';
|
|
import { useNavigate, useParams } from 'react-router-dom';
|
|
import { H2Title, IconArchive, IconArchiveOff } from 'twenty-ui';
|
|
import { z } from 'zod';
|
|
|
|
import { useFieldMetadataItem } from '@/object-metadata/hooks/useFieldMetadataItem';
|
|
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
|
|
import { useGetRelationMetadata } from '@/object-metadata/hooks/useGetRelationMetadata';
|
|
import { useUpdateOneFieldMetadataItem } from '@/object-metadata/hooks/useUpdateOneFieldMetadataItem';
|
|
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
|
import { formatFieldMetadataItemInput } from '@/object-metadata/utils/formatFieldMetadataItemInput';
|
|
import { getFieldSlug } from '@/object-metadata/utils/getFieldSlug';
|
|
import { isLabelIdentifierField } from '@/object-metadata/utils/isLabelIdentifierField';
|
|
import { useFindManyRecordsQuery } from '@/object-record/hooks/useFindManyRecordsQuery';
|
|
import { RecordFieldValueSelectorContextProvider } from '@/object-record/record-store/contexts/RecordFieldValueSelectorContext';
|
|
import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons';
|
|
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
|
|
import { FIELD_NAME_MAXIMUM_LENGTH } from '@/settings/data-model/constants/FieldNameMaximumLength';
|
|
import { SettingsDataModelFieldDescriptionForm } from '@/settings/data-model/fields/forms/components/SettingsDataModelFieldDescriptionForm';
|
|
import { SettingsDataModelFieldIconLabelForm } from '@/settings/data-model/fields/forms/components/SettingsDataModelFieldIconLabelForm';
|
|
import { SettingsDataModelFieldSettingsFormCard } from '@/settings/data-model/fields/forms/components/SettingsDataModelFieldSettingsFormCard';
|
|
import { settingsFieldFormSchema } from '@/settings/data-model/fields/forms/validation-schemas/settingsFieldFormSchema';
|
|
import { SettingsFieldType } from '@/settings/data-model/types/SettingsFieldType';
|
|
import { getSettingsPagePath } from '@/settings/utils/getSettingsPagePath';
|
|
import { AppPath } from '@/types/AppPath';
|
|
import { SettingsPath } from '@/types/SettingsPath';
|
|
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
|
|
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
|
import { Button } from '@/ui/input/button/components/Button';
|
|
import { SubMenuTopBarContainer } from '@/ui/layout/page/SubMenuTopBarContainer';
|
|
import { Section } from '@/ui/layout/section/components/Section';
|
|
import { FieldMetadataType } from '~/generated-metadata/graphql';
|
|
import { isDefined } from '~/utils/isDefined';
|
|
|
|
//TODO: fix this type
|
|
type SettingsDataModelFieldEditFormValues = z.infer<
|
|
ReturnType<typeof settingsFieldFormSchema>
|
|
> &
|
|
any;
|
|
|
|
const canPersistFieldMetadataItemUpdate = (
|
|
fieldMetadataItem: FieldMetadataItem,
|
|
) => {
|
|
return (
|
|
fieldMetadataItem.isCustom ||
|
|
fieldMetadataItem.type === FieldMetadataType.Select ||
|
|
fieldMetadataItem.type === FieldMetadataType.MultiSelect
|
|
);
|
|
};
|
|
|
|
export const SettingsObjectFieldEdit = () => {
|
|
const navigate = useNavigate();
|
|
const { enqueueSnackBar } = useSnackBar();
|
|
|
|
const { objectSlug = '', fieldSlug = '' } = useParams();
|
|
const { findObjectMetadataItemBySlug } = useFilteredObjectMetadataItems();
|
|
|
|
const objectMetadataItem = findObjectMetadataItemBySlug(objectSlug);
|
|
|
|
const { deactivateMetadataField, activateMetadataField } =
|
|
useFieldMetadataItem();
|
|
|
|
const fieldMetadataItem = objectMetadataItem?.fields.find(
|
|
(fieldMetadataItem) => getFieldSlug(fieldMetadataItem) === fieldSlug,
|
|
);
|
|
|
|
const getRelationMetadata = useGetRelationMetadata();
|
|
const { updateOneFieldMetadataItem } = useUpdateOneFieldMetadataItem();
|
|
|
|
const apolloClient = useApolloClient();
|
|
|
|
const { findManyRecordsQuery } = useFindManyRecordsQuery({
|
|
objectNameSingular: objectMetadataItem?.nameSingular || '',
|
|
});
|
|
|
|
const refetchRecords = async () => {
|
|
if (!objectMetadataItem) return;
|
|
await apolloClient.query({
|
|
query: findManyRecordsQuery,
|
|
fetchPolicy: 'network-only',
|
|
});
|
|
};
|
|
|
|
const formConfig = useForm<SettingsDataModelFieldEditFormValues>({
|
|
mode: 'onTouched',
|
|
resolver: zodResolver(settingsFieldFormSchema()),
|
|
values: {
|
|
icon: fieldMetadataItem?.icon ?? 'Icon',
|
|
type: fieldMetadataItem?.type as SettingsFieldType,
|
|
label: fieldMetadataItem?.label ?? '',
|
|
description: fieldMetadataItem?.description,
|
|
},
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (!objectMetadataItem || !fieldMetadataItem) {
|
|
navigate(AppPath.NotFound);
|
|
}
|
|
}, [fieldMetadataItem, objectMetadataItem, navigate]);
|
|
|
|
const { isDirty, isValid, isSubmitting } = formConfig.formState;
|
|
const canSave = isDirty && isValid && !isSubmitting;
|
|
|
|
if (!isDefined(objectMetadataItem) || !isDefined(fieldMetadataItem)) {
|
|
return null;
|
|
}
|
|
|
|
const isLabelIdentifier = isLabelIdentifierField({
|
|
fieldMetadataItem: fieldMetadataItem,
|
|
objectMetadataItem: objectMetadataItem,
|
|
});
|
|
|
|
const handleSave = async (
|
|
formValues: SettingsDataModelFieldEditFormValues,
|
|
) => {
|
|
const { dirtyFields } = formConfig.formState;
|
|
|
|
try {
|
|
if (
|
|
formValues.type === FieldMetadataType.Relation &&
|
|
'relation' in formValues &&
|
|
'relation' in dirtyFields
|
|
) {
|
|
const { relationFieldMetadataItem } =
|
|
getRelationMetadata({
|
|
fieldMetadataItem: fieldMetadataItem,
|
|
}) ?? {};
|
|
|
|
if (isDefined(relationFieldMetadataItem)) {
|
|
await updateOneFieldMetadataItem({
|
|
fieldMetadataIdToUpdate: relationFieldMetadataItem.id,
|
|
updatePayload: formValues.relation.field,
|
|
});
|
|
}
|
|
}
|
|
|
|
const otherDirtyFields = omit(dirtyFields, 'relation');
|
|
|
|
if (Object.keys(otherDirtyFields).length > 0) {
|
|
const formattedInput = pick(
|
|
formatFieldMetadataItemInput(formValues),
|
|
Object.keys(otherDirtyFields),
|
|
);
|
|
|
|
await updateOneFieldMetadataItem({
|
|
fieldMetadataIdToUpdate: fieldMetadataItem.id,
|
|
updatePayload: formattedInput,
|
|
});
|
|
}
|
|
|
|
navigate(`/settings/objects/${objectSlug}`);
|
|
|
|
refetchRecords();
|
|
} catch (error) {
|
|
enqueueSnackBar((error as Error).message, {
|
|
variant: SnackBarVariant.Error,
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleDeactivate = async () => {
|
|
await deactivateMetadataField(fieldMetadataItem);
|
|
navigate(`/settings/objects/${objectSlug}`);
|
|
};
|
|
|
|
const handleActivate = async () => {
|
|
await activateMetadataField(fieldMetadataItem);
|
|
navigate(`/settings/objects/${objectSlug}`);
|
|
};
|
|
|
|
const shouldDisplaySaveAndCancel =
|
|
canPersistFieldMetadataItemUpdate(fieldMetadataItem);
|
|
|
|
return (
|
|
<RecordFieldValueSelectorContextProvider>
|
|
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
|
|
<FormProvider {...formConfig}>
|
|
<SubMenuTopBarContainer
|
|
title={fieldMetadataItem?.label}
|
|
links={[
|
|
{
|
|
children: 'Workspace',
|
|
href: getSettingsPagePath(SettingsPath.Workspace),
|
|
},
|
|
{
|
|
children: 'Objects',
|
|
href: '/settings/objects',
|
|
},
|
|
{
|
|
children: objectMetadataItem.labelPlural,
|
|
href: `/settings/objects/${objectSlug}`,
|
|
},
|
|
{
|
|
children: fieldMetadataItem.label,
|
|
},
|
|
]}
|
|
actionButton={
|
|
shouldDisplaySaveAndCancel && (
|
|
<SaveAndCancelButtons
|
|
isSaveDisabled={!canSave}
|
|
isCancelDisabled={isSubmitting}
|
|
onCancel={() => navigate(`/settings/objects/${objectSlug}`)}
|
|
onSave={formConfig.handleSubmit(handleSave)}
|
|
/>
|
|
)
|
|
}
|
|
>
|
|
<SettingsPageContainer>
|
|
<Section>
|
|
<H2Title
|
|
title="Icon and Name"
|
|
description="The name and icon of this field"
|
|
/>
|
|
<SettingsDataModelFieldIconLabelForm
|
|
disabled={!fieldMetadataItem.isCustom}
|
|
fieldMetadataItem={fieldMetadataItem}
|
|
maxLength={FIELD_NAME_MAXIMUM_LENGTH}
|
|
/>
|
|
</Section>
|
|
<Section>
|
|
{fieldMetadataItem.isUnique ? (
|
|
<H2Title
|
|
title="Values"
|
|
description="The values of this field must be unique"
|
|
/>
|
|
) : (
|
|
<H2Title
|
|
title="Values"
|
|
description="The values of this field"
|
|
/>
|
|
)}
|
|
<SettingsDataModelFieldSettingsFormCard
|
|
fieldMetadataItem={fieldMetadataItem}
|
|
objectMetadataItem={objectMetadataItem}
|
|
/>
|
|
</Section>
|
|
<Section>
|
|
<H2Title
|
|
title="Description"
|
|
description="The description of this field"
|
|
/>
|
|
<SettingsDataModelFieldDescriptionForm
|
|
disabled={!fieldMetadataItem.isCustom}
|
|
fieldMetadataItem={fieldMetadataItem}
|
|
/>
|
|
</Section>
|
|
{!isLabelIdentifier && (
|
|
<Section>
|
|
<H2Title
|
|
title="Danger zone"
|
|
description="Deactivate this field"
|
|
/>
|
|
<Button
|
|
Icon={
|
|
fieldMetadataItem.isActive ? IconArchive : IconArchiveOff
|
|
}
|
|
variant="secondary"
|
|
title={fieldMetadataItem.isActive ? 'Deactivate' : 'Activate'}
|
|
size="small"
|
|
onClick={
|
|
fieldMetadataItem.isActive
|
|
? handleDeactivate
|
|
: handleActivate
|
|
}
|
|
/>
|
|
</Section>
|
|
)}
|
|
</SettingsPageContainer>
|
|
</SubMenuTopBarContainer>
|
|
</FormProvider>
|
|
</RecordFieldValueSelectorContextProvider>
|
|
);
|
|
};
|