Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f37be206aa | ||
|
|
bac1fd8f8b | ||
|
|
8c2524937e | ||
|
|
8119bba07d | ||
|
|
d134c27f10 | ||
|
|
c76dd7ba07 | ||
|
|
bbe866f3f8 | ||
|
|
0e01703e25 | ||
|
|
5bef586960 |
@@ -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');
|
||||
});
|
||||
});
|
||||
+2
-4
@@ -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 />,
|
||||
|
||||
+69
@@ -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} />;
|
||||
};
|
||||
+135
@@ -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),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
+6
-3
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+39
-18
@@ -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>,
|
||||
],
|
||||
|
||||
+281
@@ -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>
|
||||
);
|
||||
};
|
||||
+343
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
+9
@@ -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,
|
||||
});
|
||||
+2
-5
@@ -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,
|
||||
|
||||
+39
@@ -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
@@ -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 {}
|
||||
|
||||
+192
@@ -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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
+2
@@ -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,
|
||||
];
|
||||
|
||||
+7
-2
@@ -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})`,
|
||||
);
|
||||
};
|
||||
|
||||
+4
-1
@@ -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;
|
||||
|
||||
+21
@@ -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')`,
|
||||
);
|
||||
});
|
||||
});
|
||||
+6
@@ -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)`;
|
||||
+6
-2
@@ -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
|
||||
|
||||
+45
@@ -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,
|
||||
|
||||
+16
@@ -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,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
+8
-1
@@ -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;
|
||||
|
||||
+128
@@ -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();
|
||||
});
|
||||
});
|
||||
+115
@@ -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,
|
||||
};
|
||||
};
|
||||
+6
-5
@@ -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 ??
|
||||
|
||||
+1
@@ -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),
|
||||
|
||||
+259
-83
@@ -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(
|
||||
|
||||
+30
-1
@@ -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);
|
||||
|
||||
+6
-1
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -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',
|
||||
|
||||
+22
@@ -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;
|
||||
};
|
||||
|
||||
+125
@@ -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,
|
||||
]);
|
||||
});
|
||||
});
|
||||
+49
@@ -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`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+36
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+41
@@ -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,
|
||||
|
||||
+112
-12
@@ -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';
|
||||
|
||||
+46
@@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
+15
@@ -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',
|
||||
|
||||
+63
@@ -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 =
|
||||
|
||||
+6
-6
@@ -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
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user