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:
File diff suppressed because one or more lines are too long
@@ -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);
|
||||
}}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
-1
@@ -4,6 +4,5 @@ export const SETTINGS_OBJECT_DETAIL_TABS = {
|
||||
FIELDS: 'fields',
|
||||
SETTINGS: 'settings',
|
||||
LAYOUT: 'layout',
|
||||
INDEXES: 'indexes',
|
||||
},
|
||||
} as const;
|
||||
|
||||
+175
@@ -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>
|
||||
);
|
||||
};
|
||||
+18
@@ -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
|
||||
>;
|
||||
+2
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user