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
@@ -1,18 +1,15 @@
import { useContext, useEffect, useState } from 'react';
import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { ObjectFields } from '@/settings/data-model/object-details/components/tabs/ObjectFields';
import { ObjectIndexes } from '@/settings/data-model/object-details/components/tabs/ObjectIndexes';
import { ObjectLayout } from '@/settings/data-model/object-details/components/tabs/ObjectLayout';
import { ObjectSettings } from '@/settings/data-model/object-details/components/tabs/ObjectSettings';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { TabList } from '@/ui/layout/tab-list/components/TabList';
import { isAdvancedModeEnabledState } from '@/ui/navigation/navigation-drawer/states/isAdvancedModeEnabledState';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { styled } from '@linaria/react';
import {
AppPath,
@@ -28,17 +25,13 @@ import { useLingui } from '@lingui/react/macro';
import { getAppPath, getSettingsPath, isDefined } from 'twenty-shared/utils';
import {
IconArrowUpRight,
IconCodeCircle,
IconLayout,
IconListDetails,
IconPlus,
IconPoint,
IconSettings,
} from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { UndecoratedLink } from 'twenty-ui/navigation';
import { ThemeContext } from 'twenty-ui/theme-constants';
import { FeatureFlagKey } from '~/generated-metadata/graphql';
import { useNavigateApp } from '~/hooks/useNavigateApp';
import { SETTINGS_OBJECT_DETAIL_TABS } from '~/pages/settings/data-model/constants/SettingsObjectDetailTabs';
import { updatedObjectNamePluralState } from '~/pages/settings/data-model/states/updatedObjectNamePluralState';
@@ -50,7 +43,6 @@ const StyledContentContainer = styled.div`
`;
export const SettingsObjectDetailPage = () => {
const { theme } = useContext(ThemeContext);
const navigateApp = useNavigateApp();
const { t } = useLingui();
const { objectNamePlural = '' } = useParams();
@@ -77,11 +69,6 @@ export const SettingsObjectDetailPage = () => {
SETTINGS_OBJECT_DETAIL_TABS.COMPONENT_INSTANCE_ID,
);
const isAdvancedModeEnabled = useAtomStateValue(isAdvancedModeEnabledState);
const isUniqueIndexesEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_UNIQUE_INDEXES_ENABLED,
);
const [isDeleting, setIsDeleting] = useState(false);
useEffect(() => {
@@ -123,19 +110,6 @@ export const SettingsObjectDetailPage = () => {
objectMetadataItem.isRemote ||
objectMetadataItem.nameSingular === CoreObjectNameSingular.Dashboard,
},
{
id: SETTINGS_OBJECT_DETAIL_TABS.TABS_IDS.INDEXES,
title: t`Indexes`,
Icon: IconCodeCircle,
hide: !isAdvancedModeEnabled || !isUniqueIndexesEnabled,
pill: (
<IconPoint
size={12}
color={theme.color.yellow}
fill={theme.color.yellow}
/>
),
},
];
const renderActiveTabContent = () => {
@@ -152,8 +126,6 @@ export const SettingsObjectDetailPage = () => {
);
case SETTINGS_OBJECT_DETAIL_TABS.TABS_IDS.LAYOUT:
return <ObjectLayout objectMetadataItem={objectMetadataItem} />;
case SETTINGS_OBJECT_DETAIL_TABS.TABS_IDS.INDEXES:
return <ObjectIndexes objectMetadataItem={objectMetadataItem} />;
default:
return <></>;
}
@@ -1,7 +1,6 @@
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
import { settingsObjectIndexesFamilyState } from '@/settings/data-model/object-details/states/settingsObjectIndexesFamilyState';
import { SortableTableHeader } from '@/ui/layout/table/components/SortableTableHeader';
import { Table } from '@/ui/layout/table/components/Table';
import { TableBody } from '@/ui/layout/table/components/TableBody';
import { TableCell } from '@/ui/layout/table/components/TableCell';
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { TableRow } from '@/ui/layout/table/components/TableRow';
@@ -10,155 +9,121 @@ import { type TableMetadata } from '@/ui/layout/table/types/TableMetadata';
import { styled } from '@linaria/react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react/macro';
import { isNonEmptyArray } from '@sniptt/guards';
import { useAtomFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilyStateValue';
import { useSetAtomFamilyState } from '@/ui/utilities/state/jotai/hooks/useSetAtomFamilyState';
import { useEffect, useMemo, useState } from 'react';
import { IconSquareKey } from 'twenty-ui/display';
import { SearchInput } from 'twenty-ui/input';
import { IconSquareKey, IconTrash } from 'twenty-ui/display';
import { LightIconButton } from 'twenty-ui/input';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { type SettingsObjectIndexesTableItem } from '~/pages/settings/data-model/types/SettingsObjectIndexesTableItem';
import { normalizeSearchText } from '~/utils/normalizeSearchText';
const OBJECT_INDEX_TABLE_ROW_GRID_TEMPLATE_COLUMNS = '350px 70px 80px';
const OBJECT_INDEX_TABLE_GRID_TEMPLATE_COLUMNS = '1fr 70px 80px 32px';
const StyledSearchInputContainer = styled.div`
padding-bottom: ${themeCssVariables.spacing[2]};
width: 100%;
const StyledTableContainer = styled.div`
border-bottom: 1px solid ${themeCssVariables.border.color.light};
margin-top: ${themeCssVariables.spacing[3]};
`;
const StyledActionCell = styled(TableCell)`
justify-content: flex-end;
padding-right: 0;
`;
const StyledEmpty = styled.div`
color: ${themeCssVariables.font.color.light};
font-size: ${themeCssVariables.font.size.md};
padding: ${themeCssVariables.spacing[3]};
text-align: center;
`;
const TABLE_FIELDS: TableMetadata<SettingsObjectIndexesTableItem>['fields'] = [
{
fieldLabel: msg`Fields`,
fieldName: 'indexFields',
fieldType: 'string',
align: 'left',
},
{
fieldLabel: msg`Unique`,
FieldIcon: IconSquareKey,
fieldName: 'isUnique',
fieldType: 'string',
align: 'left',
},
{
fieldLabel: msg`Type`,
fieldName: 'indexType',
fieldType: 'string',
align: 'right',
},
];
const TABLE_METADATA: TableMetadata<SettingsObjectIndexesTableItem> = {
tableId: 'settingsObjectIndexes',
fields: TABLE_FIELDS,
initialSort: {
fieldName: 'indexFields',
orderBy: 'AscNullsLast',
},
};
export type SettingsObjectIndexTableProps = {
objectMetadataItem: EnrichedObjectMetadataItem;
items: SettingsObjectIndexesTableItem[];
isReadOnly: boolean;
onDeleteIndex: (item: SettingsObjectIndexesTableItem) => void;
};
export const SettingsObjectIndexTable = ({
objectMetadataItem,
items,
isReadOnly,
onDeleteIndex,
}: SettingsObjectIndexTableProps) => {
const { t } = useLingui();
const [searchTerm, setSearchTerm] = useState('');
const tableMetadata: TableMetadata<SettingsObjectIndexesTableItem> = {
tableId: 'settingsObjectIndexs',
fields: [
{
fieldLabel: msg`Fields`,
fieldName: 'indexFields',
fieldType: 'string',
align: 'left',
},
{
fieldLabel: msg`Unique`,
FieldIcon: IconSquareKey,
fieldName: 'isUnique',
fieldType: 'string',
align: 'left',
},
{
fieldLabel: msg`Type`,
fieldName: 'indexType',
fieldType: 'string',
align: 'right',
},
],
initialSort: {
fieldName: 'name',
orderBy: 'AscNullsLast',
},
};
const settingsObjectIndexes = useAtomFamilyStateValue(
settingsObjectIndexesFamilyState,
{ objectMetadataItemId: objectMetadataItem.id },
);
const setSettingsObjectIndexes = useSetAtomFamilyState(
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,
tableMetadata,
);
const filteredActiveItems = useMemo(
() =>
sortedActiveObjectSettingsDetailItems.filter((item) => {
const searchNormalized = normalizeSearchText(searchTerm);
return (
normalizeSearchText(item.name).includes(searchNormalized) ||
normalizeSearchText(item.indexType).includes(searchNormalized)
);
}),
[sortedActiveObjectSettingsDetailItems, searchTerm],
);
const sortedItems = useSortedArray(items, TABLE_METADATA);
return (
<>
<StyledSearchInputContainer>
<SearchInput
placeholder={t`Search an index...`}
value={searchTerm}
onChange={setSearchTerm}
/>
</StyledSearchInputContainer>
<StyledTableContainer>
<Table>
<TableRow
gridTemplateColumns={OBJECT_INDEX_TABLE_ROW_GRID_TEMPLATE_COLUMNS}
gridTemplateColumns={OBJECT_INDEX_TABLE_GRID_TEMPLATE_COLUMNS}
>
{tableMetadata.fields.map((item) => (
{TABLE_METADATA.fields.map((tableField) => (
<SortableTableHeader
key={item.fieldName}
fieldName={item.fieldName}
label={t(item.fieldLabel)}
Icon={item.FieldIcon}
tableId={tableMetadata.tableId}
initialSort={tableMetadata.initialSort}
key={tableField.fieldName}
fieldName={tableField.fieldName}
label={t(tableField.fieldLabel)}
Icon={tableField.FieldIcon}
tableId={TABLE_METADATA.tableId}
initialSort={TABLE_METADATA.initialSort}
/>
))}
<TableHeader></TableHeader>
</TableRow>
{isNonEmptyArray(filteredActiveItems) &&
filteredActiveItems.map((objectSettingsIndex) => (
<TableRow
gridTemplateColumns={OBJECT_INDEX_TABLE_ROW_GRID_TEMPLATE_COLUMNS}
key={objectSettingsIndex.name}
>
<TableCell>{objectSettingsIndex.indexFields}</TableCell>
<TableCell>
{objectSettingsIndex.isUnique ? (
<IconSquareKey size={14} />
) : (
''
)}
</TableCell>
<TableCell>{objectSettingsIndex.indexType}</TableCell>
</TableRow>
))}
<TableBody>
{sortedItems.length === 0 ? (
<StyledEmpty>{t`No indexes match your filters.`}</StyledEmpty>
) : (
sortedItems.map((item) => (
<TableRow
gridTemplateColumns={OBJECT_INDEX_TABLE_GRID_TEMPLATE_COLUMNS}
key={item.id}
>
<TableCell>{item.indexFields}</TableCell>
<TableCell>
{item.isUnique ? <IconSquareKey size={14} /> : ''}
</TableCell>
<TableCell>{item.indexType}</TableCell>
<StyledActionCell>
{item.isCustom && !isReadOnly && (
<LightIconButton
Icon={IconTrash}
accent="tertiary"
onClick={() => onDeleteIndex(item)}
/>
)}
</StyledActionCell>
</TableRow>
))
)}
</TableBody>
</Table>
</>
</StyledTableContainer>
);
};
@@ -4,6 +4,5 @@ export const SETTINGS_OBJECT_DETAIL_TABS = {
FIELDS: 'fields',
SETTINGS: 'settings',
LAYOUT: 'layout',
INDEXES: 'indexes',
},
} as const;
@@ -0,0 +1,175 @@
import { isDDLLockedState } from '@/client-config/states/isDDLLockedState';
import { useCreateOneIndexMetadataItem } from '@/object-metadata/hooks/useCreateOneIndexMetadataItem';
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { SEARCH_VECTOR_FIELD_NAME } from '@/object-record/constants/SearchVectorFieldName';
import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SettingsObjectIndexFieldsForm } from '@/settings/data-model/indexes/forms/components/SettingsObjectIndexFieldsForm';
import { SettingsObjectIndexOptionsForm } from '@/settings/data-model/indexes/forms/components/SettingsObjectIndexOptionsForm';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { zodResolver } from '@hookform/resolvers/zod';
import { useLingui } from '@lingui/react/macro';
import { useEffect, useMemo } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { useParams } from 'react-router-dom';
import { AppPath, RelationType, SettingsPath } from 'twenty-shared/types';
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
import { Callout, H2Title, IconAlertTriangle } from 'twenty-ui/display';
import { Section } from 'twenty-ui/layout';
import { IndexType } from '~/generated-metadata/graphql';
import { useNavigateApp } from '~/hooks/useNavigateApp';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
import { MAX_CUSTOM_INDEXES_PER_OBJECT } from 'twenty-shared/constants';
import {
settingsObjectNewIndexFormSchema,
type SettingsObjectNewIndexFormValues,
} from '~/pages/settings/data-model/new-index/SettingsObjectNewIndexFormValues';
const isFieldIndexable = (field: FieldMetadataItem): boolean => {
if (field.name === SEARCH_VECTOR_FIELD_NAME) return false;
if (field.isSystem === true) return false;
if (field.isActive !== true) return false;
// Only MANY_TO_ONE relations have a join column on this side; ONE_TO_MANY
// and MANY_TO_MANY have nothing concrete to index.
const relationType =
field.relation?.type ?? field.morphRelations?.[0]?.type ?? null;
if (isDefined(relationType) && relationType !== RelationType.MANY_TO_ONE) {
return false;
}
return true;
};
export const SettingsObjectNewIndex = () => {
const { t } = useLingui();
const navigateApp = useNavigateApp();
const navigate = useNavigateSettings();
const { objectNamePlural = '' } = useParams();
const { enqueueSuccessSnackBar } = useSnackBar();
const { findObjectMetadataItemByNamePlural } =
useFilteredObjectMetadataItems();
const activeObjectMetadataItem =
findObjectMetadataItemByNamePlural(objectNamePlural);
const { createOneIndexMetadataItem } = useCreateOneIndexMetadataItem();
const formConfig = useForm<SettingsObjectNewIndexFormValues>({
mode: 'onTouched',
resolver: zodResolver(settingsObjectNewIndexFormSchema),
defaultValues: {
fields: [],
indexType: IndexType.BTREE,
},
});
useEffect(() => {
if (!isDefined(activeObjectMetadataItem)) {
navigateApp(AppPath.NotFound);
}
}, [activeObjectMetadataItem, navigateApp]);
const isDDLLocked = useAtomStateValue(isDDLLockedState);
const indexableFields = useMemo(
() =>
(activeObjectMetadataItem?.fields ?? [])
.filter(isFieldIndexable)
.sort((a, b) => a.label.localeCompare(b.label)),
[activeObjectMetadataItem?.fields],
);
if (!isDefined(activeObjectMetadataItem)) return null;
const customIndexCount = activeObjectMetadataItem.indexMetadatas.filter(
(indexMetadata) => indexMetadata.isCustom,
).length;
const reachedCap = customIndexCount >= MAX_CUSTOM_INDEXES_PER_OBJECT;
const { isValid, isSubmitting } = formConfig.formState;
const canSave = isValid && !isSubmitting && !isDDLLocked && !reachedCap;
const handleSave = async (formValues: SettingsObjectNewIndexFormValues) => {
const result = await createOneIndexMetadataItem({
objectMetadataId: activeObjectMetadataItem.id,
fields: formValues.fields.map((entry) => ({
fieldMetadataId: entry.fieldMetadataId,
subFieldName: entry.subFieldName,
})),
indexType: formValues.indexType,
});
if (result.status === 'successful') {
enqueueSuccessSnackBar({ message: t`Index created` });
navigate(SettingsPath.ObjectDetail, { objectNamePlural });
}
};
return (
<FormProvider // oxlint-disable-next-line react/jsx-props-no-spreading
{...formConfig}
>
<SubMenuTopBarContainer
title={t`New Index`}
links={[
{
children: t`Workspace`,
href: getSettingsPath(SettingsPath.Workspace),
},
{
children: t`Objects`,
href: getSettingsPath(SettingsPath.Objects),
},
{
children: activeObjectMetadataItem.labelPlural,
href: getSettingsPath(SettingsPath.ObjectDetail, {
objectNamePlural,
}),
},
{ children: t`New Index` },
]}
actionButton={
<SaveAndCancelButtons
isLoading={isSubmitting}
isSaveDisabled={!canSave}
isCancelDisabled={isSubmitting}
onCancel={() =>
navigate(SettingsPath.ObjectDetail, { objectNamePlural })
}
onSave={formConfig.handleSubmit(handleSave)}
/>
}
>
<SettingsPageContainer>
<Section>
<Callout
variant="warning"
Icon={IconAlertTriangle}
title={t`Use indexes sparingly`}
description={t`Each index speeds up reads on the fields it covers, but slows down every insert and update, and uses disk space. Only add an index when you know which queries it serves.`}
/>
</Section>
<Section>
<H2Title
title={t`Fields`}
description={t`Pick one or more fields. The order you select them in becomes the column order in the index — important for composite queries. For composite fields like Address, pick the specific sub-column to index.`}
/>
<SettingsObjectIndexFieldsForm indexableFields={indexableFields} />
</Section>
<Section>
<H2Title
title={t`Options`}
description={t`Pick the index type. BTREE covers most queries; GIN is for full-text and JSONB.`}
/>
<SettingsObjectIndexOptionsForm />
</Section>
</SettingsPageContainer>
</SubMenuTopBarContainer>
</FormProvider>
);
};
@@ -0,0 +1,18 @@
import { z } from 'zod';
import { IndexType } from '~/generated-metadata/graphql';
export const settingsObjectNewIndexFormSchema = z.object({
fields: z
.array(
z.object({
fieldMetadataId: z.string().uuid(),
subFieldName: z.string().nullable(),
}),
)
.min(1),
indexType: z.nativeEnum(IndexType),
});
export type SettingsObjectNewIndexFormValues = z.infer<
typeof settingsObjectNewIndexFormSchema
>;
@@ -1,9 +1,11 @@
import { type IndexType } from '~/generated-metadata/graphql';
export type SettingsObjectIndexesTableItem = {
id: string;
name: string;
indexType: IndexType;
isUnique: boolean;
isCustom: boolean;
indexWhereClause?: string | null;
indexFields: string;
};