feat(data-model): custom-indexes management UI and mutations (#20846)

## Summary

Brings indexes management into the per-object Settings tab as a section
under Search (no feature flag, advanced mode only). Admins can create /
delete non-unique indexes with the UI; apps can declare indexes in code
with `defineIndex`. Composite-typed fields are now indexable by picking
a specific sub-column (e.g. `Address > City`).

A few related polish items also land here (invite-user dropdown lands on
the Invite tab; standard warning callout above the new-index form).

## What ships

### UI — custom indexes on per-object Settings
- New section directly under Search, wrapped in
`AdvancedSettingsWrapper`.
- Filter dropdown on the search bar toggles system-index visibility
(shown by default since advanced mode).
- **+ Add Index** button (disabled with tooltip once the per-object cap
is reached) navigates to a dedicated `SettingsObjectNewIndex` page
(matches the field-creation pattern, not a modal):
- Field picker mirrors the webhook event-form layout (rows of dropdowns,
implicit trailing empty row).
- Composite fields surface their sub-properties (`Address > City`,
`Currency > Amount`, …).
  - BTREE / GIN type selector.
- Standard warning Callout: "Use indexes sparingly — each one speeds
reads but slows writes."
- Trash icon on `isCustom: true` rows → confirmation modal →
`deleteOneIndex`.

### Server — `createOneIndex` / `deleteOneIndex` mutations
- Gated by `SettingsPermissionGuard(DATA_MODEL)`.
- `IndexMetadataService` wraps the existing migration runner via
`WorkspaceMigrationValidateBuildAndRunService` so the metadata row and
the SQL index land atomically.
- Validation: rejects empty fields, duplicate `(fieldMetadataId,
subFieldName)` pairs, fields not on the object, requires `subFieldName`
for composite parents, forbids `subFieldName` on scalar/relation,
enforces `MAX_CUSTOM_INDEXES_PER_OBJECT = 10`.
- Delete refuses on `isCustom: false` rows so system indexes can't be
removed via this API.
- Dedicated GraphQL exception handler maps each typed error to the right
transport error class.

### Composite sub-field indexing
- Adds `subFieldName: string | null` column to
`IndexFieldMetadataEntity` (fast instance command).
- The flat-entity flow (`UniversalFlatIndexFieldMetadata`,
`FlatIndexFieldMetadata`, `from-universal-flat-index-to-flat-index`,
runner column resolution) all carry `subFieldName` through.
- For composite parents, the runner uses
`computeCompositeColumnName({...}, property)` for the picked sub-column;
for non-composite parents, behavior is unchanged.
- The `'::'` separator encodes `(fieldMetadataId, subFieldName)` for
dedup on the wire; the frontend uses the same separator inside the
Select component's string value.

### Apps can declare indexes in code (`defineIndex`)
- New `IndexManifest` + `IndexFieldManifest` types in
`twenty-shared/application` wired into the `Manifest` type.
- `defineIndex` SDK helper + `IndexConfig`. CLI manifest builder +
extractor recognize `defineIndex` / `ManifestEntityKey.Indexes`.
- Server: `from-index-manifest-to-universal-flat-index` converter
resolves field IDs, validates composite/scalar `subFieldName` rules, and
delegates to `generateFlatIndexMetadataWithNameOrThrow` for the
deterministic name.
- Orchestrator wires the loop after the field-resolution pass;
per-object cap enforced inline against the manifest.
- Cascade on uninstall is automatic — when an app disappears its indexes
drop with it (universal-flat-entity diff handles it).
- Rich-app fixture ships a real `defineIndex` on `PostCard.status`,
exercising the full manifest → install path in CI.

### Closed for now (open later if needed)
- Apps cannot declare `isUnique` indexes — unique constraints stay with
the field-creation flow.
- Apps cannot use a partial-`indexWhereClause` — the UI surface keeps
the framework's hardcoded allowlist.
- UI cannot create unique or partial indexes either; same reasons.

### Cleanups along the way
- Reused the existing `getCompositeSubFieldLabel` +
`COMPOSITE_FIELD_SUB_FIELD_LABELS` (deleted the duplicates I'd created
early in the PR).
- Moved `MAX_CUSTOM_INDEXES_PER_OBJECT` to `twenty-shared/constants`
(single source for FE + BE).
- Replaced inline `isDefined(x) && x !== ''` with `isNonEmptyString`
(from `@sniptt/guards`).
- Hoisted the per-object fields Map + inlined the cap counter into the
indexes orchestrator loop (drops the install scan from O(indexes ×
totalFields) to O(totalFields + indexes)).
- Per design-feedback: page-based create flow (not a modal), filter
dropdown on the SearchInput (not a separate toggle), webhook-style
picker, field icons.

### Unrelated polish that lands here
- "Invite user" link in the multi-workspace dropdown now lands on the
Invite tab directly (`#invite`) instead of the first tab of the members
page.

## Test plan
- [ ] `npx nx typecheck twenty-server / twenty-front / twenty-sdk /
twenty-shared` — passes
- [ ] `npx nx lint:diff-with-main twenty-server / twenty-front` — clean
- [ ] `npx jest index-metadata.service.spec` — green
- [ ] `npx jest from-index-manifest-to-universal-flat-index` — green
(new converter spec, 8 cases)
- [ ] `npx vitest run
src/sdk/define/indexes/__tests__/define-index.spec.ts` (twenty-sdk) —
green (6 cases)
- [ ] `npx vitest run --config vitest.integration.config.ts -t
"rich-app"` — green (rich-app app-dev integration exercises the new
manifest path with the PostCard.status index)
- [ ] Advanced mode → Settings → any object → Settings tab → Indexes
section is visible under Search
- [ ] Create a single-field BTREE index, confirm SQL index exists
(verify via `pg_indexes`)
- [ ] Create a composite-field index (`Address > City`) and confirm the
column is `addressAddressCity`
- [ ] Create an index spanning two columns; column order matches the
picker order
- [ ] Attempt to create an 11th custom index → button is disabled with
tooltip
- [ ] Delete a custom index → confirmation modal → row disappears, PG
index dropped
- [ ] System indexes have no trash icon and are hidden by default
This commit is contained in:
Félix Malfait
2026-05-25 17:47:09 +02:00
committed by GitHub
parent 69d89f8cfc
commit d602f35cbd
98 changed files with 3742 additions and 387 deletions
@@ -381,6 +381,14 @@ const SettingsObjectNewFieldConfigure = lazy(() =>
}),
),
);
const SettingsObjectNewIndex = lazy(() =>
import('~/pages/settings/data-model/new-index/SettingsObjectNewIndex').then(
(module) => ({
default: module.SettingsObjectNewIndex,
}),
),
);
const SettingsObjectFieldEdit = lazy(() =>
import('~/pages/settings/data-model/SettingsObjectFieldEdit').then(
(module) => ({
@@ -742,6 +750,10 @@ export const SettingsRoutes = ({ isAdminPageEnabled }: SettingsRoutesProps) => (
path={SettingsPath.ObjectNewFieldConfigure}
element={<SettingsObjectNewFieldConfigure />}
/>
<Route
path={SettingsPath.ObjectNewIndex}
element={<SettingsObjectNewIndex />}
/>
<Route
path={SettingsPath.ObjectFieldEdit}
element={<SettingsObjectFieldEdit />}
@@ -37,6 +37,7 @@ export const OBJECT_METADATA_FRAGMENT = gql`
indexFieldMetadataList {
id
fieldMetadataId
subFieldName
createdAt
updatedAt
order
@@ -260,3 +260,35 @@ export const DELETE_ONE_FIELD_METADATA_ITEM = gql`
}
}
`;
export const CREATE_ONE_INDEX_METADATA_ITEM = gql`
mutation CreateOneIndexMetadataItem($input: CreateOneIndexInput!) {
createOneIndex(input: $input) {
id
name
indexType
isUnique
isCustom
indexWhereClause
createdAt
updatedAt
indexFieldMetadataList {
id
fieldMetadataId
subFieldName
createdAt
updatedAt
order
}
}
}
`;
export const DELETE_ONE_INDEX_METADATA_ITEM = gql`
mutation DeleteOneIndexMetadataItem($idToDelete: UUID!) {
deleteOneIndex(input: { id: $idToDelete }) {
id
name
}
}
`;
@@ -0,0 +1,91 @@
import { useMutation } from '@apollo/client/react';
import { CombinedGraphQLErrors } from '@apollo/client/errors';
import { t } from '@lingui/core/macro';
import { isDefined } from 'twenty-shared/utils';
import { CrudOperationType } from 'twenty-shared/types';
import { useMetadataErrorHandler } from '@/metadata-error-handler/hooks/useMetadataErrorHandler';
import { useUpdateMetadataStoreDraft } from '@/metadata-store/hooks/useUpdateMetadataStoreDraft';
import { type FlatIndexMetadataItem } from '@/metadata-store/types/FlatIndexMetadataItem';
import { type IndexFieldMetadataItem } from '@/object-metadata/types/IndexFieldMetadataItem';
import { type MetadataRequestResult } from '@/object-metadata/types/MetadataRequestResult.type';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import {
type CreateIndexInput,
CreateOneIndexMetadataItemDocument,
} from '~/generated-metadata/graphql';
export const useCreateOneIndexMetadataItem = () => {
const [createOneIndexMetadataItemMutation] = useMutation(
CreateOneIndexMetadataItemDocument,
);
const { handleMetadataError } = useMetadataErrorHandler();
const { enqueueErrorSnackBar } = useSnackBar();
const { addToDraft, applyChanges } = useUpdateMetadataStoreDraft();
const createOneIndexMetadataItem = async (
input: CreateIndexInput,
): Promise<
MetadataRequestResult<
Awaited<ReturnType<typeof createOneIndexMetadataItemMutation>>
>
> => {
try {
const response = await createOneIndexMetadataItemMutation({
variables: {
input: {
index: input,
},
},
});
const createdIndex = response.data?.createOneIndex;
if (isDefined(createdIndex)) {
const { __typename, indexFieldMetadataList, ...indexData } =
createdIndex;
const indexFieldMetadatas: IndexFieldMetadataItem[] =
indexFieldMetadataList.map(
({ __typename: _, ...rest }) => rest as IndexFieldMetadataItem,
);
addToDraft({
key: 'indexMetadataItems',
items: [
{
...indexData,
indexFieldMetadatas,
objectMetadataId: input.objectMetadataId,
} as FlatIndexMetadataItem,
],
});
applyChanges();
}
return {
status: 'successful',
response,
};
} catch (error) {
if (CombinedGraphQLErrors.is(error)) {
handleMetadataError(error, {
primaryMetadataName: 'index',
operationType: CrudOperationType.CREATE,
});
} else {
enqueueErrorSnackBar({ message: t`An error occurred.` });
}
return {
status: 'failed',
error,
};
}
};
return {
createOneIndexMetadataItem,
};
};
@@ -0,0 +1,64 @@
import { useMutation } from '@apollo/client/react';
import { CombinedGraphQLErrors } from '@apollo/client/errors';
import { t } from '@lingui/core/macro';
import { CrudOperationType } from 'twenty-shared/types';
import { useMetadataErrorHandler } from '@/metadata-error-handler/hooks/useMetadataErrorHandler';
import { useUpdateMetadataStoreDraft } from '@/metadata-store/hooks/useUpdateMetadataStoreDraft';
import { type MetadataRequestResult } from '@/object-metadata/types/MetadataRequestResult.type';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { DeleteOneIndexMetadataItemDocument } from '~/generated-metadata/graphql';
export const useDeleteOneIndexMetadataItem = () => {
const [deleteOneIndexMetadataItemMutation] = useMutation(
DeleteOneIndexMetadataItemDocument,
);
const { handleMetadataError } = useMetadataErrorHandler();
const { enqueueErrorSnackBar } = useSnackBar();
const { removeFromDraft, applyChanges } = useUpdateMetadataStoreDraft();
const deleteOneIndexMetadataItem = async ({
idToDelete,
}: {
idToDelete: string;
}): Promise<
MetadataRequestResult<
Awaited<ReturnType<typeof deleteOneIndexMetadataItemMutation>>
>
> => {
try {
const response = await deleteOneIndexMetadataItemMutation({
variables: {
idToDelete,
},
});
removeFromDraft({ key: 'indexMetadataItems', itemIds: [idToDelete] });
applyChanges();
return {
status: 'successful',
response,
};
} catch (error) {
if (CombinedGraphQLErrors.is(error)) {
handleMetadataError(error, {
primaryMetadataName: 'index',
operationType: CrudOperationType.DELETE,
});
} else {
enqueueErrorSnackBar({ message: t`An error occurred.` });
}
return {
status: 'failed',
error,
};
}
};
return {
deleteOneIndexMetadataItem,
};
};
@@ -0,0 +1,136 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { buildIndexableSelectOptions } from '@/settings/data-model/indexes/utils/buildIndexableSelectOptions';
import { decodeIndexableOptionValue } from '@/settings/data-model/indexes/utils/decodeIndexableOptionValue';
import { encodeIndexableOptionValue } from '@/settings/data-model/indexes/utils/encodeIndexableOptionValue';
import { Select } from '@/ui/input/components/Select';
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { useMemo } from 'react';
import { Controller, useFormContext } from 'react-hook-form';
import { IconTrash, useIcons } from 'twenty-ui/display';
import { IconButton, type SelectOption } from 'twenty-ui/input';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { type SettingsObjectNewIndexFormValues } from '~/pages/settings/data-model/new-index/SettingsObjectNewIndexFormValues';
type SettingsObjectIndexFieldsFormProps = {
indexableFields: FieldMetadataItem[];
};
const StyledFieldRow = styled.div`
align-items: center;
display: flex;
gap: ${themeCssVariables.spacing[2]};
margin-bottom: ${themeCssVariables.spacing[2]};
`;
const StyledSelectWrapper = styled.div`
flex: 1;
min-width: 0;
`;
const StyledPlaceholder = styled.div`
height: ${themeCssVariables.spacing[8]};
width: ${themeCssVariables.spacing[8]};
`;
export const SettingsObjectIndexFieldsForm = ({
indexableFields,
}: SettingsObjectIndexFieldsFormProps) => {
const { t } = useLingui();
const { getIcon } = useIcons();
const { control } = useFormContext<SettingsObjectNewIndexFormValues>();
const allOptions = useMemo(
() => buildIndexableSelectOptions({ indexableFields, getIcon }),
[indexableFields, getIcon],
);
const emptyFieldOption: SelectOption<string> = {
label: t`Select a field`,
value: '',
};
return (
<Controller
name="fields"
control={control}
render={({ field: { value, onChange } }) => {
const rows: (
| SettingsObjectNewIndexFormValues['fields'][number]
| null
)[] = [...value, null];
const pickedValues = value.map((entry) =>
encodeIndexableOptionValue(entry.fieldMetadataId, entry.subFieldName),
);
const handleSelect = (rowIndex: number, newOptionValue: string) => {
if (newOptionValue === '') return;
const next = [...value];
const decoded = decodeIndexableOptionValue(newOptionValue);
if (rowIndex < value.length) {
next[rowIndex] = decoded;
} else {
next.push(decoded);
}
onChange(next);
};
const handleRemove = (rowIndex: number) => {
onChange(value.filter((_, index) => index !== rowIndex));
};
return (
<>
{rows.map((entry, rowIndex) => {
const currentValue = entry
? encodeIndexableOptionValue(
entry.fieldMetadataId,
entry.subFieldName,
)
: '';
const availableOptions = allOptions.filter(
(option) =>
option.value === currentValue ||
!pickedValues.includes(option.value),
);
const isEmptyRow = entry === null;
return (
<StyledFieldRow key={`${rowIndex}-${currentValue || 'empty'}`}>
<StyledSelectWrapper>
<Select
dropdownId={`settings-object-new-index-field-${rowIndex}`}
value={currentValue}
options={availableOptions}
emptyOption={emptyFieldOption}
onChange={(newValue) => handleSelect(rowIndex, newValue)}
fullWidth
withSearchInput
/>
</StyledSelectWrapper>
{isEmptyRow ? (
<StyledPlaceholder />
) : (
<IconButton
Icon={IconTrash}
variant="tertiary"
size="medium"
onClick={() => handleRemove(rowIndex)}
ariaLabel={t`Remove field`}
/>
)}
</StyledFieldRow>
);
})}
</>
);
}}
/>
);
};
@@ -0,0 +1,57 @@
import { Select } from '@/ui/input/components/Select';
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { Controller, useFormContext } from 'react-hook-form';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { IndexType } from '~/generated-metadata/graphql';
import { type SettingsObjectNewIndexFormValues } from '~/pages/settings/data-model/new-index/SettingsObjectNewIndexFormValues';
const StyledContent = styled.div`
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing[4]};
`;
const StyledFieldLabel = styled.div`
color: ${themeCssVariables.font.color.light};
font-size: ${themeCssVariables.font.size.xs};
font-weight: ${themeCssVariables.font.weight.semiBold};
margin-bottom: ${themeCssVariables.spacing[1]};
`;
export const SettingsObjectIndexOptionsForm = () => {
const { t } = useLingui();
const { control } = useFormContext<SettingsObjectNewIndexFormValues>();
const indexTypeOptions = [
{
value: IndexType.BTREE,
label: t`BTREE (default, good for sorting and equality)`,
},
{
value: IndexType.GIN,
label: t`GIN (full-text search and JSONB)`,
},
];
return (
<StyledContent>
<div>
<StyledFieldLabel>{t`Type`}</StyledFieldLabel>
<Controller
name="indexType"
control={control}
render={({ field: { value, onChange } }) => (
<Select
dropdownId="settings-object-new-index-type"
value={value}
options={indexTypeOptions}
onChange={onChange}
fullWidth
/>
)}
/>
</div>
</StyledContent>
);
};
@@ -0,0 +1,47 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type IconComponent } from 'twenty-ui/display';
import { type SelectOption } from 'twenty-ui/input';
import { compositeTypeDefinitions } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { type FieldMetadataType } from '~/generated-metadata/graphql';
import { getCompositeSubFieldLabel } from '@/object-record/object-filter-dropdown/utils/getCompositeSubFieldLabel';
import { encodeIndexableOptionValue } from '@/settings/data-model/indexes/utils/encodeIndexableOptionValue';
import { type CompositeFieldSubFieldName } from '@/settings/data-model/types/CompositeFieldSubFieldName';
import { type CompositeFieldType } from '@/settings/data-model/types/CompositeFieldType';
export const buildIndexableSelectOptions = ({
indexableFields,
getIcon,
}: {
indexableFields: FieldMetadataItem[];
getIcon: (icon?: string | null) => IconComponent | undefined;
}): SelectOption<string>[] => {
const sortedFields = [...indexableFields].sort((a, b) =>
a.label.localeCompare(b.label),
);
return sortedFields.flatMap<SelectOption<string>>((field) => {
const compositeType = compositeTypeDefinitions.get(
field.type as FieldMetadataType,
);
if (isDefined(compositeType)) {
// Composite parent — emit one option per sub-property. The parent
// itself is NOT selectable because the SQL index requires picking a
// specific column.
return compositeType.properties.map<SelectOption<string>>((property) => ({
Icon: getIcon(field.icon),
label: `${field.label} > ${getCompositeSubFieldLabel(field.type as CompositeFieldType, property.name as CompositeFieldSubFieldName)}`,
value: encodeIndexableOptionValue(field.id, property.name),
}));
}
return [
{
Icon: getIcon(field.icon),
label: field.label,
value: encodeIndexableOptionValue(field.id, null),
},
];
});
};
@@ -0,0 +1,15 @@
import { isNonEmptyString } from '@sniptt/guards';
import { INDEXABLE_OPTION_SEPARATOR } from '@/settings/data-model/indexes/utils/indexableOptionSeparator';
export const decodeIndexableOptionValue = (
value: string,
): { fieldMetadataId: string; subFieldName: string | null } => {
const [fieldMetadataId, subFieldName] = value.split(
INDEXABLE_OPTION_SEPARATOR,
);
return {
fieldMetadataId,
subFieldName: isNonEmptyString(subFieldName) ? subFieldName : null,
};
};
@@ -0,0 +1,10 @@
import { isNonEmptyString } from '@sniptt/guards';
import { INDEXABLE_OPTION_SEPARATOR } from '@/settings/data-model/indexes/utils/indexableOptionSeparator';
export const encodeIndexableOptionValue = (
fieldMetadataId: string,
subFieldName: string | null,
): string =>
isNonEmptyString(subFieldName)
? `${fieldMetadataId}${INDEXABLE_OPTION_SEPARATOR}${subFieldName}`
: fieldMetadataId;
@@ -0,0 +1,5 @@
// The Select component takes string values. We encode (fieldMetadataId,
// subFieldName) as `${id}` for scalar fields and `${id}::${subFieldName}` for
// composite sub-fields. Stable, easy to parse, no collisions because UUIDs
// don't contain `::`.
export const INDEXABLE_OPTION_SEPARATOR = '::';
@@ -1,21 +0,0 @@
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
import { t } from '@lingui/core/macro';
import { H2Title } from 'twenty-ui/display';
import { Section } from 'twenty-ui/layout';
import { SettingsObjectIndexTable } from '~/pages/settings/data-model/SettingsObjectIndexTable';
type ObjectIndexesProps = {
objectMetadataItem: EnrichedObjectMetadataItem;
};
export const ObjectIndexes = ({ objectMetadataItem }: ObjectIndexesProps) => {
return (
<Section>
<H2Title
title={t`Indexes`}
description={t`Advanced feature to improve the performance of queries and to enforce unicity constraints.`}
/>
<SettingsObjectIndexTable objectMetadataItem={objectMetadataItem} />
</Section>
);
};
@@ -6,6 +6,7 @@ import { isDDLLockedState } from '@/client-config/states/isDDLLockedState';
import { isObjectMetadataReadOnly } from '@/object-record/read-only/utils/isObjectMetadataReadOnly';
import { AdvancedSettingsWrapper } from '@/settings/components/AdvancedSettingsWrapper';
import { SettingsUpdateDataModelObjectAboutForm } from '@/settings/data-model/object-details/components/SettingsUpdateDataModelObjectAboutForm';
import { SettingsObjectIndexesSection } from '@/settings/data-model/object-details/components/tabs/SettingsObjectIndexesSection';
import { SettingsObjectSearchSection } from '@/settings/data-model/object-details/components/tabs/SettingsObjectSearchSection';
import { SettingsDataModelObjectSettingsFormCard } from '@/settings/data-model/objects/forms/components/SettingsDataModelObjectSettingsFormCard';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
@@ -135,6 +136,20 @@ export const ObjectSettings = ({
</Section>
</StyledFormSectionContainer>
</AdvancedSettingsWrapper>
<AdvancedSettingsWrapper>
<StyledFormSectionContainer>
<Section>
<H2Title
title={t`Indexes`}
description={t`Speed up reads on the fields you filter or sort by most. Each index also slows down writes and uses disk space, so add them with intent.`}
/>
<SettingsObjectIndexesSection
objectMetadataItem={objectMetadataItem}
isReadOnly={isReadOnly}
/>
</Section>
</StyledFormSectionContainer>
</AdvancedSettingsWrapper>
{!isReadOnly && (
<StyledFormSectionContainer>
<Section>
@@ -0,0 +1,209 @@
import { useDeleteOneIndexMetadataItem } from '@/object-metadata/hooks/useDeleteOneIndexMetadataItem';
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { type ReactNode, useMemo, useState } from 'react';
import { IconEyeOff, IconPlus } from 'twenty-ui/display';
import { Button, SearchInput } from 'twenty-ui/input';
import { MenuItemToggle, UndecoratedLink } from 'twenty-ui/navigation';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { SettingsPath } from 'twenty-shared/types';
import { isNonEmptyString } from '@sniptt/guards';
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
import { normalizeSearchText } from '~/utils/normalizeSearchText';
import { MAX_CUSTOM_INDEXES_PER_OBJECT } from 'twenty-shared/constants';
import { SettingsObjectIndexTable } from '~/pages/settings/data-model/SettingsObjectIndexTable';
import { type SettingsObjectIndexesTableItem } from '~/pages/settings/data-model/types/SettingsObjectIndexesTableItem';
import { getCompositeSubFieldLabel } from '@/object-record/object-filter-dropdown/utils/getCompositeSubFieldLabel';
import { type CompositeFieldSubFieldName } from '@/settings/data-model/types/CompositeFieldSubFieldName';
import { type CompositeFieldType } from '@/settings/data-model/types/CompositeFieldType';
type SettingsObjectIndexesSectionProps = {
objectMetadataItem: EnrichedObjectMetadataItem;
isReadOnly: boolean;
};
const StyledContent = styled.div`
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing[3]};
`;
const StyledButtonContainer = styled.div`
display: flex;
justify-content: flex-end;
padding-top: ${themeCssVariables.spacing[2]};
`;
const DELETE_INDEX_MODAL_ID = 'delete-index-modal';
const HIDE_SYSTEM_INDEXES_DROPDOWN_ID =
'settings-object-indexes-filter-dropdown';
export const SettingsObjectIndexesSection = ({
objectMetadataItem,
isReadOnly,
}: SettingsObjectIndexesSectionProps) => {
const { t } = useLingui();
const { openModal, closeModal } = useModal();
const { enqueueSuccessSnackBar } = useSnackBar();
const { deleteOneIndexMetadataItem } = useDeleteOneIndexMetadataItem();
const [searchTerm, setSearchTerm] = useState('');
const [hideSystemIndexes, setHideSystemIndexes] = useState(false);
const [pendingDelete, setPendingDelete] =
useState<SettingsObjectIndexesTableItem | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const tableItems = useMemo<SettingsObjectIndexesTableItem[]>(() => {
const fieldsById = new Map(
objectMetadataItem.fields.map((field) => [field.id, field]),
);
return objectMetadataItem.indexMetadatas.map((indexMetadataItem) => ({
id: indexMetadataItem.id,
name: indexMetadataItem.name,
isUnique: indexMetadataItem.isUnique,
isCustom: indexMetadataItem.isCustom ?? false,
indexType: indexMetadataItem.indexType,
indexWhereClause: indexMetadataItem.indexWhereClause,
indexFields:
indexMetadataItem.indexFieldMetadatas
?.map((indexField) => {
const fieldMetadataItem = fieldsById.get(
indexField.fieldMetadataId,
);
if (!isDefined(fieldMetadataItem)) return undefined;
if (isNonEmptyString(indexField.subFieldName)) {
return `${fieldMetadataItem.label} > ${getCompositeSubFieldLabel(
fieldMetadataItem.type as CompositeFieldType,
indexField.subFieldName as CompositeFieldSubFieldName,
)}`;
}
return fieldMetadataItem.label;
})
.filter((label): label is string => Boolean(label))
.join(', ') ?? '',
}));
}, [objectMetadataItem.indexMetadatas, objectMetadataItem.fields]);
const filteredItems = useMemo(() => {
const searchNormalized = normalizeSearchText(searchTerm);
return tableItems
.filter((item) => (hideSystemIndexes ? item.isCustom : true))
.filter((item) =>
searchNormalized.length === 0
? true
: normalizeSearchText(item.indexFields).includes(searchNormalized) ||
normalizeSearchText(item.indexType).includes(searchNormalized),
);
}, [tableItems, searchTerm, hideSystemIndexes]);
const customIndexCount = tableItems.filter((item) => item.isCustom).length;
const reachedCap = customIndexCount >= MAX_CUSTOM_INDEXES_PER_OBJECT;
const canCreate = !isReadOnly && !reachedCap;
const handleRequestDelete = (item: SettingsObjectIndexesTableItem) => {
setPendingDelete(item);
openModal(DELETE_INDEX_MODAL_ID);
};
const handleConfirmDelete = async () => {
if (pendingDelete === null) return;
setIsDeleting(true);
const result = await deleteOneIndexMetadataItem({
idToDelete: pendingDelete.id,
});
setIsDeleting(false);
closeModal(DELETE_INDEX_MODAL_ID);
if (result.status === 'successful') {
enqueueSuccessSnackBar({ message: t`Index deleted` });
setPendingDelete(null);
}
};
return (
<StyledContent>
<SearchInput
placeholder={t`Search an index...`}
value={searchTerm}
onChange={setSearchTerm}
filterDropdown={(filterButton: ReactNode) => (
<Dropdown
dropdownId={HIDE_SYSTEM_INDEXES_DROPDOWN_ID}
dropdownPlacement="bottom-end"
dropdownOffset={{ x: 0, y: 8 }}
clickableComponent={filterButton}
dropdownComponents={
<DropdownContent>
<DropdownMenuItemsContainer>
<MenuItemToggle
LeftIcon={IconEyeOff}
onToggleChange={() =>
setHideSystemIndexes(!hideSystemIndexes)
}
toggled={hideSystemIndexes}
text={t`Hide system indexes`}
toggleSize="small"
/>
</DropdownMenuItemsContainer>
</DropdownContent>
}
/>
)}
/>
<SettingsObjectIndexTable
items={filteredItems}
isReadOnly={isReadOnly}
onDeleteIndex={handleRequestDelete}
/>
{!isReadOnly && (
<StyledButtonContainer>
{canCreate ? (
<UndecoratedLink
to={getSettingsPath(SettingsPath.ObjectNewIndex, {
objectNamePlural: objectMetadataItem.namePlural,
})}
>
<Button
Icon={IconPlus}
title={t`Add Index`}
size="small"
variant="secondary"
/>
</UndecoratedLink>
) : (
<Button
Icon={IconPlus}
title={t`Add Index`}
size="small"
variant="secondary"
disabled
/>
)}
</StyledButtonContainer>
)}
<ConfirmationModal
modalInstanceId={DELETE_INDEX_MODAL_ID}
title={t`Delete this index?`}
subtitle={t`Queries that relied on it will fall back to a sequential scan. You can recreate it later.`}
confirmButtonText={t`Delete`}
onConfirmClick={handleConfirmDelete}
onClose={() => setPendingDelete(null)}
loading={isDeleting}
/>
</StyledContent>
);
};
@@ -135,8 +135,8 @@ export const SettingsObjectSearchSection = ({
<Card rounded>
<SettingsOptionCardContentToggle
Icon={IconEye}
title={t`Include in default search`}
description={t`If disabled, use advanced search filters to find these records`}
title={t`Global search`}
description={t`Show this object's records in the command menu (⌘K).`}
checked={isSearchable}
advancedMode
onChange={handleToggleSearchable}
@@ -1,14 +0,0 @@
import { type IndexMetadataItem } from '@/object-metadata/types/IndexMetadataItem';
import { createAtomFamilyState } from '@/ui/utilities/state/jotai/utils/createAtomFamilyState';
export type SortedIndexByTableFamilyStateKey = {
objectMetadataItemId: string;
};
export const settingsObjectIndexesFamilyState = createAtomFamilyState<
IndexMetadataItem[] | null,
SortedIndexByTableFamilyStateKey
>({
key: 'settingsObjectIndexesFamilyState',
defaultValue: null,
});
@@ -217,7 +217,7 @@ export const MultiWorkspaceDropdownDefaultComponents = () => {
onClick={() => setMultiWorkspaceDropdown('themes')}
/>
<UndecoratedLink
to={getSettingsPath(SettingsPath.WorkspaceMembersPage)}
to={`${getSettingsPath(SettingsPath.WorkspaceMembersPage)}#invite`}
onClick={() => {
closeDropdown(MULTI_WORKSPACE_DROPDOWN_ID);
}}