Add unique indexes and indexes for composite types (#7162)

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>
This commit is contained in:
Félix Malfait
2024-10-13 10:21:03 +02:00
committed by GitHub
parent d1d4af0c63
commit b792d2a4d3
137 changed files with 22351 additions and 17974 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -136,6 +136,7 @@ describe('useCommandMenu', () => {
'ab7901eb-43e1-4dc7-8f3b-cdee2857eb9a',
imageIdentifierFieldMetadataId: null,
fields: [],
indexMetadatas: [],
},
]);
});

View File

@ -1,4 +1,4 @@
import React, { useCallback, useEffect } from 'react';
import { useCallback, useEffect } from 'react';
import { ObjectMetadataItemNotFoundError } from '@/object-metadata/errors/ObjectMetadataNotFoundError';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
@ -20,7 +20,7 @@ export const PromiseRejectionEffect = () => {
},
);
} else {
enqueueSnackBar(`Error: ${event.reason}`, {
enqueueSnackBar(`${error.message}`, {
variant: SnackBarVariant.Error,
});
}

View File

@ -47,38 +47,36 @@ export const initialFavorites = [
},
];
export const sortedFavorites = [
{
"avatarType": "rounded",
"avatarUrl": "",
"id": "1",
"labelIdentifier": " ",
"link": "/object/person/1",
"position": 0,
"recordId": "1",
"workspaceMemberId": undefined,
},
{
"avatarType": "rounded",
"avatarUrl": "",
"id": "2",
"labelIdentifier": " ",
"link": "/object/person/3",
"position": 1,
"recordId": "3",
"workspaceMemberId": undefined,
},
{
"avatarType": "squared",
"avatarUrl": "example.com",
"id": "3",
"key": "8f3b2121-f194-4ba4-9fbf-2d5a37126806",
"labelIdentifier": "favoriteLabel",
"link": "example.com",
"position": 2,
"recordId": "1",
},
]
export const sortedFavorites = [
{
id: '1',
recordId: '2',
position: 0,
avatarType: 'squared',
avatarUrl: undefined,
labelIdentifier: 'ABC Corp',
link: '/object/company/2',
},
{
id: '2',
recordId: '4',
position: 1,
avatarType: 'squared',
avatarUrl: undefined,
labelIdentifier: 'Company Test',
link: '/object/company/4',
},
{
id: '3',
position: 2,
key: '8f3b2121-f194-4ba4-9fbf-2d5a37126806',
labelIdentifier: 'favoriteLabel',
avatarUrl: 'example.com',
avatarType: 'squared',
link: 'example.com',
recordId: '1',
},
];
export const mocks = [
{
@ -343,8 +341,8 @@ export const mocks = [
mutation DeleteOneFavorite($idToDelete: ID!) {
deleteFavorite(id: $idToDelete) {
__typename
deletedAt
id
deletedAt
}
}
`,

View File

@ -0,0 +1,15 @@
import { findAvailableTimeZoneOption } from '@/localization/utils/findAvailableTimeZoneOption';
describe('findAvailableTimeZoneOption', () => {
it('should find the matching available IANA time zone select option from a given IANA time zone', () => {
const ianaTimeZone = 'Europe/Paris';
const expectedOption = {
label: '(GMT+02:00) Central European Summer Time - Paris',
value: 'Europe/Paris',
};
const option = findAvailableTimeZoneOption(ianaTimeZone);
expect(option).toEqual(expectedOption);
});
});

View File

@ -6,24 +6,11 @@ import { currentUserState } from '@/auth/states/currentUserState';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { useFindManyObjectMetadataItems } from '@/object-metadata/hooks/useFindManyObjectMetadataItems';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { WorkspaceActivationStatus } from '~/generated/graphql';
import { generatedMockObjectMetadataItems } from '~/testing/mock-data/generatedMockObjectMetadataItems';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
const filterTsVectorFields = (
objectMetadataItems: ObjectMetadataItem[],
): ObjectMetadataItem[] => {
return objectMetadataItems.map((item) => ({
...item,
fields: item.fields.filter(
(field) => field.type !== FieldMetadataType.TsVector,
),
}));
};
export const ObjectMetadataItemsLoadEffect = () => {
const currentUser = useRecoilValue(currentUserState);
const currentWorkspace = useRecoilValue(currentWorkspaceState);
@ -37,13 +24,12 @@ export const ObjectMetadataItemsLoadEffect = () => {
const updateObjectMetadataItems = useRecoilCallback(
({ set, snapshot }) =>
() => {
const filteredFields = filterTsVectorFields(newObjectMetadataItems);
const toSetObjectMetadataItems =
isUndefinedOrNull(currentUser) ||
currentWorkspace?.activationStatus !==
WorkspaceActivationStatus.Active
? generatedMockObjectMetadataItems
: filteredFields;
: newObjectMetadataItems;
if (
!isDeeplyEqual(

View File

@ -24,6 +24,30 @@ export const FIND_MANY_OBJECT_METADATA_ITEMS = gql`
updatedAt
labelIdentifierFieldMetadataId
imageIdentifierFieldMetadataId
indexMetadatas(paging: { first: 100 }) {
edges {
node {
id
createdAt
updatedAt
name
indexWhereClause
indexType
isUnique
indexFieldMetadatas(paging: { first: 100 }) {
edges {
node {
id
createdAt
updatedAt
order
fieldMetadataId
}
}
}
}
}
}
fields(paging: { first: 1000 }, filter: $fieldFilter) {
edges {
node {
@ -37,6 +61,7 @@ export const FIND_MANY_OBJECT_METADATA_ITEMS = gql`
isActive
isSystem
isNullable
isUnique
createdAt
updatedAt
defaultValue

View File

@ -1,5 +1,5 @@
import { useMemo } from 'react';
import { useQuery } from '@apollo/client';
import { useMemo } from 'react';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
@ -41,12 +41,9 @@ export const useFindManyObjectMetadataItems = ({
skip: skip || !apolloMetadataClient,
onError: (error) => {
logError('useFindManyObjectMetadataItems error : ' + error);
enqueueSnackBar(
`Error during useFindManyObjectMetadataItems, ${error.message}`,
{
variant: SnackBarVariant.Error,
},
);
enqueueSnackBar(`${error.message}`, {
variant: SnackBarVariant.Error,
});
},
});

View File

@ -0,0 +1,5 @@
import { IndexField as GeneratedIndexField } from '~/generated-metadata/graphql';
export type IndexFieldMetadataItem = Omit<GeneratedIndexField, '__typename'> & {
__typename?: string;
};

View File

@ -0,0 +1,10 @@
import { IndexFieldMetadataItem } from '@/object-metadata/types/IndexFieldMetadataItem';
import { Index as GeneratedIndex } from '~/generated-metadata/graphql';
export type IndexMetadataItem = Omit<
GeneratedIndex,
'__typename' | 'indexFieldMetadatas' | 'objectMetadata'
> & {
__typename?: string;
indexFieldMetadatas: IndexFieldMetadataItem[];
};

View File

@ -1,11 +1,13 @@
import { Object as GeneratedObject } from '~/generated-metadata/graphql';
import { IndexMetadataItem } from '@/object-metadata/types/IndexMetadataItem';
import { FieldMetadataItem } from './FieldMetadataItem';
export type ObjectMetadataItem = Omit<
GeneratedObject,
'__typename' | 'fields' | 'dataSourceId'
'__typename' | 'fields' | 'dataSourceId' | 'indexMetadatas'
> & {
__typename?: string;
fields: FieldMetadataItem[];
indexMetadatas: IndexMetadataItem[];
};

View File

@ -11,6 +11,12 @@ export const mapPaginatedObjectMetadataItemsToObjectMetadataItems = ({
pagedObjectMetadataItems?.objects.edges.map((object) => ({
...object.node,
fields: object.node.fields.edges.map((field) => field.node),
indexMetadatas: object.node.indexMetadatas.edges.map((index) => ({
...index.node,
indexFieldMetadatas: index.node.indexFieldMetadatas?.edges.map(
(indexField) => indexField.node,
),
})),
})) ?? [];
return formattedObjects;

View File

@ -20,6 +20,7 @@ export const fieldMetadataItemSchema = (existingLabels?: string[]) => {
isActive: z.boolean(),
isCustom: z.boolean(),
isNullable: z.boolean(),
isUnique: z.boolean(),
isSystem: z.boolean(),
label: metadataLabelSchema(existingLabels),
name: camelCaseStringSchema,

View File

@ -0,0 +1,12 @@
import { z } from 'zod';
import { IndexFieldMetadataItem } from '@/object-metadata/types/IndexFieldMetadataItem';
export const indexFieldMetadataItemSchema = z.object({
__typename: z.literal('indexField'),
fieldMetadataId: z.string().uuid(),
id: z.string(),
createdAt: z.string(),
updatedAt: z.string(),
order: z.number(),
}) satisfies z.ZodType<IndexFieldMetadataItem>;

View File

@ -0,0 +1,18 @@
import { z } from 'zod';
import { IndexMetadataItem } from '@/object-metadata/types/IndexMetadataItem';
import { indexFieldMetadataItemSchema } from '@/object-metadata/validation-schemas/indexFieldMetadataItemSchema';
import { IndexType } from '~/generated-metadata/graphql';
export const indexMetadataItemSchema = z.object({
__typename: z.literal('index'),
id: z.string().uuid(),
name: z.string(),
indexFieldMetadatas: z.array(indexFieldMetadataItemSchema),
createdAt: z.string(),
updatedAt: z.string(),
indexType: z.nativeEnum(IndexType),
indexWhereClause: z.string().nullable(),
isUnique: z.boolean(),
objectMetadata: z.any(),
}) satisfies z.ZodType<IndexMetadataItem>;

View File

@ -2,6 +2,7 @@ import { z } from 'zod';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { fieldMetadataItemSchema } from '@/object-metadata/validation-schemas/fieldMetadataItemSchema';
import { indexMetadataItemSchema } from '@/object-metadata/validation-schemas/indexMetadataItemSchema';
import { metadataLabelSchema } from '@/object-metadata/validation-schemas/metadataLabelSchema';
import { camelCaseStringSchema } from '~/utils/validation-schemas/camelCaseStringSchema';
@ -11,6 +12,7 @@ export const objectMetadataItemSchema = z.object({
dataSourceId: z.string().uuid(),
description: z.string().trim().nullable().optional(),
fields: z.array(fieldMetadataItemSchema()),
indexMetadatas: z.array(indexMetadataItemSchema),
icon: z.string().startsWith('Icon').trim(),
id: z.string().uuid(),
imageIdentifierFieldMetadataId: z.string().uuid().nullable(),

View File

@ -13,6 +13,7 @@ import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { getDeleteManyRecordsMutationResponseField } from '@/object-record/utils/getDeleteManyRecordsMutationResponseField';
import { useRecoilValue } from 'recoil';
import { isDefined } from '~/utils/isDefined';
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
import { sleep } from '~/utils/sleep';
import { capitalize } from '~/utils/string/capitalize';
@ -132,7 +133,7 @@ export const useDeleteManyRecords = ({
})
.catch((error: Error) => {
cachedRecords.forEach((cachedRecord) => {
if (!cachedRecord) {
if (isUndefinedOrNull(cachedRecord?.id)) {
return;
}

View File

@ -53,12 +53,9 @@ export const useFindDuplicateRecords = <T extends ObjectRecord = ObjectRecord>({
`useFindDuplicateRecords for "${objectMetadataItem.nameSingular}" error : ` +
error,
);
enqueueSnackBar(
`Error during useFindDuplicateRecords for "${objectMetadataItem.nameSingular}", ${error.message}`,
{
variant: SnackBarVariant.Error,
},
);
enqueueSnackBar(`Error finding duplicates:", ${error.message}`, {
variant: SnackBarVariant.Error,
});
},
},
);

View File

@ -19,12 +19,9 @@ export const useHandleFindManyRecordsError = ({
`useFindManyRecords for "${objectMetadataItem.namePlural}" error : ` +
error,
);
enqueueSnackBar(
`Error during useFindManyRecords for "${objectMetadataItem.namePlural}", ${error.message}`,
{
variant: SnackBarVariant.Error,
},
);
enqueueSnackBar(`${error.message}`, {
variant: SnackBarVariant.Error,
});
handleError?.(error);
};

View File

@ -11,6 +11,7 @@ import { useUpdateOneRecordMutation } from '@/object-record/hooks/useUpdateOneRe
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { getUpdateOneRecordMutationResponseField } from '@/object-record/utils/getUpdateOneRecordMutationResponseField';
import { sanitizeRecordInput } from '@/object-record/utils/sanitizeRecordInput';
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
import { capitalize } from '~/utils/string/capitalize';
type useUpdateOneRecordProps = {
@ -130,7 +131,7 @@ export const useUpdateOneRecord = <
},
})
.catch((error: Error) => {
if (!cachedRecord) {
if (isUndefinedOrNull(cachedRecord?.id)) {
throw error;
}
updateRecordFromCache({

View File

@ -13,6 +13,7 @@ const sortDefinition: SortDefinition = {
const objectMetadataItem: ObjectMetadataItem = {
id: 'object1',
fields: [],
indexMetadatas: [],
createdAt: '2021-01-01',
updatedAt: '2021-01-01',
nameSingular: 'object1',

View File

@ -156,6 +156,11 @@ export type FieldPhonesMetadata = {
fieldName: string;
};
export type FieldTsVectorMetadata = {
objectMetadataNameSingular?: string;
fieldName: string;
};
export type FieldMetadata =
| FieldBooleanMetadata
| FieldCurrencyMetadata
@ -174,8 +179,8 @@ export type FieldMetadata =
| FieldUuidMetadata
| FieldAddressMetadata
| FieldActorMetadata
| FieldArrayMetadata;
| FieldArrayMetadata
| FieldTsVectorMetadata;
export type FieldTextValue = string;
export type FieldUUidValue = string; // TODO: can we replace with a template literal type, or maybe overkill ?
export type FieldDateTimeValue = string | null;

View File

@ -0,0 +1,10 @@
import { FieldMetadata, FieldTsVectorMetadata } from '../FieldMetadata';
import { FieldDefinition } from '../FieldDefinition';
import { FieldMetadataType } from '~/generated-metadata/graphql';
export const isFieldTsVector = (
field: Pick<FieldDefinition<FieldMetadata>, 'type'>,
): field is FieldDefinition<FieldTsVectorMetadata> =>
field.type === FieldMetadataType.TsVector;

View File

@ -32,6 +32,7 @@ import { isFieldRichText } from '@/object-record/record-field/types/guards/isFie
import { isFieldSelect } from '@/object-record/record-field/types/guards/isFieldSelect';
import { isFieldSelectValue } from '@/object-record/record-field/types/guards/isFieldSelectValue';
import { isFieldText } from '@/object-record/record-field/types/guards/isFieldText';
import { isFieldTsVector } from '@/object-record/record-field/types/guards/isFieldTsVectorValue';
import { isFieldUuid } from '@/object-record/record-field/types/guards/isFieldUuid';
import { isDefined } from '~/utils/isDefined';
import { stripSimpleQuotesFromString } from '~/utils/string/stripSimpleQuotesFromString';
@ -130,6 +131,10 @@ export const isFieldValueEmpty = ({
);
}
if (isFieldTsVector(fieldDefinition)) {
return false;
}
throw new Error(
`Entity field type not supported in isFieldValueEmpty : ${fieldDefinition.type}}`,
);

View File

@ -220,7 +220,7 @@ describe('useTableData', () => {
delayMs: 0,
viewType: ViewType.Kanban,
}),
setKanbanFieldName: useRecordBoard(recordIndexId),
useRecordBoardHook: useRecordBoard(recordIndexId),
kanbanFieldName: useRecoilValue(kanbanFieldNameState),
kanbanData: useRecordIndexOptionsForBoard({
objectNameSingular,
@ -243,7 +243,7 @@ describe('useTableData', () => {
);
await act(async () => {
result.current.setKanbanFieldName.setKanbanFieldMetadataName(
result.current.useRecordBoardHook.setKanbanFieldMetadataName(
updatedAtFieldMetadataItem?.name,
);
});
@ -278,10 +278,14 @@ describe('useTableData', () => {
relationObjectMetadataNameSingular: '',
relationType: undefined,
targetFieldMetadataName: '',
settings: {},
settings: {
displayAsRelativeDate: true,
},
},
position: expect.any(Number),
settings: {
displayAsRelativeDate: true,
},
position: 7,
settings: {},
showLabel: undefined,
size: 100,
type: 'DATE_TIME',

View File

@ -27,6 +27,7 @@ describe('useLimitPerMetadataItem', () => {
nameSingular: 'nameSingular',
updatedAt: 'updatedAt',
fields: [],
indexMetadatas: [],
},
];

View File

@ -46,6 +46,7 @@ const objectData: ObjectMetadataItem[] = [
isActive: true,
},
],
indexMetadatas: [],
},
];

View File

@ -0,0 +1,14 @@
import { IndexMetadataItem } from '@/object-metadata/types/IndexMetadataItem';
import { createFamilyState } from '@/ui/utilities/state/utils/createFamilyState';
export type SortedIndexByTableFamilyStateKey = {
objectMetadataItemId: string;
};
export const settingsObjectIndexesFamilyState = createFamilyState<
IndexMetadataItem[] | null,
SortedIndexByTableFamilyStateKey
>({
key: 'settingsObjectIndexesFamilyState',
defaultValue: null,
});

View File

@ -1,7 +1,7 @@
import { styled } from '@linaria/react';
const StyledEllipsisDisplay = styled.div<{ maxWidth?: number }>`
max-width: ${({ maxWidth }) => maxWidth ?? '100%'};
max-width: ${({ maxWidth }) => (maxWidth ? maxWidth + 'px' : '100%')};
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;

View File

@ -2,7 +2,7 @@ import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { sortedFieldByTableFamilyState } from '@/ui/layout/table/states/sortedFieldByTableFamilyState';
import { TableSortValue } from '@/ui/layout/table/types/TableSortValue';
import { useRecoilState } from 'recoil';
import { IconArrowDown, IconArrowUp } from 'twenty-ui';
import { IconArrowDown, IconArrowUp, IconComponent } from 'twenty-ui';
export const SortableTableHeader = ({
tableId,
@ -10,12 +10,14 @@ export const SortableTableHeader = ({
label,
align = 'left',
initialSort,
Icon,
}: {
tableId: string;
fieldName: string;
label: string;
align?: 'left' | 'center' | 'right';
initialSort?: TableSortValue;
Icon?: IconComponent;
}) => {
const [sortedFieldByTable, setSortedFieldByTable] = useRecoilState(
sortedFieldByTableFamilyState({ tableId }),
@ -54,6 +56,7 @@ export const SortableTableHeader = ({
<IconArrowDown size="14" />
)
) : null}
{Icon && <Icon size={14} />}
{label}
{isSortActive && align === 'left' ? (
isAsc ? (

View File

@ -1,6 +1,9 @@
import { IconComponent } from 'twenty-ui';
export type TableFieldMetadata<ItemType> = {
fieldLabel: string;
fieldName: keyof ItemType;
fieldType: 'string' | 'number';
align: 'left' | 'right';
FieldIcon?: IconComponent;
};

View File

@ -14,4 +14,5 @@ export type FeatureFlagKey =
| 'IS_QUERY_RUNNER_TWENTY_ORM_ENABLED'
| 'IS_GMAIL_SEND_EMAIL_SCOPE_ENABLED'
| 'IS_WORKSPACE_MIGRATED_FOR_SEARCH'
| 'IS_ANALYTICS_V2_ENABLED';
| 'IS_ANALYTICS_V2_ENABLED'
| 'IS_UNIQUE_INDEXES_ENABLED';

View File

@ -8,10 +8,14 @@ import { Button } from '@/ui/input/button/components/Button';
import { SubMenuTopBarContainer } from '@/ui/layout/page/SubMenuTopBarContainer';
import { Section } from '@/ui/layout/section/components/Section';
import { UndecoratedLink } from '@/ui/navigation/link/components/UndecoratedLink';
import { isAdvancedModeEnabledState } from '@/ui/navigation/navigation-drawer/states/isAdvancedModeEnabledState';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import styled from '@emotion/styled';
import { useNavigate } from 'react-router-dom';
import { useRecoilValue } from 'recoil';
import { H2Title, IconPlus } from 'twenty-ui';
import { SettingsObjectFieldTable } from '~/pages/settings/data-model/SettingsObjectFieldTable';
import { SettingsObjectIndexTable } from '~/pages/settings/data-model/SettingsObjectIndexTable';
const StyledDiv = styled.div`
display: flex;
@ -40,6 +44,12 @@ export const SettingsObjectDetailPageContent = ({
const shouldDisplayAddFieldButton = !objectMetadataItem.isRemote;
const isAdvancedModeEnabled = useRecoilValue(isAdvancedModeEnabledState);
const isUniqueIndexesEnabled = useIsFeatureEnabled(
'IS_UNIQUE_INDEXES_ENABLED',
);
return (
<SubMenuTopBarContainer
title={objectMetadataItem.labelPlural}
@ -85,6 +95,15 @@ export const SettingsObjectDetailPageContent = ({
</StyledDiv>
)}
</Section>
{isAdvancedModeEnabled && isUniqueIndexesEnabled && (
<Section>
<H2Title
title="Indexes"
description={`Advanced feature to improve the performance of queries and to enforce unicity constraints.`}
/>
<SettingsObjectIndexTable objectMetadataItem={objectMetadataItem} />
</Section>
)}
</SettingsPageContainer>
</SubMenuTopBarContainer>
);

View File

@ -223,7 +223,17 @@ export const SettingsObjectFieldEdit = () => {
/>
</Section>
<Section>
<H2Title title="Values" description="The values of this field" />
{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}

View File

@ -0,0 +1,152 @@
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { settingsObjectIndexesFamilyState } from '@/settings/data-model/object-details/states/settingsObjectIndexesFamilyState';
import { TextInput } from '@/ui/input/components/TextInput';
import { SortableTableHeader } from '@/ui/layout/table/components/SortableTableHeader';
import { Table } from '@/ui/layout/table/components/Table';
import { TableCell } from '@/ui/layout/table/components/TableCell';
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import { useSortedArray } from '@/ui/layout/table/hooks/useSortedArray';
import { TableMetadata } from '@/ui/layout/table/types/TableMetadata';
import styled from '@emotion/styled';
import { isNonEmptyArray } from '@sniptt/guards';
import { useEffect, useMemo, useState } from 'react';
import { useRecoilState } from 'recoil';
import { IconSearch, IconSquareKey } from 'twenty-ui';
import { SettingsObjectIndexesTableItem } from '~/pages/settings/data-model/types/SettingsObjectIndexesTableItem';
export const StyledObjectIndexTableRow = styled(TableRow)`
grid-template-columns: 350px 70px 80px;
`;
const SETTINGS_OBJECT_DETAIL_TABLE_METADATA_STANDARD: TableMetadata<SettingsObjectIndexesTableItem> =
{
tableId: 'settingsObjectIndexs',
fields: [
{
fieldLabel: 'Fields',
fieldName: 'indexFields',
fieldType: 'string',
align: 'left',
},
{
fieldLabel: '',
FieldIcon: IconSquareKey,
fieldName: 'isUnique',
fieldType: 'string',
align: 'left',
},
{
fieldLabel: 'Type',
fieldName: 'indexType',
fieldType: 'string',
align: 'right',
},
],
initialSort: {
fieldName: 'name',
orderBy: 'AscNullsLast',
},
};
const StyledSearchInput = styled(TextInput)`
padding-bottom: ${({ theme }) => theme.spacing(2)};
width: 100%;
`;
export type SettingsObjectIndexTableProps = {
objectMetadataItem: ObjectMetadataItem;
};
export const SettingsObjectIndexTable = ({
objectMetadataItem,
}: SettingsObjectIndexTableProps) => {
const [searchTerm, setSearchTerm] = useState('');
const [settingsObjectIndexes, setSettingsObjectIndexes] = useRecoilState(
settingsObjectIndexesFamilyState({
objectMetadataItemId: objectMetadataItem.id,
}),
);
useEffect(() => {
setSettingsObjectIndexes(objectMetadataItem.indexMetadatas);
}, [objectMetadataItem, setSettingsObjectIndexes]);
const objectSettingsDetailItems = useMemo(() => {
return (
settingsObjectIndexes?.map((indexMetadataItem) => {
return {
name: indexMetadataItem.name,
isUnique: indexMetadataItem.isUnique,
indexType: indexMetadataItem.indexType,
indexFields: indexMetadataItem.indexFieldMetadatas
?.map((indexField) => {
const fieldMetadataItem = objectMetadataItem.fields.find(
(field) => field.id === indexField.fieldMetadataId,
);
return fieldMetadataItem?.label;
})
.join(', '),
};
}) ?? []
);
}, [settingsObjectIndexes, objectMetadataItem]);
const sortedActiveObjectSettingsDetailItems = useSortedArray(
objectSettingsDetailItems,
SETTINGS_OBJECT_DETAIL_TABLE_METADATA_STANDARD,
);
const filteredActiveItems = useMemo(
() =>
sortedActiveObjectSettingsDetailItems.filter(
(item) =>
item.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
item.indexType.toLowerCase().includes(searchTerm.toLowerCase()),
),
[sortedActiveObjectSettingsDetailItems, searchTerm],
);
return (
<>
<StyledSearchInput
LeftIcon={IconSearch}
placeholder="Search an index..."
value={searchTerm}
onChange={setSearchTerm}
/>
<Table>
<StyledObjectIndexTableRow>
{SETTINGS_OBJECT_DETAIL_TABLE_METADATA_STANDARD.fields.map((item) => (
<SortableTableHeader
key={item.fieldName}
fieldName={item.fieldName}
label={item.fieldLabel}
Icon={item.FieldIcon}
tableId={SETTINGS_OBJECT_DETAIL_TABLE_METADATA_STANDARD.tableId}
initialSort={
SETTINGS_OBJECT_DETAIL_TABLE_METADATA_STANDARD.initialSort
}
/>
))}
<TableHeader></TableHeader>
</StyledObjectIndexTableRow>
{isNonEmptyArray(filteredActiveItems) &&
filteredActiveItems.map((objectSettingsIndex) => (
<StyledObjectIndexTableRow key={objectSettingsIndex.name}>
<TableCell>{objectSettingsIndex.indexFields}</TableCell>
<TableCell>
{objectSettingsIndex.isUnique ? (
<IconSquareKey size={14} />
) : (
''
)}
</TableCell>
<TableCell>{objectSettingsIndex.indexType}</TableCell>
</StyledObjectIndexTableRow>
))}
</Table>
</>
);
};

View File

@ -0,0 +1,9 @@
import { IndexType } from '~/generated-metadata/graphql';
export type SettingsObjectIndexesTableItem = {
name: string;
indexType: IndexType;
isUnique: boolean;
indexWhereClause?: string | null;
indexFields: string;
};

View File

@ -5,4 +5,10 @@ export const generatedMockObjectMetadataItems: ObjectMetadataItem[] =
mockedStandardObjectMetadataQueryResult.objects.edges.map((edge) => ({
...edge.node,
fields: edge.node.fields.edges.map((edge) => edge.node),
indexMetadatas: edge.node.indexMetadatas.edges.map((index) => ({
...index.node,
indexFieldMetadatas: index.node.indexFieldMetadatas?.edges.map(
(indexField) => indexField.node,
),
})),
}));