[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
@@ -7,6 +7,7 @@ export type CommandMenuItemManifest = SyncableEntityOptions & {
availabilityType?: 'GLOBAL' | 'SINGLE_RECORD' | 'BULK_RECORDS';
availabilityObjectUniversalIdentifier?: string;
frontComponentUniversalIdentifier: string;
conditionalAvailabilityExpression?: string;
};
export type FrontComponentCommandManifest = Omit<
@@ -0,0 +1 @@
export const BACKEND_BATCH_REQUEST_MAX_COUNT = 10000;
@@ -7,6 +7,7 @@
* |___/
*/
export { BACKEND_BATCH_REQUEST_MAX_COUNT } from './BackendBatchRequestMaxCount';
export { CalendarStartDay } from './CalendarStartDay';
export { COMPOSITE_FIELD_TYPE_SUB_FIELDS_NAMES } from './CompositeFieldTypeSubFieldsNames';
export { CurrencyCode } from './CurrencyCode';
+95
View File
@@ -0,0 +1,95 @@
declare module 'expr-eval' {
export type Value =
| number
| string
| boolean
| null
| undefined
| Value[]
| ((...args: Value[]) => Value)
| { [propertyName: string]: Value };
export type EvaluationContext = Record<string, unknown>;
export interface Values {
[propertyName: string]: Value;
}
export interface ParserOptions {
allowMemberAccess?: boolean;
operators?: {
add?: boolean;
comparison?: boolean;
concatenate?: boolean;
conditional?: boolean;
divide?: boolean;
factorial?: boolean;
logical?: boolean;
multiply?: boolean;
power?: boolean;
remainder?: boolean;
subtract?: boolean;
sin?: boolean;
cos?: boolean;
tan?: boolean;
asin?: boolean;
acos?: boolean;
atan?: boolean;
sinh?: boolean;
cosh?: boolean;
tanh?: boolean;
asinh?: boolean;
acosh?: boolean;
atanh?: boolean;
sqrt?: boolean;
log?: boolean;
ln?: boolean;
lg?: boolean;
log10?: boolean;
abs?: boolean;
ceil?: boolean;
floor?: boolean;
round?: boolean;
trunc?: boolean;
exp?: boolean;
length?: boolean;
in?: boolean;
random?: boolean;
min?: boolean;
max?: boolean;
assignment?: boolean;
fndef?: boolean;
cbrt?: boolean;
expm1?: boolean;
log1p?: boolean;
sign?: boolean;
log2?: boolean;
};
}
export class Parser {
constructor(options?: ParserOptions);
unaryOps: any;
functions: any;
consts: any;
parse(expression: string): Expression;
evaluate(expression: string, values?: EvaluationContext): number;
static parse(expression: string): Expression;
static evaluate(expression: string, values?: EvaluationContext): number;
}
export interface Expression {
simplify(values?: EvaluationContext): Expression;
evaluate(values?: EvaluationContext): any;
substitute(
variable: string,
value: Expression | string | number,
): Expression;
symbols(options?: { withMembers?: boolean }): string[];
variables(options?: { withMembers?: boolean }): string[];
toJSFunction(
params: string,
values?: EvaluationContext,
): (...args: any[]) => number;
}
}
@@ -0,0 +1,8 @@
export enum ActionViewType {
GLOBAL = 'GLOBAL',
INDEX_PAGE_BULK_SELECTION = 'INDEX_PAGE_BULK_SELECTION',
INDEX_PAGE_SINGLE_RECORD_SELECTION = 'INDEX_PAGE_SINGLE_RECORD_SELECTION',
INDEX_PAGE_NO_SELECTION = 'INDEX_PAGE_NO_SELECTION',
SHOW_PAGE = 'SHOW_PAGE',
PAGE_EDIT_MODE = 'PAGE_EDIT_MODE',
}
@@ -0,0 +1,18 @@
import { type ObjectPermissions } from './ObjectPermissions';
import { type ObjectRecord } from './ObjectRecord';
export type CommandMenuContextApi = {
isShowPage: boolean;
isInRightDrawer: boolean;
isFavorite: boolean;
isRemote: boolean;
isNoteOrTask: boolean;
isSelectAll: boolean;
hasAnySoftDeleteFilterOnView: boolean;
numberOfSelectedRecords: number;
objectPermissions: ObjectPermissions & { objectMetadataId: string };
selectedRecord: ObjectRecord | undefined;
featureFlags: Record<string, boolean>;
targetObjectReadPermissions: Record<string, boolean>;
targetObjectWritePermissions: Record<string, boolean>;
};
@@ -0,0 +1,34 @@
export enum CoreObjectNameSingular {
Activity = 'activity',
ActivityTarget = 'activityTarget',
ApiKey = 'apiKey',
Attachment = 'attachment',
Blocklist = 'blocklist',
CalendarChannel = 'calendarChannel',
CalendarEvent = 'calendarEvent',
Comment = 'comment',
Company = 'company',
ConnectedAccount = 'connectedAccount',
Dashboard = 'dashboard',
TimelineActivity = 'timelineActivity',
Favorite = 'favorite',
FavoriteFolder = 'favoriteFolder',
Message = 'message',
MessageChannel = 'messageChannel',
MessageParticipant = 'messageParticipant',
MessageFolder = 'messageFolder',
MessageThread = 'messageThread',
Note = 'note',
NoteTarget = 'noteTarget',
Opportunity = 'opportunity',
Person = 'person',
Task = 'task',
TaskTarget = 'taskTarget',
Webhook = 'webhook',
WorkspaceMember = 'workspaceMember',
MessageThreadSubscriber = 'messageThreadSubscriber',
Workflow = 'workflow',
MessageChannelMessageAssociation = 'messageChannelMessageAssociation',
WorkflowVersion = 'workflowVersion',
WorkflowRun = 'workflowRun',
}
@@ -0,0 +1,25 @@
export enum FeatureFlagKey {
IS_UNIQUE_INDEXES_ENABLED = 'IS_UNIQUE_INDEXES_ENABLED',
IS_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_ENABLED',
IS_AI_ENABLED = 'IS_AI_ENABLED',
IS_APPLICATION_ENABLED = 'IS_APPLICATION_ENABLED',
IS_APPLICATION_INSTALLATION_FROM_TARBALL_ENABLED = 'IS_APPLICATION_INSTALLATION_FROM_TARBALL_ENABLED',
IS_MARKETPLACE_ENABLED = 'IS_MARKETPLACE_ENABLED',
IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED = 'IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED',
IS_PUBLIC_DOMAIN_ENABLED = 'IS_PUBLIC_DOMAIN_ENABLED',
IS_EMAILING_DOMAIN_ENABLED = 'IS_EMAILING_DOMAIN_ENABLED',
IS_DASHBOARD_V2_ENABLED = 'IS_DASHBOARD_V2_ENABLED',
IS_ATTACHMENT_MIGRATED = 'IS_ATTACHMENT_MIGRATED',
IS_NOTE_TARGET_MIGRATED = 'IS_NOTE_TARGET_MIGRATED',
IS_TASK_TARGET_MIGRATED = 'IS_TASK_TARGET_MIGRATED',
IS_FILES_FIELD_MIGRATED = 'IS_FILES_FIELD_MIGRATED',
IS_CORE_PICTURE_MIGRATED = 'IS_CORE_PICTURE_MIGRATED',
IS_OTHER_FILE_MIGRATED = 'IS_OTHER_FILE_MIGRATED',
IS_ROW_LEVEL_PERMISSION_PREDICATES_ENABLED = 'IS_ROW_LEVEL_PERMISSION_PREDICATES_ENABLED',
IS_JUNCTION_RELATIONS_ENABLED = 'IS_JUNCTION_RELATIONS_ENABLED',
IS_COMMAND_MENU_ITEM_ENABLED = 'IS_COMMAND_MENU_ITEM_ENABLED',
IS_NAVIGATION_MENU_ITEM_ENABLED = 'IS_NAVIGATION_MENU_ITEM_ENABLED',
IS_DATE_TIME_WHOLE_DAY_FILTER_ENABLED = 'IS_DATE_TIME_WHOLE_DAY_FILTER_ENABLED',
IS_NAVIGATION_MENU_ITEM_EDITING_ENABLED = 'IS_NAVIGATION_MENU_ITEM_EDITING_ENABLED',
IS_DRAFT_EMAIL_ENABLED = 'IS_DRAFT_EMAIL_ENABLED',
}
@@ -7,6 +7,7 @@
* |___/
*/
export { ActionViewType } from './ActionViewType';
export type { AllowedAddressSubField } from './AddressFieldsType';
export { ALLOWED_ADDRESS_SUBFIELDS } from './AddressFieldsType';
export { AggregateOperations } from './AggregateOperations';
@@ -14,6 +15,7 @@ export { AppBasePath } from './AppBasePath';
export { AppPath } from './AppPath';
export type { Arrayable } from './Arrayable';
export type { ArraySortDirection } from './ArraySortDirection';
export type { CommandMenuContextApi } from './CommandMenuContextApi';
export { CommandMenuPages } from './CommandMenuPages';
export type { ActorMetadata } from './composite-types/actor.composite-type';
export {
@@ -52,6 +54,7 @@ export {
export type { CompositeFieldSubFieldName } from './CompositeFieldSubFieldNameType';
export type { ConfigVariableValue } from './ConfigVariableValue';
export { ConnectedAccountProvider } from './ConnectedAccountProvider';
export { CoreObjectNameSingular } from './CoreObjectNameSingular';
export { CrudOperationType } from './CrudOperationType';
export type {
SnackBarVariant,
@@ -63,6 +66,7 @@ export type { ExcludeFunctions } from './ExcludeFunctions';
export type { ExtractPropertiesThatEndsWithId } from './ExtractPropertiesThatEndsWithId';
export type { ExtractPropertiesThatEndsWithIds } from './ExtractPropertiesThatEndsWithIds';
export type { ExtractSerializedRelationProperties } from './ExtractSerializedRelationProperties.type';
export { FeatureFlagKey } from './FeatureFlagKey';
export type {
FieldMetadataDefaultValueFunctionNames,
FieldMetadataDefaultValueUuidFunction,
@@ -0,0 +1,26 @@
import { isNonEmptyString } from '@sniptt/guards';
import { type EvaluationContext, Parser } from 'expr-eval';
import { isDefined } from '../validation/isDefined';
const parser = new Parser();
parser.functions.isDefined = (value: unknown) => isDefined(value);
parser.functions.isNonEmptyString = (value: unknown) => isNonEmptyString(value);
export const evaluateConditionalAvailabilityExpression = (
expression: string | null | undefined,
context: EvaluationContext,
): boolean => {
if (!isNonEmptyString(expression)) {
return true;
}
try {
const parsed = parser.parse(expression);
return parsed.evaluate(context) === true;
} catch {
return false;
}
};
@@ -23,6 +23,7 @@ export { upsertPropertiesOfItemIntoArrayOfObjectsComparingId } from './array/ups
export { assertUnreachable } from './assertUnreachable';
export { base64UrlEncode } from './base64UrlEncode';
export { computeDiffBetweenObjects } from './compute-diff-between-objects';
export { evaluateConditionalAvailabilityExpression } from './conditional-availability/evaluateConditionalAvailabilityExpression';
export { isPlainDateAfter } from './date/isPlainDateAfter';
export { isPlainDateBefore } from './date/isPlainDateBefore';
export { isPlainDateBeforeOrEqual } from './date/isPlainDateBeforeOrEqual';