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:
@@ -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
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
+91
@@ -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,
|
||||
};
|
||||
};
|
||||
+64
@@ -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,
|
||||
};
|
||||
};
|
||||
+136
@@ -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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+57
@@ -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>
|
||||
);
|
||||
};
|
||||
+47
@@ -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),
|
||||
},
|
||||
];
|
||||
});
|
||||
};
|
||||
+15
@@ -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,
|
||||
};
|
||||
};
|
||||
+10
@@ -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;
|
||||
+5
@@ -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 = '::';
|
||||
-21
@@ -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>
|
||||
);
|
||||
};
|
||||
+15
@@ -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>
|
||||
|
||||
+209
@@ -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>
|
||||
);
|
||||
};
|
||||
+2
-2
@@ -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}
|
||||
|
||||
-14
@@ -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,
|
||||
});
|
||||
+1
-1
@@ -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);
|
||||
}}
|
||||
|
||||
Reference in New Issue
Block a user