Add standard command menu items (#18527)

## Add standard command menu items

### Summary

This PR introduces standard command menu items, migrating hardcoded
command menu actions to the backend command menu item architecture
powered by front components. It adds a new `twenty-standard-application`
package that defines, builds, and registers front components as standard
command menu items, gated behind the `IS_COMMAND_MENU_ITEM_ENABLED`
feature flag.

### Description

- **New `twenty-standard-application` package**: Contains front
component definitions with an esbuild-based build pipeline that
generates minified `.mjs` bundles and a manifest with checksums.
- **Server-side registration**: New constants register all items with
metadata (labels, icons, positions, availability types, conditional
expressions). A `StandardFrontComponentUploadService` uploads built
components to file storage.
- **`FALLBACK` availability type**: New enum value for command menu
items that appear as fallback options (e.g., "Search Records" fallback).
- **`CommandMenuContextApi` refactor**
- **Conditional availability enhancements**: New array-based helper
functions for evaluating multi-record conditions.
- **Frontend wiring** (twenty-front):
`useCommandMenuItemFrontComponentCommands`

## Next steps

Only simple commands have been implemented for now:
- **Navigation (9)** -- `CommandLink`: go-to-companies,
go-to-dashboards, go-to-notes, go-to-opportunities, go-to-people,
go-to-runs, go-to-settings, go-to-tasks, go-to-workflows
- **Side panel (4)** -- `CommandOpenSidePanelPage`: ask-ai,
search-records, search-records-fallback, view-previous-ai-chats

We still have to implement front components for all the following
commands:
All have placeholder `execute` logic (`async () => {}`) with a `// TODO:
implement execute logic` comment:

**Record (22)**
- `add-to-favorites`, `remove-from-favorites`
- `create-new-record`, `create-new-view`
- `delete-single-record`, `delete-multiple-records`
- `destroy-single-record`, `destroy-multiple-records`
- `restore-single-record`, `restore-multiple-records`
- `export-from-record-index`, `export-from-record-show`,
`export-multiple-records`, `export-note-to-pdf`, `export-view`
- `hide-deleted-records`, `see-deleted-records`
- `import-records`, `merge-multiple-records`, `update-multiple-records`
- `navigate-to-next-record`, `navigate-to-previous-record`

**Page layout (3)** -- `cancel-record-page-layout`,
`edit-record-page-layout`, `save-record-page-layout`

**Dashboard (4)** -- `cancel-dashboard-layout`, `duplicate-dashboard`,
`edit-dashboard-layout`, `save-dashboard-layout`

**Workflow (10)** -- `activate-workflow`, `add-node-workflow`,
`deactivate-workflow`, `discard-draft-workflow`, `duplicate-workflow`,
`see-active-version-workflow`, `see-runs-workflow`,
`see-versions-workflow`, `test-workflow`, `tidy-up-workflow`

**Workflow version (4)** -- `see-runs-workflow-version`,
`see-versions-workflow-version`, `see-workflow-workflow-version`,
`use-as-draft-workflow-version`

**Workflow run (3)** -- `see-version-workflow-run`,
`see-workflow-workflow-run`, `stop-workflow-run`
This commit is contained in:
Raphaël Bosi
2026-03-10 18:36:41 +01:00
committed by GitHub
parent 25d9f2fcce
commit 2de022afcf
252 changed files with 10972 additions and 253 deletions
@@ -13,7 +13,7 @@ import {
LOGIC_FUNCTION_EXTERNAL_MODULES,
createSdkClientsResolverPlugin,
} from '@/cli/utilities/build/common/esbuild-watcher';
import { FRONT_COMPONENT_EXTERNAL_MODULES } from '@/cli/utilities/build/common/front-component-build/constants/front-component-external-modules';
import { getBaseFrontComponentBuildOptions } from '@/cli/utilities/build/common/front-component-build/utils/get-base-front-component-build-options';
import { getFrontComponentBuildPlugins } from '@/cli/utilities/build/common/front-component-build/utils/get-front-component-build-plugins';
import { type OnFileBuiltCallback } from '@/cli/utilities/build/common/restartable-watcher-interface';
import { type EntityFilePaths } from '@/cli/utilities/build/manifest/manifest-extract-config';
@@ -90,17 +90,9 @@ export const buildApplication = async (
sourcePaths: frontComponents,
fileFolder: FileFolder.BuiltFrontComponent,
buildOptions: {
bundle: true,
splitting: false,
format: 'esm',
...getBaseFrontComponentBuildOptions(),
outdir: join(options.appPath, OUTPUT_DIR),
outExtension: { '.js': '.mjs' },
external: FRONT_COMPONENT_EXTERNAL_MODULES,
tsconfig: join(options.appPath, 'tsconfig.json'),
jsx: 'automatic',
sourcemap: true,
metafile: true,
logLevel: 'silent',
plugins: [
createSdkClientsResolverPlugin(options.appPath),
...getFrontComponentBuildPlugins(),
@@ -1,8 +1,9 @@
import {
defineFrontComponent,
none,
numberOfSelectedRecords,
objectPermissions,
selectedRecord,
selectedRecords,
} from '@/sdk';
const MyComponent = () => null;
@@ -15,7 +16,7 @@ export default defineFrontComponent({
label: 'Complex Soft Delete',
conditionalAvailabilityExpression:
objectPermissions.canSoftDeleteObjectRecords &&
!selectedRecord.isRemote &&
none(selectedRecords, 'isRemote') &&
numberOfSelectedRecords > 0,
},
});
@@ -1,4 +1,4 @@
import { defineFrontComponent, isDefined, selectedRecord } from '@/sdk';
import { defineFrontComponent, someDefined, selectedRecords } from '@/sdk';
const MyComponent = () => null;
@@ -8,6 +8,9 @@ export default defineFrontComponent({
command: {
universalIdentifier: 'custom-function-cmd',
label: 'Custom Function',
conditionalAvailabilityExpression: isDefined(selectedRecord.deletedAt),
conditionalAvailabilityExpression: someDefined(
selectedRecords,
'deletedAt',
),
},
});
@@ -1,4 +1,9 @@
import { defineFrontComponent, isFavorite, isRemote, isShowPage } from '@/sdk';
import {
defineFrontComponent,
favoriteRecordIds,
objectMetadataItem,
pageType,
} from '@/sdk';
const MyComponent = () => null;
@@ -8,6 +13,8 @@ export default defineFrontComponent({
command: {
universalIdentifier: 'parenthesized-expression-cmd',
label: 'Parenthesized Expression',
conditionalAvailabilityExpression: (isShowPage || isFavorite) && !isRemote,
conditionalAvailabilityExpression:
(pageType === 'RECORD_PAGE' || favoriteRecordIds.length > 0) &&
!objectMetadataItem.isRemote,
},
});
@@ -1,4 +1,4 @@
import { defineFrontComponent, isShowPage } from '@/sdk';
import { defineFrontComponent, pageType } from '@/sdk';
const MyComponent = () => null;
@@ -8,6 +8,6 @@ export default defineFrontComponent({
command: {
universalIdentifier: 'simple-boolean-cmd',
label: 'Simple Boolean',
conditionalAvailabilityExpression: isShowPage,
conditionalAvailabilityExpression: pageType === 'RECORD_PAGE',
},
});
@@ -1,4 +1,9 @@
import { defineFrontComponent, isShowPage, selectedRecord } from '@/sdk';
import {
defineFrontComponent,
everyEquals,
pageType,
selectedRecords,
} from '@/sdk';
const MyComponent = () => null;
@@ -9,6 +14,7 @@ export default defineFrontComponent({
universalIdentifier: 'string-comparison-cmd',
label: 'String Comparison',
conditionalAvailabilityExpression:
isShowPage && selectedRecord.company.name === 'apple',
pageType === 'RECORD_PAGE' &&
everyEquals(selectedRecords, 'company.name', 'apple'),
},
});
@@ -1,6 +1,6 @@
import {
defineFrontComponent,
isShowPage,
pageType,
targetObjectWritePermissions,
} from '@/sdk';
@@ -13,6 +13,6 @@ export default defineFrontComponent({
universalIdentifier: 'target-permissions-cmd',
label: 'Target Permissions',
conditionalAvailabilityExpression:
isShowPage && targetObjectWritePermissions.person,
pageType === 'RECORD_PAGE' && targetObjectWritePermissions.person,
},
});
@@ -77,6 +77,20 @@ describe('toExprEval', () => {
});
});
describe('array .length to arrayLength()', () => {
it('should convert favoriteRecordIds.length to arrayLength(favoriteRecordIds)', () => {
expect(toExprEval('favoriteRecordIds.length > 0')).toBe(
'arrayLength(favoriteRecordIds) > 0',
);
});
it('should convert selectedRecords.length to arrayLength(selectedRecords)', () => {
expect(toExprEval('selectedRecords.length === 1')).toBe(
'arrayLength(selectedRecords) == 1',
);
});
});
describe('edge cases', () => {
it('should return an empty string unchanged', () => {
expect(toExprEval('')).toBe('');
@@ -2,7 +2,10 @@ import * as fs from 'fs';
import * as path from 'path';
import { transformConditionalAvailabilityExpressionsForEsBuildPlugin } from '@/cli/utilities/build/common/conditional-availability/utils/transform-conditional-availability-expressions';
import { type CommandMenuContextApi } from 'twenty-shared/types';
import {
CommandMenuContextApiPageType,
type CommandMenuContextApi,
} from 'twenty-shared/types';
import { evaluateConditionalAvailabilityExpression } from 'twenty-shared/utils';
const MOCKS_DIR = path.join(__dirname, '__mocks__');
@@ -13,11 +16,10 @@ const readMock = (filename: string): string =>
const buildMockCommandMenuContextApi = (
overrides: Partial<CommandMenuContextApi> = {},
): CommandMenuContextApi => ({
isShowPage: false,
pageType: CommandMenuContextApiPageType.INDEX_PAGE,
isInSidePanel: false,
isFavorite: false,
isRemote: false,
isNoteOrTask: false,
isPageInEditMode: false,
favoriteRecordIds: [],
isSelectAll: false,
hasAnySoftDeleteFilterOnView: false,
numberOfSelectedRecords: 0,
@@ -31,10 +33,11 @@ const buildMockCommandMenuContextApi = (
rowLevelPermissionPredicates: [],
rowLevelPermissionPredicateGroups: [],
},
selectedRecord: undefined,
selectedRecords: [],
featureFlags: {},
targetObjectReadPermissions: {},
targetObjectWritePermissions: {},
objectMetadataItem: {},
...overrides,
});
@@ -156,35 +159,37 @@ describe('transformConditionalAvailabilityExpressionsForEsBuildPlugin', () => {
it('should handle multiple expressions in one source', () => {
const source = [
'const a = { conditionalAvailabilityExpression: isShowPage };',
'const b = { conditionalAvailabilityExpression: isFavorite && !isRemote };',
'const a = { conditionalAvailabilityExpression: pageType === "RECORD_PAGE" };',
'const b = { conditionalAvailabilityExpression: favoriteRecordIds.length > 0 && !objectMetadataItem.isRemote };',
].join('\n');
const transformed =
transformConditionalAvailabilityExpressionsForEsBuildPlugin(source);
expect(transformed).toContain(
'conditionalAvailabilityExpression: "isShowPage"',
'conditionalAvailabilityExpression: "pageType == \\"RECORD_PAGE\\""',
);
expect(transformed).toContain(
'conditionalAvailabilityExpression: "isFavorite and not isRemote"',
'conditionalAvailabilityExpression: "arrayLength(favoriteRecordIds) > 0 and not objectMetadataItem.isRemote"',
);
});
it('should handle extra spaces around colon', () => {
const source =
'{ conditionalAvailabilityExpression : isShowPage && isFavorite }';
'{ conditionalAvailabilityExpression : pageType === "RECORD_PAGE" && favoriteRecordIds.length > 0 }';
const transformed =
transformConditionalAvailabilityExpressionsForEsBuildPlugin(source);
expect(transformed).toContain('"isShowPage and isFavorite"');
expect(transformed).toContain(
'"pageType == \\"RECORD_PAGE\\" and arrayLength(favoriteRecordIds) > 0"',
);
});
});
describe('e2e: mock file -> transform -> evaluate', () => {
describe('simple-boolean-front-component', () => {
it('should evaluate isShowPage when true', () => {
it('should evaluate pageType when RECORD_PAGE', () => {
const context = buildMockCommandMenuContextApi({
isShowPage: true,
pageType: CommandMenuContextApiPageType.RECORD_PAGE,
});
expect(
@@ -195,9 +200,9 @@ describe('transformConditionalAvailabilityExpressionsForEsBuildPlugin', () => {
).toBe(true);
});
it('should evaluate isShowPage when false', () => {
it('should evaluate pageType when INDEX_PAGE', () => {
const context = buildMockCommandMenuContextApi({
isShowPage: false,
pageType: CommandMenuContextApiPageType.INDEX_PAGE,
});
expect(
@@ -297,13 +302,15 @@ describe('transformConditionalAvailabilityExpressionsForEsBuildPlugin', () => {
it('should allow soft delete for local record with selection', () => {
const context = buildMockCommandMenuContextApi({
numberOfSelectedRecords: 2,
selectedRecord: {
id: 'rec-1',
createdAt: '2024-01-01',
updatedAt: '2024-01-01',
deletedAt: null,
isRemote: false,
},
selectedRecords: [
{
id: 'rec-1',
createdAt: '2024-01-01',
updatedAt: '2024-01-01',
deletedAt: null,
isRemote: false,
},
],
});
expect(
@@ -317,13 +324,15 @@ describe('transformConditionalAvailabilityExpressionsForEsBuildPlugin', () => {
it('should deny soft delete when record is remote', () => {
const context = buildMockCommandMenuContextApi({
numberOfSelectedRecords: 1,
selectedRecord: {
id: 'rec-1',
createdAt: '2024-01-01',
updatedAt: '2024-01-01',
deletedAt: null,
isRemote: true,
},
selectedRecords: [
{
id: 'rec-1',
createdAt: '2024-01-01',
updatedAt: '2024-01-01',
deletedAt: null,
isRemote: true,
},
],
});
expect(
@@ -338,9 +347,9 @@ describe('transformConditionalAvailabilityExpressionsForEsBuildPlugin', () => {
describe('parenthesized-expression-front-component', () => {
it('should allow when favorite and not remote', () => {
const context = buildMockCommandMenuContextApi({
isShowPage: false,
isFavorite: true,
isRemote: false,
pageType: CommandMenuContextApiPageType.INDEX_PAGE,
favoriteRecordIds: ['rec-1'],
objectMetadataItem: { isRemote: false },
});
expect(
@@ -351,11 +360,11 @@ describe('transformConditionalAvailabilityExpressionsForEsBuildPlugin', () => {
).toBe(true);
});
it('should deny when remote even if show page', () => {
it('should deny when remote even if record page', () => {
const context = buildMockCommandMenuContextApi({
isShowPage: true,
isFavorite: false,
isRemote: true,
pageType: CommandMenuContextApiPageType.RECORD_PAGE,
favoriteRecordIds: [],
objectMetadataItem: { isRemote: true },
});
expect(
@@ -370,12 +379,14 @@ describe('transformConditionalAvailabilityExpressionsForEsBuildPlugin', () => {
describe('custom-function-front-component', () => {
it('should return false when deletedAt is null', () => {
const context = buildMockCommandMenuContextApi({
selectedRecord: {
id: 'rec-1',
createdAt: '2024-01-01',
updatedAt: '2024-01-01',
deletedAt: null,
},
selectedRecords: [
{
id: 'rec-1',
createdAt: '2024-01-01',
updatedAt: '2024-01-01',
deletedAt: null,
},
],
});
expect(
@@ -388,12 +399,14 @@ describe('transformConditionalAvailabilityExpressionsForEsBuildPlugin', () => {
it('should return true when deletedAt is set', () => {
const context = buildMockCommandMenuContextApi({
selectedRecord: {
id: 'rec-1',
createdAt: '2024-01-01',
updatedAt: '2024-01-01',
deletedAt: '2024-06-01',
},
selectedRecords: [
{
id: 'rec-1',
createdAt: '2024-01-01',
updatedAt: '2024-01-01',
deletedAt: '2024-06-01',
},
],
});
expect(
@@ -406,16 +419,18 @@ describe('transformConditionalAvailabilityExpressionsForEsBuildPlugin', () => {
});
describe('string-comparison-front-component', () => {
it('should match when on show page and company name matches', () => {
it('should match when on record page and company name matches', () => {
const context = buildMockCommandMenuContextApi({
isShowPage: true,
selectedRecord: {
id: 'rec-1',
createdAt: '2024-01-01',
updatedAt: '2024-01-01',
deletedAt: null,
company: { name: 'apple' },
},
pageType: CommandMenuContextApiPageType.RECORD_PAGE,
selectedRecords: [
{
id: 'rec-1',
createdAt: '2024-01-01',
updatedAt: '2024-01-01',
deletedAt: null,
company: { name: 'apple' },
},
],
});
expect(
@@ -428,14 +443,16 @@ describe('transformConditionalAvailabilityExpressionsForEsBuildPlugin', () => {
it('should not match when company name differs', () => {
const context = buildMockCommandMenuContextApi({
isShowPage: true,
selectedRecord: {
id: 'rec-1',
createdAt: '2024-01-01',
updatedAt: '2024-01-01',
deletedAt: null,
company: { name: 'google' },
},
pageType: CommandMenuContextApiPageType.RECORD_PAGE,
selectedRecords: [
{
id: 'rec-1',
createdAt: '2024-01-01',
updatedAt: '2024-01-01',
deletedAt: null,
company: { name: 'google' },
},
],
});
expect(
@@ -448,9 +465,9 @@ describe('transformConditionalAvailabilityExpressionsForEsBuildPlugin', () => {
});
describe('target-permissions-front-component', () => {
it('should allow when on show page with write permission', () => {
it('should allow when on record page with write permission', () => {
const context = buildMockCommandMenuContextApi({
isShowPage: true,
pageType: CommandMenuContextApiPageType.RECORD_PAGE,
targetObjectWritePermissions: { person: true },
});
@@ -462,9 +479,9 @@ describe('transformConditionalAvailabilityExpressionsForEsBuildPlugin', () => {
).toBe(true);
});
it('should deny when not on show page', () => {
it('should deny when not on record page', () => {
const context = buildMockCommandMenuContextApi({
isShowPage: false,
pageType: CommandMenuContextApiPageType.INDEX_PAGE,
targetObjectWritePermissions: { person: true },
});
@@ -1,7 +1,16 @@
const ARRAY_LENGTH_REPLACEMENTS: [RegExp, string][] = [
[/\bfavoriteRecordIds\.length\b/g, 'arrayLength(favoriteRecordIds)'],
[/\bselectedRecords\.length\b/g, 'arrayLength(selectedRecords)'],
];
export const toExprEval = (raw: string): string =>
raw
.replace(/!==/g, '!=')
.replace(/===/g, '==')
.replace(/&&/g, 'and')
.replace(/\|\|/g, 'or')
.replace(/!(?!=)/g, 'not ');
ARRAY_LENGTH_REPLACEMENTS.reduce(
(acc, [pattern, replacement]) => acc.replace(pattern, replacement),
raw
.replace(/!==/g, '!=')
.replace(/===/g, '==')
.replace(/&&/g, 'and')
.replace(/\|\|/g, 'or')
.replace(/!(?!=)/g, 'not ')
.replace(/,(\s*\))/g, '$1'),
);
@@ -1,7 +1,7 @@
import { toExprEval } from './to-expr-eval';
const CONDITIONAL_AVAILABILITY_EXPRESSION_PATTERN =
/(conditionalAvailabilityExpression\s*:\s*)(?!['"`])([^,}]+)/g;
/(conditionalAvailabilityExpression\s*:\s*)(?!['"`])((?:[^,}()]|\([^()]*\))+)/g;
export const transformConditionalAvailabilityExpressionsForEsBuildPlugin = (
source: string,
@@ -0,0 +1,17 @@
import type * as esbuild from 'esbuild';
import { FRONT_COMPONENT_EXTERNAL_MODULES } from '../constants/front-component-external-modules';
import { getFrontComponentBuildPlugins } from './get-front-component-build-plugins';
export const getBaseFrontComponentBuildOptions = (): esbuild.BuildOptions => ({
bundle: true,
splitting: false,
format: 'esm',
outExtension: { '.js': '.mjs' },
external: FRONT_COMPONENT_EXTERNAL_MODULES,
jsx: 'automatic',
sourcemap: true,
metafile: true,
logLevel: 'silent',
plugins: [...getFrontComponentBuildPlugins()],
});