[FRONT COMPONENTS] Introduce conditionalAvailabilityExpression to command menu items (#18319)

## PR Description

- Uses `expr-eval` to enable front components (SDK plugins) to define
conditional availability as declarative expressions.
- Moves shared types and constants to `twenty-shared`
- Introduces a `conditionalAvailabilityExpression` field on
`CommandMenuItemEntity`, allowing command menu items to store an
`expr-eval` compatible expression string that is evaluated against a
CommandMenuContext to determine if the item should be shown.
- Creates an esbuild transform plugin
`conditional-availability-transform-plugin` in `twenty-sdk` that
converts TypeScript conditional availability expressions into
`expr-eval` compatible syntax at build time, so SDK developers can write
natural TS expressions that get transformed to evaluable strings.
- Removes deprecated `forceRegisteredActionsByKey` state and its usage.
- Creates `useCommandMenuContext` hook that builds the full
`CommandMenuContext` object from React state, which is then passed to
`useCommandMenuItemFrontComponentActions` for evaluating conditional
availability expressions.
This commit is contained in:
Raphaël Bosi
2026-03-04 16:33:58 +01:00
committed by GitHub
parent f09a9cc25a
commit b11f77df2a
349 changed files with 1648 additions and 454 deletions
@@ -0,0 +1,13 @@
import { defineFrontComponent, numberOfSelectedRecords } from '@/sdk';
const MyComponent = () => null;
export default defineFrontComponent({
universalIdentifier: 'comparison-operator',
component: MyComponent,
command: {
universalIdentifier: 'comparison-operator-cmd',
label: 'Comparison Operator',
conditionalAvailabilityExpression: numberOfSelectedRecords > 0,
},
});
@@ -0,0 +1,21 @@
import {
defineFrontComponent,
numberOfSelectedRecords,
objectPermissions,
selectedRecord,
} from '@/sdk';
const MyComponent = () => null;
export default defineFrontComponent({
universalIdentifier: 'complex-soft-delete',
component: MyComponent,
command: {
universalIdentifier: 'complex-soft-delete-cmd',
label: 'Complex Soft Delete',
conditionalAvailabilityExpression:
objectPermissions.canSoftDeleteObjectRecords &&
!selectedRecord.isRemote &&
numberOfSelectedRecords > 0,
},
});
@@ -0,0 +1,13 @@
import { defineFrontComponent, isDefined, selectedRecord } from '@/sdk';
const MyComponent = () => null;
export default defineFrontComponent({
universalIdentifier: 'custom-function',
component: MyComponent,
command: {
universalIdentifier: 'custom-function-cmd',
label: 'Custom Function',
conditionalAvailabilityExpression: isDefined(selectedRecord.deletedAt),
},
});
@@ -0,0 +1,14 @@
import { defineFrontComponent, featureFlags, objectPermissions } from '@/sdk';
const MyComponent = () => null;
export default defineFrontComponent({
universalIdentifier: 'feature-flag-gated',
component: MyComponent,
command: {
universalIdentifier: 'feature-flag-gated-cmd',
label: 'Feature Flag Gated',
conditionalAvailabilityExpression:
featureFlags.IS_AI_ENABLED && objectPermissions.canReadObjectRecords,
},
});
@@ -0,0 +1,13 @@
import { defineFrontComponent, isFavorite, isRemote, isShowPage } from '@/sdk';
const MyComponent = () => null;
export default defineFrontComponent({
universalIdentifier: 'parenthesized-expression',
component: MyComponent,
command: {
universalIdentifier: 'parenthesized-expression-cmd',
label: 'Parenthesized Expression',
conditionalAvailabilityExpression: (isShowPage || isFavorite) && !isRemote,
},
});
@@ -0,0 +1,18 @@
import {
defineFrontComponent,
isInRightDrawer,
objectPermissions,
} from '@/sdk';
const MyComponent = () => null;
export default defineFrontComponent({
universalIdentifier: 'permissions-check',
component: MyComponent,
command: {
universalIdentifier: 'permissions-check-cmd',
label: 'Permissions Check',
conditionalAvailabilityExpression:
objectPermissions.canUpdateObjectRecords && !isInRightDrawer,
},
});
@@ -0,0 +1,13 @@
import { defineFrontComponent, isShowPage } from '@/sdk';
const MyComponent = () => null;
export default defineFrontComponent({
universalIdentifier: 'simple-boolean',
component: MyComponent,
command: {
universalIdentifier: 'simple-boolean-cmd',
label: 'Simple Boolean',
conditionalAvailabilityExpression: isShowPage,
},
});
@@ -0,0 +1,14 @@
import { defineFrontComponent, isShowPage, selectedRecord } from '@/sdk';
const MyComponent = () => null;
export default defineFrontComponent({
universalIdentifier: 'string-comparison',
component: MyComponent,
command: {
universalIdentifier: 'string-comparison-cmd',
label: 'String Comparison',
conditionalAvailabilityExpression:
isShowPage && selectedRecord.company.name === 'apple',
},
});
@@ -0,0 +1,18 @@
import {
defineFrontComponent,
isShowPage,
targetObjectWritePermissions,
} from '@/sdk';
const MyComponent = () => null;
export default defineFrontComponent({
universalIdentifier: 'target-permissions',
component: MyComponent,
command: {
universalIdentifier: 'target-permissions-cmd',
label: 'Target Permissions',
conditionalAvailabilityExpression:
isShowPage && targetObjectWritePermissions.person,
},
});
@@ -0,0 +1,93 @@
import { toExprEval } from '@/cli/utilities/build/common/conditional-availability/utils/to-expr-eval';
describe('toExprEval', () => {
describe('strict equality (===) to loose equality (==)', () => {
it('should convert a single ===', () => {
expect(toExprEval('a === b')).toBe('a == b');
});
it('should convert multiple ===', () => {
expect(toExprEval('a === b === c')).toBe('a == b == c');
});
});
describe('strict inequality (!==) to loose inequality (!=)', () => {
it('should convert a single !==', () => {
expect(toExprEval('a !== b')).toBe('a != b');
});
it('should convert multiple !==', () => {
expect(toExprEval('a !== 0 !== b')).toBe('a != 0 != b');
});
});
describe('logical AND (&&) to and', () => {
it('should convert a single &&', () => {
expect(toExprEval('a && b')).toBe('a and b');
});
it('should convert multiple &&', () => {
expect(toExprEval('a && b && c')).toBe('a and b and c');
});
});
describe('logical OR (||) to or', () => {
it('should convert a single ||', () => {
expect(toExprEval('a || b')).toBe('a or b');
});
it('should convert multiple ||', () => {
expect(toExprEval('a || b || c')).toBe('a or b or c');
});
});
describe('logical NOT (!) to not', () => {
it('should convert a prefix !', () => {
expect(toExprEval('!a')).toBe('not a');
});
it('should convert multiple prefix !', () => {
expect(toExprEval('!a && !b')).toBe('not a and not b');
});
it('should not convert ! that is part of !==', () => {
expect(toExprEval('a !== b')).toBe('a != b');
});
it('should not convert ! that is part of !=', () => {
expect(toExprEval('a != b')).toBe('a != b');
});
});
describe('combined operators', () => {
it('should convert a complex expression with all operators', () => {
expect(toExprEval('a && !b || c === 1 && d !== 0')).toBe(
'a and not b or c == 1 and d != 0',
);
});
it('should handle parenthesized expressions', () => {
expect(toExprEval('(a || b) && !c')).toBe('(a or b) and not c');
});
it('should handle nested property access', () => {
expect(toExprEval('obj.prop === true && !obj.other')).toBe(
'obj.prop == true and not obj.other',
);
});
});
describe('edge cases', () => {
it('should return an empty string unchanged', () => {
expect(toExprEval('')).toBe('');
});
it('should return a plain identifier unchanged', () => {
expect(toExprEval('isShowPage')).toBe('isShowPage');
});
it('should leave numeric comparisons intact', () => {
expect(toExprEval('count > 0')).toBe('count > 0');
});
});
});
@@ -0,0 +1,480 @@
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 { evaluateConditionalAvailabilityExpression } from 'twenty-shared/utils';
const MOCKS_DIR = path.join(__dirname, '__mocks__');
const readMock = (filename: string): string =>
fs.readFileSync(path.join(MOCKS_DIR, filename), 'utf8');
const buildMockCommandMenuContextApi = (
overrides: Partial<CommandMenuContextApi> = {},
): CommandMenuContextApi => ({
isShowPage: false,
isInRightDrawer: false,
isFavorite: false,
isRemote: false,
isNoteOrTask: false,
isSelectAll: false,
hasAnySoftDeleteFilterOnView: false,
numberOfSelectedRecords: 0,
objectPermissions: {
objectMetadataId: 'test-metadata-id',
canReadObjectRecords: true,
canUpdateObjectRecords: true,
canSoftDeleteObjectRecords: true,
canDestroyObjectRecords: false,
restrictedFields: {},
rowLevelPermissionPredicates: [],
rowLevelPermissionPredicateGroups: [],
},
selectedRecord: undefined,
featureFlags: {},
targetObjectReadPermissions: {},
targetObjectWritePermissions: {},
...overrides,
});
const CONDITIONAL_AVAILABILITY_EXPRESSION_REGEX =
/conditionalAvailabilityExpression\s*:\s*("(?:[^"\\]|\\.)*")/;
const extractConditionalAvailabilityExpressionFromTransformedSource = (
transformedSource: string,
): string | undefined => {
const match = transformedSource.match(
CONDITIONAL_AVAILABILITY_EXPRESSION_REGEX,
);
if (!match?.[1]) {
return undefined;
}
return JSON.parse(match[1]) as string;
};
const transformMockAndEvaluate = (
filename: string,
context: CommandMenuContextApi,
): boolean => {
const source = readMock(filename);
const transformedSourceForEsBuildPlugin =
transformConditionalAvailabilityExpressionsForEsBuildPlugin(source);
const conditionalAvailabilityExpression =
extractConditionalAvailabilityExpressionFromTransformedSource(
transformedSourceForEsBuildPlugin,
);
return evaluateConditionalAvailabilityExpression(
conditionalAvailabilityExpression,
context,
);
};
describe('transformConditionalAvailabilityExpressionsForEsBuildPlugin', () => {
describe('transform correctness', () => {
it('should convert && to and', () => {
const source = '{ conditionalAvailabilityExpression: a && b }';
const result =
transformConditionalAvailabilityExpressionsForEsBuildPlugin(source);
expect(result).toBe('{ conditionalAvailabilityExpression: "a and b"}');
});
it('should convert || to or', () => {
const source = '{ conditionalAvailabilityExpression: a || b }';
const result =
transformConditionalAvailabilityExpressionsForEsBuildPlugin(source);
expect(result).toBe('{ conditionalAvailabilityExpression: "a or b"}');
});
it('should convert ! to not', () => {
const source = '{ conditionalAvailabilityExpression: !a }';
const result =
transformConditionalAvailabilityExpressionsForEsBuildPlugin(source);
expect(result).toBe('{ conditionalAvailabilityExpression: "not a"}');
});
it('should convert === to ==', () => {
const source = '{ conditionalAvailabilityExpression: a === 1 }';
const result =
transformConditionalAvailabilityExpressionsForEsBuildPlugin(source);
expect(result).toBe('{ conditionalAvailabilityExpression: "a == 1"}');
});
it('should convert !== to !=', () => {
const source = '{ conditionalAvailabilityExpression: a !== 0 }';
const result =
transformConditionalAvailabilityExpressionsForEsBuildPlugin(source);
expect(result).toBe('{ conditionalAvailabilityExpression: "a != 0"}');
});
it('should convert all operators in a complex expression', () => {
const source =
'{ conditionalAvailabilityExpression: a && !b || c === 1 && d !== 0 }';
const result =
transformConditionalAvailabilityExpressionsForEsBuildPlugin(source);
expect(result).toBe(
'{ conditionalAvailabilityExpression: "a and not b or c == 1 and d != 0"}',
);
});
});
describe('regex edge cases', () => {
it('should skip already-quoted string expressions without leading space', () => {
const source = '{ conditionalAvailabilityExpression:"already quoted" }';
const transformed =
transformConditionalAvailabilityExpressionsForEsBuildPlugin(source);
expect(transformed).toBe(source);
});
it('should skip single-quoted string expressions without leading space', () => {
const source = "{ conditionalAvailabilityExpression:'already quoted' }";
const transformed =
transformConditionalAvailabilityExpressionsForEsBuildPlugin(source);
expect(transformed).toBe(source);
});
it('should skip template literal expressions without leading space', () => {
const source = '{ conditionalAvailabilityExpression:`already quoted` }';
const transformed =
transformConditionalAvailabilityExpressionsForEsBuildPlugin(source);
expect(transformed).toBe(source);
});
it('should handle multiple expressions in one source', () => {
const source = [
'const a = { conditionalAvailabilityExpression: isShowPage };',
'const b = { conditionalAvailabilityExpression: isFavorite && !isRemote };',
].join('\n');
const transformed =
transformConditionalAvailabilityExpressionsForEsBuildPlugin(source);
expect(transformed).toContain(
'conditionalAvailabilityExpression: "isShowPage"',
);
expect(transformed).toContain(
'conditionalAvailabilityExpression: "isFavorite and not isRemote"',
);
});
it('should handle extra spaces around colon', () => {
const source =
'{ conditionalAvailabilityExpression : isShowPage && isFavorite }';
const transformed =
transformConditionalAvailabilityExpressionsForEsBuildPlugin(source);
expect(transformed).toContain('"isShowPage and isFavorite"');
});
});
describe('e2e: mock file -> transform -> evaluate', () => {
describe('simple-boolean-front-component', () => {
it('should evaluate isShowPage when true', () => {
const context = buildMockCommandMenuContextApi({
isShowPage: true,
});
expect(
transformMockAndEvaluate(
'simple-boolean-front-component.ts',
context,
),
).toBe(true);
});
it('should evaluate isShowPage when false', () => {
const context = buildMockCommandMenuContextApi({
isShowPage: false,
});
expect(
transformMockAndEvaluate(
'simple-boolean-front-component.ts',
context,
),
).toBe(false);
});
});
describe('permissions-check-front-component', () => {
it('should allow when has permission and not in right drawer', () => {
const context = buildMockCommandMenuContextApi({
isInRightDrawer: false,
});
expect(
transformMockAndEvaluate(
'permissions-check-front-component.ts',
context,
),
).toBe(true);
});
it('should deny when in right drawer', () => {
const context = buildMockCommandMenuContextApi({
isInRightDrawer: true,
});
expect(
transformMockAndEvaluate(
'permissions-check-front-component.ts',
context,
),
).toBe(false);
});
});
describe('comparison-operator-front-component', () => {
it('should allow when records are selected', () => {
const context = buildMockCommandMenuContextApi({
numberOfSelectedRecords: 3,
});
expect(
transformMockAndEvaluate(
'comparison-operator-front-component.ts',
context,
),
).toBe(true);
});
it('should deny when no records are selected', () => {
const context = buildMockCommandMenuContextApi({
numberOfSelectedRecords: 0,
});
expect(
transformMockAndEvaluate(
'comparison-operator-front-component.ts',
context,
),
).toBe(false);
});
});
describe('feature-flag-gated-front-component', () => {
it('should allow when feature flag is enabled', () => {
const context = buildMockCommandMenuContextApi({
featureFlags: { IS_AI_ENABLED: true },
});
expect(
transformMockAndEvaluate(
'feature-flag-gated-front-component.ts',
context,
),
).toBe(true);
});
it('should deny when feature flag is disabled', () => {
const context = buildMockCommandMenuContextApi({
featureFlags: { IS_AI_ENABLED: false },
});
expect(
transformMockAndEvaluate(
'feature-flag-gated-front-component.ts',
context,
),
).toBe(false);
});
});
describe('complex-soft-delete-front-component', () => {
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,
},
});
expect(
transformMockAndEvaluate(
'complex-soft-delete-front-component.ts',
context,
),
).toBe(true);
});
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,
},
});
expect(
transformMockAndEvaluate(
'complex-soft-delete-front-component.ts',
context,
),
).toBe(false);
});
});
describe('parenthesized-expression-front-component', () => {
it('should allow when favorite and not remote', () => {
const context = buildMockCommandMenuContextApi({
isShowPage: false,
isFavorite: true,
isRemote: false,
});
expect(
transformMockAndEvaluate(
'parenthesized-expression-front-component.ts',
context,
),
).toBe(true);
});
it('should deny when remote even if show page', () => {
const context = buildMockCommandMenuContextApi({
isShowPage: true,
isFavorite: false,
isRemote: true,
});
expect(
transformMockAndEvaluate(
'parenthesized-expression-front-component.ts',
context,
),
).toBe(false);
});
});
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,
},
});
expect(
transformMockAndEvaluate(
'custom-function-front-component.ts',
context,
),
).toBe(false);
});
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',
},
});
expect(
transformMockAndEvaluate(
'custom-function-front-component.ts',
context,
),
).toBe(true);
});
});
describe('string-comparison-front-component', () => {
it('should match when on show 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' },
},
});
expect(
transformMockAndEvaluate(
'string-comparison-front-component.ts',
context,
),
).toBe(true);
});
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' },
},
});
expect(
transformMockAndEvaluate(
'string-comparison-front-component.ts',
context,
),
).toBe(false);
});
});
describe('target-permissions-front-component', () => {
it('should allow when on show page with write permission', () => {
const context = buildMockCommandMenuContextApi({
isShowPage: true,
targetObjectWritePermissions: { person: true },
});
expect(
transformMockAndEvaluate(
'target-permissions-front-component.ts',
context,
),
).toBe(true);
});
it('should deny when not on show page', () => {
const context = buildMockCommandMenuContextApi({
isShowPage: false,
targetObjectWritePermissions: { person: true },
});
expect(
transformMockAndEvaluate(
'target-permissions-front-component.ts',
context,
),
).toBe(false);
});
});
});
});
@@ -0,0 +1,29 @@
import type * as esbuild from 'esbuild';
import * as fs from 'fs/promises';
import { transformConditionalAvailabilityExpressionsForEsBuildPlugin } from './utils/transform-conditional-availability-expressions';
export const conditionalAvailabilityTransformPlugin: esbuild.Plugin = {
name: 'conditional-availability-transform',
setup: (build) => {
build.onLoad({ filter: /\.tsx?$/ }, async (args) => {
const source = await fs.readFile(args.path, 'utf8');
if (!source.includes('conditionalAvailabilityExpression')) {
return null;
}
const transformedSource =
transformConditionalAvailabilityExpressionsForEsBuildPlugin(source);
if (transformedSource === source) {
return null;
}
return {
contents: transformedSource,
loader: args.path.endsWith('.tsx') ? 'tsx' : 'ts',
};
});
},
};
@@ -0,0 +1,7 @@
export const toExprEval = (raw: string): string =>
raw
.replace(/!==/g, '!=')
.replace(/===/g, '==')
.replace(/&&/g, 'and')
.replace(/\|\|/g, 'or')
.replace(/!(?!=)/g, 'not ');
@@ -0,0 +1,13 @@
import { toExprEval } from './to-expr-eval';
const CONDITIONAL_AVAILABILITY_EXPRESSION_PATTERN =
/(conditionalAvailabilityExpression\s*:\s*)(?!['"`])([^,}]+)/g;
export const transformConditionalAvailabilityExpressionsForEsBuildPlugin = (
source: string,
): string =>
source.replace(
CONDITIONAL_AVAILABILITY_EXPRESSION_PATTERN,
(_, prefix: string, rawExpression: string) =>
prefix + JSON.stringify(toExprEval(rawExpression.trim())),
);
@@ -6,6 +6,7 @@ import {
TargetFunction,
} from '@/cli/utilities/build/manifest/manifest-extract-config';
import { extractManifestFromFile } from '@/cli/utilities/build/manifest/manifest-extract-config-from-file';
import { getDefaultFieldsInObjectFields } from '@/cli/utilities/build/manifest/utils/get-default-fields-in-object-fields';
import {
type ApplicationConfig,
type FrontComponentConfig,
@@ -22,6 +23,7 @@ import {
type AssetManifest,
ASSETS_DIR,
type FieldManifest,
type FrontComponentCommandManifest,
type FrontComponentManifest,
type LogicFunctionManifest,
type Manifest,
@@ -34,7 +36,6 @@ import {
} from 'twenty-shared/application';
import { getInputSchemaFromSourceCode } from 'twenty-shared/logic-function';
import { assertUnreachable } from 'twenty-shared/utils';
import { getDefaultFieldsInObjectFields } from '@/cli/utilities/build/manifest/utils/get-default-fields-in-object-fields';
const loadSources = async (appPath: string): Promise<string[]> => {
return await glob(['**/*.ts', '**/*.tsx'], {
@@ -237,7 +238,7 @@ export const buildManifest = async (
errors.push(...extract.errors);
const { component, ...rest } = extract.config;
const { component, command, ...rest } = extract.config;
const relativeFilePath = relative(appPath, filePath);
@@ -248,6 +249,8 @@ export const buildManifest = async (
builtComponentPath: relativeFilePath.replace(/\.tsx?$/, '.mjs'),
builtComponentChecksum: '',
isHeadless: rest.isHeadless ?? false,
// transformed by conditionalAvailabilityTransformPlugin
command: command as FrontComponentCommandManifest,
};
frontComponents.push(config);
@@ -1,3 +1,4 @@
import { conditionalAvailabilityTransformPlugin } from '@/cli/utilities/build/common/conditional-availability/conditional-availability-transform-plugin';
import { type ValidationResult } from '@/sdk';
import * as esbuild from 'esbuild';
import * as fs from 'fs-extra';
@@ -74,7 +75,7 @@ const loadModule = async ({
...(reactPath && { react: reactPath }),
...(reactDomPath && { 'react-dom': reactDomPath }),
},
plugins: [manifestMockPlugin],
plugins: [conditionalAvailabilityTransformPlugin, manifestMockPlugin],
logLevel: 'silent',
});