Compare commits

...
Author SHA1 Message Date
Thomas des Francs f37be206aa Merge remote-tracking branch 'origin/main' into bonapara/new-record-form
# Conflicts:
#	packages/twenty-server/src/database/commands/upgrade-version-command/2-8/2-8-upgrade-version-command.module.ts
2026-05-23 17:03:30 +02:00
Thomas des Francs bac1fd8f8b Fix create record side panel test type 2026-05-23 16:55:29 +02:00
Thomas des Francs 8c2524937e Add create record command menu e2e tests 2026-05-23 14:42:02 +02:00
Thomas des Francs 8119bba07d Fix create record command payload handling 2026-05-23 12:48:56 +02:00
Thomas des Francs d134c27f10 Merge remote-tracking branch 'origin/main' into bonapara/new-record-form 2026-05-23 11:00:17 +02:00
Thomas des Francs c76dd7ba07 Merge remote-tracking branch 'origin/main' into bonapara/new-record-form
# Conflicts:
#	packages/twenty-server/src/database/commands/upgrade-version-command/2-8/2-8-upgrade-version-command.module.ts
#	packages/twenty-server/src/database/commands/upgrade-version-command/instance-commands.constant.ts
#	packages/twenty-server/test/integration/metadata/suites/object-metadata/command-menu-item-side-effect-on-object-metadata.integration-spec.ts
2026-05-23 10:56:14 +02:00
Thomas des Francs bbe866f3f8 Harden create record command validation 2026-05-23 10:35:03 +02:00
Thomas des Francs 0e01703e25 Add dynamic create record commands 2026-05-22 19:57:16 +02:00
Thomas des Francs 5bef586960 Add dynamic create record commands 2026-05-22 18:44:36 +02:00
48 changed files with 2568 additions and 167 deletions
@@ -1,4 +1,38 @@
import { Locator, Page } from '@playwright/test';
import {
expect,
type Locator,
type Page,
type Response,
} from '@playwright/test';
type CreateObjectParams = {
singularLabel: string;
pluralLabel: string;
};
type CreateObjectMetadataResponseBody = {
data?: {
createOneObject?: {
namePlural?: string;
};
};
errors?: unknown[];
};
const isCreateObjectMetadataResponse = (response: Response) => {
if (!response.url().endsWith('/metadata')) {
return false;
}
try {
return (
response.request().postDataJSON().operationName ===
'CreateOneObjectMetadataItem'
);
} catch {
return false;
}
};
export class DataModelSection {
private readonly searchObjectInput: Locator;
@@ -186,4 +220,48 @@ export class DataModelSection {
async clickSaveButton() {
await this.saveButton.click();
}
async createObject({ singularLabel, pluralLabel }: CreateObjectParams) {
await Promise.all([
this.page.waitForURL(/\/settings\/objects\/new/),
this.clickAddObjectButton(),
]);
await this.typeObjectSingularName(singularLabel);
await this.typeObjectPluralName(pluralLabel);
await this.objectPluralNameInput.blur();
await expect(this.saveButton).toBeEnabled();
const createObjectResponsePromise = this.page.waitForResponse(
isCreateObjectMetadataResponse,
);
await this.clickSaveButton();
const createObjectResponse = await createObjectResponsePromise;
const createObjectResponseBody =
(await createObjectResponse.json()) as CreateObjectMetadataResponseBody;
if (!createObjectResponse.ok() || createObjectResponseBody.errors?.length) {
throw new Error(
`Object creation failed: ${JSON.stringify(createObjectResponseBody)}`,
);
}
const createdObjectNamePlural =
createObjectResponseBody.data?.createOneObject?.namePlural;
if (createdObjectNamePlural === undefined) {
throw new Error(
`Object creation response is missing createOneObject.namePlural: ${JSON.stringify(
createObjectResponseBody,
)}`,
);
}
await this.page.waitForURL(
new RegExp(`/settings/objects/${createdObjectNamePlural}(?:[?#]|$)`),
);
}
}
@@ -1,9 +1,19 @@
import { Page } from '@playwright/test';
import { type Page } from '@playwright/test';
const MAC = process.platform === 'darwin';
async function isMacBrowser(page: Page) {
return page.evaluate(() => {
const navigatorWithUserAgentData = navigator as Navigator & {
userAgentData?: { platform?: string };
};
const platform =
navigatorWithUserAgentData.userAgentData?.platform ?? navigator.platform;
return /mac|iphone|ipad|ipod/i.test(platform);
});
}
async function keyDownCtrlOrMeta(page: Page) {
if (MAC) {
if (await isMacBrowser(page)) {
await page.keyboard.down('Meta');
} else {
await page.keyboard.down('Control');
@@ -11,7 +21,7 @@ async function keyDownCtrlOrMeta(page: Page) {
}
async function keyUpCtrlOrMeta(page: Page) {
if (MAC) {
if (await isMacBrowser(page)) {
await page.keyboard.up('Meta');
} else {
await page.keyboard.up('Control');
@@ -0,0 +1,144 @@
import { type Page } from '@playwright/test';
import { expect, test } from '../lib/fixtures/screenshot';
import { DataModelSection } from '../lib/pom/settings/dataModelSection';
import { withCtrlOrMeta } from '../lib/utils/keyboardShortcuts';
const sidePanel = (page: Page) => page.locator('[data-side-panel]');
const commandMenuSearchInput = (page: Page) =>
page.getByTestId('side-panel-focus');
async function openCommandMenuWithShortcut(page: Page) {
await expect(page.getByTestId('page-header-side-panel-button')).toBeVisible();
await withCtrlOrMeta(page, () => page.keyboard.press('k'));
await expect(commandMenuSearchInput(page)).toBeVisible();
await expect(commandMenuSearchInput(page)).toBeFocused();
}
async function openCommandMenuWithHeaderButton(page: Page) {
const sidePanelButton = page.getByTestId('page-header-side-panel-button');
await expect(sidePanelButton).toBeVisible();
await sidePanelButton.click();
await expect(commandMenuSearchInput(page)).toBeVisible();
await expect(commandMenuSearchInput(page)).toBeFocused();
}
async function searchCommandMenu(page: Page, query: string) {
await commandMenuSearchInput(page).fill(query);
await expect(commandMenuSearchInput(page)).toHaveValue(query);
}
async function selectVisibleCommandMenuItem(page: Page, label: string) {
await expect(sidePanel(page).getByText(label, { exact: true })).toBeVisible();
await page.keyboard.press('Enter');
}
async function expectCreateRecordForm(page: Page, objectLabelSingular: string) {
await expect(
sidePanel(page).getByText(`New ${objectLabelSingular}`, { exact: true }),
).toBeVisible();
}
async function fillCreateRecordName(page: Page, name: string) {
const nameInput = sidePanel(page).getByRole('textbox').first();
await expect(nameInput).toBeVisible();
await nameInput.fill(name);
}
async function expectCreateRecordSubmitButton(page: Page) {
const createButton = sidePanel(page)
.getByRole('button', { name: /Create/ })
.last();
await expect(createButton).toBeVisible();
await expect(createButton.locator('svg')).toHaveCount(1);
await expect(createButton).toContainText(/(Ctrl\s*⏎|⌘⏎)/);
}
async function submitCreateRecordFormWithShortcut(page: Page) {
await withCtrlOrMeta(page, () => page.keyboard.press('Enter'));
}
test.describe.serial('Dynamic create record commands', () => {
test.setTimeout(90_000);
const suffix = `A${Date.now().toString(36).replace(/[0-9]/g, '')}`;
const customObjectSingularLabel = `Codex Vessel ${suffix}`;
const customObjectPluralLabel = `Codex Vessels ${suffix}`;
const customRecordName = `E2E Record ${suffix}`;
test('custom object command works from keyboard shortcut', async ({
page,
}) => {
const dataModelSection = new DataModelSection(page);
await page.goto('/settings/objects');
await dataModelSection.createObject({
singularLabel: customObjectSingularLabel,
pluralLabel: customObjectPluralLabel,
});
await page.goto('/objects/people');
await openCommandMenuWithShortcut(page);
await searchCommandMenu(page, `create ${customObjectSingularLabel}`);
await selectVisibleCommandMenuItem(
page,
`Create ${customObjectSingularLabel}`,
);
await expectCreateRecordForm(page, customObjectSingularLabel);
await fillCreateRecordName(page, customRecordName);
await expectCreateRecordSubmitButton(page);
await submitCreateRecordFormWithShortcut(page);
await expect(
sidePanel(page).getByText(customRecordName, { exact: true }),
).toBeVisible();
await expect(
sidePanel(page).getByText(`New ${customObjectSingularLabel}`, {
exact: true,
}),
).not.toBeVisible();
await page.getByTestId('page-header-side-panel-button').click();
await expect(commandMenuSearchInput(page)).not.toBeVisible();
});
test('custom object command works from header command menu button', async ({
page,
}) => {
await page.goto('/objects/people');
await openCommandMenuWithHeaderButton(page);
await searchCommandMenu(page, `create ${customObjectSingularLabel}`);
await selectVisibleCommandMenuItem(
page,
`Create ${customObjectSingularLabel}`,
);
await expectCreateRecordForm(page, customObjectSingularLabel);
await page.getByTestId('page-header-side-panel-button').click();
await expect(commandMenuSearchInput(page)).not.toBeVisible();
});
test('same-object create command is not duplicated', async ({ page }) => {
await page.goto('/objects/companies');
await openCommandMenuWithHeaderButton(page);
await searchCommandMenu(page, 'create company');
await expect(
sidePanel(page).getByText('Create Company', { exact: true }),
).toHaveCount(1);
await searchCommandMenu(page, 'create person');
await selectVisibleCommandMenuItem(page, 'Create Person');
await expectCreateRecordForm(page, 'Person');
});
});
@@ -10,7 +10,7 @@ import { RestoreRecordsCommand } from '@/command-menu-item/engine-command/record
import { TriggerWorkflowVersionEngineCommand } from '@/command-menu-item/engine-command/record/components/TriggerWorkflowVersionEngineCommand';
import { MergeMultipleRecordsCommand } from '@/command-menu-item/engine-command/record/multiple-records/components/MergeMultipleRecordsCommand';
import { UpdateMultipleRecordsCommand } from '@/command-menu-item/engine-command/record/multiple-records/components/UpdateMultipleRecordsCommand';
import { CreateNewIndexRecordNoSelectionRecordCommand } from '@/command-menu-item/engine-command/record/no-selection/components/CreateNewIndexRecordNoSelectionRecordCommand';
import { CreateNewRecordCommand } from '@/command-menu-item/engine-command/record/no-selection/components/CreateNewRecordCommand';
import { CreateNewViewNoSelectionRecordCommand } from '@/command-menu-item/engine-command/record/no-selection/components/CreateNewViewNoSelectionRecordCommand';
import { HideDeletedRecordsNoSelectionRecordCommand } from '@/command-menu-item/engine-command/record/no-selection/components/HideDeletedRecordsNoSelectionRecordCommand';
import { ImportRecordsNoSelectionRecordCommand } from '@/command-menu-item/engine-command/record/no-selection/components/ImportRecordsNoSelectionRecordCommand';
@@ -53,9 +53,7 @@ export const ENGINE_COMPONENT_KEY_COMPONENT_MAP: Record<
EngineComponentKey,
React.ReactNode
> = {
[EngineComponentKey.CREATE_NEW_RECORD]: (
<CreateNewIndexRecordNoSelectionRecordCommand />
),
[EngineComponentKey.CREATE_NEW_RECORD]: <CreateNewRecordCommand />,
[EngineComponentKey.DELETE_RECORDS]: <DeleteRecordsCommand />,
[EngineComponentKey.RESTORE_RECORDS]: <RestoreRecordsCommand />,
[EngineComponentKey.DESTROY_RECORDS]: <DestroyRecordsCommand />,
@@ -0,0 +1,69 @@
import { HeadlessEngineCommandWrapperEffect } from '@/command-menu-item/engine-command/components/HeadlessEngineCommandWrapperEffect';
import { useHeadlessCommandContextApi } from '@/command-menu-item/engine-command/hooks/useHeadlessCommandContextApi';
import { CreateNewIndexRecordNoSelectionRecordCommand } from '@/command-menu-item/engine-command/record/no-selection/components/CreateNewIndexRecordNoSelectionRecordCommand';
import { isObjectMetadataCommandMenuItemPayload } from '@/command-menu-item/engine-command/utils/isObjectMetadataCommandMenuItemPayload';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { getObjectColorWithFallback } from '@/object-metadata/utils/getObjectColorWithFallback';
import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu';
import { createRecordObjectMetadataItemIdComponentState } from '@/side-panel/pages/create-record/states/createRecordObjectMetadataItemIdComponentState';
import { t } from '@lingui/core/macro';
import { useStore } from 'jotai';
import { isObjectMetadataManuallyCreatable } from 'twenty-shared/metadata';
import { SidePanelPages } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { getIconTileColorShades, useIcons } from 'twenty-ui/display';
import { v4 } from 'uuid';
export const CreateNewRecordCommand = () => {
const { payload } = useHeadlessCommandContextApi();
const { objectMetadataItems } = useObjectMetadataItems();
const { navigateSidePanelMenu } = useSidePanelMenu();
const store = useStore();
const { getIcon } = useIcons();
if (!isDefined(payload)) {
return <CreateNewIndexRecordNoSelectionRecordCommand />;
}
const onExecute = () => {
if (!isObjectMetadataCommandMenuItemPayload(payload)) {
return;
}
const objectMetadataItem = objectMetadataItems.find(
(item) => item.id === payload.objectMetadataItemId,
);
if (
!isDefined(objectMetadataItem) ||
!isObjectMetadataManuallyCreatable(objectMetadataItem)
) {
return;
}
const pageComponentInstanceId = v4();
store.set(
createRecordObjectMetadataItemIdComponentState.atomFamily({
instanceId: pageComponentInstanceId,
}),
objectMetadataItem.id,
);
const Icon = objectMetadataItem.icon
? getIcon(objectMetadataItem.icon)
: getIcon('IconList');
navigateSidePanelMenu({
page: SidePanelPages.CreateRecord,
pageTitle: t`New ${objectMetadataItem.labelSingular}`,
pageIcon: Icon,
pageIconColor: getIconTileColorShades(
getObjectColorWithFallback(objectMetadataItem),
).iconColor,
pageId: pageComponentInstanceId,
});
};
return <HeadlessEngineCommandWrapperEffect execute={onExecute} />;
};
@@ -0,0 +1,135 @@
import { act, render } from '@testing-library/react';
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
import { CreateNewRecordCommand } from '@/command-menu-item/engine-command/record/no-selection/components/CreateNewRecordCommand';
import { SidePanelPages } from 'twenty-shared/types';
const mockExecute = jest.fn();
const mockNavigateSidePanelMenu = jest.fn();
const mockStoreSet = jest.fn();
const mockIcon = jest.fn(() => null);
const mockGetIcon = jest.fn(() => mockIcon);
const mockCreateNewIndexRecordNoSelectionRecordCommand = jest.fn(() => null);
let mockPayload: unknown;
let mockObjectMetadataItems: EnrichedObjectMetadataItem[] = [];
jest.mock(
'@/command-menu-item/engine-command/components/HeadlessEngineCommandWrapperEffect',
() => ({
HeadlessEngineCommandWrapperEffect: ({
execute,
}: {
execute: () => void | Promise<unknown>;
}) => {
mockExecute(execute);
return null;
},
}),
);
jest.mock(
'@/command-menu-item/engine-command/record/no-selection/components/CreateNewIndexRecordNoSelectionRecordCommand',
() => ({
CreateNewIndexRecordNoSelectionRecordCommand: () =>
mockCreateNewIndexRecordNoSelectionRecordCommand(),
}),
);
jest.mock(
'@/command-menu-item/engine-command/hooks/useHeadlessCommandContextApi',
() => ({
useHeadlessCommandContextApi: () => ({
payload: mockPayload,
}),
}),
);
jest.mock('@/object-metadata/hooks/useObjectMetadataItems', () => ({
useObjectMetadataItems: () => ({
objectMetadataItems: mockObjectMetadataItems,
}),
}));
jest.mock('@/object-metadata/utils/getObjectColorWithFallback', () => ({
getObjectColorWithFallback: () => 'turquoise',
}));
jest.mock('@/side-panel/hooks/useSidePanelMenu', () => ({
useSidePanelMenu: () => ({
navigateSidePanelMenu: mockNavigateSidePanelMenu,
}),
}));
jest.mock('jotai', () => ({
...jest.requireActual('jotai'),
useStore: () => ({
set: mockStoreSet,
}),
}));
jest.mock('twenty-ui/display', () => ({
getIconTileColorShades: () => ({
iconColor: 'icon-color',
}),
useIcons: () => ({
getIcon: mockGetIcon,
}),
}));
const shipObjectMetadataItem = {
id: 'ship-object-metadata-id',
nameSingular: 'ship',
labelSingular: 'Ship',
icon: 'IconShip',
isActive: true,
isSystem: false,
} as EnrichedObjectMetadataItem;
describe('CreateNewRecordCommand', () => {
beforeEach(() => {
jest.clearAllMocks();
mockPayload = {
__typename: 'ObjectMetadataCommandMenuItemPayload',
objectMetadataItemId: shipObjectMetadataItem.id,
};
mockObjectMetadataItems = [shipObjectMetadataItem];
});
it('keeps the current index create behavior when payload is null', () => {
mockPayload = null;
render(<CreateNewRecordCommand />);
expect(
mockCreateNewIndexRecordNoSelectionRecordCommand,
).toHaveBeenCalledTimes(1);
expect(mockExecute).not.toHaveBeenCalled();
});
it('opens the create record side panel page for object metadata payloads', () => {
render(<CreateNewRecordCommand />);
const execute = mockExecute.mock.calls[0]?.[0];
expect(execute).toBeDefined();
act(() => {
execute?.();
});
expect(mockStoreSet).toHaveBeenCalledWith(
expect.anything(),
shipObjectMetadataItem.id,
);
expect(mockNavigateSidePanelMenu).toHaveBeenCalledWith(
expect.objectContaining({
page: SidePanelPages.CreateRecord,
pageTitle: 'New Ship',
pageIcon: mockIcon,
pageIconColor: 'icon-color',
pageId: expect.any(String),
}),
);
});
});
@@ -20,6 +20,10 @@ import {
responseData as findManyObjectMetadataItemsResponseData,
} from '@/object-metadata/hooks/__mocks__/useFindManyObjectMetadataItems';
const findManyObjectMetadataItemsResult = jest.fn(() => ({
data: findManyObjectMetadataItemsResponseData,
}));
const mocks = [
{
request: {
@@ -48,9 +52,7 @@ const mocks = [
query: findManyObjectMetadataItemsQuery,
variables: {},
},
result: jest.fn(() => ({
data: findManyObjectMetadataItemsResponseData,
})),
result: findManyObjectMetadataItemsResult,
},
{
request: {
@@ -109,6 +111,7 @@ describe('useCreateOneObjectMetadataItem', () => {
});
jestExpectSuccessfulMetadataRequestResult(res);
expect(res.response).toEqual({ data: { createOneObject: responseData } });
expect(findManyObjectMetadataItemsResult).toHaveBeenCalledTimes(1);
});
});
});
@@ -5,13 +5,16 @@ import {
FindManyCommandMenuItemsDocument,
FindManyNavigationMenuItemsDocument,
FindManyViewsDocument,
type ObjectMetadataItemsQuery,
} from '~/generated-metadata/graphql';
import { useMetadataErrorHandler } from '@/metadata-error-handler/hooks/useMetadataErrorHandler';
import { useUpdateMetadataStoreDraft } from '@/metadata-store/hooks/useUpdateMetadataStoreDraft';
import { type FlatFieldMetadataItem } from '@/metadata-store/types/FlatFieldMetadataItem';
import { type FlatObjectMetadataItem } from '@/metadata-store/types/FlatObjectMetadataItem';
import { splitObjectMetadataGqlResponse } from '@/metadata-store/utils/splitObjectMetadataGqlResponse';
import { splitViewWithRelated } from '@/metadata-store/utils/splitViewWithRelated';
import { FIND_MANY_OBJECT_METADATA_ITEMS } from '@/object-metadata/graphql/queries';
import { type MetadataRequestResult } from '@/object-metadata/types/MetadataRequestResult.type';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useLoadCurrentUser } from '@/users/hooks/useLoadCurrentUser';
@@ -60,7 +63,7 @@ export const useCreateOneObjectMetadataItem = () => {
items: [objectData as FlatObjectMetadataItem],
});
const flatFields = fieldsList.map((field) => {
const createdObjectFlatFields = fieldsList.map((field) => {
const { __typename: _fieldTypename, ...fieldData } = field;
return {
@@ -69,29 +72,47 @@ export const useCreateOneObjectMetadataItem = () => {
} as FlatFieldMetadataItem;
});
addToDraft({ key: 'fieldMetadataItems', items: flatFields });
addToDraft({
key: 'fieldMetadataItems',
items: createdObjectFlatFields,
});
applyChanges();
const [viewsResult, navItemsResult, commandMenuItemsResult] =
await Promise.all([
client.query({
query: FindManyViewsDocument,
variables: { objectMetadataId: createdObject.id },
fetchPolicy: 'network-only',
}),
client.query({
query: FindManyNavigationMenuItemsDocument,
fetchPolicy: 'network-only',
}),
client.query({
query: FindManyCommandMenuItemsDocument,
fetchPolicy: 'network-only',
}),
]);
const [
objectMetadataItemsResult,
viewsResult,
navItemsResult,
commandMenuItemsResult,
] = await Promise.all([
client.query<ObjectMetadataItemsQuery>({
query: FIND_MANY_OBJECT_METADATA_ITEMS,
fetchPolicy: 'network-only',
}),
client.query({
query: FindManyViewsDocument,
variables: { objectMetadataId: createdObject.id },
fetchPolicy: 'network-only',
}),
client.query({
query: FindManyNavigationMenuItemsDocument,
fetchPolicy: 'network-only',
}),
client.query({
query: FindManyCommandMenuItemsDocument,
fetchPolicy: 'network-only',
}),
]);
const fetchedViews = viewsResult.data?.getViews ?? [];
const { flatObjects, flatFields, flatIndexes } =
splitObjectMetadataGqlResponse(objectMetadataItemsResult.data);
replaceDraft('objectMetadataItems', flatObjects);
replaceDraft('fieldMetadataItems', flatFields);
replaceDraft('indexMetadataItems', flatIndexes);
const {
flatViews,
flatViewFields,
@@ -120,6 +120,15 @@ export const SidePanelPageInfo = ({ pageChip }: SidePanelPageInfoProps) => {
return <SidePanelAskAiInfo />;
}
if (pageChip.page?.page === SidePanelPages.CreateRecord) {
return (
<SidePanelPageInfoLayout
icon={pageChip.Icons[0]}
title={<OverflowingTextWithTooltip text={pageChip.text ?? ''} />}
/>
);
}
if (pageChip.page?.page === SidePanelPages.NavigationMenuAddItem) {
return (
<SidePanelPageInfoLayout
@@ -6,6 +6,7 @@ import { SidePanelAiChatThreadsPage } from '@/side-panel/pages/ai-chat-threads/c
import { SidePanelAskAiPage } from '@/side-panel/pages/ask-ai/components/SidePanelAskAiPage';
import { SidePanelCalendarEventPage } from '@/side-panel/pages/calendar-event/components/SidePanelCalendarEventPage';
import { SidePanelComposeEmailPage } from '@/side-panel/pages/compose-email/components/SidePanelComposeEmailPage';
import { SidePanelCreateRecordPage } from '@/side-panel/pages/create-record/components/SidePanelCreateRecordPage';
import { SidePanelFrontComponentPage } from '@/side-panel/pages/front-component/components/SidePanelFrontComponentPage';
import { SidePanelDashboardChartSettings } from '@/side-panel/pages/page-layout/components/dashboard/SidePanelDashboardChartSettings';
import { SidePanelDashboardIframeSettings } from '@/side-panel/pages/page-layout/components/dashboard/SidePanelDashboardIframeSettings';
@@ -35,6 +36,7 @@ export const SIDE_PANEL_PAGES_CONFIG = new Map<SidePanelPages, React.ReactNode>(
<SidePanelCommandMenuItemDisplayPage />,
],
[SidePanelPages.ViewRecord, <SidePanelRecordPage />],
[SidePanelPages.CreateRecord, <SidePanelCreateRecordPage />],
[SidePanelPages.MergeRecords, <SidePanelMergeRecordPage />],
[SidePanelPages.UpdateRecords, <SidePanelUpdateMultipleRecords />],
[SidePanelPages.ViewCalendarEvent, <SidePanelCalendarEventPage />],
@@ -55,6 +55,11 @@ export const useSidePanelContextChips = () => {
(page) => page.page !== SidePanelPages.CommandMenuDisplay,
);
const getPageIconColor = (pageIconColor: string | undefined) =>
isDefined(pageIconColor) && pageIconColor !== 'currentColor'
? pageIconColor
: themeCssVariables.font.color.tertiary;
return filteredSidePanelNavigationStack
.map((page, index) => {
const isLastChip =
@@ -109,17 +114,17 @@ export const useSidePanelContextChips = () => {
return {
page,
Icons: isLastChip
? [<page.pageIcon size={iconSizeSm} />]
? [
<page.pageIcon
size={iconSizeSm}
color={getPageIconColor(page.pageIconColor)}
/>,
]
: [
<SidePanelContextChipIconWrapper>
<page.pageIcon
size={iconSizeSm}
color={
isDefined(page.pageIconColor) &&
page.pageIconColor !== 'currentColor'
? page.pageIconColor
: themeCssVariables.font.color.tertiary
}
color={getPageIconColor(page.pageIconColor)}
/>
</SidePanelContextChipIconWrapper>,
],
@@ -0,0 +1,281 @@
import { type KeyboardEvent, useCallback, useMemo, useState } from 'react';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
import { formatFieldMetadataItemAsFieldDefinition } from '@/object-metadata/utils/formatFieldMetadataItemAsFieldDefinition';
import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord';
import { FormFieldInput } from '@/object-record/record-field/ui/components/FormFieldInput';
import { isFieldRelation } from '@/object-record/record-field/ui/types/guards/isFieldRelation';
import { isUpdateRecordValueEmpty } from '@/object-record/record-update-multiple/utils/isUpdateRecordValueEmpty';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { useOpenRecordInSidePanel } from '@/side-panel/hooks/useOpenRecordInSidePanel';
import { createRecordObjectMetadataItemIdComponentState } from '@/side-panel/pages/create-record/states/createRecordObjectMetadataItemIdComponentState';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useViewOrDefaultView } from '@/views/hooks/useViewOrDefaultView';
import { shouldDisplayFormField } from '@/workflow/workflow-steps/workflow-actions/utils/shouldDisplayFormField';
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { isObjectMetadataManuallyCreatable } from 'twenty-shared/metadata';
import { FieldMetadataType } from 'twenty-shared/types';
import {
computeRelationGqlFieldJoinColumnName,
isDefined,
} from 'twenty-shared/utils';
import { IconPlus } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { getOsControlSymbol } from 'twenty-ui/utilities';
import { type JsonValue } from 'type-fest';
const StyledPage = styled.div`
display: flex;
flex-direction: column;
min-height: 100%;
`;
const StyledSectionContainer = styled.div`
flex: 1;
> * {
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing[6]};
padding: ${themeCssVariables.spacing[4]} ${themeCssVariables.spacing[3]};
width: auto;
}
`;
const StyledFooterContainer = styled.div`
align-items: center;
background: ${themeCssVariables.background.primary};
border-top: 1px solid ${themeCssVariables.border.color.light};
bottom: 0;
display: flex;
gap: ${themeCssVariables.spacing[2]};
justify-content: flex-end;
padding: ${themeCssVariables.spacing[2]};
position: sticky;
`;
export const SidePanelCreateRecordPage = () => {
const createRecordObjectMetadataItemId = useAtomComponentStateValue(
createRecordObjectMetadataItemIdComponentState,
);
const { objectMetadataItems } = useObjectMetadataItems();
const objectMetadataItem = objectMetadataItems.find(
(item) => item.id === createRecordObjectMetadataItemId,
);
if (!objectMetadataItem) {
throw new Error('Object metadata item is required to create a record');
}
if (!isObjectMetadataManuallyCreatable(objectMetadataItem)) {
throw new Error(
`Object ${objectMetadataItem.nameSingular} cannot be manually created`,
);
}
return <SidePanelCreateRecordForm objectMetadataItem={objectMetadataItem} />;
};
const SidePanelCreateRecordForm = ({
objectMetadataItem,
}: {
objectMetadataItem: EnrichedObjectMetadataItem;
}) => {
const { t } = useLingui();
const { enqueueErrorSnackBar } = useSnackBar();
const { openRecordInSidePanel } = useOpenRecordInSidePanel();
const [formValues, setFormValues] = useState<Partial<ObjectRecord>>({});
const [isSubmitting, setIsSubmitting] = useState(false);
const { createOneRecord } = useCreateOneRecord({
objectNameSingular: objectMetadataItem.nameSingular,
});
const { view: indexView } = useViewOrDefaultView({
objectMetadataItemId: objectMetadataItem.id,
});
const fieldsWithDefinitions = useMemo(
() =>
objectMetadataItem.fields
.map((fieldMetadataItem, metadataFieldPosition) => {
const viewField = indexView?.viewFields.find(
(viewField) => viewField.fieldMetadataId === fieldMetadataItem.id,
);
return {
fieldMetadataItem,
metadataFieldPosition,
viewFieldPosition: viewField?.position,
};
})
.filter(({ fieldMetadataItem }) =>
shouldDisplayFormField({
fieldMetadataItem,
actionType: 'CREATE_RECORD',
}),
)
.sort((fieldA, fieldB) => {
if (
isDefined(fieldA.viewFieldPosition) &&
isDefined(fieldB.viewFieldPosition)
) {
return fieldA.viewFieldPosition - fieldB.viewFieldPosition;
}
if (isDefined(fieldA.viewFieldPosition)) {
return -1;
}
if (isDefined(fieldB.viewFieldPosition)) {
return 1;
}
return fieldA.metadataFieldPosition - fieldB.metadataFieldPosition;
})
.map(({ fieldMetadataItem }) => ({
fieldMetadataItem,
fieldDefinition: formatFieldMetadataItemAsFieldDefinition({
field: fieldMetadataItem,
objectMetadataItem,
showLabel: true,
labelWidth: 90,
}),
})),
[indexView?.viewFields, objectMetadataItem],
);
const updateFormValue = (fieldName: string, value: unknown) => {
setFormValues((previousValues) => {
const nextValues = { ...previousValues };
if (value === undefined) {
delete nextValues[fieldName];
} else {
nextValues[fieldName] = value;
}
return nextValues;
});
};
const handleSave = useCallback(async () => {
if (isSubmitting) {
return;
}
setIsSubmitting(true);
try {
const createdRecord = await createOneRecord(formValues);
openRecordInSidePanel({
recordId: createdRecord.id,
objectNameSingular: objectMetadataItem.nameSingular,
resetNavigationStack: true,
});
} catch (error) {
enqueueErrorSnackBar({
message: error instanceof Error ? error.message : t`An error occurred.`,
});
setIsSubmitting(false);
}
}, [
createOneRecord,
enqueueErrorSnackBar,
formValues,
isSubmitting,
objectMetadataItem.nameSingular,
openRecordInSidePanel,
t,
]);
const handleFormKeyDown = useCallback(
(event: KeyboardEvent<HTMLDivElement>) => {
const isSubmitShortcut =
(event.metaKey || event.ctrlKey) &&
(event.key === 'Enter' || event.code === 'NumpadEnter');
if (!isSubmitShortcut) {
return;
}
event.preventDefault();
event.stopPropagation();
event.nativeEvent.stopImmediatePropagation();
if (!isSubmitting) {
handleSave();
}
},
[handleSave, isSubmitting],
);
return (
<StyledPage onKeyDownCapture={handleFormKeyDown}>
<StyledSectionContainer>
<Section>
{fieldsWithDefinitions.map(
({ fieldMetadataItem, fieldDefinition }) => {
const fieldName = fieldDefinition.metadata.fieldName;
const isRelation = isFieldRelation(fieldDefinition);
const fieldNameOrRelationIdName =
isRelation &&
fieldMetadataItem.type === FieldMetadataType.RELATION
? computeRelationGqlFieldJoinColumnName({
name: fieldMetadataItem.name,
})
: fieldName;
const value = formValues[fieldNameOrRelationIdName];
const handleValueChange = (newValue: JsonValue) => {
if (newValue === null) {
updateFormValue(fieldNameOrRelationIdName, null);
} else if (isUpdateRecordValueEmpty(newValue)) {
updateFormValue(fieldNameOrRelationIdName, undefined);
} else {
updateFormValue(fieldNameOrRelationIdName, newValue);
}
};
return (
<FormFieldInput
key={fieldDefinition.metadata.fieldName}
readonly={isSubmitting}
field={fieldDefinition}
defaultValue={value as JsonValue}
onChange={handleValueChange}
onClear={() =>
updateFormValue(fieldNameOrRelationIdName, undefined)
}
/>
);
},
)}
</Section>
</StyledSectionContainer>
<StyledFooterContainer>
<Button
title={isSubmitting ? t`Creating` : t`Create`}
variant="primary"
accent="blue"
size="small"
Icon={IconPlus}
isLoading={isSubmitting}
hotkeys={isSubmitting ? undefined : [getOsControlSymbol(), '⏎']}
disabled={isSubmitting}
onClick={handleSave}
/>
</StyledFooterContainer>
</StyledPage>
);
};
@@ -0,0 +1,343 @@
import { i18n } from '@lingui/core';
import { I18nProvider } from '@lingui/react';
import { act, fireEvent, render } from '@testing-library/react';
import { type ReactNode } from 'react';
import { FieldMetadataType } from 'twenty-shared/types';
import { type JsonValue } from 'type-fest';
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
import { SidePanelCreateRecordPage } from '@/side-panel/pages/create-record/components/SidePanelCreateRecordPage';
type MockFieldDefinition = {
metadata: {
fieldName: string;
};
};
type MockFormFieldInputProps = {
field: MockFieldDefinition;
defaultValue?: JsonValue;
onChange: (value: JsonValue) => void;
onClear: () => void;
readonly?: boolean;
};
type MockButtonProps = {
onClick: () => void | Promise<void>;
title: string;
disabled?: boolean;
hotkeys?: string[];
Icon?: unknown;
isLoading?: boolean;
};
const mockFormFieldInput = jest.fn();
const mockButton = jest.fn();
const mockCreateOneRecord = jest.fn();
const mockUseCreateOneRecord = jest.fn((_args: unknown) => ({
createOneRecord: mockCreateOneRecord,
}));
const mockOpenRecordInSidePanel = jest.fn();
const mockEnqueueErrorSnackBar = jest.fn();
let mockCreateRecordObjectMetadataItemId = 'ship-object-metadata-id';
let mockObjectMetadataItems: EnrichedObjectMetadataItem[] = [];
let mockIndexView:
| {
viewFields: Array<{ fieldMetadataId: string; position: number }>;
}
| undefined;
jest.mock(
'@/object-metadata/utils/formatFieldMetadataItemAsFieldDefinition',
() => ({
formatFieldMetadataItemAsFieldDefinition: ({
field,
}: {
field: { name: string };
}): MockFieldDefinition => ({
metadata: {
fieldName: field.name,
},
}),
}),
);
jest.mock('@/object-metadata/hooks/useObjectMetadataItems', () => ({
useObjectMetadataItems: () => ({
objectMetadataItems: mockObjectMetadataItems,
}),
}));
jest.mock('@/object-record/hooks/useCreateOneRecord', () => ({
useCreateOneRecord: (args: unknown) => mockUseCreateOneRecord(args),
}));
jest.mock('@/object-record/record-field/ui/components/FormFieldInput', () => ({
FormFieldInput: (props: MockFormFieldInputProps) => {
mockFormFieldInput(props);
return null;
},
}));
jest.mock(
'@/object-record/record-field/ui/types/guards/isFieldRelation',
() => ({
isFieldRelation: (field: MockFieldDefinition) =>
field.metadata.fieldName === 'captain',
}),
);
jest.mock('@/side-panel/hooks/useOpenRecordInSidePanel', () => ({
useOpenRecordInSidePanel: () => ({
openRecordInSidePanel: mockOpenRecordInSidePanel,
}),
}));
jest.mock('@/ui/feedback/snack-bar-manager/hooks/useSnackBar', () => ({
useSnackBar: () => ({
enqueueErrorSnackBar: mockEnqueueErrorSnackBar,
}),
}));
jest.mock(
'@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue',
() => ({
useAtomComponentStateValue: () => mockCreateRecordObjectMetadataItemId,
}),
);
jest.mock('@/views/hooks/useViewOrDefaultView', () => ({
useViewOrDefaultView: () => ({
view: mockIndexView,
}),
}));
jest.mock('twenty-ui/display', () => ({
IconPlus: jest.fn(() => null),
}));
jest.mock('twenty-ui/input', () => ({
Button: (props: MockButtonProps) => {
mockButton(props);
return null;
},
}));
jest.mock('twenty-ui/layout', () => ({
Section: ({ children }: { children: ReactNode }) => children,
}));
jest.mock('twenty-ui/utilities', () => ({
getOsControlSymbol: () => '⌘',
}));
const buildTextField = ({
id,
name,
isUIReadOnly = false,
}: {
id: string;
name: string;
isUIReadOnly?: boolean;
}) => ({
id,
name,
label: name,
type: FieldMetadataType.TEXT,
isActive: true,
isSystem: false,
isUIReadOnly,
settings: null,
});
const buildRelationField = ({ id, name }: { id: string; name: string }) => ({
id,
name,
label: name,
type: FieldMetadataType.RELATION,
isActive: true,
isSystem: false,
isUIReadOnly: false,
settings: {
relationType: 'MANY_TO_ONE',
},
});
const shipObjectMetadataItem = {
id: 'ship-object-metadata-id',
nameSingular: 'ship',
labelSingular: 'Ship',
isActive: true,
isSystem: false,
fields: [
buildTextField({ id: 'name-field-id', name: 'name' }),
buildTextField({
id: 'readonly-field-id',
name: 'readonlyField',
isUIReadOnly: true,
}),
buildTextField({ id: 'description-field-id', name: 'description' }),
],
} as EnrichedObjectMetadataItem;
const renderPage = () =>
render(
<I18nProvider i18n={i18n}>
<SidePanelCreateRecordPage />
</I18nProvider>,
);
describe('SidePanelCreateRecordPage', () => {
beforeEach(() => {
jest.clearAllMocks();
mockCreateRecordObjectMetadataItemId = shipObjectMetadataItem.id;
mockObjectMetadataItems = [shipObjectMetadataItem];
mockIndexView = {
viewFields: [
{ fieldMetadataId: 'description-field-id', position: 0 },
{ fieldMetadataId: 'name-field-id', position: 1 },
{ fieldMetadataId: 'readonly-field-id', position: 2 },
],
};
mockCreateOneRecord.mockResolvedValue({ id: 'created-ship-id' });
});
it('renders creatable fields in index view order and opens the created record after save', async () => {
renderPage();
expect(mockUseCreateOneRecord).toHaveBeenCalledWith({
objectNameSingular: 'ship',
});
const renderedFieldNames = mockFormFieldInput.mock.calls.map(
([props]: [MockFormFieldInputProps]) => props.field.metadata.fieldName,
);
expect(renderedFieldNames).toEqual(['description', 'name']);
const fieldPropsByName = new Map(
mockFormFieldInput.mock.calls.map(
([props]: [MockFormFieldInputProps]) => [
props.field.metadata.fieldName,
props,
],
),
);
await act(async () => {
fieldPropsByName.get('description')?.onChange('');
fieldPropsByName.get('name')?.onChange('SHIPCODEX');
});
const latestButtonProps = mockButton.mock.calls[
mockButton.mock.calls.length - 1
]?.[0] as MockButtonProps | undefined;
expect(latestButtonProps).toBeDefined();
expect(latestButtonProps).toEqual(
expect.objectContaining({
title: 'Create',
Icon: expect.any(Function),
hotkeys: ['⌘', '⏎'],
disabled: false,
isLoading: false,
}),
);
await act(async () => {
await latestButtonProps?.onClick();
});
expect(mockCreateOneRecord).toHaveBeenCalledWith({
name: 'SHIPCODEX',
});
expect(mockOpenRecordInSidePanel).toHaveBeenCalledWith({
recordId: 'created-ship-id',
objectNameSingular: 'ship',
resetNavigationStack: true,
});
});
it('creates the record with the command enter shortcut', async () => {
const { container } = renderPage();
const fieldPropsByName = new Map(
mockFormFieldInput.mock.calls.map(
([props]: [MockFormFieldInputProps]) => [
props.field.metadata.fieldName,
props,
],
),
);
await act(async () => {
fieldPropsByName.get('name')?.onChange('SHIPKEYBOARD');
});
await act(async () => {
fireEvent.keyDown(container.firstChild as HTMLElement, {
key: 'Enter',
metaKey: true,
});
await Promise.resolve();
});
expect(mockCreateOneRecord).toHaveBeenCalledWith({
name: 'SHIPKEYBOARD',
});
});
it('uses relation join-column names when creating the record', async () => {
mockObjectMetadataItems = [
{
...shipObjectMetadataItem,
fields: [
buildTextField({ id: 'name-field-id', name: 'name' }),
buildRelationField({
id: 'captain-field-id',
name: 'captain',
}),
],
} as EnrichedObjectMetadataItem,
];
mockIndexView = {
viewFields: [
{ fieldMetadataId: 'name-field-id', position: 0 },
{ fieldMetadataId: 'captain-field-id', position: 1 },
],
};
renderPage();
const fieldPropsByName = new Map(
mockFormFieldInput.mock.calls.map(
([props]: [MockFormFieldInputProps]) => [
props.field.metadata.fieldName,
props,
],
),
);
await act(async () => {
fieldPropsByName.get('name')?.onChange('SHIPRELATION');
fieldPropsByName
.get('captain')
?.onChange('00000000-0000-0000-0000-000000000001');
});
const latestButtonProps = mockButton.mock.calls[
mockButton.mock.calls.length - 1
]?.[0] as MockButtonProps | undefined;
await act(async () => {
await latestButtonProps?.onClick();
});
expect(mockCreateOneRecord).toHaveBeenCalledWith({
name: 'SHIPRELATION',
captainId: '00000000-0000-0000-0000-000000000001',
});
});
});
@@ -0,0 +1,9 @@
import { SidePanelPageComponentInstanceContext } from '@/side-panel/states/contexts/SidePanelPageComponentInstanceContext';
import { createAtomComponentState } from '@/ui/utilities/state/jotai/utils/createAtomComponentState';
export const createRecordObjectMetadataItemIdComponentState =
createAtomComponentState<string | null>({
key: 'side-panel/create-record-object-metadata-item-id',
defaultValue: null,
componentInstanceContext: SidePanelPageComponentInstanceContext,
});
@@ -15,7 +15,7 @@ import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components
import { t } from '@lingui/core/macro';
import { useEffect, useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { canObjectBeManagedByWorkflow } from 'twenty-shared/workflow';
import { isObjectMetadataManuallyCreatable } from 'twenty-shared/metadata';
import { HorizontalSeparator } from 'twenty-ui/display';
import { type SelectOption } from 'twenty-ui/input';
import { type JsonValue } from 'type-fest';
@@ -74,10 +74,7 @@ export const WorkflowEditActionCreateRecord = ({
const availableMetadata: Array<SelectOption<string>> =
activeNonSystemObjectMetadataItems
.filter((objectMetadataItem) =>
canObjectBeManagedByWorkflow({
nameSingular: objectMetadataItem.nameSingular,
isSystem: objectMetadataItem.isSystem,
}),
isObjectMetadataManuallyCreatable(objectMetadataItem),
)
.map((item) => ({
label: item.labelPlural,
@@ -0,0 +1,39 @@
import { type QueryRunner } from 'typeorm';
import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator';
import { type FastInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface';
import {
COMMAND_MENU_ITEM_ENGINE_KEY_COHERENCE_CONSTRAINT,
COMMAND_MENU_ITEM_ENGINE_KEY_COHERENCE_CONSTRAINT_SQL,
} from 'src/engine/metadata-modules/command-menu-item/constants/command-menu-item-engine-key-coherence-constraint-sql.constant';
const LEGACY_PAYLOAD_CONSTRAINT_SQL = `("engineComponentKey" = 'TRIGGER_WORKFLOW_VERSION' AND "workflowVersionId" IS NOT NULL AND "frontComponentId" IS NULL AND "payload" IS NULL) OR ("engineComponentKey" = 'FRONT_COMPONENT_RENDERER' AND "frontComponentId" IS NOT NULL AND "workflowVersionId" IS NULL AND "payload" IS NULL) OR ("engineComponentKey" = 'NAVIGATION' AND "payload" IS NOT NULL AND "workflowVersionId" IS NULL AND "frontComponentId" IS NULL) OR ("engineComponentKey" NOT IN ('TRIGGER_WORKFLOW_VERSION', 'FRONT_COMPONENT_RENDERER', 'NAVIGATION') AND "workflowVersionId" IS NULL AND "frontComponentId" IS NULL AND "payload" IS NULL)`;
@RegisteredInstanceCommand('2.8.0', 1799000001000)
export class AllowCreateRecordCommandMenuItemPayloadFastInstanceCommand
implements FastInstanceCommand
{
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."commandMenuItem" DROP CONSTRAINT IF EXISTS "${COMMAND_MENU_ITEM_ENGINE_KEY_COHERENCE_CONSTRAINT}"`,
);
await queryRunner.query(
`ALTER TABLE "core"."commandMenuItem" ADD CONSTRAINT "${COMMAND_MENU_ITEM_ENGINE_KEY_COHERENCE_CONSTRAINT}" CHECK (${COMMAND_MENU_ITEM_ENGINE_KEY_COHERENCE_CONSTRAINT_SQL})`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."commandMenuItem" DROP CONSTRAINT IF EXISTS "${COMMAND_MENU_ITEM_ENGINE_KEY_COHERENCE_CONSTRAINT}"`,
);
await queryRunner.query(
`UPDATE "core"."commandMenuItem" SET "payload" = NULL WHERE "engineComponentKey" = 'CREATE_NEW_RECORD'`,
);
await queryRunner.query(
`ALTER TABLE "core"."commandMenuItem" ADD CONSTRAINT "${COMMAND_MENU_ITEM_ENGINE_KEY_COHERENCE_CONSTRAINT}" CHECK (${LEGACY_PAYLOAD_CONSTRAINT_SQL})`,
);
}
}
@@ -4,7 +4,9 @@ import { WorkspaceIteratorModule } from 'src/database/commands/command-runners/w
import { DropChannelStandardObjectsCommand } from 'src/database/commands/upgrade-version-command/2-8/2-8-workspace-command-1798000050000-drop-channel-standard-objects.command';
import { BackfillRelationJoinColumnIndexesCommand } from 'src/database/commands/upgrade-version-command/2-8/2-8-workspace-command-1798100000000-backfill-relation-join-column-indexes.command';
import { GateDefaultCommandMenuItemsByPermissionFlagCommand } from 'src/database/commands/upgrade-version-command/2-8/2-8-workspace-command-1798100010000-gate-default-command-menu-items-by-permission-flag.command';
import { BackfillCreateRecordCommandMenuItemsCommand } from 'src/database/commands/upgrade-version-command/2-8/2-8-workspace-command-1799000000000-backfill-create-record-command-menu-items.command';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module';
import { WorkspaceSchemaManagerModule } from 'src/engine/twenty-orm/workspace-schema-manager/workspace-schema-manager.module';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace-migration/workspace-migration.module';
@@ -12,6 +14,7 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
@Module({
imports: [
ApplicationModule,
ObjectMetadataModule,
WorkspaceCacheModule,
WorkspaceIteratorModule,
WorkspaceMigrationModule,
@@ -21,6 +24,7 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
DropChannelStandardObjectsCommand,
BackfillRelationJoinColumnIndexesCommand,
GateDefaultCommandMenuItemsByPermissionFlagCommand,
BackfillCreateRecordCommandMenuItemsCommand,
],
})
export class V2_8_UpgradeVersionCommandModule {}
@@ -0,0 +1,192 @@
import { Command } from 'nest-commander';
import { isObjectMetadataManuallyCreatable } from 'twenty-shared/metadata';
import { isDefined } from 'twenty-shared/utils';
import { v4, v5 } from 'uuid';
import { ActiveOrSuspendedWorkspaceCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspace.command-runner';
import { WorkspaceIteratorService } from 'src/database/commands/command-runners/workspace-iterator.service';
import { type RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspace.command-runner';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { RegisteredWorkspaceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-workspace-command.decorator';
import {
CREATE_RECORD_COMMAND_UUID_NAMESPACE,
buildCreateRecordFlatCommandMenuItem,
buildUpdatedCreateRecordFlatCommandMenuItem,
} from 'src/engine/metadata-modules/flat-command-menu-item/utils/build-create-record-flat-command-menu-item.util';
import { type FlatCommandMenuItem } from 'src/engine/metadata-modules/flat-command-menu-item/types/flat-command-menu-item.type';
import { seedCompareObjectMetadataForNavigationPosition } from 'src/engine/metadata-modules/flat-command-menu-item/utils/seed-compare-object-metadata-for-navigation-position.util';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { STANDARD_COMMAND_MENU_ITEMS } from 'src/engine/workspace-manager/twenty-standard-application/constants/standard-command-menu-item.constant';
import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service';
@RegisteredWorkspaceCommand('2.8.0', 1799000000000)
@Command({
name: 'upgrade:2-8:backfill-create-record-command-menu-items',
description:
'Backfill missing global create record command menu items for eligible active objects and update create command titles',
})
export class BackfillCreateRecordCommandMenuItemsCommand extends ActiveOrSuspendedWorkspaceCommandRunner {
constructor(
protected readonly workspaceIteratorService: WorkspaceIteratorService,
private readonly applicationService: ApplicationService,
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
private readonly workspaceCacheService: WorkspaceCacheService,
) {
super(workspaceIteratorService);
}
override async runOnWorkspace({
workspaceId,
options,
}: RunOnWorkspaceArgs): Promise<void> {
const isDryRun = options.dryRun ?? false;
this.logger.log(
`${isDryRun ? '[DRY RUN] ' : ''}Backfilling create record command menu items for workspace ${workspaceId}`,
);
const { twentyStandardFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{ workspaceId },
);
const { flatCommandMenuItemMaps, flatObjectMetadataMaps } =
await this.workspaceCacheService.getOrRecompute(workspaceId, [
'flatCommandMenuItemMaps',
'flatObjectMetadataMaps',
]);
const existingCommandMenuItems = Object.values(
flatCommandMenuItemMaps.byUniversalIdentifier,
).filter(isDefined);
const now = new Date().toISOString();
let nextPosition =
existingCommandMenuItems.length > 0
? Math.max(...existingCommandMenuItems.map((item) => item.position)) + 1
: 0;
const commandMenuItemsToUpdate: FlatCommandMenuItem[] = [];
const standardCreateRecordCommandMenuItem =
flatCommandMenuItemMaps.byUniversalIdentifier[
STANDARD_COMMAND_MENU_ITEMS.createNewRecord.universalIdentifier
];
if (
isDefined(standardCreateRecordCommandMenuItem) &&
(standardCreateRecordCommandMenuItem.label !==
STANDARD_COMMAND_MENU_ITEMS.createNewRecord.label ||
standardCreateRecordCommandMenuItem.shortLabel !==
STANDARD_COMMAND_MENU_ITEMS.createNewRecord.shortLabel)
) {
commandMenuItemsToUpdate.push({
...standardCreateRecordCommandMenuItem,
label: STANDARD_COMMAND_MENU_ITEMS.createNewRecord.label,
shortLabel: STANDARD_COMMAND_MENU_ITEMS.createNewRecord.shortLabel,
updatedAt: now,
});
}
const commandMenuItemsToCreate = Object.values(
flatObjectMetadataMaps.byUniversalIdentifier,
)
.filter(isDefined)
.filter(isObjectMetadataManuallyCreatable)
.sort(seedCompareObjectMetadataForNavigationPosition)
.map((flatObjectMetadata) => {
const createCommandUniversalIdentifier = v5(
flatObjectMetadata.universalIdentifier,
CREATE_RECORD_COMMAND_UUID_NAMESPACE,
);
const existingCreateCommand =
flatCommandMenuItemMaps.byUniversalIdentifier[
createCommandUniversalIdentifier
];
const expectedCreateCommand = buildCreateRecordFlatCommandMenuItem({
objectMetadata: flatObjectMetadata,
commandMenuItemId: existingCreateCommand?.id ?? v4(),
applicationId:
existingCreateCommand?.applicationId ??
twentyStandardFlatApplication.id,
workspaceId,
position: existingCreateCommand?.position ?? nextPosition++,
now,
});
if (isDefined(existingCreateCommand)) {
const updatedCreateCommand =
buildUpdatedCreateRecordFlatCommandMenuItem({
existingCommandMenuItem: existingCreateCommand,
objectMetadata: flatObjectMetadata,
now,
});
if (isDefined(updatedCreateCommand)) {
commandMenuItemsToUpdate.push(updatedCreateCommand);
}
return undefined;
}
return expectedCreateCommand;
})
.filter(isDefined);
if (
commandMenuItemsToCreate.length === 0 &&
commandMenuItemsToUpdate.length === 0
) {
this.logger.log(
`Create record command menu items already up to date for workspace ${workspaceId}`,
);
return;
}
this.logger.log(
`Found ${commandMenuItemsToCreate.length} create record command menu item(s) to backfill and ${commandMenuItemsToUpdate.length} to update for workspace ${workspaceId}`,
);
if (isDryRun) {
this.logger.log(
`[DRY RUN] Would backfill ${commandMenuItemsToCreate.length} create record command menu item(s) and update ${commandMenuItemsToUpdate.length} for workspace ${workspaceId}`,
);
return;
}
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
allFlatEntityOperationByMetadataName: {
commandMenuItem: {
flatEntityToCreate: commandMenuItemsToCreate,
flatEntityToDelete: [],
flatEntityToUpdate: commandMenuItemsToUpdate,
},
},
workspaceId,
applicationUniversalIdentifier:
twentyStandardFlatApplication.universalIdentifier,
},
);
if (validateAndBuildResult.status === 'fail') {
this.logger.error(
`Failed to backfill create record command menu items:\n${JSON.stringify(validateAndBuildResult, null, 2)}`,
);
throw new Error(
`Failed to backfill create record command menu items for workspace ${workspaceId}`,
);
}
this.logger.log(
`Successfully backfilled ${commandMenuItemsToCreate.length} create record command menu item(s) and updated ${commandMenuItemsToUpdate.length} for workspace ${workspaceId}`,
);
}
}
@@ -53,6 +53,7 @@ import { DropPostgresCredentialsTableFastInstanceCommand } from 'src/database/co
import { AddRelationTargetFieldMetadataIdToViewFilterFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-6/2-6-instance-command-fast-1798000005000-add-relation-target-field-metadata-id-to-view-filter';
import { AddChannelSyncStageIndexesFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-6/2-6-instance-command-fast-1798000010000-add-channel-sync-stage-indexes';
import { FinalizeRolePermissionFlagCutoverFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-7/2-7-instance-command-fast-1779600000000-finalize-role-permission-flag-cutover';
import { AllowCreateRecordCommandMenuItemPayloadFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-8/2-8-instance-command-fast-1799000001000-allow-create-record-command-menu-item-payload';
export const INSTANCE_COMMANDS = [
AddViewFieldGroupIdIndexOnViewFieldFastInstanceCommand,
@@ -108,4 +109,5 @@ export const INSTANCE_COMMANDS = [
AddRelationTargetFieldMetadataIdToViewFilterFastInstanceCommand,
AddChannelSyncStageIndexesFastInstanceCommand,
FinalizeRolePermissionFlagCutoverFastInstanceCommand,
AllowCreateRecordCommandMenuItemPayloadFastInstanceCommand,
];
@@ -1,13 +1,18 @@
import { type QueryRunner } from 'typeorm';
import {
COMMAND_MENU_ITEM_ENGINE_KEY_COHERENCE_CONSTRAINT,
COMMAND_MENU_ITEM_ENGINE_KEY_COHERENCE_CONSTRAINT_SQL,
} from 'src/engine/metadata-modules/command-menu-item/constants/command-menu-item-engine-key-coherence-constraint-sql.constant';
export const addPayloadCheckConstraintToCommandMenuItem = async (
queryRunner: QueryRunner,
): Promise<void> => {
await queryRunner.query(
`ALTER TABLE "core"."commandMenuItem" DROP CONSTRAINT IF EXISTS "CHK_CMD_MENU_ITEM_ENGINE_KEY_COHERENCE"`,
`ALTER TABLE "core"."commandMenuItem" DROP CONSTRAINT IF EXISTS "${COMMAND_MENU_ITEM_ENGINE_KEY_COHERENCE_CONSTRAINT}"`,
);
await queryRunner.query(
`ALTER TABLE "core"."commandMenuItem" ADD CONSTRAINT "CHK_CMD_MENU_ITEM_ENGINE_KEY_COHERENCE" CHECK (("engineComponentKey" = 'TRIGGER_WORKFLOW_VERSION' AND "workflowVersionId" IS NOT NULL AND "frontComponentId" IS NULL AND "payload" IS NULL) OR ("engineComponentKey" = 'FRONT_COMPONENT_RENDERER' AND "frontComponentId" IS NOT NULL AND "workflowVersionId" IS NULL AND "payload" IS NULL) OR ("engineComponentKey" = 'NAVIGATION' AND "payload" IS NOT NULL AND "workflowVersionId" IS NULL AND "frontComponentId" IS NULL) OR ("engineComponentKey" NOT IN ('TRIGGER_WORKFLOW_VERSION', 'FRONT_COMPONENT_RENDERER', 'NAVIGATION') AND "workflowVersionId" IS NULL AND "frontComponentId" IS NULL AND "payload" IS NULL))`,
`ALTER TABLE "core"."commandMenuItem" ADD CONSTRAINT "${COMMAND_MENU_ITEM_ENGINE_KEY_COHERENCE_CONSTRAINT}" CHECK (${COMMAND_MENU_ITEM_ENGINE_KEY_COHERENCE_CONSTRAINT_SQL})`,
);
};
@@ -318,7 +318,10 @@ export class CommandMenuItemService {
workspaceId: string;
}): Promise<ObjectMetadataDTO | null> {
if (
commandMenuItem.engineComponentKey !== EngineComponentKey.NAVIGATION ||
![
EngineComponentKey.NAVIGATION,
EngineComponentKey.CREATE_NEW_RECORD,
].includes(commandMenuItem.engineComponentKey) ||
!isObjectMetadataCommandMenuItemPayload(commandMenuItem.payload)
) {
return null;
@@ -0,0 +1,21 @@
import { COMMAND_MENU_ITEM_ENGINE_KEY_COHERENCE_CONSTRAINT_SQL } from 'src/engine/metadata-modules/command-menu-item/constants/command-menu-item-engine-key-coherence-constraint-sql.constant';
describe('COMMAND_MENU_ITEM_ENGINE_KEY_COHERENCE_CONSTRAINT_SQL', () => {
it('constrains CREATE_NEW_RECORD payloads to null or object metadata payloads', () => {
expect(COMMAND_MENU_ITEM_ENGINE_KEY_COHERENCE_CONSTRAINT_SQL).toContain(
`"engineComponentKey" = 'CREATE_NEW_RECORD'`,
);
expect(COMMAND_MENU_ITEM_ENGINE_KEY_COHERENCE_CONSTRAINT_SQL).toContain(
`"payload" IS NULL OR`,
);
expect(COMMAND_MENU_ITEM_ENGINE_KEY_COHERENCE_CONSTRAINT_SQL).toContain(
`"payload" ? 'objectMetadataItemId'`,
);
expect(COMMAND_MENU_ITEM_ENGINE_KEY_COHERENCE_CONSTRAINT_SQL).toContain(
`"payload" ->> 'objectMetadataItemId' <> ''`,
);
expect(COMMAND_MENU_ITEM_ENGINE_KEY_COHERENCE_CONSTRAINT_SQL).toContain(
`NOT ("payload" ? 'path')`,
);
});
});
@@ -0,0 +1,6 @@
export const COMMAND_MENU_ITEM_ENGINE_KEY_COHERENCE_CONSTRAINT =
'CHK_CMD_MENU_ITEM_ENGINE_KEY_COHERENCE';
const CREATE_NEW_RECORD_PAYLOAD_SQL = `("payload" IS NULL OR (jsonb_typeof("payload") = 'object' AND "payload" ? 'objectMetadataItemId' AND jsonb_typeof("payload" -> 'objectMetadataItemId') = 'string' AND "payload" ->> 'objectMetadataItemId' <> '' AND NOT ("payload" ? 'path')))`;
export const COMMAND_MENU_ITEM_ENGINE_KEY_COHERENCE_CONSTRAINT_SQL = `("engineComponentKey" = 'TRIGGER_WORKFLOW_VERSION' AND "workflowVersionId" IS NOT NULL AND "frontComponentId" IS NULL AND "payload" IS NULL) OR ("engineComponentKey" = 'FRONT_COMPONENT_RENDERER' AND "frontComponentId" IS NOT NULL AND "workflowVersionId" IS NULL AND "payload" IS NULL) OR ("engineComponentKey" = 'NAVIGATION' AND "payload" IS NOT NULL AND "workflowVersionId" IS NULL AND "frontComponentId" IS NULL) OR ("engineComponentKey" = 'CREATE_NEW_RECORD' AND "workflowVersionId" IS NULL AND "frontComponentId" IS NULL AND ${CREATE_NEW_RECORD_PAYLOAD_SQL}) OR ("engineComponentKey" NOT IN ('TRIGGER_WORKFLOW_VERSION', 'FRONT_COMPONENT_RENDERER', 'NAVIGATION', 'CREATE_NEW_RECORD') AND "workflowVersionId" IS NULL AND "frontComponentId" IS NULL AND "payload" IS NULL)`;
@@ -11,6 +11,10 @@ import {
UpdateDateColumn,
} from 'typeorm';
import {
COMMAND_MENU_ITEM_ENGINE_KEY_COHERENCE_CONSTRAINT,
COMMAND_MENU_ITEM_ENGINE_KEY_COHERENCE_CONSTRAINT_SQL,
} from 'src/engine/metadata-modules/command-menu-item/constants/command-menu-item-engine-key-coherence-constraint-sql.constant';
import { type CommandMenuItemPayload } from 'src/engine/metadata-modules/command-menu-item/dtos/command-menu-item-payload.union';
import { CommandMenuItemAvailabilityType } from 'src/engine/metadata-modules/command-menu-item/enums/command-menu-item-availability-type.enum';
import { EngineComponentKey } from 'src/engine/metadata-modules/command-menu-item/enums/engine-component-key.enum';
@@ -36,8 +40,8 @@ import { SyncableEntity } from 'src/engine/workspace-manager/types/syncable-enti
'workspaceId',
])
@Check(
'CHK_CMD_MENU_ITEM_ENGINE_KEY_COHERENCE',
`("engineComponentKey" = 'TRIGGER_WORKFLOW_VERSION' AND "workflowVersionId" IS NOT NULL AND "frontComponentId" IS NULL AND "payload" IS NULL) OR ("engineComponentKey" = 'FRONT_COMPONENT_RENDERER' AND "frontComponentId" IS NOT NULL AND "workflowVersionId" IS NULL AND "payload" IS NULL) OR ("engineComponentKey" = 'NAVIGATION' AND "payload" IS NOT NULL AND "workflowVersionId" IS NULL AND "frontComponentId" IS NULL) OR ("engineComponentKey" NOT IN ('TRIGGER_WORKFLOW_VERSION', 'FRONT_COMPONENT_RENDERER', 'NAVIGATION') AND "workflowVersionId" IS NULL AND "frontComponentId" IS NULL AND "payload" IS NULL)`,
COMMAND_MENU_ITEM_ENGINE_KEY_COHERENCE_CONSTRAINT,
COMMAND_MENU_ITEM_ENGINE_KEY_COHERENCE_CONSTRAINT_SQL,
)
export class CommandMenuItemEntity
extends SyncableEntity
@@ -3,6 +3,11 @@ import { type I18n } from '@lingui/core';
import { CommandMenuItemAvailabilityType } from 'src/engine/metadata-modules/command-menu-item/enums/command-menu-item-availability-type.enum';
import { EngineComponentKey } from 'src/engine/metadata-modules/command-menu-item/enums/engine-component-key.enum';
import { interpolateNavigationCommandMenuItemField } from 'src/engine/metadata-modules/command-menu-item/utils/interpolate-navigation-command-menu-item-field.util';
import {
CREATE_RECORD_INTERPOLATED_ICON,
CREATE_RECORD_INTERPOLATED_LABEL,
CREATE_RECORD_INTERPOLATED_SHORT_LABEL,
} from 'src/engine/metadata-modules/flat-command-menu-item/utils/build-create-record-flat-command-menu-item.util';
import {
NAVIGATION_INTERPOLATED_ICON,
NAVIGATION_INTERPOLATED_LABEL,
@@ -95,6 +100,46 @@ describe('interpolateNavigationCommandMenuItemField', () => {
expect(result).toBe('Create New Record');
});
it('should resolve templates for CREATE_NEW_RECORD items with object metadata payloads', () => {
const createRecordItem = {
...baseCommandMenuItem,
engineComponentKey: EngineComponentKey.CREATE_NEW_RECORD,
label: CREATE_RECORD_INTERPOLATED_LABEL,
shortLabel: CREATE_RECORD_INTERPOLATED_SHORT_LABEL,
icon: CREATE_RECORD_INTERPOLATED_ICON,
};
expect(
interpolateNavigationCommandMenuItemField({
commandMenuItem: createRecordItem,
fieldName: 'label',
objectMetadata: mockObjectMetadata,
locale: undefined,
i18nInstance: mockI18nInstance,
}),
).toBe('Create Person');
expect(
interpolateNavigationCommandMenuItemField({
commandMenuItem: createRecordItem,
fieldName: 'shortLabel',
objectMetadata: mockObjectMetadata,
locale: undefined,
i18nInstance: mockI18nInstance,
}),
).toBe('Create Person');
expect(
interpolateNavigationCommandMenuItemField({
commandMenuItem: createRecordItem,
fieldName: 'icon',
objectMetadata: mockObjectMetadata,
locale: undefined,
i18nInstance: mockI18nInstance,
}),
).toBe('IconUser');
});
it('should return undefined when object metadata is null for a NAVIGATION item', () => {
const result = interpolateNavigationCommandMenuItemField({
commandMenuItem: baseCommandMenuItem,
@@ -38,6 +38,13 @@ export const buildNavigationInterpolationContext = ({
i18nInstance,
);
const resolvedLabelSingular = resolveObjectMetadataStandardOverride(
overrideInput,
'labelSingular',
locale,
i18nInstance,
);
const resolvedIcon = resolveObjectMetadataStandardOverride(
overrideInput,
'icon',
@@ -50,5 +57,14 @@ export const buildNavigationInterpolationContext = ({
labelPlural: resolvedLabelPlural,
icon: resolvedIcon,
},
createObjectMetadataItem: {
labelSingular: resolvedLabelSingular,
icon: resolvedIcon,
},
targetObjectMetadataItem: {
labelPlural: resolvedLabelPlural,
labelSingular: resolvedLabelSingular,
icon: resolvedIcon,
},
};
};
@@ -12,6 +12,11 @@ import { buildNavigationInterpolationContext } from 'src/engine/metadata-modules
import { isObjectMetadataCommandMenuItemPayload } from 'src/engine/metadata-modules/command-menu-item/utils/is-object-metadata-command-menu-item-payload.util';
import { type ObjectMetadataDTO } from 'src/engine/metadata-modules/object-metadata/dtos/object-metadata.dto';
const OBJECT_METADATA_TEMPLATE_ENGINE_COMPONENT_KEYS = [
EngineComponentKey.NAVIGATION,
EngineComponentKey.CREATE_NEW_RECORD,
];
export const interpolateNavigationCommandMenuItemField = ({
commandMenuItem,
fieldName,
@@ -28,7 +33,9 @@ export const interpolateNavigationCommandMenuItemField = ({
const rawValue = commandMenuItem[fieldName];
if (
commandMenuItem.engineComponentKey !== EngineComponentKey.NAVIGATION ||
!OBJECT_METADATA_TEMPLATE_ENGINE_COMPONENT_KEYS.includes(
commandMenuItem.engineComponentKey,
) ||
!isObjectMetadataCommandMenuItemPayload(commandMenuItem.payload)
) {
return rawValue;
@@ -0,0 +1,128 @@
import { v5 } from 'uuid';
import { CommandMenuItemAvailabilityType } from 'src/engine/metadata-modules/command-menu-item/enums/command-menu-item-availability-type.enum';
import { EngineComponentKey } from 'src/engine/metadata-modules/command-menu-item/enums/engine-component-key.enum';
import {
CREATE_RECORD_COMMAND_UUID_NAMESPACE,
CREATE_RECORD_INTERPOLATED_ICON,
CREATE_RECORD_INTERPOLATED_LABEL,
CREATE_RECORD_INTERPOLATED_SHORT_LABEL,
buildCreateRecordFlatCommandMenuItem,
buildUpdatedCreateRecordFlatCommandMenuItem,
} from 'src/engine/metadata-modules/flat-command-menu-item/utils/build-create-record-flat-command-menu-item.util';
import { TWENTY_STANDARD_APPLICATION } from 'src/engine/workspace-manager/twenty-standard-application/constants/twenty-standard-applications';
describe('buildCreateRecordFlatCommandMenuItem', () => {
it('builds a deterministic global create command for an object', () => {
const objectUniversalIdentifier = '5da6fdc9-48db-4cd1-b41c-907d8edaaf7b';
const result = buildCreateRecordFlatCommandMenuItem({
objectMetadata: {
id: 'object-metadata-id',
universalIdentifier: objectUniversalIdentifier,
nameSingular: 'company',
},
commandMenuItemId: 'command-menu-item-id',
applicationId: 'application-id',
workspaceId: 'workspace-id',
position: 42,
now: '2026-05-22T00:00:00.000Z',
});
expect(result).toEqual({
id: 'command-menu-item-id',
universalIdentifier: v5(
objectUniversalIdentifier,
CREATE_RECORD_COMMAND_UUID_NAMESPACE,
),
applicationId: 'application-id',
applicationUniversalIdentifier:
TWENTY_STANDARD_APPLICATION.universalIdentifier,
workspaceId: 'workspace-id',
label: CREATE_RECORD_INTERPOLATED_LABEL,
shortLabel: CREATE_RECORD_INTERPOLATED_SHORT_LABEL,
icon: CREATE_RECORD_INTERPOLATED_ICON,
position: 42,
isPinned: false,
availabilityType: CommandMenuItemAvailabilityType.GLOBAL,
conditionalAvailabilityExpression:
'targetObjectWritePermissions.company and not (pageType == "INDEX_PAGE" and objectMetadataItem.nameSingular == "company")',
frontComponentId: null,
frontComponentUniversalIdentifier: null,
engineComponentKey: EngineComponentKey.CREATE_NEW_RECORD,
payload: { objectMetadataItemId: 'object-metadata-id' },
hotKeys: null,
workflowVersionId: null,
availabilityObjectMetadataId: null,
availabilityObjectMetadataUniversalIdentifier: null,
pageLayoutId: null,
pageLayoutUniversalIdentifier: null,
createdAt: '2026-05-22T00:00:00.000Z',
updatedAt: '2026-05-22T00:00:00.000Z',
});
});
it('updates stale create command metadata while preserving stable fields', () => {
const existingCommandMenuItem = buildCreateRecordFlatCommandMenuItem({
objectMetadata: {
id: 'object-metadata-id',
universalIdentifier: '5da6fdc9-48db-4cd1-b41c-907d8edaaf7b',
nameSingular: 'company',
},
commandMenuItemId: 'command-menu-item-id',
applicationId: 'application-id',
workspaceId: 'workspace-id',
position: 42,
now: '2026-05-22T00:00:00.000Z',
});
const result = buildUpdatedCreateRecordFlatCommandMenuItem({
existingCommandMenuItem,
objectMetadata: {
id: 'object-metadata-id',
universalIdentifier: '5da6fdc9-48db-4cd1-b41c-907d8edaaf7b',
nameSingular: 'organization',
},
now: '2026-05-23T00:00:00.000Z',
});
expect(result).toMatchObject({
id: 'command-menu-item-id',
applicationId: 'application-id',
workspaceId: 'workspace-id',
position: 42,
conditionalAvailabilityExpression:
'targetObjectWritePermissions.organization and not (pageType == "INDEX_PAGE" and objectMetadataItem.nameSingular == "organization")',
payload: { objectMetadataItemId: 'object-metadata-id' },
createdAt: '2026-05-22T00:00:00.000Z',
updatedAt: '2026-05-23T00:00:00.000Z',
});
});
it('does not update an already current create command', () => {
const existingCommandMenuItem = buildCreateRecordFlatCommandMenuItem({
objectMetadata: {
id: 'object-metadata-id',
universalIdentifier: '5da6fdc9-48db-4cd1-b41c-907d8edaaf7b',
nameSingular: 'company',
},
commandMenuItemId: 'command-menu-item-id',
applicationId: 'application-id',
workspaceId: 'workspace-id',
position: 42,
now: '2026-05-22T00:00:00.000Z',
});
const result = buildUpdatedCreateRecordFlatCommandMenuItem({
existingCommandMenuItem,
objectMetadata: {
id: 'object-metadata-id',
universalIdentifier: '5da6fdc9-48db-4cd1-b41c-907d8edaaf7b',
nameSingular: 'company',
},
now: '2026-05-23T00:00:00.000Z',
});
expect(result).toBeUndefined();
});
});
@@ -0,0 +1,115 @@
import isEqual from 'lodash.isequal';
import { v5 } from 'uuid';
import { CommandMenuItemAvailabilityType } from 'src/engine/metadata-modules/command-menu-item/enums/command-menu-item-availability-type.enum';
import { EngineComponentKey } from 'src/engine/metadata-modules/command-menu-item/enums/engine-component-key.enum';
import { type FlatCommandMenuItem } from 'src/engine/metadata-modules/flat-command-menu-item/types/flat-command-menu-item.type';
import { TWENTY_STANDARD_APPLICATION } from 'src/engine/workspace-manager/twenty-standard-application/constants/twenty-standard-applications';
export const CREATE_RECORD_COMMAND_UUID_NAMESPACE =
'02959e3f-c244-4f89-9127-f6335cfc0f57';
export const CREATE_RECORD_INTERPOLATED_LABEL =
'Create ${createObjectMetadataItem.labelSingular}';
export const CREATE_RECORD_INTERPOLATED_SHORT_LABEL =
'Create ${createObjectMetadataItem.labelSingular}';
export const CREATE_RECORD_INTERPOLATED_ICON =
'${createObjectMetadataItem.icon}';
export const buildCreateRecordFlatCommandMenuItem = ({
objectMetadata,
commandMenuItemId,
applicationId,
workspaceId,
position,
now,
}: {
objectMetadata: {
id: string;
universalIdentifier: string;
nameSingular: string;
};
commandMenuItemId: string;
applicationId: string;
workspaceId: string;
position: number;
now: string;
}): FlatCommandMenuItem => {
const universalIdentifier = v5(
objectMetadata.universalIdentifier,
CREATE_RECORD_COMMAND_UUID_NAMESPACE,
);
return {
id: commandMenuItemId,
universalIdentifier,
applicationId,
applicationUniversalIdentifier:
TWENTY_STANDARD_APPLICATION.universalIdentifier,
workspaceId,
label: CREATE_RECORD_INTERPOLATED_LABEL,
shortLabel: CREATE_RECORD_INTERPOLATED_SHORT_LABEL,
icon: CREATE_RECORD_INTERPOLATED_ICON,
position,
isPinned: false,
availabilityType: CommandMenuItemAvailabilityType.GLOBAL,
conditionalAvailabilityExpression: `targetObjectWritePermissions.${objectMetadata.nameSingular} and not (pageType == "INDEX_PAGE" and objectMetadataItem.nameSingular == "${objectMetadata.nameSingular}")`,
frontComponentId: null,
frontComponentUniversalIdentifier: null,
engineComponentKey: EngineComponentKey.CREATE_NEW_RECORD,
payload: { objectMetadataItemId: objectMetadata.id },
hotKeys: null,
workflowVersionId: null,
availabilityObjectMetadataId: null,
availabilityObjectMetadataUniversalIdentifier: null,
pageLayoutId: null,
pageLayoutUniversalIdentifier: null,
createdAt: now,
updatedAt: now,
};
};
export const buildUpdatedCreateRecordFlatCommandMenuItem = ({
existingCommandMenuItem,
objectMetadata,
now,
}: {
existingCommandMenuItem: FlatCommandMenuItem;
objectMetadata: {
id: string;
universalIdentifier: string;
nameSingular: string;
};
now: string;
}): FlatCommandMenuItem | undefined => {
const expectedCommandMenuItem = buildCreateRecordFlatCommandMenuItem({
objectMetadata,
commandMenuItemId: existingCommandMenuItem.id,
applicationId: existingCommandMenuItem.applicationId,
workspaceId: existingCommandMenuItem.workspaceId,
position: existingCommandMenuItem.position,
now,
});
if (
existingCommandMenuItem.label === expectedCommandMenuItem.label &&
existingCommandMenuItem.shortLabel === expectedCommandMenuItem.shortLabel &&
existingCommandMenuItem.icon === expectedCommandMenuItem.icon &&
existingCommandMenuItem.conditionalAvailabilityExpression ===
expectedCommandMenuItem.conditionalAvailabilityExpression &&
isEqual(existingCommandMenuItem.payload, expectedCommandMenuItem.payload)
) {
return undefined;
}
return {
...existingCommandMenuItem,
label: expectedCommandMenuItem.label,
shortLabel: expectedCommandMenuItem.shortLabel,
icon: expectedCommandMenuItem.icon,
conditionalAvailabilityExpression:
expectedCommandMenuItem.conditionalAvailabilityExpression,
payload: expectedCommandMenuItem.payload,
updatedAt: now,
};
};
@@ -57,11 +57,12 @@ export const fromCreateCommandMenuItemInputToFlatCommandMenuItemToCreate = ({
shortLabel: createCommandMenuItemInput.shortLabel ?? null,
position: createCommandMenuItemInput.position ?? 0,
isPinned: createCommandMenuItemInput.isPinned ?? false,
payload:
createCommandMenuItemInput.engineComponentKey ===
EngineComponentKey.NAVIGATION
? (createCommandMenuItemInput.payload ?? null)
: null,
payload: [
EngineComponentKey.NAVIGATION,
EngineComponentKey.CREATE_NEW_RECORD,
].includes(createCommandMenuItemInput.engineComponentKey)
? (createCommandMenuItemInput.payload ?? null)
: null,
hotKeys: createCommandMenuItemInput.hotKeys ?? null,
availabilityType:
createCommandMenuItemInput.availabilityType ??
@@ -22,6 +22,7 @@ export const fromFlatCommandMenuItemToCommandMenuItemDto = (
flatCommandMenuItem.availabilityObjectMetadataId ?? undefined,
pageLayoutId: flatCommandMenuItem.pageLayoutId ?? undefined,
workspaceId: flatCommandMenuItem.workspaceId,
universalIdentifier: flatCommandMenuItem.universalIdentifier,
applicationId: flatCommandMenuItem.applicationId ?? undefined,
createdAt: new Date(flatCommandMenuItem.createdAt),
updatedAt: new Date(flatCommandMenuItem.updatedAt),
@@ -8,6 +8,7 @@ import {
ViewType,
ViewVisibility,
} from 'twenty-shared/types';
import { isObjectMetadataManuallyCreatable } from 'twenty-shared/metadata';
import { fromArrayToUniqueKeyRecord, isDefined } from 'twenty-shared/utils';
import { FindManyOptions, FindOneOptions, Repository } from 'typeorm';
import { v4, v5 } from 'uuid';
@@ -15,6 +16,11 @@ import { v4, v5 } from 'uuid';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type';
import { type FlatCommandMenuItem } from 'src/engine/metadata-modules/flat-command-menu-item/types/flat-command-menu-item.type';
import {
buildCreateRecordFlatCommandMenuItem,
buildUpdatedCreateRecordFlatCommandMenuItem,
CREATE_RECORD_COMMAND_UUID_NAMESPACE,
} from 'src/engine/metadata-modules/flat-command-menu-item/utils/build-create-record-flat-command-menu-item.util';
import {
buildNavigationFlatCommandMenuItem,
NAVIGATION_COMMAND_UUID_NAMESPACE,
@@ -124,30 +130,20 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
flatEntityId: updateObjectInput.id,
});
const {
commandMenuItemsToCreate,
commandMenuItemsToDelete,
commandMenuItemsToUpdate,
} = this.computeCommandMenuItemChangesForObjectUpdate({
existingFlatObjectMetadata,
flatObjectMetadataToUpdate,
flatCommandMenuItemMaps: existingFlatCommandMenuItemMaps,
workspaceId,
applicationId: twentyStandardFlatApplication.id,
});
const isActiveChangeDefined = isDefined(updateObjectInput.update.isActive);
const isBeingEnabled =
isActiveChangeDefined &&
updateObjectInput.update.isActive === true &&
isDefined(existingFlatObjectMetadata) &&
!existingFlatObjectMetadata.isActive;
const isBeingDisabled =
isActiveChangeDefined &&
updateObjectInput.update.isActive === false &&
isDefined(existingFlatObjectMetadata) &&
existingFlatObjectMetadata.isActive;
const { commandMenuItemsToCreate, commandMenuItemsToDelete } =
this.computeCommandMenuItemChangesForActiveToggle({
isBeingEnabled,
isBeingDisabled,
existingFlatObjectMetadata,
flatCommandMenuItemMaps: existingFlatCommandMenuItemMaps,
workspaceId,
applicationId: twentyStandardFlatApplication.id,
});
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
@@ -192,7 +188,8 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
const hasCommandMenuItemChanges =
commandMenuItemsToCreate.length > 0 ||
commandMenuItemsToDelete.length > 0;
commandMenuItemsToDelete.length > 0 ||
commandMenuItemsToUpdate.length > 0;
if (hasCommandMenuItemChanges) {
const commandMenuItemMigrationResult =
@@ -202,7 +199,7 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
commandMenuItem: {
flatEntityToCreate: commandMenuItemsToCreate,
flatEntityToDelete: commandMenuItemsToDelete,
flatEntityToUpdate: [],
flatEntityToUpdate: commandMenuItemsToUpdate,
},
},
workspaceId,
@@ -245,10 +242,12 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
]);
}
if (isActiveChangeDefined) {
if (isActiveChangeDefined || hasCommandMenuItemChanges) {
await this.flatEntityMapsCacheService.invalidateFlatEntityMaps({
workspaceId,
flatMapsKeys: ['flatNavigationMenuItemMaps', 'flatCommandMenuItemMaps'],
flatMapsKeys: isActiveChangeDefined
? ['flatNavigationMenuItemMaps', 'flatCommandMenuItemMaps']
: ['flatCommandMenuItemMaps'],
});
}
@@ -404,13 +403,18 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
);
const flatCommandMenuItemsToDelete = flatObjectMetadatasToDelete
.map((flatObjectMetadataToDelete) =>
.flatMap((flatObjectMetadataToDelete) => [
this.findNavigationCommandMenuItemForObject({
objectUniversalIdentifier:
flatObjectMetadataToDelete.universalIdentifier,
flatCommandMenuItemMaps,
}),
)
this.findCreateRecordCommandMenuItemForObject({
objectUniversalIdentifier:
flatObjectMetadataToDelete.universalIdentifier,
flatCommandMenuItemMaps,
}),
])
.filter(isDefined);
const validateAndBuildResult =
@@ -520,14 +524,13 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
},
);
const flatCommandMenuItemToCreate = this.buildFlatNavigationCommandMenuItem(
{
const flatCommandMenuItemsToCreate =
this.buildFlatCommandMenuItemsForObject({
objectMetadata: flatObjectMetadataToCreate,
workspaceId,
applicationId: twentyStandardFlatApplication.id,
flatCommandMenuItemMaps,
},
);
});
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
@@ -590,7 +593,7 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
{
allFlatEntityOperationByMetadataName: {
commandMenuItem: {
flatEntityToCreate: [flatCommandMenuItemToCreate],
flatEntityToCreate: flatCommandMenuItemsToCreate,
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
@@ -845,6 +848,7 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
workspaceId,
applicationId,
flatCommandMenuItemMaps,
position,
}: {
objectMetadata: {
id: string;
@@ -859,26 +863,114 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
flatCommandMenuItemMaps: {
byUniversalIdentifier: Record<string, FlatCommandMenuItem | undefined>;
};
position?: number;
}): FlatCommandMenuItem {
const existingItems = Object.values(
flatCommandMenuItemMaps.byUniversalIdentifier,
).filter(isDefined);
const nextPosition =
existingItems.length > 0
? Math.max(...existingItems.map((item) => item.position)) + 1
: 0;
return buildNavigationFlatCommandMenuItem({
objectMetadata,
commandMenuItemId: v4(),
applicationId,
workspaceId,
position: nextPosition,
position:
position ??
this.computeNextCommandMenuItemPosition(flatCommandMenuItemMaps),
now: new Date().toISOString(),
});
}
private buildFlatCreateRecordCommandMenuItem({
objectMetadata,
workspaceId,
applicationId,
flatCommandMenuItemMaps,
position,
}: {
objectMetadata: {
id: string;
universalIdentifier: string;
nameSingular: string;
};
workspaceId: string;
applicationId: string;
flatCommandMenuItemMaps: {
byUniversalIdentifier: Record<string, FlatCommandMenuItem | undefined>;
};
position?: number;
}): FlatCommandMenuItem {
return buildCreateRecordFlatCommandMenuItem({
objectMetadata,
commandMenuItemId: v4(),
applicationId,
workspaceId,
position:
position ??
this.computeNextCommandMenuItemPosition(flatCommandMenuItemMaps),
now: new Date().toISOString(),
});
}
private buildFlatCommandMenuItemsForObject({
objectMetadata,
workspaceId,
applicationId,
flatCommandMenuItemMaps,
}: {
objectMetadata: {
id: string;
universalIdentifier: string;
labelPlural: string;
icon: string | null;
nameSingular: string;
shortcut: string | null;
isActive: boolean;
isSystem: boolean;
};
workspaceId: string;
applicationId: string;
flatCommandMenuItemMaps: {
byUniversalIdentifier: Record<string, FlatCommandMenuItem | undefined>;
};
}): FlatCommandMenuItem[] {
let nextPosition = this.computeNextCommandMenuItemPosition(
flatCommandMenuItemMaps,
);
const commandMenuItems = [
this.buildFlatNavigationCommandMenuItem({
objectMetadata,
workspaceId,
applicationId,
flatCommandMenuItemMaps,
position: nextPosition++,
}),
];
if (isObjectMetadataManuallyCreatable(objectMetadata)) {
commandMenuItems.push(
this.buildFlatCreateRecordCommandMenuItem({
objectMetadata,
workspaceId,
applicationId,
flatCommandMenuItemMaps,
position: nextPosition++,
}),
);
}
return commandMenuItems;
}
private computeNextCommandMenuItemPosition(flatCommandMenuItemMaps: {
byUniversalIdentifier: Record<string, FlatCommandMenuItem | undefined>;
}): number {
const existingItems = Object.values(
flatCommandMenuItemMaps.byUniversalIdentifier,
).filter(isDefined);
return existingItems.length > 0
? Math.max(...existingItems.map((item) => item.position)) + 1
: 0;
}
private findNavigationCommandMenuItemForObject({
objectUniversalIdentifier,
flatCommandMenuItemMaps,
@@ -899,17 +991,43 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
});
}
private computeCommandMenuItemChangesForActiveToggle({
isBeingEnabled,
isBeingDisabled,
private findCreateRecordCommandMenuItemForObject({
objectUniversalIdentifier,
flatCommandMenuItemMaps,
}: {
objectUniversalIdentifier: string;
flatCommandMenuItemMaps: {
byUniversalIdentifier: Record<string, FlatCommandMenuItem | undefined>;
};
}): FlatCommandMenuItem | undefined {
const commandMenuItemUniversalIdentifier = v5(
objectUniversalIdentifier,
CREATE_RECORD_COMMAND_UUID_NAMESPACE,
);
return findFlatEntityByUniversalIdentifier({
flatEntityMaps: flatCommandMenuItemMaps,
universalIdentifier: commandMenuItemUniversalIdentifier,
});
}
private computeCommandMenuItemChangesForObjectUpdate({
existingFlatObjectMetadata,
flatObjectMetadataToUpdate,
flatCommandMenuItemMaps,
workspaceId,
applicationId,
}: {
isBeingEnabled: boolean;
isBeingDisabled: boolean;
existingFlatObjectMetadata: FlatObjectMetadata | undefined;
flatObjectMetadataToUpdate: {
universalIdentifier: string;
labelPlural: string;
icon: string | null;
nameSingular: string;
shortcut: string | null;
isActive: boolean;
isSystem: boolean;
};
flatCommandMenuItemMaps: {
byUniversalIdentifier: Record<string, FlatCommandMenuItem | undefined>;
};
@@ -917,52 +1035,110 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
applicationId: string;
}): {
commandMenuItemsToCreate: FlatCommandMenuItem[];
commandMenuItemsToUpdate: FlatCommandMenuItem[];
commandMenuItemsToDelete: FlatCommandMenuItem[];
} {
if (!isDefined(existingFlatObjectMetadata)) {
return { commandMenuItemsToCreate: [], commandMenuItemsToDelete: [] };
return {
commandMenuItemsToCreate: [],
commandMenuItemsToUpdate: [],
commandMenuItemsToDelete: [],
};
}
if (isBeingEnabled) {
const existingCommandMenuItem =
this.findNavigationCommandMenuItemForObject({
objectUniversalIdentifier:
existingFlatObjectMetadata.universalIdentifier,
const updatedFlatObjectMetadataWithId = {
...flatObjectMetadataToUpdate,
id: existingFlatObjectMetadata.id,
};
const existingNavigationCommandMenuItem =
this.findNavigationCommandMenuItemForObject({
objectUniversalIdentifier:
existingFlatObjectMetadata.universalIdentifier,
flatCommandMenuItemMaps,
});
const existingCreateRecordCommandMenuItem =
this.findCreateRecordCommandMenuItemForObject({
objectUniversalIdentifier:
existingFlatObjectMetadata.universalIdentifier,
flatCommandMenuItemMaps,
});
if (!flatObjectMetadataToUpdate.isActive) {
return {
commandMenuItemsToCreate: [],
commandMenuItemsToUpdate: [],
commandMenuItemsToDelete: [
existingNavigationCommandMenuItem,
existingCreateRecordCommandMenuItem,
].filter(isDefined),
};
}
const commandMenuItemsToCreate: FlatCommandMenuItem[] = [];
const commandMenuItemsToUpdate: FlatCommandMenuItem[] = [];
const commandMenuItemsToDelete: FlatCommandMenuItem[] = [];
let nextPosition = this.computeNextCommandMenuItemPosition(
flatCommandMenuItemMaps,
);
if (!isDefined(existingNavigationCommandMenuItem)) {
commandMenuItemsToCreate.push(
this.buildFlatNavigationCommandMenuItem({
objectMetadata: updatedFlatObjectMetadataWithId,
workspaceId,
applicationId,
flatCommandMenuItemMaps,
position: nextPosition++,
}),
);
}
const isManuallyCreatableAfterUpdate = isObjectMetadataManuallyCreatable(
flatObjectMetadataToUpdate,
);
if (!isManuallyCreatableAfterUpdate) {
if (isDefined(existingCreateRecordCommandMenuItem)) {
commandMenuItemsToDelete.push(existingCreateRecordCommandMenuItem);
}
return {
commandMenuItemsToCreate,
commandMenuItemsToUpdate,
commandMenuItemsToDelete,
};
}
if (!isDefined(existingCreateRecordCommandMenuItem)) {
commandMenuItemsToCreate.push(
this.buildFlatCreateRecordCommandMenuItem({
objectMetadata: updatedFlatObjectMetadataWithId,
workspaceId,
applicationId,
flatCommandMenuItemMaps,
position: nextPosition++,
}),
);
} else {
const updatedCreateRecordCommandMenuItem =
buildUpdatedCreateRecordFlatCommandMenuItem({
existingCommandMenuItem: existingCreateRecordCommandMenuItem,
objectMetadata: updatedFlatObjectMetadataWithId,
now: new Date().toISOString(),
});
if (!isDefined(existingCommandMenuItem)) {
return {
commandMenuItemsToCreate: [
this.buildFlatNavigationCommandMenuItem({
objectMetadata: existingFlatObjectMetadata,
workspaceId,
applicationId,
flatCommandMenuItemMaps,
}),
],
commandMenuItemsToDelete: [],
};
if (isDefined(updatedCreateRecordCommandMenuItem)) {
commandMenuItemsToUpdate.push(updatedCreateRecordCommandMenuItem);
}
}
if (isBeingDisabled) {
const commandMenuItemToDelete =
this.findNavigationCommandMenuItemForObject({
objectUniversalIdentifier:
existingFlatObjectMetadata.universalIdentifier,
flatCommandMenuItemMaps,
});
if (isDefined(commandMenuItemToDelete)) {
return {
commandMenuItemsToCreate: [],
commandMenuItemsToDelete: [commandMenuItemToDelete],
};
}
}
return { commandMenuItemsToCreate: [], commandMenuItemsToDelete: [] };
return {
commandMenuItemsToCreate,
commandMenuItemsToUpdate,
commandMenuItemsToDelete,
};
}
public async findOneWithinWorkspace(
@@ -4,6 +4,11 @@ import { SOURCE_LOCALE } from 'twenty-shared/translations';
import { EngineComponentKey } from 'src/engine/metadata-modules/command-menu-item/enums/engine-component-key.enum';
import { type FlatCommandMenuItem } from 'src/engine/metadata-modules/flat-command-menu-item/types/flat-command-menu-item.type';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import {
CREATE_RECORD_INTERPOLATED_ICON,
CREATE_RECORD_INTERPOLATED_LABEL,
CREATE_RECORD_INTERPOLATED_SHORT_LABEL,
} from 'src/engine/metadata-modules/flat-command-menu-item/utils/build-create-record-flat-command-menu-item.util';
import {
NAVIGATION_INTERPOLATED_ICON,
NAVIGATION_INTERPOLATED_LABEL,
@@ -81,7 +86,31 @@ describe('enrichCommandMenuItemEventWithResolvedNavigation', () => {
expect(result.icon).toBe('IconUser');
});
it('should return record unchanged for non-NAVIGATION items', () => {
it('should resolve label, shortLabel, and icon templates for CREATE_NEW_RECORD items', () => {
const flatObjectMetadata = makeFlatObjectMetadata();
const flatObjectMetadataMaps =
makeFlatObjectMetadataMaps(flatObjectMetadata);
const record = makeNavigationRecord({
engineComponentKey: EngineComponentKey.CREATE_NEW_RECORD,
label: CREATE_RECORD_INTERPOLATED_LABEL,
shortLabel: CREATE_RECORD_INTERPOLATED_SHORT_LABEL,
icon: CREATE_RECORD_INTERPOLATED_ICON,
});
const result = enrichCommandMenuItemEventWithResolvedNavigation({
record,
flatObjectMetadataMaps,
locale: SOURCE_LOCALE,
i18nInstance: mockI18nInstance,
});
expect(result.label).toBe('Create Person');
expect(result.shortLabel).toBe('Create Person');
expect(result.icon).toBe('IconUser');
});
it('should return record unchanged for CREATE_NEW_RECORD items without object metadata payloads', () => {
const flatObjectMetadata = makeFlatObjectMetadata();
const flatObjectMetadataMaps =
makeFlatObjectMetadataMaps(flatObjectMetadata);
@@ -26,7 +26,12 @@ export const enrichCommandMenuItemEventWithResolvedNavigation = ({
locale,
i18nInstance,
}: EnrichCommandMenuItemEventArgs): FlatCommandMenuItem => {
if (record.engineComponentKey !== EngineComponentKey.NAVIGATION) {
if (
![
EngineComponentKey.NAVIGATION,
EngineComponentKey.CREATE_NEW_RECORD,
].includes(record.engineComponentKey)
) {
return record;
}
@@ -37,11 +37,11 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
},
createNewRecord: {
universalIdentifier: '08d255bf-58cd-47a5-bd82-78c5c58592f1',
label: 'Create new ${capitalize(objectMetadataItem.labelSingular)}',
label: 'Create ${capitalize(objectMetadataItem.labelSingular)}',
icon: 'IconPlus',
isPinned: true,
position: 3,
shortLabel: 'New ${capitalize(objectMetadataItem.labelSingular)}',
shortLabel: 'Create ${capitalize(objectMetadataItem.labelSingular)}',
availabilityType: CommandMenuItemAvailabilityType.GLOBAL_OBJECT_CONTEXT,
conditionalAvailabilityExpression:
'pageType == "INDEX_PAGE" and objectPermissions.canUpdateObjectRecords and not hasAnySoftDeleteFilterOnView',
@@ -1,7 +1,9 @@
import { isObjectMetadataManuallyCreatable } from 'twenty-shared/metadata';
import { isDefined } from 'twenty-shared/utils';
import { v4 } from 'uuid';
import { type FlatCommandMenuItemMaps } from 'src/engine/metadata-modules/flat-command-menu-item/types/flat-command-menu-item-maps.type';
import { buildCreateRecordFlatCommandMenuItem } from 'src/engine/metadata-modules/flat-command-menu-item/utils/build-create-record-flat-command-menu-item.util';
import { buildNavigationFlatCommandMenuItem } from 'src/engine/metadata-modules/flat-command-menu-item/utils/build-navigation-flat-command-menu-item.util';
import { seedCompareObjectMetadataForNavigationPosition } from 'src/engine/metadata-modules/flat-command-menu-item/utils/seed-compare-object-metadata-for-navigation-position.util';
import { createEmptyFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/constant/create-empty-flat-entity-maps.constant';
@@ -83,5 +85,25 @@ export const buildStandardFlatCommandMenuItemMaps = ({
});
}
const manuallyCreatableActiveObjects = activeObjects.filter((flatObject) =>
isObjectMetadataManuallyCreatable(flatObject),
);
for (const flatObject of manuallyCreatableActiveObjects) {
const createRecordItem = buildCreateRecordFlatCommandMenuItem({
objectMetadata: flatObject,
commandMenuItemId: v4(),
applicationId: twentyStandardApplicationId,
workspaceId,
position: nextPosition++,
now,
});
addFlatEntityToFlatEntityMapsThroughMutationOrThrow({
flatEntity: createRecordItem,
flatEntityMapsToMutate: flatCommandMenuItemMaps,
});
}
return flatCommandMenuItemMaps;
};
@@ -0,0 +1,125 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { CommandMenuItemExceptionCode } from 'src/engine/metadata-modules/command-menu-item/command-menu-item.exception';
import { CommandMenuItemAvailabilityType } from 'src/engine/metadata-modules/command-menu-item/enums/command-menu-item-availability-type.enum';
import { EngineComponentKey } from 'src/engine/metadata-modules/command-menu-item/enums/engine-component-key.enum';
import { type FlatCommandMenuItem } from 'src/engine/metadata-modules/flat-command-menu-item/types/flat-command-menu-item.type';
import { FlatCommandMenuItemValidatorService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-command-menu-item-validator.service';
const buildFlatCommandMenuItem = (
overrides: Partial<FlatCommandMenuItem> = {},
): FlatCommandMenuItem =>
({
id: 'command-menu-item-id',
universalIdentifier: 'command-menu-item-universal-id',
applicationId: 'application-id',
applicationUniversalIdentifier: 'application-universal-id',
workspaceId: 'workspace-id',
label: 'Command',
shortLabel: null,
icon: null,
position: 1,
isPinned: false,
availabilityType: CommandMenuItemAvailabilityType.GLOBAL,
conditionalAvailabilityExpression: null,
frontComponentId: null,
frontComponentUniversalIdentifier: null,
engineComponentKey: EngineComponentKey.CREATE_NEW_RECORD,
payload: null,
hotKeys: null,
workflowVersionId: null,
availabilityObjectMetadataId: null,
availabilityObjectMetadataUniversalIdentifier: null,
pageLayoutId: null,
pageLayoutUniversalIdentifier: null,
createdAt: '2026-05-22T00:00:00.000Z',
updatedAt: '2026-05-22T00:00:00.000Z',
...overrides,
}) as FlatCommandMenuItem;
const buildCreationArgs = (flatEntityToValidate: FlatCommandMenuItem) =>
({
flatEntityToValidate,
optimisticFlatEntityMapsAndRelatedFlatEntityMaps: {},
buildOptions: {},
}) as unknown as Parameters<
FlatCommandMenuItemValidatorService['validateFlatCommandMenuItemCreation']
>[0];
describe('FlatCommandMenuItemValidatorService', () => {
let service: FlatCommandMenuItemValidatorService;
beforeEach(async () => {
const moduleRef: TestingModule = await Test.createTestingModule({
providers: [FlatCommandMenuItemValidatorService],
}).compile();
service = moduleRef.get(FlatCommandMenuItemValidatorService);
});
it('accepts null payload for CREATE_NEW_RECORD', () => {
const result = service.validateFlatCommandMenuItemCreation(
buildCreationArgs(buildFlatCommandMenuItem({ payload: null })),
);
expect(result.errors).toHaveLength(0);
});
it('accepts object metadata payload for CREATE_NEW_RECORD', () => {
const result = service.validateFlatCommandMenuItemCreation(
buildCreationArgs(
buildFlatCommandMenuItem({
payload: { objectMetadataItemId: 'object-metadata-id' },
}),
),
);
expect(result.errors).toHaveLength(0);
});
it('rejects path payload for CREATE_NEW_RECORD', () => {
const result = service.validateFlatCommandMenuItemCreation(
buildCreationArgs(
buildFlatCommandMenuItem({
payload: { path: '/settings/profile' },
}),
),
);
expect(result.errors.map((error) => error.code)).toEqual([
CommandMenuItemExceptionCode.INVALID_COMMAND_MENU_ITEM_INPUT,
]);
});
it('rejects mixed object metadata and path payload for CREATE_NEW_RECORD', () => {
const result = service.validateFlatCommandMenuItemCreation(
buildCreationArgs(
buildFlatCommandMenuItem({
payload: {
objectMetadataItemId: 'object-metadata-id',
path: '/settings/profile',
},
}),
),
);
expect(result.errors.map((error) => error.code)).toEqual([
CommandMenuItemExceptionCode.INVALID_COMMAND_MENU_ITEM_INPUT,
]);
});
it('still requires payload for NAVIGATION', () => {
const result = service.validateFlatCommandMenuItemCreation(
buildCreationArgs(
buildFlatCommandMenuItem({
engineComponentKey: EngineComponentKey.NAVIGATION,
payload: null,
}),
),
);
expect(result.errors.map((error) => error.code)).toEqual([
CommandMenuItemExceptionCode.INVALID_COMMAND_MENU_ITEM_INPUT,
]);
});
});
@@ -232,6 +232,27 @@ export class FlatCommandMenuItemValidatorService {
break;
}
case EngineComponentKey.CREATE_NEW_RECORD: {
this.validateCreateNewRecordPayload({ payload, validationResult });
if (isNonEmptyString(workflowVersionId)) {
validationResult.errors.push({
code: CommandMenuItemExceptionCode.INVALID_COMMAND_MENU_ITEM_INPUT,
message: t`workflowVersionId must not be set for engine component key ${engineComponentKey}`,
userFriendlyMessage: msg`Workflow version must not be set for this item type`,
});
}
if (isNonEmptyString(frontComponentUniversalIdentifier)) {
validationResult.errors.push({
code: CommandMenuItemExceptionCode.INVALID_COMMAND_MENU_ITEM_INPUT,
message: t`frontComponentId must not be set for engine component key ${engineComponentKey}`,
userFriendlyMessage: msg`Front component must not be set for this item type`,
});
}
break;
}
default: {
if (isNonEmptyString(workflowVersionId)) {
validationResult.errors.push({
@@ -287,4 +308,32 @@ export class FlatCommandMenuItemValidatorService {
});
}
}
private validateCreateNewRecordPayload({
payload,
validationResult,
}: {
payload: CommandMenuItemPayload | null;
validationResult: FailedFlatEntityValidation<
'commandMenuItem',
'create' | 'update'
>;
}): void {
if (!isDefined(payload)) {
return;
}
const hasObjectMetadataItemId =
'objectMetadataItemId' in payload &&
isNonEmptyString(payload.objectMetadataItemId);
const hasPath = 'path' in payload && isNonEmptyString(payload.path);
if (!hasObjectMetadataItemId || hasPath) {
validationResult.errors.push({
code: CommandMenuItemExceptionCode.INVALID_COMMAND_MENU_ITEM_INPUT,
message: t`payload for CREATE_NEW_RECORD must be null or contain an "objectMetadataItemId" property without a "path" property`,
userFriendlyMessage: msg`Payload for create record items must be empty or contain an object metadata item identifier without a path`,
});
}
}
}
@@ -13,6 +13,9 @@ type TestContext = {
input: CreateCommandMenuItemInput;
};
const CREATE_NEW_RECORD_PAYLOAD_ERROR_MESSAGE =
'payload for CREATE_NEW_RECORD must be null or contain';
const failingCommandMenuItemCreationTestCases: EachTestingContext<TestContext>[] =
[
{
@@ -138,4 +141,37 @@ describe('CommandMenuItem creation should fail', () => {
});
},
);
it('should reject CREATE_NEW_RECORD command menu item with a path payload', async () => {
const { errors } = await createCommandMenuItem({
expectToFail: true,
input: {
engineComponentKey: EngineComponentKey.CREATE_NEW_RECORD,
label: 'Create from path',
payload: { path: '/objects/companies' },
},
});
expect(JSON.stringify(errors)).toContain(
CREATE_NEW_RECORD_PAYLOAD_ERROR_MESSAGE,
);
});
it('should reject CREATE_NEW_RECORD command menu item with mixed object and path payload', async () => {
const { errors } = await createCommandMenuItem({
expectToFail: true,
input: {
engineComponentKey: EngineComponentKey.CREATE_NEW_RECORD,
label: 'Create from mixed payload',
payload: {
objectMetadataItemId: faker.string.uuid(),
path: '/objects/companies',
},
},
});
expect(JSON.stringify(errors)).toContain(
CREATE_NEW_RECORD_PAYLOAD_ERROR_MESSAGE,
);
});
});
@@ -218,6 +218,47 @@ describe('CommandMenuItem creation should succeed', () => {
});
});
it('should create CREATE_NEW_RECORD command menu item without payload', async () => {
const { data } = await createCommandMenuItem({
expectToFail: false,
input: {
engineComponentKey: EngineComponentKey.CREATE_NEW_RECORD,
label: 'Create current record',
},
});
createdCommandMenuItemId = data?.createCommandMenuItem?.id;
expect(data.createCommandMenuItem).toMatchObject({
id: expect.any(String),
engineComponentKey: EngineComponentKey.CREATE_NEW_RECORD,
label: 'Create current record',
payload: null,
});
});
it('should create CREATE_NEW_RECORD command menu item with objectMetadataItemId payload', async () => {
const { data } = await createCommandMenuItem({
expectToFail: false,
input: {
engineComponentKey: EngineComponentKey.CREATE_NEW_RECORD,
label: 'Create Company',
payload: { objectMetadataItemId: companyObjectMetadataId },
},
});
createdCommandMenuItemId = data?.createCommandMenuItem?.id;
expect(data.createCommandMenuItem).toMatchObject({
id: expect.any(String),
engineComponentKey: EngineComponentKey.CREATE_NEW_RECORD,
label: 'Create Company',
payload: {
objectMetadataItemId: companyObjectMetadataId,
},
});
});
it('should create command menu item with frontComponentId', async () => {
const { data: frontComponentData } = await createFrontComponent({
expectToFail: false,
@@ -4,27 +4,60 @@ import { createOneObjectMetadata } from 'test/integration/metadata/suites/object
import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util';
import { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util';
import { isDefined } from 'twenty-shared/utils';
import { v5 } from 'uuid';
import { type CommandMenuItemDTO } from 'src/engine/metadata-modules/command-menu-item/dtos/command-menu-item.dto';
import { type ObjectMetadataCommandMenuItemPayload } from 'src/engine/metadata-modules/command-menu-item/dtos/types/object-metadata-command-menu-item-payload.type';
import { CommandMenuItemAvailabilityType } from 'src/engine/metadata-modules/command-menu-item/enums/command-menu-item-availability-type.enum';
import { EngineComponentKey } from 'src/engine/metadata-modules/command-menu-item/enums/engine-component-key.enum';
import { CREATE_RECORD_COMMAND_UUID_NAMESPACE } from 'src/engine/metadata-modules/flat-command-menu-item/utils/build-create-record-flat-command-menu-item.util';
const findObjectCommandMenuItemForObject = ({
commandMenuItems,
engineComponentKey,
objectMetadataId,
}: {
commandMenuItems: CommandMenuItemDTO[];
engineComponentKey: EngineComponentKey;
objectMetadataId: string;
}) =>
commandMenuItems.find(
(item) =>
item.engineComponentKey === engineComponentKey &&
(item.payload as ObjectMetadataCommandMenuItemPayload | undefined)
?.objectMetadataItemId === objectMetadataId,
);
const findNavigationCommandMenuItemForObject = (
commandMenuItems: CommandMenuItemDTO[],
objectMetadataId: string,
) =>
commandMenuItems.find(
(item) =>
item.engineComponentKey === EngineComponentKey.NAVIGATION &&
(item.payload as ObjectMetadataCommandMenuItemPayload | undefined)
?.objectMetadataItemId === objectMetadataId,
);
findObjectCommandMenuItemForObject({
commandMenuItems,
engineComponentKey: EngineComponentKey.NAVIGATION,
objectMetadataId,
});
const findCreateRecordCommandMenuItemForObject = (
commandMenuItems: CommandMenuItemDTO[],
objectMetadataId: string,
) =>
findObjectCommandMenuItemForObject({
commandMenuItems,
engineComponentKey: EngineComponentKey.CREATE_NEW_RECORD,
objectMetadataId,
});
const COMMAND_MENU_ITEM_GQL_FIELDS = `
id
universalIdentifier
engineComponentKey
label
shortLabel
icon
isPinned
availabilityType
conditionalAvailabilityExpression
payload {
... on PathCommandMenuItemPayload {
path
@@ -35,7 +68,7 @@ const COMMAND_MENU_ITEM_GQL_FIELDS = `
}
`;
describe('Command menu item side effect on object metadata', () => {
describe('Command menu item side effects on object metadata', () => {
let createdObjectMetadataId: string | undefined = undefined;
const uniqueSuffix = Date.now().toString().slice(-8);
@@ -71,13 +104,13 @@ describe('Command menu item side effect on object metadata', () => {
createdObjectMetadataId = undefined;
});
it('should create a navigation command menu item when a custom object is created', async () => {
it('should create navigation and create record command menu items when a custom object is created', async () => {
const {
data: { createOneObject },
} = await createOneObjectMetadata({
expectToFail: false,
input: createObjectInput,
gqlFields: 'id',
gqlFields: 'id universalIdentifier',
});
createdObjectMetadataId = createOneObject.id;
@@ -102,9 +135,33 @@ describe('Command menu item side effect on object metadata', () => {
engineComponentKey: EngineComponentKey.NAVIGATION,
}),
);
const createRecordItem = findCreateRecordCommandMenuItemForObject(
commandMenuItems,
createdObjectMetadataId,
);
expect(createRecordItem).toEqual(
expect.objectContaining({
universalIdentifier: v5(
createOneObject.universalIdentifier,
CREATE_RECORD_COMMAND_UUID_NAMESPACE,
),
label: `Create ${createObjectInput.labelSingular}`,
shortLabel: `Create ${createObjectInput.labelSingular}`,
icon: createObjectInput.icon,
isPinned: false,
availabilityType: CommandMenuItemAvailabilityType.GLOBAL,
conditionalAvailabilityExpression: `targetObjectWritePermissions.${createObjectInput.nameSingular} and not (pageType == "INDEX_PAGE" and objectMetadataItem.nameSingular == "${createObjectInput.nameSingular}")`,
engineComponentKey: EngineComponentKey.CREATE_NEW_RECORD,
}),
);
expect(createRecordItem?.payload).toEqual({
objectMetadataItemId: createdObjectMetadataId,
});
});
it('should delete the navigation command menu item when a custom object is deleted', async () => {
it('should delete navigation and create record command menu items when a custom object is deleted', async () => {
const {
data: { createOneObject },
} = await createOneObjectMetadata({
@@ -130,6 +187,12 @@ describe('Command menu item side effect on object metadata', () => {
deletedObjectId,
),
).toBeDefined();
expect(
findCreateRecordCommandMenuItemForObject(
itemsBeforeDelete,
deletedObjectId,
),
).toBeDefined();
await updateOneObjectMetadata({
expectToFail: false,
@@ -157,9 +220,15 @@ describe('Command menu item side effect on object metadata', () => {
expect(
findNavigationCommandMenuItemForObject(itemsAfterDelete, deletedObjectId),
).toBeUndefined();
expect(
findCreateRecordCommandMenuItemForObject(
itemsAfterDelete,
deletedObjectId,
),
).toBeUndefined();
});
it('should delete the navigation command menu item when a custom object is disabled', async () => {
it('should delete navigation and create record command menu items when a custom object is disabled', async () => {
const {
data: { createOneObject },
} = await createOneObjectMetadata({
@@ -184,6 +253,12 @@ describe('Command menu item side effect on object metadata', () => {
createdObjectMetadataId,
),
).toBeDefined();
expect(
findCreateRecordCommandMenuItemForObject(
itemsBeforeDisable,
createdObjectMetadataId,
),
).toBeDefined();
await updateOneObjectMetadata({
expectToFail: false,
@@ -207,9 +282,15 @@ describe('Command menu item side effect on object metadata', () => {
createdObjectMetadataId,
),
).toBeUndefined();
expect(
findCreateRecordCommandMenuItemForObject(
itemsAfterDisable,
createdObjectMetadataId,
),
).toBeUndefined();
});
it('should recreate the navigation command menu item when a disabled object is re-enabled', async () => {
it('should recreate navigation and create record command menu items when a disabled object is re-enabled', async () => {
const {
data: { createOneObject },
} = await createOneObjectMetadata({
@@ -242,6 +323,12 @@ describe('Command menu item side effect on object metadata', () => {
createdObjectMetadataId,
),
).toBeUndefined();
expect(
findCreateRecordCommandMenuItemForObject(
itemsWhileDisabled,
createdObjectMetadataId,
),
).toBeUndefined();
await updateOneObjectMetadata({
expectToFail: false,
@@ -271,5 +358,18 @@ describe('Command menu item side effect on object metadata', () => {
engineComponentKey: EngineComponentKey.NAVIGATION,
}),
);
expect(
findCreateRecordCommandMenuItemForObject(
itemsAfterReEnable,
createdObjectMetadataId,
),
).toEqual(
expect.objectContaining({
label: `Create ${createObjectInput.labelSingular}`,
icon: createObjectInput.icon,
engineComponentKey: EngineComponentKey.CREATE_NEW_RECORD,
}),
);
});
});
@@ -27,3 +27,7 @@ export { WorkspaceMigrationV2ExceptionCode } from './types/MetadataValidationErr
export { addCustomSuffixIfIsReserved } from './utils/add-custom-suffix-if-reserved.util';
export { computeMetadataNameFromLabel } from './utils/compute-metadata-name-from-label.util';
export { computeMetadataNamesFromLabelsOrThrow } from './utils/compute-metadata-names-from-labels-or-throw.util';
export {
MANUAL_RECORD_CREATION_DISABLED_OBJECT_NAME_SINGULARS,
isObjectMetadataManuallyCreatable,
} from './utils/is-object-metadata-manually-creatable.util';
@@ -0,0 +1,46 @@
import { isObjectMetadataManuallyCreatable } from '@/metadata/utils/is-object-metadata-manually-creatable.util';
describe('isObjectMetadataManuallyCreatable', () => {
it('returns true for active non-system objects', () => {
expect(
isObjectMetadataManuallyCreatable({
nameSingular: 'company',
isActive: true,
isSystem: false,
}),
).toBe(true);
});
it('returns false for inactive objects', () => {
expect(
isObjectMetadataManuallyCreatable({
nameSingular: 'company',
isActive: false,
isSystem: false,
}),
).toBe(false);
});
it('returns false for system objects', () => {
expect(
isObjectMetadataManuallyCreatable({
nameSingular: 'company',
isActive: true,
isSystem: true,
}),
).toBe(false);
});
it.each(['workflow', 'workflowVersion', 'workflowRun', 'dashboard'])(
'returns false for manually-disabled %s objects',
(nameSingular) => {
expect(
isObjectMetadataManuallyCreatable({
nameSingular,
isActive: true,
isSystem: false,
}),
).toBe(false);
},
);
});
@@ -0,0 +1,15 @@
export const MANUAL_RECORD_CREATION_DISABLED_OBJECT_NAME_SINGULARS: readonly string[] =
['workflow', 'workflowVersion', 'workflowRun', 'dashboard'];
export const isObjectMetadataManuallyCreatable = ({
isActive,
isSystem,
nameSingular,
}: {
isActive: boolean;
isSystem: boolean;
nameSingular: string;
}): boolean =>
isActive === true &&
isSystem === false &&
!MANUAL_RECORD_CREATION_DISABLED_OBJECT_NAME_SINGULARS.includes(nameSingular);
@@ -1,6 +1,7 @@
export enum SidePanelPages {
CommandMenuDisplay = 'command-menu-display',
ViewRecord = 'view-record',
CreateRecord = 'create-record',
MergeRecords = 'merge-records',
UpdateRecords = 'update-records',
ViewCalendarEvent = 'view-calendar-event',
@@ -35,6 +35,69 @@ const buildContext = (
});
describe('evaluateConditionalAvailabilityExpression', () => {
describe('object metadata context', () => {
const createCompanyExpression =
'targetObjectWritePermissions.company and not (pageType == "INDEX_PAGE" and objectMetadataItem.nameSingular == "company")';
it('hides object create command on the matching object index page', () => {
const context = buildContext({
targetObjectWritePermissions: { company: true },
objectMetadataItem: { nameSingular: 'company' },
});
expect(
evaluateConditionalAvailabilityExpression(
createCompanyExpression,
context,
),
).toBe(false);
});
it('keeps object create command available outside the matching index page', () => {
const context = buildContext({
targetObjectWritePermissions: { company: true },
pageType: ContextStorePageType.Record,
objectMetadataItem: { nameSingular: 'company' },
});
expect(
evaluateConditionalAvailabilityExpression(
createCompanyExpression,
context,
),
).toBe(true);
});
it('keeps object create command available without current object context', () => {
const context = buildContext({
targetObjectWritePermissions: { company: true },
pageType: ContextStorePageType.Record,
objectMetadataItem: {},
});
expect(
evaluateConditionalAvailabilityExpression(
createCompanyExpression,
context,
),
).toBe(true);
});
it('gates object create command on target object write permission', () => {
const context = buildContext({
targetObjectWritePermissions: { company: false },
objectMetadataItem: { nameSingular: 'person' },
});
expect(
evaluateConditionalAvailabilityExpression(
createCompanyExpression,
context,
),
).toBe(false);
});
});
describe('arrayLength for favoriteRecordIds', () => {
it('should evaluate arrayLength(favoriteRecordIds) < numberOfSelectedRecords when some selected are not favorites', () => {
const expression =
@@ -111,10 +111,10 @@ describe('interpolateCommandMenuItemTemplate', () => {
expect(
interpolateCommandMenuItemTemplate({
label: 'Create new ${objectMetadataItem.labelSingular}',
label: 'Create ${objectMetadataItem.labelSingular}',
context,
}),
).toBe('Create new person');
).toBe('Create person');
});
it('should interpolate objectMetadataItem.labelPlural', () => {
@@ -185,10 +185,10 @@ describe('interpolateCommandMenuItemTemplate', () => {
expect(
interpolateCommandMenuItemTemplate({
label: 'Create new ${objectMetadataItem.labelSingular}',
label: 'Create ${objectMetadataItem.labelSingular}',
context,
}),
).toBe('Create new ');
).toBe('Create ');
});
});
@@ -242,10 +242,10 @@ describe('interpolateCommandMenuItemTemplate', () => {
expect(
interpolateCommandMenuItemTemplate({
label: 'Create new ${objectMetadataItem.labelSingular}',
label: 'Create ${objectMetadataItem.labelSingular}',
context,
}),
).toBe('Create new Person');
).toBe('Create Person');
});
});
@@ -1,3 +1,5 @@
import { MANUAL_RECORD_CREATION_DISABLED_OBJECT_NAME_SINGULARS } from '@/metadata/utils/is-object-metadata-manually-creatable.util';
export const canObjectBeManagedByWorkflow = ({
nameSingular,
isSystem,
@@ -5,15 +7,9 @@ export const canObjectBeManagedByWorkflow = ({
nameSingular: string;
isSystem: boolean;
}) => {
const excludedNonSystemObjectMetadataItemNames = [
'workflow',
'workflowVersion',
'workflowRun',
'dashboard',
];
return (
!excludedNonSystemObjectMetadataItemNames.includes(nameSingular) &&
!isSystem
!MANUAL_RECORD_CREATION_DISABLED_OBJECT_NAME_SINGULARS.includes(
nameSingular,
) && !isSystem
);
};