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
@@ -0,0 +1,385 @@
import {
CommandMenuContextApiPageType,
type CommandMenuContextApi,
} from '@/types';
import { evaluateConditionalAvailabilityExpression } from '../evaluateConditionalAvailabilityExpression';
const buildContext = (
overrides: Partial<CommandMenuContextApi> = {},
): CommandMenuContextApi => ({
pageType: CommandMenuContextApiPageType.INDEX_PAGE,
isInSidePanel: false,
isPageInEditMode: false,
favoriteRecordIds: [],
isSelectAll: false,
hasAnySoftDeleteFilterOnView: false,
numberOfSelectedRecords: 0,
objectPermissions: {
objectMetadataId: '',
canReadObjectRecords: true,
canUpdateObjectRecords: true,
canSoftDeleteObjectRecords: true,
canDestroyObjectRecords: false,
restrictedFields: {},
rowLevelPermissionPredicates: [],
rowLevelPermissionPredicateGroups: [],
},
selectedRecords: [],
featureFlags: {},
targetObjectReadPermissions: {},
targetObjectWritePermissions: {},
objectMetadataItem: {},
...overrides,
});
describe('evaluateConditionalAvailabilityExpression', () => {
describe('arrayLength for favoriteRecordIds', () => {
it('should evaluate arrayLength(favoriteRecordIds) < numberOfSelectedRecords when some selected are not favorites', () => {
const expression =
'arrayLength(favoriteRecordIds) < numberOfSelectedRecords and noneDefined(selectedRecords, "deletedAt") and not hasAnySoftDeleteFilterOnView';
const context = buildContext({
favoriteRecordIds: ['id-1'],
numberOfSelectedRecords: 2,
selectedRecords: [
{ id: 'id-1', createdAt: '', updatedAt: '', deletedAt: null },
{ id: 'id-2', createdAt: '', updatedAt: '', deletedAt: null },
],
hasAnySoftDeleteFilterOnView: false,
});
expect(
evaluateConditionalAvailabilityExpression(expression, context),
).toBe(true);
});
it('should deny when all selected are already favorites', () => {
const expression =
'arrayLength(favoriteRecordIds) < numberOfSelectedRecords and noneDefined(selectedRecords, "deletedAt") and not hasAnySoftDeleteFilterOnView';
const context = buildContext({
favoriteRecordIds: ['id-1', 'id-2'],
numberOfSelectedRecords: 2,
selectedRecords: [
{ id: 'id-1', createdAt: '', updatedAt: '', deletedAt: null },
{ id: 'id-2', createdAt: '', updatedAt: '', deletedAt: null },
],
hasAnySoftDeleteFilterOnView: false,
});
expect(
evaluateConditionalAvailabilityExpression(expression, context),
).toBe(false);
});
it('should evaluate arrayLength(favoriteRecordIds) == numberOfSelectedRecords when all selected are favorites', () => {
const expression =
'arrayLength(favoriteRecordIds) == numberOfSelectedRecords and noneDefined(selectedRecords, "deletedAt") and not hasAnySoftDeleteFilterOnView';
const context = buildContext({
favoriteRecordIds: ['id-1', 'id-2'],
numberOfSelectedRecords: 2,
selectedRecords: [
{ id: 'id-1', createdAt: '', updatedAt: '', deletedAt: null },
{ id: 'id-2', createdAt: '', updatedAt: '', deletedAt: null },
],
hasAnySoftDeleteFilterOnView: false,
});
expect(
evaluateConditionalAvailabilityExpression(expression, context),
).toBe(true);
});
it('should reject favoriteRecordIds.length (expr-eval parses "length" as unary op)', () => {
const expression = 'favoriteRecordIds.length < numberOfSelectedRecords';
const context = buildContext({
favoriteRecordIds: ['id-1'],
numberOfSelectedRecords: 2,
});
expect(
evaluateConditionalAvailabilityExpression(expression, context),
).toBe(false);
});
});
describe('includesSome', () => {
it('should return true when some records include the value in the array prop', () => {
const expression = 'includesSome(selectedRecords, "statuses", "ACTIVE")';
const context = buildContext({
selectedRecords: [
{ id: 'id-1', statuses: ['ACTIVE'] },
{ id: 'id-2', statuses: ['DRAFT'] },
],
});
expect(
evaluateConditionalAvailabilityExpression(expression, context),
).toBe(true);
});
it('should return false when no record includes the value in the array prop', () => {
const expression = 'includesSome(selectedRecords, "statuses", "ACTIVE")';
const context = buildContext({
selectedRecords: [
{ id: 'id-1', statuses: ['DRAFT'] },
{ id: 'id-2', statuses: ['DRAFT'] },
],
});
expect(
evaluateConditionalAvailabilityExpression(expression, context),
).toBe(false);
});
});
describe('includesNone', () => {
it('should return true when no record includes the value in the array prop', () => {
const expression = 'includesNone(selectedRecords, "statuses", "ACTIVE")';
const context = buildContext({
selectedRecords: [
{ id: 'id-1', statuses: ['DRAFT'] },
{ id: 'id-2', statuses: ['DRAFT'] },
],
});
expect(
evaluateConditionalAvailabilityExpression(expression, context),
).toBe(true);
});
it('should return false when any record includes the value in the array prop', () => {
const expression = 'includesNone(selectedRecords, "statuses", "ACTIVE")';
const context = buildContext({
selectedRecords: [
{ id: 'id-1', statuses: ['ACTIVE'] },
{ id: 'id-2', statuses: ['DRAFT'] },
],
});
expect(
evaluateConditionalAvailabilityExpression(expression, context),
).toBe(false);
});
it('should return false when all records include the value in the array prop', () => {
const expression = 'includesNone(selectedRecords, "statuses", "ACTIVE")';
const context = buildContext({
selectedRecords: [
{ id: 'id-1', statuses: ['ACTIVE', 'DRAFT'] },
{ id: 'id-2', statuses: ['ACTIVE'] },
],
});
expect(
evaluateConditionalAvailabilityExpression(expression, context),
).toBe(false);
});
});
describe('empty array guard against vacuous truth', () => {
it('should return false for every() on empty selectedRecords', () => {
const context = buildContext({ selectedRecords: [] });
expect(
evaluateConditionalAvailabilityExpression(
'every(selectedRecords, "active")',
context,
),
).toBe(false);
});
it('should return false for everyDefined() on empty selectedRecords', () => {
const context = buildContext({ selectedRecords: [] });
expect(
evaluateConditionalAvailabilityExpression(
'everyDefined(selectedRecords, "name")',
context,
),
).toBe(false);
});
it('should return false for none() on empty selectedRecords', () => {
const context = buildContext({ selectedRecords: [] });
expect(
evaluateConditionalAvailabilityExpression(
'none(selectedRecords, "deletedAt")',
context,
),
).toBe(false);
});
it('should return false for noneDefined() on empty selectedRecords', () => {
const context = buildContext({ selectedRecords: [] });
expect(
evaluateConditionalAvailabilityExpression(
'noneDefined(selectedRecords, "deletedAt")',
context,
),
).toBe(false);
});
it('should return false for everyEquals() on empty selectedRecords', () => {
const context = buildContext({ selectedRecords: [] });
expect(
evaluateConditionalAvailabilityExpression(
'everyEquals(selectedRecords, "status", "DRAFT")',
context,
),
).toBe(false);
});
it('should return false for noneEquals() on empty selectedRecords', () => {
const context = buildContext({ selectedRecords: [] });
expect(
evaluateConditionalAvailabilityExpression(
'noneEquals(selectedRecords, "status", "ACTIVE")',
context,
),
).toBe(false);
});
it('should return false for includesEvery() on empty selectedRecords', () => {
const context = buildContext({ selectedRecords: [] });
expect(
evaluateConditionalAvailabilityExpression(
'includesEvery(selectedRecords, "statuses", "ACTIVE")',
context,
),
).toBe(false);
});
it('should return false for includesNone() on empty selectedRecords', () => {
const context = buildContext({ selectedRecords: [] });
expect(
evaluateConditionalAvailabilityExpression(
'includesNone(selectedRecords, "statuses", "ACTIVE")',
context,
),
).toBe(false);
});
it('should return false for some() on empty selectedRecords', () => {
const context = buildContext({ selectedRecords: [] });
expect(
evaluateConditionalAvailabilityExpression(
'some(selectedRecords, "active")',
context,
),
).toBe(false);
});
it('should return false for someDefined() on empty selectedRecords', () => {
const context = buildContext({ selectedRecords: [] });
expect(
evaluateConditionalAvailabilityExpression(
'someDefined(selectedRecords, "name")',
context,
),
).toBe(false);
});
it('should return false for includesSome() on empty selectedRecords', () => {
const context = buildContext({ selectedRecords: [] });
expect(
evaluateConditionalAvailabilityExpression(
'includesSome(selectedRecords, "statuses", "ACTIVE")',
context,
),
).toBe(false);
});
});
describe('activateWorkflow expression regression', () => {
const activateWorkflowExpression =
'everyDefined(selectedRecords, "currentVersion.trigger") and everyDefined(selectedRecords, "currentVersion.steps") and every(selectedRecords, "currentVersion.steps.length") and (everyEquals(selectedRecords, "currentVersion.status", "DRAFT") or includesNone(selectedRecords, "statuses", "ACTIVE")) and noneDefined(selectedRecords, "deletedAt")';
it('should not show Activate for an already-active workflow', () => {
const context = buildContext({
selectedRecords: [
{
id: 'wf-1',
deletedAt: null,
statuses: ['ACTIVE'],
currentVersion: {
status: 'ACTIVE',
trigger: { type: 'MANUAL' },
steps: [{ id: 'step-1' }],
},
},
],
});
expect(
evaluateConditionalAvailabilityExpression(
activateWorkflowExpression,
context,
),
).toBe(false);
});
it('should show Activate for a draft workflow with no active version', () => {
const context = buildContext({
selectedRecords: [
{
id: 'wf-1',
deletedAt: null,
statuses: ['DRAFT'],
currentVersion: {
status: 'DRAFT',
trigger: { type: 'MANUAL' },
steps: [{ id: 'step-1' }],
},
},
],
});
expect(
evaluateConditionalAvailabilityExpression(
activateWorkflowExpression,
context,
),
).toBe(true);
});
it('should show Activate for a draft version when another version is active', () => {
const context = buildContext({
selectedRecords: [
{
id: 'wf-1',
deletedAt: null,
statuses: ['ACTIVE', 'DRAFT'],
currentVersion: {
status: 'DRAFT',
trigger: { type: 'MANUAL' },
steps: [{ id: 'step-1' }],
},
},
],
});
expect(
evaluateConditionalAvailabilityExpression(
activateWorkflowExpression,
context,
),
).toBe(true);
});
});
});
@@ -1,12 +1,129 @@
import { isNonEmptyString } from '@sniptt/guards';
import {
isNonEmptyArray,
isNonEmptyString,
isObject,
isString,
} from '@sniptt/guards';
import { type EvaluationContext, Parser } from 'expr-eval';
import { isDefined } from '../validation/isDefined';
const BLOCKED_PROPERTY_NAMES = new Set([
'__proto__',
'constructor',
'prototype',
]);
const safeGetNestedProperty = (
objectToEvaluate: unknown,
path: string,
): unknown => {
if (!isString(path)) {
return undefined;
}
const parts = path.split('.');
let currentObject: unknown = objectToEvaluate;
for (const part of parts) {
if (!isDefined(currentObject) || !isObject(currentObject)) {
return undefined;
}
if (BLOCKED_PROPERTY_NAMES.has(part)) {
return undefined;
}
currentObject = (currentObject as Record<string, unknown>)[part];
}
return currentObject;
};
type ArrayMethod = 'every' | 'some';
const createArrayPropCheck = (
method: ArrayMethod,
predicate: (value: unknown) => boolean,
) => {
return (array: unknown, prop: string) => {
if (!isNonEmptyArray(array)) {
return false;
}
return array[method]((item) =>
predicate(safeGetNestedProperty(item, prop)),
);
};
};
const createArrayPropValueCheck = (
method: ArrayMethod,
predicate: (value: unknown, target: unknown) => boolean,
) => {
return (array: unknown, prop: string, value: unknown) => {
if (!isNonEmptyArray(array)) {
return false;
}
return array[method]((item) =>
predicate(safeGetNestedProperty(item, prop), value),
);
};
};
const parser = new Parser();
parser.functions.isDefined = (value: unknown) => isDefined(value);
parser.functions.isNonEmptyString = (value: unknown) => isNonEmptyString(value);
parser.functions.includes = (array: unknown, value: unknown) =>
Array.isArray(array) && array.includes(value);
parser.functions.arrayLength = (value: unknown) =>
Array.isArray(value) ? value.length : 0;
parser.functions.every = createArrayPropCheck('every', Boolean);
parser.functions.everyDefined = createArrayPropCheck('every', isDefined);
parser.functions.some = createArrayPropCheck('some', Boolean);
parser.functions.someDefined = createArrayPropCheck('some', isDefined);
parser.functions.someNonEmptyString = createArrayPropCheck(
'some',
isNonEmptyString,
);
parser.functions.none = createArrayPropCheck(
'every',
(value) => !Boolean(value),
);
parser.functions.noneDefined = createArrayPropCheck(
'every',
(value) => !isDefined(value),
);
parser.functions.everyEquals = createArrayPropValueCheck(
'every',
(a, b) => a === b,
);
parser.functions.someEquals = createArrayPropValueCheck(
'some',
(a, b) => a === b,
);
parser.functions.noneEquals = createArrayPropValueCheck(
'every',
(a, b) => a !== b,
);
parser.functions.includesEvery = createArrayPropValueCheck(
'every',
(array, value) => Array.isArray(array) && array.includes(value),
);
parser.functions.includesSome = createArrayPropValueCheck(
'some',
(array, value) => Array.isArray(array) && array.includes(value),
);
parser.functions.includesNone = createArrayPropValueCheck(
'every',
(array, value) => Array.isArray(array) && !array.includes(value),
);
export const evaluateConditionalAvailabilityExpression = (
expression: string | null | undefined,
@@ -152,6 +152,7 @@ export { appendCopySuffix } from './strings/appendCopySuffix';
export { camelToKebab } from './strings/camelToKebab';
export { camelToSnakeCase } from './strings/camelToSnakeCase';
export { capitalize } from './strings/capitalize';
export { kebabToCamelCase } from './strings/kebabToCamelCase';
export { pascalCase } from './strings/pascalCase';
export { pascalToKebab } from './strings/pascalToKebab';
export { stringifySafely } from './strings/stringifySafely';
@@ -0,0 +1,19 @@
import { kebabToCamelCase } from '../kebabToCamelCase';
describe('kebabToCamelCase', () => {
it('should convert kebab-case to camelCase', () => {
expect(kebabToCamelCase('user-profile')).toBe('userProfile');
});
it('should convert single word', () => {
expect(kebabToCamelCase('button')).toBe('button');
});
it('should handle multi-word kebab-case', () => {
expect(kebabToCamelCase('my-component-name')).toBe('myComponentName');
});
it('should handle empty string', () => {
expect(kebabToCamelCase('')).toBe('');
});
});
@@ -1,4 +1,5 @@
export * from './appendCopySuffix';
export * from './capitalize';
export * from './kebabToCamelCase';
export * from './pascalCase';
export * from './pascalToKebab';
@@ -0,0 +1,2 @@
export const kebabToCamelCase = (str: string): string =>
str.replace(/-([a-z])/g, (_, char) => char.toUpperCase());