feat(sdk): add defineCommandMenuItem (#20256)

## Summary

- Add `defineCommandMenuItem` and `definePageLayoutWidget` as standalone
SDK defines, mirroring the existing `definePageLayoutTab` pattern. Both
entities can still be declared nested inside their parent
(`defineFrontComponent.command` / `definePageLayout.tabs[].widgets[]`).
- Add `CommandMenuItem` and `PageLayoutWidget` to the `SyncableEntity`
enum and the dev-mode UI labels.
- Wire the SDK manifest-build to extract the two new defines into
top-level `commandMenuItems` / `pageLayoutWidgets` arrays on the
manifest, and the server aggregator to consume them through the existing
flat-entity converters.
- On the server, expose `Application.commandMenuItems` (relation + DTO +
service hydration in `findOneApplication`).
- On the front, list command menu items in the application content tab
and add a dedicated detail page with a settings tab, mirroring how
`frontComponents` are surfaced.
- Add `twenty add` templates and Vitest unit tests for both new defines.
- Document the standalone-vs-nested pattern in
`packages/twenty-sdk/README.md`.

### Why

Until now, command menu items could only be declared as the nested
`command:` field on `defineFrontComponent` — there was no way to
register a command menu item from a separate file or from another
package. The `SyncableEntity` enum had 12 values, while the server
already synced 18 (including `commandMenuItem` and `pageLayoutWidget`).
The same gap existed for `pageLayoutWidget`, which had no top-level
define despite being synced server-side. This PR closes both gaps and
aligns the SDK surface with what the server actually accepts.

The standalone defines coexist with the nested form — pick one per
entity, never both with the same `universalIdentifier` (the manifest
aggregator will throw on duplicates). The README now documents this.

## Test plan

- [x] `npx nx typecheck twenty-sdk` / `twenty-server` / `twenty-front`
- [x] `npx nx lint:diff-with-main twenty-front` / `twenty-server`
- [x] `npx nx lint twenty-sdk` / `twenty-shared`
- [x] New unit tests: `define-command-menu-item.spec.ts`,
`define-page-layout-widget.spec.ts`
- [x] Existing manifest extract config tests still pass
- [ ] Codegen `npx nx run twenty-front:graphql:generate
--configuration=metadata` should be re-run after merge — the generated
`graphql.ts` was patched manually to include `commandMenuItems` on
`Application` and the `FindOneApplication` document.
- [ ] Smoke test: scaffold an app with `twenty add` for both new entity
types, run `twenty dev`, confirm the dev UI shows them in the sync list
and the settings page surfaces command menu items in the content tab.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: martmull <martmull@hotmail.fr>
Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Félix Malfait
2026-05-05 16:16:04 +02:00
committed by GitHub
parent 4dd08097ce
commit 633553f729
55 changed files with 1644 additions and 1210 deletions
@@ -324,6 +324,115 @@ type FrontComponent {
applicationTokenPair: ApplicationTokenPair
}
type CommandMenuItem {
id: UUID!
workflowVersionId: UUID
frontComponentId: UUID
frontComponent: FrontComponent
engineComponentKey: EngineComponentKey!
label: String!
icon: String
shortLabel: String
position: Float!
isPinned: Boolean!
availabilityType: CommandMenuItemAvailabilityType!
payload: CommandMenuItemPayload
hotKeys: [String!]
conditionalAvailabilityExpression: String
availabilityObjectMetadataId: UUID
pageLayoutId: UUID
universalIdentifier: UUID
applicationId: UUID
createdAt: DateTime!
updatedAt: DateTime!
}
enum EngineComponentKey {
NAVIGATE_TO_NEXT_RECORD
NAVIGATE_TO_PREVIOUS_RECORD
CREATE_NEW_RECORD
DELETE_RECORDS
RESTORE_RECORDS
DESTROY_RECORDS
ADD_TO_FAVORITES
REMOVE_FROM_FAVORITES
EXPORT_NOTE_TO_PDF
EXPORT_RECORDS
UPDATE_MULTIPLE_RECORDS
MERGE_MULTIPLE_RECORDS
IMPORT_RECORDS
EXPORT_VIEW
SEE_DELETED_RECORDS
CREATE_NEW_VIEW
HIDE_DELETED_RECORDS
EDIT_RECORD_PAGE_LAYOUT
EDIT_DASHBOARD_LAYOUT
SAVE_DASHBOARD_LAYOUT
CANCEL_DASHBOARD_LAYOUT
DUPLICATE_DASHBOARD
ACTIVATE_WORKFLOW
DEACTIVATE_WORKFLOW
DISCARD_DRAFT_WORKFLOW
TEST_WORKFLOW
SEE_ACTIVE_VERSION_WORKFLOW
SEE_RUNS_WORKFLOW
SEE_VERSIONS_WORKFLOW
ADD_NODE_WORKFLOW
TIDY_UP_WORKFLOW
DUPLICATE_WORKFLOW
SEE_VERSION_WORKFLOW_RUN
SEE_WORKFLOW_WORKFLOW_RUN
STOP_WORKFLOW_RUN
SEE_RUNS_WORKFLOW_VERSION
SEE_WORKFLOW_WORKFLOW_VERSION
USE_AS_DRAFT_WORKFLOW_VERSION
SEE_VERSIONS_WORKFLOW_VERSION
SEARCH_RECORDS
SEARCH_RECORDS_FALLBACK
ASK_AI
VIEW_PREVIOUS_AI_CHATS
NAVIGATION
TRIGGER_WORKFLOW_VERSION
FRONT_COMPONENT_RENDERER
REPLY_TO_EMAIL_THREAD
COMPOSE_EMAIL
GO_TO_PEOPLE
GO_TO_COMPANIES
GO_TO_DASHBOARDS
GO_TO_OPPORTUNITIES
GO_TO_SETTINGS
GO_TO_TASKS
GO_TO_NOTES
GO_TO_WORKFLOWS
GO_TO_RUNS
DELETE_SINGLE_RECORD
DELETE_MULTIPLE_RECORDS
RESTORE_SINGLE_RECORD
RESTORE_MULTIPLE_RECORDS
DESTROY_SINGLE_RECORD
DESTROY_MULTIPLE_RECORDS
EXPORT_FROM_RECORD_INDEX
EXPORT_FROM_RECORD_SHOW
EXPORT_MULTIPLE_RECORDS
}
enum CommandMenuItemAvailabilityType {
GLOBAL
GLOBAL_OBJECT_CONTEXT
RECORD_SELECTION
FALLBACK
}
union CommandMenuItemPayload = PathCommandMenuItemPayload | ObjectMetadataCommandMenuItemPayload
type PathCommandMenuItemPayload {
path: String!
}
type ObjectMetadataCommandMenuItemPayload {
objectMetadataItemId: UUID!
}
type LogicFunction {
id: UUID!
name: String!
@@ -595,6 +704,7 @@ type Application {
defaultLogicFunctionRole: Role
agents: [Agent!]!
frontComponents: [FrontComponent!]!
commandMenuItems: [CommandMenuItem!]!
logicFunctions: [LogicFunction!]!
objects: [Object!]!
applicationVariables: [ApplicationVariable!]!
@@ -2324,114 +2434,6 @@ type PostgresCredentials {
workspaceId: UUID!
}
type CommandMenuItem {
id: UUID!
workflowVersionId: UUID
frontComponentId: UUID
frontComponent: FrontComponent
engineComponentKey: EngineComponentKey!
label: String!
icon: String
shortLabel: String
position: Float!
isPinned: Boolean!
availabilityType: CommandMenuItemAvailabilityType!
payload: CommandMenuItemPayload
hotKeys: [String!]
conditionalAvailabilityExpression: String
availabilityObjectMetadataId: UUID
pageLayoutId: UUID
applicationId: UUID
createdAt: DateTime!
updatedAt: DateTime!
}
enum EngineComponentKey {
NAVIGATE_TO_NEXT_RECORD
NAVIGATE_TO_PREVIOUS_RECORD
CREATE_NEW_RECORD
DELETE_RECORDS
RESTORE_RECORDS
DESTROY_RECORDS
ADD_TO_FAVORITES
REMOVE_FROM_FAVORITES
EXPORT_NOTE_TO_PDF
EXPORT_RECORDS
UPDATE_MULTIPLE_RECORDS
MERGE_MULTIPLE_RECORDS
IMPORT_RECORDS
EXPORT_VIEW
SEE_DELETED_RECORDS
CREATE_NEW_VIEW
HIDE_DELETED_RECORDS
EDIT_RECORD_PAGE_LAYOUT
EDIT_DASHBOARD_LAYOUT
SAVE_DASHBOARD_LAYOUT
CANCEL_DASHBOARD_LAYOUT
DUPLICATE_DASHBOARD
ACTIVATE_WORKFLOW
DEACTIVATE_WORKFLOW
DISCARD_DRAFT_WORKFLOW
TEST_WORKFLOW
SEE_ACTIVE_VERSION_WORKFLOW
SEE_RUNS_WORKFLOW
SEE_VERSIONS_WORKFLOW
ADD_NODE_WORKFLOW
TIDY_UP_WORKFLOW
DUPLICATE_WORKFLOW
SEE_VERSION_WORKFLOW_RUN
SEE_WORKFLOW_WORKFLOW_RUN
STOP_WORKFLOW_RUN
SEE_RUNS_WORKFLOW_VERSION
SEE_WORKFLOW_WORKFLOW_VERSION
USE_AS_DRAFT_WORKFLOW_VERSION
SEE_VERSIONS_WORKFLOW_VERSION
SEARCH_RECORDS
SEARCH_RECORDS_FALLBACK
ASK_AI
VIEW_PREVIOUS_AI_CHATS
NAVIGATION
TRIGGER_WORKFLOW_VERSION
FRONT_COMPONENT_RENDERER
REPLY_TO_EMAIL_THREAD
COMPOSE_EMAIL
GO_TO_PEOPLE
GO_TO_COMPANIES
GO_TO_DASHBOARDS
GO_TO_OPPORTUNITIES
GO_TO_SETTINGS
GO_TO_TASKS
GO_TO_NOTES
GO_TO_WORKFLOWS
GO_TO_RUNS
DELETE_SINGLE_RECORD
DELETE_MULTIPLE_RECORDS
RESTORE_SINGLE_RECORD
RESTORE_MULTIPLE_RECORDS
DESTROY_SINGLE_RECORD
DESTROY_MULTIPLE_RECORDS
EXPORT_FROM_RECORD_INDEX
EXPORT_FROM_RECORD_SHOW
EXPORT_MULTIPLE_RECORDS
}
enum CommandMenuItemAvailabilityType {
GLOBAL
GLOBAL_OBJECT_CONTEXT
RECORD_SELECTION
FALLBACK
}
union CommandMenuItemPayload = PathCommandMenuItemPayload | ObjectMetadataCommandMenuItemPayload
type PathCommandMenuItemPayload {
path: String!
}
type ObjectMetadataCommandMenuItemPayload {
objectMetadataItemId: UUID!
}
type ToolIndexEntry {
name: String!
description: String!
@@ -278,6 +278,46 @@ export interface FrontComponent {
__typename: 'FrontComponent'
}
export interface CommandMenuItem {
id: Scalars['UUID']
workflowVersionId?: Scalars['UUID']
frontComponentId?: Scalars['UUID']
frontComponent?: FrontComponent
engineComponentKey: EngineComponentKey
label: Scalars['String']
icon?: Scalars['String']
shortLabel?: Scalars['String']
position: Scalars['Float']
isPinned: Scalars['Boolean']
availabilityType: CommandMenuItemAvailabilityType
payload?: CommandMenuItemPayload
hotKeys?: Scalars['String'][]
conditionalAvailabilityExpression?: Scalars['String']
availabilityObjectMetadataId?: Scalars['UUID']
pageLayoutId?: Scalars['UUID']
universalIdentifier?: Scalars['UUID']
applicationId?: Scalars['UUID']
createdAt: Scalars['DateTime']
updatedAt: Scalars['DateTime']
__typename: 'CommandMenuItem'
}
export type EngineComponentKey = 'NAVIGATE_TO_NEXT_RECORD' | 'NAVIGATE_TO_PREVIOUS_RECORD' | 'CREATE_NEW_RECORD' | 'DELETE_RECORDS' | 'RESTORE_RECORDS' | 'DESTROY_RECORDS' | 'ADD_TO_FAVORITES' | 'REMOVE_FROM_FAVORITES' | 'EXPORT_NOTE_TO_PDF' | 'EXPORT_RECORDS' | 'UPDATE_MULTIPLE_RECORDS' | 'MERGE_MULTIPLE_RECORDS' | 'IMPORT_RECORDS' | 'EXPORT_VIEW' | 'SEE_DELETED_RECORDS' | 'CREATE_NEW_VIEW' | 'HIDE_DELETED_RECORDS' | 'EDIT_RECORD_PAGE_LAYOUT' | 'EDIT_DASHBOARD_LAYOUT' | 'SAVE_DASHBOARD_LAYOUT' | 'CANCEL_DASHBOARD_LAYOUT' | 'DUPLICATE_DASHBOARD' | 'ACTIVATE_WORKFLOW' | 'DEACTIVATE_WORKFLOW' | 'DISCARD_DRAFT_WORKFLOW' | 'TEST_WORKFLOW' | 'SEE_ACTIVE_VERSION_WORKFLOW' | 'SEE_RUNS_WORKFLOW' | 'SEE_VERSIONS_WORKFLOW' | 'ADD_NODE_WORKFLOW' | 'TIDY_UP_WORKFLOW' | 'DUPLICATE_WORKFLOW' | 'SEE_VERSION_WORKFLOW_RUN' | 'SEE_WORKFLOW_WORKFLOW_RUN' | 'STOP_WORKFLOW_RUN' | 'SEE_RUNS_WORKFLOW_VERSION' | 'SEE_WORKFLOW_WORKFLOW_VERSION' | 'USE_AS_DRAFT_WORKFLOW_VERSION' | 'SEE_VERSIONS_WORKFLOW_VERSION' | 'SEARCH_RECORDS' | 'SEARCH_RECORDS_FALLBACK' | 'ASK_AI' | 'VIEW_PREVIOUS_AI_CHATS' | 'NAVIGATION' | 'TRIGGER_WORKFLOW_VERSION' | 'FRONT_COMPONENT_RENDERER' | 'REPLY_TO_EMAIL_THREAD' | 'COMPOSE_EMAIL' | 'GO_TO_PEOPLE' | 'GO_TO_COMPANIES' | 'GO_TO_DASHBOARDS' | 'GO_TO_OPPORTUNITIES' | 'GO_TO_SETTINGS' | 'GO_TO_TASKS' | 'GO_TO_NOTES' | 'GO_TO_WORKFLOWS' | 'GO_TO_RUNS' | 'DELETE_SINGLE_RECORD' | 'DELETE_MULTIPLE_RECORDS' | 'RESTORE_SINGLE_RECORD' | 'RESTORE_MULTIPLE_RECORDS' | 'DESTROY_SINGLE_RECORD' | 'DESTROY_MULTIPLE_RECORDS' | 'EXPORT_FROM_RECORD_INDEX' | 'EXPORT_FROM_RECORD_SHOW' | 'EXPORT_MULTIPLE_RECORDS'
export type CommandMenuItemAvailabilityType = 'GLOBAL' | 'GLOBAL_OBJECT_CONTEXT' | 'RECORD_SELECTION' | 'FALLBACK'
export type CommandMenuItemPayload = (PathCommandMenuItemPayload | ObjectMetadataCommandMenuItemPayload) & { __isUnion?: true }
export interface PathCommandMenuItemPayload {
path: Scalars['String']
__typename: 'PathCommandMenuItemPayload'
}
export interface ObjectMetadataCommandMenuItemPayload {
objectMetadataItemId: Scalars['UUID']
__typename: 'ObjectMetadataCommandMenuItemPayload'
}
export interface LogicFunction {
id: Scalars['UUID']
name: Scalars['String']
@@ -429,6 +469,7 @@ export interface Application {
defaultLogicFunctionRole?: Role
agents: Agent[]
frontComponents: FrontComponent[]
commandMenuItems: CommandMenuItem[]
logicFunctions: LogicFunction[]
objects: Object[]
applicationVariables: ApplicationVariable[]
@@ -2070,45 +2111,6 @@ export interface PostgresCredentials {
__typename: 'PostgresCredentials'
}
export interface CommandMenuItem {
id: Scalars['UUID']
workflowVersionId?: Scalars['UUID']
frontComponentId?: Scalars['UUID']
frontComponent?: FrontComponent
engineComponentKey: EngineComponentKey
label: Scalars['String']
icon?: Scalars['String']
shortLabel?: Scalars['String']
position: Scalars['Float']
isPinned: Scalars['Boolean']
availabilityType: CommandMenuItemAvailabilityType
payload?: CommandMenuItemPayload
hotKeys?: Scalars['String'][]
conditionalAvailabilityExpression?: Scalars['String']
availabilityObjectMetadataId?: Scalars['UUID']
pageLayoutId?: Scalars['UUID']
applicationId?: Scalars['UUID']
createdAt: Scalars['DateTime']
updatedAt: Scalars['DateTime']
__typename: 'CommandMenuItem'
}
export type EngineComponentKey = 'NAVIGATE_TO_NEXT_RECORD' | 'NAVIGATE_TO_PREVIOUS_RECORD' | 'CREATE_NEW_RECORD' | 'DELETE_RECORDS' | 'RESTORE_RECORDS' | 'DESTROY_RECORDS' | 'ADD_TO_FAVORITES' | 'REMOVE_FROM_FAVORITES' | 'EXPORT_NOTE_TO_PDF' | 'EXPORT_RECORDS' | 'UPDATE_MULTIPLE_RECORDS' | 'MERGE_MULTIPLE_RECORDS' | 'IMPORT_RECORDS' | 'EXPORT_VIEW' | 'SEE_DELETED_RECORDS' | 'CREATE_NEW_VIEW' | 'HIDE_DELETED_RECORDS' | 'EDIT_RECORD_PAGE_LAYOUT' | 'EDIT_DASHBOARD_LAYOUT' | 'SAVE_DASHBOARD_LAYOUT' | 'CANCEL_DASHBOARD_LAYOUT' | 'DUPLICATE_DASHBOARD' | 'ACTIVATE_WORKFLOW' | 'DEACTIVATE_WORKFLOW' | 'DISCARD_DRAFT_WORKFLOW' | 'TEST_WORKFLOW' | 'SEE_ACTIVE_VERSION_WORKFLOW' | 'SEE_RUNS_WORKFLOW' | 'SEE_VERSIONS_WORKFLOW' | 'ADD_NODE_WORKFLOW' | 'TIDY_UP_WORKFLOW' | 'DUPLICATE_WORKFLOW' | 'SEE_VERSION_WORKFLOW_RUN' | 'SEE_WORKFLOW_WORKFLOW_RUN' | 'STOP_WORKFLOW_RUN' | 'SEE_RUNS_WORKFLOW_VERSION' | 'SEE_WORKFLOW_WORKFLOW_VERSION' | 'USE_AS_DRAFT_WORKFLOW_VERSION' | 'SEE_VERSIONS_WORKFLOW_VERSION' | 'SEARCH_RECORDS' | 'SEARCH_RECORDS_FALLBACK' | 'ASK_AI' | 'VIEW_PREVIOUS_AI_CHATS' | 'NAVIGATION' | 'TRIGGER_WORKFLOW_VERSION' | 'FRONT_COMPONENT_RENDERER' | 'REPLY_TO_EMAIL_THREAD' | 'COMPOSE_EMAIL' | 'GO_TO_PEOPLE' | 'GO_TO_COMPANIES' | 'GO_TO_DASHBOARDS' | 'GO_TO_OPPORTUNITIES' | 'GO_TO_SETTINGS' | 'GO_TO_TASKS' | 'GO_TO_NOTES' | 'GO_TO_WORKFLOWS' | 'GO_TO_RUNS' | 'DELETE_SINGLE_RECORD' | 'DELETE_MULTIPLE_RECORDS' | 'RESTORE_SINGLE_RECORD' | 'RESTORE_MULTIPLE_RECORDS' | 'DESTROY_SINGLE_RECORD' | 'DESTROY_MULTIPLE_RECORDS' | 'EXPORT_FROM_RECORD_INDEX' | 'EXPORT_FROM_RECORD_SHOW' | 'EXPORT_MULTIPLE_RECORDS'
export type CommandMenuItemAvailabilityType = 'GLOBAL' | 'GLOBAL_OBJECT_CONTEXT' | 'RECORD_SELECTION' | 'FALLBACK'
export type CommandMenuItemPayload = (PathCommandMenuItemPayload | ObjectMetadataCommandMenuItemPayload) & { __isUnion?: true }
export interface PathCommandMenuItemPayload {
path: Scalars['String']
__typename: 'PathCommandMenuItemPayload'
}
export interface ObjectMetadataCommandMenuItemPayload {
objectMetadataItemId: Scalars['UUID']
__typename: 'ObjectMetadataCommandMenuItemPayload'
}
export interface ToolIndexEntry {
name: Scalars['String']
description: Scalars['String']
@@ -3145,6 +3147,49 @@ export interface FrontComponentGenqlSelection{
__scalar?: boolean | number
}
export interface CommandMenuItemGenqlSelection{
id?: boolean | number
workflowVersionId?: boolean | number
frontComponentId?: boolean | number
frontComponent?: FrontComponentGenqlSelection
engineComponentKey?: boolean | number
label?: boolean | number
icon?: boolean | number
shortLabel?: boolean | number
position?: boolean | number
isPinned?: boolean | number
availabilityType?: boolean | number
payload?: CommandMenuItemPayloadGenqlSelection
hotKeys?: boolean | number
conditionalAvailabilityExpression?: boolean | number
availabilityObjectMetadataId?: boolean | number
pageLayoutId?: boolean | number
universalIdentifier?: boolean | number
applicationId?: boolean | number
createdAt?: boolean | number
updatedAt?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface CommandMenuItemPayloadGenqlSelection{
on_PathCommandMenuItemPayload?:PathCommandMenuItemPayloadGenqlSelection,
on_ObjectMetadataCommandMenuItemPayload?:ObjectMetadataCommandMenuItemPayloadGenqlSelection,
__typename?: boolean | number
}
export interface PathCommandMenuItemPayloadGenqlSelection{
path?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface ObjectMetadataCommandMenuItemPayloadGenqlSelection{
objectMetadataItemId?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface LogicFunctionGenqlSelection{
id?: boolean | number
name?: boolean | number
@@ -3333,6 +3378,7 @@ export interface ApplicationGenqlSelection{
defaultLogicFunctionRole?: RoleGenqlSelection
agents?: AgentGenqlSelection
frontComponents?: FrontComponentGenqlSelection
commandMenuItems?: CommandMenuItemGenqlSelection
logicFunctions?: LogicFunctionGenqlSelection
objects?: ObjectGenqlSelection
applicationVariables?: ApplicationVariableGenqlSelection
@@ -5068,48 +5114,6 @@ export interface PostgresCredentialsGenqlSelection{
__scalar?: boolean | number
}
export interface CommandMenuItemGenqlSelection{
id?: boolean | number
workflowVersionId?: boolean | number
frontComponentId?: boolean | number
frontComponent?: FrontComponentGenqlSelection
engineComponentKey?: boolean | number
label?: boolean | number
icon?: boolean | number
shortLabel?: boolean | number
position?: boolean | number
isPinned?: boolean | number
availabilityType?: boolean | number
payload?: CommandMenuItemPayloadGenqlSelection
hotKeys?: boolean | number
conditionalAvailabilityExpression?: boolean | number
availabilityObjectMetadataId?: boolean | number
pageLayoutId?: boolean | number
applicationId?: boolean | number
createdAt?: boolean | number
updatedAt?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface CommandMenuItemPayloadGenqlSelection{
on_PathCommandMenuItemPayload?:PathCommandMenuItemPayloadGenqlSelection,
on_ObjectMetadataCommandMenuItemPayload?:ObjectMetadataCommandMenuItemPayloadGenqlSelection,
__typename?: boolean | number
}
export interface PathCommandMenuItemPayloadGenqlSelection{
path?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface ObjectMetadataCommandMenuItemPayloadGenqlSelection{
objectMetadataItemId?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface ToolIndexEntryGenqlSelection{
name?: boolean | number
description?: boolean | number
@@ -6441,6 +6445,38 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
const CommandMenuItem_possibleTypes: string[] = ['CommandMenuItem']
export const isCommandMenuItem = (obj?: { __typename?: any } | null): obj is CommandMenuItem => {
if (!obj?.__typename) throw new Error('__typename is missing in "isCommandMenuItem"')
return CommandMenuItem_possibleTypes.includes(obj.__typename)
}
const CommandMenuItemPayload_possibleTypes: string[] = ['PathCommandMenuItemPayload','ObjectMetadataCommandMenuItemPayload']
export const isCommandMenuItemPayload = (obj?: { __typename?: any } | null): obj is CommandMenuItemPayload => {
if (!obj?.__typename) throw new Error('__typename is missing in "isCommandMenuItemPayload"')
return CommandMenuItemPayload_possibleTypes.includes(obj.__typename)
}
const PathCommandMenuItemPayload_possibleTypes: string[] = ['PathCommandMenuItemPayload']
export const isPathCommandMenuItemPayload = (obj?: { __typename?: any } | null): obj is PathCommandMenuItemPayload => {
if (!obj?.__typename) throw new Error('__typename is missing in "isPathCommandMenuItemPayload"')
return PathCommandMenuItemPayload_possibleTypes.includes(obj.__typename)
}
const ObjectMetadataCommandMenuItemPayload_possibleTypes: string[] = ['ObjectMetadataCommandMenuItemPayload']
export const isObjectMetadataCommandMenuItemPayload = (obj?: { __typename?: any } | null): obj is ObjectMetadataCommandMenuItemPayload => {
if (!obj?.__typename) throw new Error('__typename is missing in "isObjectMetadataCommandMenuItemPayload"')
return ObjectMetadataCommandMenuItemPayload_possibleTypes.includes(obj.__typename)
}
const LogicFunction_possibleTypes: string[] = ['LogicFunction']
export const isLogicFunction = (obj?: { __typename?: any } | null): obj is LogicFunction => {
if (!obj?.__typename) throw new Error('__typename is missing in "isLogicFunction"')
@@ -7873,38 +7909,6 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
const CommandMenuItem_possibleTypes: string[] = ['CommandMenuItem']
export const isCommandMenuItem = (obj?: { __typename?: any } | null): obj is CommandMenuItem => {
if (!obj?.__typename) throw new Error('__typename is missing in "isCommandMenuItem"')
return CommandMenuItem_possibleTypes.includes(obj.__typename)
}
const CommandMenuItemPayload_possibleTypes: string[] = ['PathCommandMenuItemPayload','ObjectMetadataCommandMenuItemPayload']
export const isCommandMenuItemPayload = (obj?: { __typename?: any } | null): obj is CommandMenuItemPayload => {
if (!obj?.__typename) throw new Error('__typename is missing in "isCommandMenuItemPayload"')
return CommandMenuItemPayload_possibleTypes.includes(obj.__typename)
}
const PathCommandMenuItemPayload_possibleTypes: string[] = ['PathCommandMenuItemPayload']
export const isPathCommandMenuItemPayload = (obj?: { __typename?: any } | null): obj is PathCommandMenuItemPayload => {
if (!obj?.__typename) throw new Error('__typename is missing in "isPathCommandMenuItemPayload"')
return PathCommandMenuItemPayload_possibleTypes.includes(obj.__typename)
}
const ObjectMetadataCommandMenuItemPayload_possibleTypes: string[] = ['ObjectMetadataCommandMenuItemPayload']
export const isObjectMetadataCommandMenuItemPayload = (obj?: { __typename?: any } | null): obj is ObjectMetadataCommandMenuItemPayload => {
if (!obj?.__typename) throw new Error('__typename is missing in "isObjectMetadataCommandMenuItemPayload"')
return ObjectMetadataCommandMenuItemPayload_possibleTypes.includes(obj.__typename)
}
const ToolIndexEntry_possibleTypes: string[] = ['ToolIndexEntry']
export const isToolIndexEntry = (obj?: { __typename?: any } | null): obj is ToolIndexEntry => {
if (!obj?.__typename) throw new Error('__typename is missing in "isToolIndexEntry"')
@@ -8304,6 +8308,82 @@ export const enumWorkspaceMemberNumberFormatEnum = {
APOSTROPHE_AND_DOT: 'APOSTROPHE_AND_DOT' as const
}
export const enumEngineComponentKey = {
NAVIGATE_TO_NEXT_RECORD: 'NAVIGATE_TO_NEXT_RECORD' as const,
NAVIGATE_TO_PREVIOUS_RECORD: 'NAVIGATE_TO_PREVIOUS_RECORD' as const,
CREATE_NEW_RECORD: 'CREATE_NEW_RECORD' as const,
DELETE_RECORDS: 'DELETE_RECORDS' as const,
RESTORE_RECORDS: 'RESTORE_RECORDS' as const,
DESTROY_RECORDS: 'DESTROY_RECORDS' as const,
ADD_TO_FAVORITES: 'ADD_TO_FAVORITES' as const,
REMOVE_FROM_FAVORITES: 'REMOVE_FROM_FAVORITES' as const,
EXPORT_NOTE_TO_PDF: 'EXPORT_NOTE_TO_PDF' as const,
EXPORT_RECORDS: 'EXPORT_RECORDS' as const,
UPDATE_MULTIPLE_RECORDS: 'UPDATE_MULTIPLE_RECORDS' as const,
MERGE_MULTIPLE_RECORDS: 'MERGE_MULTIPLE_RECORDS' as const,
IMPORT_RECORDS: 'IMPORT_RECORDS' as const,
EXPORT_VIEW: 'EXPORT_VIEW' as const,
SEE_DELETED_RECORDS: 'SEE_DELETED_RECORDS' as const,
CREATE_NEW_VIEW: 'CREATE_NEW_VIEW' as const,
HIDE_DELETED_RECORDS: 'HIDE_DELETED_RECORDS' as const,
EDIT_RECORD_PAGE_LAYOUT: 'EDIT_RECORD_PAGE_LAYOUT' as const,
EDIT_DASHBOARD_LAYOUT: 'EDIT_DASHBOARD_LAYOUT' as const,
SAVE_DASHBOARD_LAYOUT: 'SAVE_DASHBOARD_LAYOUT' as const,
CANCEL_DASHBOARD_LAYOUT: 'CANCEL_DASHBOARD_LAYOUT' as const,
DUPLICATE_DASHBOARD: 'DUPLICATE_DASHBOARD' as const,
ACTIVATE_WORKFLOW: 'ACTIVATE_WORKFLOW' as const,
DEACTIVATE_WORKFLOW: 'DEACTIVATE_WORKFLOW' as const,
DISCARD_DRAFT_WORKFLOW: 'DISCARD_DRAFT_WORKFLOW' as const,
TEST_WORKFLOW: 'TEST_WORKFLOW' as const,
SEE_ACTIVE_VERSION_WORKFLOW: 'SEE_ACTIVE_VERSION_WORKFLOW' as const,
SEE_RUNS_WORKFLOW: 'SEE_RUNS_WORKFLOW' as const,
SEE_VERSIONS_WORKFLOW: 'SEE_VERSIONS_WORKFLOW' as const,
ADD_NODE_WORKFLOW: 'ADD_NODE_WORKFLOW' as const,
TIDY_UP_WORKFLOW: 'TIDY_UP_WORKFLOW' as const,
DUPLICATE_WORKFLOW: 'DUPLICATE_WORKFLOW' as const,
SEE_VERSION_WORKFLOW_RUN: 'SEE_VERSION_WORKFLOW_RUN' as const,
SEE_WORKFLOW_WORKFLOW_RUN: 'SEE_WORKFLOW_WORKFLOW_RUN' as const,
STOP_WORKFLOW_RUN: 'STOP_WORKFLOW_RUN' as const,
SEE_RUNS_WORKFLOW_VERSION: 'SEE_RUNS_WORKFLOW_VERSION' as const,
SEE_WORKFLOW_WORKFLOW_VERSION: 'SEE_WORKFLOW_WORKFLOW_VERSION' as const,
USE_AS_DRAFT_WORKFLOW_VERSION: 'USE_AS_DRAFT_WORKFLOW_VERSION' as const,
SEE_VERSIONS_WORKFLOW_VERSION: 'SEE_VERSIONS_WORKFLOW_VERSION' as const,
SEARCH_RECORDS: 'SEARCH_RECORDS' as const,
SEARCH_RECORDS_FALLBACK: 'SEARCH_RECORDS_FALLBACK' as const,
ASK_AI: 'ASK_AI' as const,
VIEW_PREVIOUS_AI_CHATS: 'VIEW_PREVIOUS_AI_CHATS' as const,
NAVIGATION: 'NAVIGATION' as const,
TRIGGER_WORKFLOW_VERSION: 'TRIGGER_WORKFLOW_VERSION' as const,
FRONT_COMPONENT_RENDERER: 'FRONT_COMPONENT_RENDERER' as const,
REPLY_TO_EMAIL_THREAD: 'REPLY_TO_EMAIL_THREAD' as const,
COMPOSE_EMAIL: 'COMPOSE_EMAIL' as const,
GO_TO_PEOPLE: 'GO_TO_PEOPLE' as const,
GO_TO_COMPANIES: 'GO_TO_COMPANIES' as const,
GO_TO_DASHBOARDS: 'GO_TO_DASHBOARDS' as const,
GO_TO_OPPORTUNITIES: 'GO_TO_OPPORTUNITIES' as const,
GO_TO_SETTINGS: 'GO_TO_SETTINGS' as const,
GO_TO_TASKS: 'GO_TO_TASKS' as const,
GO_TO_NOTES: 'GO_TO_NOTES' as const,
GO_TO_WORKFLOWS: 'GO_TO_WORKFLOWS' as const,
GO_TO_RUNS: 'GO_TO_RUNS' as const,
DELETE_SINGLE_RECORD: 'DELETE_SINGLE_RECORD' as const,
DELETE_MULTIPLE_RECORDS: 'DELETE_MULTIPLE_RECORDS' as const,
RESTORE_SINGLE_RECORD: 'RESTORE_SINGLE_RECORD' as const,
RESTORE_MULTIPLE_RECORDS: 'RESTORE_MULTIPLE_RECORDS' as const,
DESTROY_SINGLE_RECORD: 'DESTROY_SINGLE_RECORD' as const,
DESTROY_MULTIPLE_RECORDS: 'DESTROY_MULTIPLE_RECORDS' as const,
EXPORT_FROM_RECORD_INDEX: 'EXPORT_FROM_RECORD_INDEX' as const,
EXPORT_FROM_RECORD_SHOW: 'EXPORT_FROM_RECORD_SHOW' as const,
EXPORT_MULTIPLE_RECORDS: 'EXPORT_MULTIPLE_RECORDS' as const
}
export const enumCommandMenuItemAvailabilityType = {
GLOBAL: 'GLOBAL' as const,
GLOBAL_OBJECT_CONTEXT: 'GLOBAL_OBJECT_CONTEXT' as const,
RECORD_SELECTION: 'RECORD_SELECTION' as const,
FALLBACK: 'FALLBACK' as const
}
export const enumFieldMetadataType = {
ACTOR: 'ACTOR' as const,
ADDRESS: 'ADDRESS' as const,
@@ -8663,82 +8743,6 @@ export const enumEmailingDomainStatus = {
TEMPORARY_FAILURE: 'TEMPORARY_FAILURE' as const
}
export const enumEngineComponentKey = {
NAVIGATE_TO_NEXT_RECORD: 'NAVIGATE_TO_NEXT_RECORD' as const,
NAVIGATE_TO_PREVIOUS_RECORD: 'NAVIGATE_TO_PREVIOUS_RECORD' as const,
CREATE_NEW_RECORD: 'CREATE_NEW_RECORD' as const,
DELETE_RECORDS: 'DELETE_RECORDS' as const,
RESTORE_RECORDS: 'RESTORE_RECORDS' as const,
DESTROY_RECORDS: 'DESTROY_RECORDS' as const,
ADD_TO_FAVORITES: 'ADD_TO_FAVORITES' as const,
REMOVE_FROM_FAVORITES: 'REMOVE_FROM_FAVORITES' as const,
EXPORT_NOTE_TO_PDF: 'EXPORT_NOTE_TO_PDF' as const,
EXPORT_RECORDS: 'EXPORT_RECORDS' as const,
UPDATE_MULTIPLE_RECORDS: 'UPDATE_MULTIPLE_RECORDS' as const,
MERGE_MULTIPLE_RECORDS: 'MERGE_MULTIPLE_RECORDS' as const,
IMPORT_RECORDS: 'IMPORT_RECORDS' as const,
EXPORT_VIEW: 'EXPORT_VIEW' as const,
SEE_DELETED_RECORDS: 'SEE_DELETED_RECORDS' as const,
CREATE_NEW_VIEW: 'CREATE_NEW_VIEW' as const,
HIDE_DELETED_RECORDS: 'HIDE_DELETED_RECORDS' as const,
EDIT_RECORD_PAGE_LAYOUT: 'EDIT_RECORD_PAGE_LAYOUT' as const,
EDIT_DASHBOARD_LAYOUT: 'EDIT_DASHBOARD_LAYOUT' as const,
SAVE_DASHBOARD_LAYOUT: 'SAVE_DASHBOARD_LAYOUT' as const,
CANCEL_DASHBOARD_LAYOUT: 'CANCEL_DASHBOARD_LAYOUT' as const,
DUPLICATE_DASHBOARD: 'DUPLICATE_DASHBOARD' as const,
ACTIVATE_WORKFLOW: 'ACTIVATE_WORKFLOW' as const,
DEACTIVATE_WORKFLOW: 'DEACTIVATE_WORKFLOW' as const,
DISCARD_DRAFT_WORKFLOW: 'DISCARD_DRAFT_WORKFLOW' as const,
TEST_WORKFLOW: 'TEST_WORKFLOW' as const,
SEE_ACTIVE_VERSION_WORKFLOW: 'SEE_ACTIVE_VERSION_WORKFLOW' as const,
SEE_RUNS_WORKFLOW: 'SEE_RUNS_WORKFLOW' as const,
SEE_VERSIONS_WORKFLOW: 'SEE_VERSIONS_WORKFLOW' as const,
ADD_NODE_WORKFLOW: 'ADD_NODE_WORKFLOW' as const,
TIDY_UP_WORKFLOW: 'TIDY_UP_WORKFLOW' as const,
DUPLICATE_WORKFLOW: 'DUPLICATE_WORKFLOW' as const,
SEE_VERSION_WORKFLOW_RUN: 'SEE_VERSION_WORKFLOW_RUN' as const,
SEE_WORKFLOW_WORKFLOW_RUN: 'SEE_WORKFLOW_WORKFLOW_RUN' as const,
STOP_WORKFLOW_RUN: 'STOP_WORKFLOW_RUN' as const,
SEE_RUNS_WORKFLOW_VERSION: 'SEE_RUNS_WORKFLOW_VERSION' as const,
SEE_WORKFLOW_WORKFLOW_VERSION: 'SEE_WORKFLOW_WORKFLOW_VERSION' as const,
USE_AS_DRAFT_WORKFLOW_VERSION: 'USE_AS_DRAFT_WORKFLOW_VERSION' as const,
SEE_VERSIONS_WORKFLOW_VERSION: 'SEE_VERSIONS_WORKFLOW_VERSION' as const,
SEARCH_RECORDS: 'SEARCH_RECORDS' as const,
SEARCH_RECORDS_FALLBACK: 'SEARCH_RECORDS_FALLBACK' as const,
ASK_AI: 'ASK_AI' as const,
VIEW_PREVIOUS_AI_CHATS: 'VIEW_PREVIOUS_AI_CHATS' as const,
NAVIGATION: 'NAVIGATION' as const,
TRIGGER_WORKFLOW_VERSION: 'TRIGGER_WORKFLOW_VERSION' as const,
FRONT_COMPONENT_RENDERER: 'FRONT_COMPONENT_RENDERER' as const,
REPLY_TO_EMAIL_THREAD: 'REPLY_TO_EMAIL_THREAD' as const,
COMPOSE_EMAIL: 'COMPOSE_EMAIL' as const,
GO_TO_PEOPLE: 'GO_TO_PEOPLE' as const,
GO_TO_COMPANIES: 'GO_TO_COMPANIES' as const,
GO_TO_DASHBOARDS: 'GO_TO_DASHBOARDS' as const,
GO_TO_OPPORTUNITIES: 'GO_TO_OPPORTUNITIES' as const,
GO_TO_SETTINGS: 'GO_TO_SETTINGS' as const,
GO_TO_TASKS: 'GO_TO_TASKS' as const,
GO_TO_NOTES: 'GO_TO_NOTES' as const,
GO_TO_WORKFLOWS: 'GO_TO_WORKFLOWS' as const,
GO_TO_RUNS: 'GO_TO_RUNS' as const,
DELETE_SINGLE_RECORD: 'DELETE_SINGLE_RECORD' as const,
DELETE_MULTIPLE_RECORDS: 'DELETE_MULTIPLE_RECORDS' as const,
RESTORE_SINGLE_RECORD: 'RESTORE_SINGLE_RECORD' as const,
RESTORE_MULTIPLE_RECORDS: 'RESTORE_MULTIPLE_RECORDS' as const,
DESTROY_SINGLE_RECORD: 'DESTROY_SINGLE_RECORD' as const,
DESTROY_MULTIPLE_RECORDS: 'DESTROY_MULTIPLE_RECORDS' as const,
EXPORT_FROM_RECORD_INDEX: 'EXPORT_FROM_RECORD_INDEX' as const,
EXPORT_FROM_RECORD_SHOW: 'EXPORT_FROM_RECORD_SHOW' as const,
EXPORT_MULTIPLE_RECORDS: 'EXPORT_MULTIPLE_RECORDS' as const
}
export const enumCommandMenuItemAvailabilityType = {
GLOBAL: 'GLOBAL' as const,
GLOBAL_OBJECT_CONTEXT: 'GLOBAL_OBJECT_CONTEXT' as const,
RECORD_SELECTION: 'RECORD_SELECTION' as const,
FALLBACK: 'FALLBACK' as const
}
export const enumCalendarChannelSyncStatus = {
NOT_SYNCED: 'NOT_SYNCED' as const,
ONGOING: 'ONGOING' as const,
File diff suppressed because it is too large Load Diff
@@ -15,7 +15,7 @@ Front components can render in two locations within Twenty:
## Basic example
The quickest way to see a front component in action is to register it as a **command**. Adding a `command` field with `isPinned: true` makes it appear as a quick-action button in the top-right corner of the page — no page layout needed:
The quickest way to see a front component in action is to register it with a **command menu item**. Use `defineCommandMenuItem` in a separate file to make the component appear as a quick-action button in the top-right corner of the page:
```tsx src/front-components/hello-world.tsx
import { defineFrontComponent } from 'twenty-sdk/define';
@@ -34,14 +34,20 @@ export default defineFrontComponent({
name: 'hello-world',
description: 'A simple front component',
component: HelloWorld,
command: {
universalIdentifier: 'd4e5f6a7-b8c9-0123-defa-456789012345',
shortLabel: 'Hello',
label: 'Hello World',
icon: 'IconBolt',
isPinned: true,
availabilityType: 'GLOBAL',
},
});
```
```ts src/command-menu-items/hello-world.command-menu-item.ts
import { defineCommandMenuItem } from 'twenty-sdk/define';
export default defineCommandMenuItem({
universalIdentifier: 'd4e5f6a7-b8c9-0123-defa-456789012345',
shortLabel: 'Hello',
label: 'Hello World',
icon: 'IconBolt',
isPinned: true,
availabilityType: 'GLOBAL',
frontComponentUniversalIdentifier: '74c526eb-cb68-4cf7-b05c-0dd8c288d948',
});
```
@@ -62,7 +68,6 @@ Click it to render the component inline.
| `name` | No | Display name |
| `description` | No | Description of what the component does |
| `isHeadless` | No | Set to `true` if the component has no visible UI (see below) |
| `command` | No | Register the component as a command (see [command options](#command-options) below) |
## Placing a front component on a page
@@ -141,11 +146,17 @@ export default defineFrontComponent({
description: 'Creates a task from the command menu',
component: RunAction,
isHeadless: true,
command: {
universalIdentifier: 'f6a7b8c9-d0e1-2345-fabc-456789012345',
label: 'Run my action',
icon: 'IconPlayerPlay',
},
});
```
```ts src/command-menu-items/run-action.command-menu-item.ts
import { defineCommandMenuItem } from 'twenty-sdk/define';
export default defineCommandMenuItem({
universalIdentifier: 'f6a7b8c9-d0e1-2345-fabc-456789012345',
label: 'Run my action',
icon: 'IconPlayerPlay',
frontComponentUniversalIdentifier: 'e5f6a7b8-c9d0-1234-efab-345678901234',
});
```
@@ -177,11 +188,6 @@ export default defineFrontComponent({
description: 'Deletes a draft with confirmation',
component: DeleteDraft,
isHeadless: true,
command: {
universalIdentifier: 'b8c9d0e1-f2a3-4567-bcde-678901234567',
label: 'Delete draft',
icon: 'IconTrash',
},
});
```
@@ -342,14 +348,29 @@ export default defineFrontComponent({
});
```
## Command options
## defineCommandMenuItem
Adding a `command` field to `defineFrontComponent` registers the component in the command menu (Cmd+K). If `isPinned` is `true`, it also appears as a quick-action button in the top-right corner of the page.
Use `defineCommandMenuItem` to register a front component in the command menu (Cmd+K). If `isPinned` is `true`, it also appears as a quick-action button in the top-right corner of the page.
```ts src/command-menu-items/open-dashboard.command-menu-item.ts
import { defineCommandMenuItem } from 'twenty-sdk/define';
export default defineCommandMenuItem({
universalIdentifier: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
label: 'Open Dashboard',
shortLabel: 'Dashboard',
icon: 'IconLayoutDashboard',
isPinned: true,
availabilityType: 'GLOBAL',
frontComponentUniversalIdentifier: '74c526eb-cb68-4cf7-b05c-0dd8c288d948',
});
```
| Field | Required | Description |
|-------|----------|-------------|
| `universalIdentifier` | Yes | Stable unique ID for the command |
| `label` | Yes | Full label shown in the command menu (Cmd+K) |
| `frontComponentUniversalIdentifier` | Yes | The `universalIdentifier` of the front component this command opens |
| `shortLabel` | No | Shorter label displayed on the pinned quick-action button |
| `icon` | No | Icon name displayed next to the label (e.g. `'IconBolt'`, `'IconSend'`) |
| `isPinned` | No | When `true`, shows the command as a quick-action button in the top-right corner of the page |
@@ -361,30 +382,23 @@ Adding a `command` field to `defineFrontComponent` registers the component in th
The `conditionalAvailabilityExpression` field lets you control when a command is visible based on the current page context. Import typed variables and operators from `twenty-sdk` to build expressions:
```tsx
import { defineFrontComponent } from 'twenty-sdk/define';
```ts src/command-menu-items/bulk-update.command-menu-item.ts
import { defineCommandMenuItem } from 'twenty-sdk/define';
import {
pageType,
numberOfSelectedRecords,
objectPermissions,
everyEquals,
isDefined,
} from 'twenty-sdk/front-component';
export default defineFrontComponent({
export default defineCommandMenuItem({
universalIdentifier: '...',
name: 'bulk-action',
component: BulkAction,
command: {
universalIdentifier: '...',
label: 'Bulk Update',
availabilityType: 'RECORD_SELECTION',
conditionalAvailabilityExpression: everyEquals(
objectPermissions,
'canUpdateObjectRecords',
true,
),
},
label: 'Bulk Update',
availabilityType: 'RECORD_SELECTION',
frontComponentUniversalIdentifier: '...',
conditionalAvailabilityExpression: everyEquals(
objectPermissions,
'canUpdateObjectRecords',
true,
),
});
```
File diff suppressed because one or more lines are too long
@@ -188,6 +188,14 @@ const SettingsApplicationFrontComponentDetail = lazy(() =>
})),
);
const SettingsApplicationCommandMenuItemDetail = lazy(() =>
import(
'~/pages/settings/applications/SettingsApplicationCommandMenuItemDetail'
).then((module) => ({
default: module.SettingsApplicationCommandMenuItemDetail,
})),
);
const SettingsLayoutViewDetail = lazy(() =>
import('~/pages/settings/layout/SettingsLayoutViewDetail').then((module) => ({
default: module.SettingsLayoutViewDetail,
@@ -802,6 +810,10 @@ export const SettingsRoutes = ({ isAdminPageEnabled }: SettingsRoutesProps) => (
path={SettingsPath.ApplicationFrontComponentDetail}
element={<SettingsApplicationFrontComponentDetail />}
/>
<Route
path={SettingsPath.ApplicationCommandMenuItemDetail}
element={<SettingsApplicationCommandMenuItemDetail />}
/>
<Route
path={SettingsPath.ApplicationViewDetail}
element={<SettingsLayoutViewDetail />}
@@ -48,6 +48,20 @@ export const APPLICATION_FRAGMENT = gql`
createdAt
updatedAt
}
commandMenuItems {
id
label
shortLabel
icon
isPinned
availabilityType
conditionalAvailabilityExpression
frontComponentId
universalIdentifier
applicationId
createdAt
updatedAt
}
objects {
...ObjectMetadataFields
}
@@ -5,6 +5,7 @@ export const CUSTOM_WORKSPACE_APPLICATION_MOCK = {
agents: [],
applicationVariables: [],
frontComponents: [],
commandMenuItems: [],
availablePackages: {},
canBeUninstalled: false,
description: 'workpace custom application',
@@ -10,7 +10,7 @@ import { type ApplicationContentRow } from '~/pages/settings/applications/compon
type InstalledApplicationForObjectAndFields = Omit<
Application,
'objects' | 'universalIdentifier' | 'frontComponents'
'objects' | 'universalIdentifier' | 'frontComponents' | 'commandMenuItems'
> & {
objects: { id: string }[];
};
@@ -0,0 +1,81 @@
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SettingsSectionSkeletonLoader } from '@/settings/components/SettingsSectionSkeletonLoader';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { useQuery } from '@apollo/client/react';
import { t } from '@lingui/core/macro';
import { useParams } from 'react-router-dom';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
import { FindOneApplicationDocument } from '~/generated-metadata/graphql';
import { SettingsApplicationCommandMenuItemSettingsTab } from '~/pages/settings/applications/tabs/SettingsApplicationCommandMenuItemSettingsTab';
export const SettingsApplicationCommandMenuItemDetail = () => {
const { applicationId = '', commandMenuItemId = '' } = useParams<{
applicationId: string;
commandMenuItemId: string;
}>();
const { data, loading } = useQuery(FindOneApplicationDocument, {
variables: { id: applicationId },
skip: !applicationId,
});
const application = data?.findOneApplication;
const commandMenuItem = application?.commandMenuItems?.find(
(item) => item.id === commandMenuItemId,
);
const frontComponent = commandMenuItem?.frontComponentId
? application?.frontComponents?.find(
(fc) => fc.id === commandMenuItem.frontComponentId,
)
: undefined;
const applicationContentHref = getSettingsPath(
SettingsPath.ApplicationDetail,
{ applicationId },
undefined,
'content',
);
const breadcrumbLinks = [
{
children: t`Workspace`,
href: getSettingsPath(SettingsPath.Workspace),
},
{
children: t`Applications`,
href: getSettingsPath(SettingsPath.Applications),
},
{ children: application?.name ?? '', href: applicationContentHref },
{ children: t`Command menu items`, href: applicationContentHref },
{ children: commandMenuItem?.label ?? '' },
];
return (
<SubMenuTopBarContainer
title={commandMenuItem?.label ?? t`Command menu item`}
links={breadcrumbLinks}
>
<SettingsPageContainer>
{loading || !isDefined(commandMenuItem) ? (
<SettingsSectionSkeletonLoader />
) : (
<SettingsApplicationCommandMenuItemSettingsTab
label={commandMenuItem.label}
shortLabel={commandMenuItem.shortLabel}
icon={commandMenuItem.icon}
isPinned={commandMenuItem.isPinned}
availabilityType={commandMenuItem.availabilityType}
conditionalAvailabilityExpression={
commandMenuItem.conditionalAvailabilityExpression
}
frontComponentName={frontComponent?.name}
universalIdentifier={commandMenuItem.universalIdentifier}
createdAt={commandMenuItem.createdAt}
updatedAt={commandMenuItem.updatedAt}
/>
)}
</SettingsPageContainer>
</SubMenuTopBarContainer>
);
};
@@ -138,10 +138,9 @@ export const SettingsAvailableApplicationDetails = () => {
icon: IconGraph,
count: (manifest?.frontComponents ?? []).filter(
(fc) =>
!isDefined(fc.command) &&
fc.universalIdentifier !==
manifest?.application
.settingsCustomTabFrontComponentUniversalIdentifier,
!(manifest?.commandMenuItems ?? [])
.map((cm) => cm.frontComponentUniversalIdentifier)
.includes(fc.universalIdentifier),
).length,
one: t`widget`,
many: t`widgets`,
@@ -149,7 +148,11 @@ export const SettingsAvailableApplicationDetails = () => {
{
icon: IconCommand,
count: (manifest?.frontComponents ?? []).filter(
(fc) => isDefined(fc.command) && !fc.isHeadless,
(fc) =>
!fc.isHeadless &&
(manifest?.commandMenuItems ?? [])
.map((cm) => cm.frontComponentUniversalIdentifier)
.includes(fc.universalIdentifier),
).length,
one: t`command`,
many: t`commands`,
@@ -0,0 +1,146 @@
import { Table } from '@/ui/layout/table/components/Table';
import { TableCell } from '@/ui/layout/table/components/TableCell';
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import { TableSection } from '@/ui/layout/table/components/TableSection';
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { type ReactNode } from 'react';
import { H2Title } from 'twenty-ui/display';
import { Section } from 'twenty-ui/layout';
import { themeCssVariables } from 'twenty-ui/theme-constants';
type SettingsApplicationCommandMenuItemSettingsTabProps = {
label: string;
shortLabel?: string | null;
icon?: string | null;
isPinned: boolean;
availabilityType: string;
conditionalAvailabilityExpression?: string | null;
frontComponentName?: string | null;
universalIdentifier?: string | null;
createdAt: string;
updatedAt: string;
};
const StyledMonoText = styled.span`
color: ${themeCssVariables.font.color.primary};
font-family: ${themeCssVariables.code.font.family}, monospace;
font-size: ${themeCssVariables.font.size.sm};
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const formatDateTime = (isoString: string): string => {
const date = new Date(isoString);
if (Number.isNaN(date.getTime())) {
return isoString;
}
return date.toLocaleString();
};
const GRID_TEMPLATE = '220px 1fr';
export const SettingsApplicationCommandMenuItemSettingsTab = ({
label,
shortLabel,
icon,
isPinned,
availabilityType,
conditionalAvailabilityExpression,
frontComponentName,
universalIdentifier,
createdAt,
updatedAt,
}: SettingsApplicationCommandMenuItemSettingsTabProps) => {
const detailRows: { key: string; label: string; value: ReactNode }[] = [
{
key: 'label',
label: t`Label`,
value: label,
},
{
key: 'shortLabel',
label: t`Short label`,
value: shortLabel ?? t`Not set`,
},
{
key: 'icon',
label: t`Icon`,
value: icon ? <StyledMonoText>{icon}</StyledMonoText> : t`Not set`,
},
{
key: 'isPinned',
label: t`Pinned`,
value: isPinned ? t`Yes` : t`No`,
},
{
key: 'availabilityType',
label: t`Availability`,
value: <StyledMonoText>{availabilityType}</StyledMonoText>,
},
{
key: 'conditionalAvailabilityExpression',
label: t`Conditional availability`,
value: conditionalAvailabilityExpression ? (
<StyledMonoText>{conditionalAvailabilityExpression}</StyledMonoText>
) : (
t`Not set`
),
},
{
key: 'frontComponent',
label: t`Front component`,
value: frontComponentName ? (
<StyledMonoText>{frontComponentName}</StyledMonoText>
) : (
t`Not set`
),
},
{
key: 'universalIdentifier',
label: t`Universal identifier`,
value: (
<StyledMonoText>{universalIdentifier ?? t`Not set`}</StyledMonoText>
),
},
{
key: 'createdAt',
label: t`Created`,
value: formatDateTime(createdAt),
},
{
key: 'updatedAt',
label: t`Updated`,
value: formatDateTime(updatedAt),
},
];
return (
<Section>
<H2Title
title={t`Details`}
description={t`Configuration of this command menu item`}
/>
<Table>
<TableRow gridTemplateColumns={GRID_TEMPLATE}>
<TableHeader>{t`Property`}</TableHeader>
<TableHeader>{t`Value`}</TableHeader>
</TableRow>
<TableSection title={t`Command menu item`}>
{detailRows.map((row) => (
<TableRow key={row.key} gridTemplateColumns={GRID_TEMPLATE}>
<TableCell color={themeCssVariables.font.color.secondary}>
{row.label}
</TableCell>
<TableCell minWidth="0" overflow="hidden">
{row.value}
</TableCell>
</TableRow>
))}
</TableSection>
</Table>
</Section>
);
};
@@ -20,7 +20,7 @@ import { normalizeSearchText } from '~/utils/normalizeSearchText';
type InstalledApplicationForContentTab = Omit<
Application,
'objects' | 'frontComponents'
'objects' | 'frontComponents' | 'commandMenuItems'
> & {
objects: { id: string }[];
frontComponents?: {
@@ -28,6 +28,11 @@ type InstalledApplicationForContentTab = Omit<
name: string;
description?: string | null;
}[];
commandMenuItems?: {
id: string;
label: string;
shortLabel?: string | null;
}[];
};
type SettingsApplicationDetailContentTabProps = {
@@ -124,6 +129,24 @@ export const SettingsApplicationDetailContentTab = ({
secondary: fc.description ?? undefined,
}));
const commandMenuItemRows: ApplicationContentRow[] = isDefined(
installedApplication,
)
? (installedApplication.commandMenuItems ?? []).map((item) => ({
key: item.id,
name: item.label,
secondary: item.shortLabel ?? undefined,
link: getSettingsPath(SettingsPath.ApplicationCommandMenuItemDetail, {
applicationId,
commandMenuItemId: item.id,
}),
}))
: (manifestContent?.commandMenuItems ?? []).map((item) => ({
key: item.universalIdentifier,
name: item.label,
secondary: item.shortLabel ?? undefined,
}));
const [searchTerm, setSearchTerm] = useState('');
const normalizedSearch = normalizeSearchText(searchTerm);
@@ -134,6 +157,7 @@ export const SettingsApplicationDetailContentTab = ({
views: filterRows(viewRows, normalizedSearch),
navigation: filterRows(navigationMenuItemRows, normalizedSearch),
frontComponents: filterRows(frontComponentRows, normalizedSearch),
commandMenuItems: filterRows(commandMenuItemRows, normalizedSearch),
logicFunctions: filterRows(logicFunctionRows, normalizedSearch),
agents: filterRows(agentRows, normalizedSearch),
skills: filterRows(skillRows, normalizedSearch),
@@ -146,7 +170,8 @@ export const SettingsApplicationDetailContentTab = ({
filtered.pageLayouts.length > 0 ||
filtered.views.length > 0 ||
filtered.navigation.length > 0 ||
filtered.frontComponents.length > 0;
filtered.frontComponents.length > 0 ||
filtered.commandMenuItems.length > 0;
const hasLogic =
filtered.logicFunctions.length > 0 ||
filtered.agents.length > 0 ||
@@ -222,6 +247,12 @@ export const SettingsApplicationDetailContentTab = ({
applicationId={applicationId}
fallbackApplicationData={fallbackApplicationData}
/>
<SettingsApplicationContentSubtable
title={t`Command menu items`}
rows={filtered.commandMenuItems}
applicationId={applicationId}
fallbackApplicationData={fallbackApplicationData}
/>
</Table>
</Section>
)}
@@ -7,6 +7,7 @@ import {
} from 'twenty-shared/types';
export const EXPECTED_MANIFEST: Manifest = {
commandMenuItems: [],
application: {
universalIdentifier: 'e1e2e3e4-e5e6-4000-8000-000000000001',
displayName: 'Root App',
@@ -12,6 +12,7 @@ import {
} from 'twenty-shared/types';
export const EXPECTED_MANIFEST: Manifest = {
commandMenuItems: [],
pageLayouts: [],
pageLayoutTabs: [
{
@@ -33,6 +33,7 @@ export const normalizeManifestForComparison = <T extends Manifest>(
navigationMenuItems: sortById(manifest.navigationMenuItems),
pageLayouts: sortById(manifest.pageLayouts),
pageLayoutTabs: sortById(manifest.pageLayoutTabs ?? []),
commandMenuItems: sortById(manifest.commandMenuItems ?? []),
logicFunctions: sortById(
manifest.logicFunctions?.map((fn) => ({
...fn,
@@ -15,6 +15,7 @@ import { getFrontComponentBaseFile } from '@/cli/utilities/entity/entity-front-c
import { getLogicFunctionBaseFile } from '@/cli/utilities/entity/entity-logic-function-template';
import { getNavigationMenuItemBaseFile } from '@/cli/utilities/entity/entity-navigation-menu-item-template';
import { getObjectBaseFile } from '@/cli/utilities/entity/entity-object-template';
import { getCommandMenuItemBaseFile } from '@/cli/utilities/entity/entity-command-menu-item-template';
import { getPageLayoutBaseFile } from '@/cli/utilities/entity/entity-page-layout-template';
import { getPageLayoutTabBaseFile } from '@/cli/utilities/entity/entity-page-layout-tab-template';
import { getRecordPageLayoutBaseFile } from '@/cli/utilities/entity/entity-record-page-layout-template';
@@ -217,6 +218,15 @@ export class EntityAddCommand {
return { name, file };
}
case SyncableEntity.CommandMenuItem: {
const name = await this.getEntityName(entity);
const file = getCommandMenuItemBaseFile({
name,
});
return { name, file };
}
default:
assertUnreachable(entity);
}
@@ -1,14 +1,9 @@
import { defineFrontComponent } from '@/sdk/define';
import { defineCommandMenuItem } from '@/sdk/define';
import { numberOfSelectedRecords } from '@/sdk/front-component';
const MyComponent = () => null;
export default defineFrontComponent({
universalIdentifier: 'comparison-operator',
component: MyComponent,
command: {
universalIdentifier: 'comparison-operator-cmd',
label: 'Comparison Operator',
conditionalAvailabilityExpression: numberOfSelectedRecords > 0,
},
export default defineCommandMenuItem({
universalIdentifier: 'comparison-operator-cmd',
label: 'Comparison Operator',
frontComponentUniversalIdentifier: 'comparison-operator',
conditionalAvailabilityExpression: numberOfSelectedRecords > 0,
});
@@ -1,4 +1,4 @@
import { defineFrontComponent } from '@/sdk/define';
import { defineCommandMenuItem } from '@/sdk/define';
import {
none,
numberOfSelectedRecords,
@@ -6,17 +6,12 @@ import {
selectedRecords,
} from '@/sdk/front-component';
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 &&
none(selectedRecords, 'isRemote') &&
numberOfSelectedRecords > 0,
},
export default defineCommandMenuItem({
universalIdentifier: 'complex-soft-delete-cmd',
label: 'Complex Soft Delete',
frontComponentUniversalIdentifier: 'complex-soft-delete',
conditionalAvailabilityExpression:
objectPermissions.canSoftDeleteObjectRecords &&
none(selectedRecords, 'isRemote') &&
numberOfSelectedRecords > 0,
});
@@ -1,17 +1,9 @@
import { defineFrontComponent } from '@/sdk/define';
import { defineCommandMenuItem } from '@/sdk/define';
import { someDefined, selectedRecords } from '@/sdk/front-component';
const MyComponent = () => null;
export default defineFrontComponent({
universalIdentifier: 'custom-function',
component: MyComponent,
command: {
universalIdentifier: 'custom-function-cmd',
label: 'Custom Function',
conditionalAvailabilityExpression: someDefined(
selectedRecords,
'deletedAt',
),
},
export default defineCommandMenuItem({
universalIdentifier: 'custom-function-cmd',
label: 'Custom Function',
frontComponentUniversalIdentifier: 'custom-function',
conditionalAvailabilityExpression: someDefined(selectedRecords, 'deletedAt'),
});
@@ -1,16 +1,11 @@
import { defineFrontComponent } from '@/sdk/define';
import { defineCommandMenuItem } from '@/sdk/define';
import { featureFlags, objectPermissions } from '@/sdk/front-component';
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_JUNCTION_RELATIONS_ENABLED &&
objectPermissions.canReadObjectRecords,
},
export default defineCommandMenuItem({
universalIdentifier: 'feature-flag-gated-cmd',
label: 'Feature Flag Gated',
frontComponentUniversalIdentifier: 'feature-flag-gated',
conditionalAvailabilityExpression:
featureFlags.IS_JUNCTION_RELATIONS_ENABLED &&
objectPermissions.canReadObjectRecords,
});
@@ -1,20 +1,15 @@
import { defineFrontComponent } from '@/sdk/define';
import { defineCommandMenuItem } from '@/sdk/define';
import {
favoriteRecordIds,
objectMetadataItem,
pageType,
} from '@/sdk/front-component';
const MyComponent = () => null;
export default defineFrontComponent({
universalIdentifier: 'parenthesized-expression',
component: MyComponent,
command: {
universalIdentifier: 'parenthesized-expression-cmd',
label: 'Parenthesized Expression',
conditionalAvailabilityExpression:
(pageType === 'RECORD_PAGE' || favoriteRecordIds.length > 0) &&
!objectMetadataItem.isRemote,
},
export default defineCommandMenuItem({
universalIdentifier: 'parenthesized-expression-cmd',
label: 'Parenthesized Expression',
frontComponentUniversalIdentifier: 'parenthesized-expression',
conditionalAvailabilityExpression:
(pageType === 'RECORD_PAGE' || favoriteRecordIds.length > 0) &&
!objectMetadataItem.isRemote,
});
@@ -1,15 +1,10 @@
import { defineFrontComponent } from '@/sdk/define';
import { defineCommandMenuItem } from '@/sdk/define';
import { isInSidePanel, objectPermissions } from '@/sdk/front-component';
const MyComponent = () => null;
export default defineFrontComponent({
universalIdentifier: 'permissions-check',
component: MyComponent,
command: {
universalIdentifier: 'permissions-check-cmd',
label: 'Permissions Check',
conditionalAvailabilityExpression:
objectPermissions.canUpdateObjectRecords && !isInSidePanel,
},
export default defineCommandMenuItem({
universalIdentifier: 'permissions-check-cmd',
label: 'Permissions Check',
frontComponentUniversalIdentifier: 'permissions-check',
conditionalAvailabilityExpression:
objectPermissions.canUpdateObjectRecords && !isInSidePanel,
});
@@ -1,14 +1,9 @@
import { defineFrontComponent } from '@/sdk/define';
import { defineCommandMenuItem } from '@/sdk/define';
import { pageType } from '@/sdk/front-component';
const MyComponent = () => null;
export default defineFrontComponent({
universalIdentifier: 'simple-boolean',
component: MyComponent,
command: {
universalIdentifier: 'simple-boolean-cmd',
label: 'Simple Boolean',
conditionalAvailabilityExpression: pageType === 'RECORD_PAGE',
},
export default defineCommandMenuItem({
universalIdentifier: 'simple-boolean-cmd',
label: 'Simple Boolean',
frontComponentUniversalIdentifier: 'simple-boolean',
conditionalAvailabilityExpression: pageType === 'RECORD_PAGE',
});
@@ -1,16 +1,11 @@
import { defineFrontComponent } from '@/sdk/define';
import { defineCommandMenuItem } from '@/sdk/define';
import { everyEquals, pageType, selectedRecords } from '@/sdk/front-component';
const MyComponent = () => null;
export default defineFrontComponent({
universalIdentifier: 'string-comparison',
component: MyComponent,
command: {
universalIdentifier: 'string-comparison-cmd',
label: 'String Comparison',
conditionalAvailabilityExpression:
pageType === 'RECORD_PAGE' &&
everyEquals(selectedRecords, 'company.name', 'apple'),
},
export default defineCommandMenuItem({
universalIdentifier: 'string-comparison-cmd',
label: 'String Comparison',
frontComponentUniversalIdentifier: 'string-comparison',
conditionalAvailabilityExpression:
pageType === 'RECORD_PAGE' &&
everyEquals(selectedRecords, 'company.name', 'apple'),
});
@@ -1,15 +1,10 @@
import { defineFrontComponent } from '@/sdk/define';
import { defineCommandMenuItem } from '@/sdk/define';
import { pageType, targetObjectWritePermissions } from '@/sdk/front-component';
const MyComponent = () => null;
export default defineFrontComponent({
universalIdentifier: 'target-permissions',
component: MyComponent,
command: {
universalIdentifier: 'target-permissions-cmd',
label: 'Target Permissions',
conditionalAvailabilityExpression:
pageType === 'RECORD_PAGE' && targetObjectWritePermissions.person,
},
export default defineCommandMenuItem({
universalIdentifier: 'target-permissions-cmd',
label: 'Target Permissions',
frontComponentUniversalIdentifier: 'target-permissions',
conditionalAvailabilityExpression:
pageType === 'RECORD_PAGE' && targetObjectWritePermissions.person,
});
@@ -33,6 +33,7 @@ exports[`stub-twenty-sdk-define plugin > matches the recorded export partition 1
"createValidationResult",
"defineAgent",
"defineApplication",
"defineCommandMenuItem",
"defineConnectionProvider",
"defineField",
"defineFrontComponent",
@@ -1,7 +1,7 @@
import {
type ApplicationManifest,
type Manifest,
type FieldManifest,
type Manifest,
} from 'twenty-shared/application';
import { FieldMetadataType, RelationType } from 'twenty-shared/types';
import { manifestValidate } from '@/cli/utilities/build/manifest/manifest-validate';
@@ -25,6 +25,7 @@ const validField: FieldManifest = {
};
const validManifest: Manifest = {
commandMenuItems: [],
application: validApplication,
objects: [],
frontComponents: [],
@@ -8,6 +8,7 @@ import {
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 LogicFunctionConfig } from '@/sdk/define';
import { type CommandMenuItemConfig } from '@/sdk/define/command-menu-items/command-menu-item-config';
import { type FrontComponentConfig } from '@/sdk/define/front-component/front-component-config';
import { type ObjectConfig } from '@/sdk/define/objects/object-config';
import { type PageLayoutConfig } from '@/sdk/define/page-layouts/page-layout-config';
@@ -21,9 +22,9 @@ import {
type ApplicationManifest,
type AssetManifest,
ASSETS_DIR,
type CommandMenuItemManifest,
type ConnectionProviderManifest,
type FieldManifest,
type FrontComponentCommandManifest,
type FrontComponentManifest,
type LogicFunctionManifest,
type Manifest,
@@ -89,6 +90,7 @@ export const buildManifest = async (
const navigationMenuItems: NavigationMenuItemManifest[] = [];
const pageLayouts: PageLayoutManifest[] = [];
const pageLayoutTabs: PageLayoutTabManifest[] = [];
const commandMenuItems: CommandMenuItemManifest[] = [];
const postInstallLogicFunctions: PostInstallLogicFunctionApplicationManifest[] =
[];
const preInstallLogicFunctions: PreInstallLogicFunctionApplicationManifest[] =
@@ -107,6 +109,7 @@ export const buildManifest = async (
const navigationMenuItemsFilePaths: string[] = [];
const pageLayoutsFilePaths: string[] = [];
const pageLayoutTabsFilePaths: string[] = [];
const commandMenuItemsFilePaths: string[] = [];
for (const filePath of filePaths) {
const fileContent = await readFile(filePath, 'utf-8');
@@ -321,7 +324,7 @@ export const buildManifest = async (
errors.push(...extract.errors);
const { component, command, ...rest } = extract.config;
const { component, ...rest } = extract.config;
const relativeFilePath = relative(appPath, filePath);
@@ -332,8 +335,6 @@ export const buildManifest = async (
builtComponentPath: relativeFilePath.replace(/\.tsx?$/, '.mjs'),
builtComponentChecksum: '',
isHeadless: rest.isHeadless ?? false,
// transformed by conditionalAvailabilityTransformPlugin
command: command as FrontComponentCommandManifest,
};
frontComponents.push(config);
@@ -397,6 +398,19 @@ export const buildManifest = async (
pageLayoutTabsFilePaths.push(relativePath);
break;
}
case ManifestEntityKey.CommandMenuItems: {
const extract = await extractManifestFromFile<CommandMenuItemConfig>({
appPath,
filePath,
});
commandMenuItems.push(
extract.config as unknown as CommandMenuItemManifest,
);
errors.push(...extract.errors);
commandMenuItemsFilePaths.push(relativePath);
break;
}
case ManifestEntityKey.PublicAssets: {
// Public assets are handled below
break;
@@ -475,6 +489,7 @@ export const buildManifest = async (
navigationMenuItems: navigationMenuItems.sort(byId),
pageLayouts: pageLayouts.sort(byId),
pageLayoutTabs: pageLayoutTabs.sort(byId),
commandMenuItems: commandMenuItems.sort(byId),
};
const entityFilePaths: EntityFilePaths = {
@@ -492,6 +507,7 @@ export const buildManifest = async (
navigationMenuItems: navigationMenuItemsFilePaths,
pageLayouts: pageLayoutsFilePaths,
pageLayoutTabs: pageLayoutTabsFilePaths,
commandMenuItems: commandMenuItemsFilePaths,
};
return { manifest, filePaths: entityFilePaths, errors };
@@ -16,6 +16,7 @@ export enum TargetFunction {
DefineNavigationMenuItem = 'defineNavigationMenuItem',
DefinePageLayout = 'definePageLayout',
DefinePageLayoutTab = 'definePageLayoutTab',
DefineCommandMenuItem = 'defineCommandMenuItem',
}
export enum ManifestEntityKey {
@@ -33,6 +34,7 @@ export enum ManifestEntityKey {
NavigationMenuItems = 'navigationMenuItems',
PageLayouts = 'pageLayouts',
PageLayoutTabs = 'pageLayoutTabs',
CommandMenuItems = 'commandMenuItems',
}
export type EntityFilePaths = Record<ManifestEntityKey, string[]>;
@@ -60,6 +62,7 @@ export const TARGET_FUNCTION_TO_ENTITY_KEY_MAPPING: Record<
ManifestEntityKey.NavigationMenuItems,
[TargetFunction.DefinePageLayout]: ManifestEntityKey.PageLayouts,
[TargetFunction.DefinePageLayoutTab]: ManifestEntityKey.PageLayoutTabs,
[TargetFunction.DefineCommandMenuItem]: ManifestEntityKey.CommandMenuItems,
};
const computeIsTargetFunctionCall = (node: ts.Node): string | undefined => {
@@ -76,6 +76,7 @@ const ENTITY_TYPE_TO_SYNCABLE: Record<string, SyncableEntity | undefined> = {
navigationMenuItems: SyncableEntity.NavigationMenuItem,
pageLayouts: SyncableEntity.PageLayout,
pageLayoutTabs: SyncableEntity.PageLayoutTab,
commandMenuItems: SyncableEntity.CommandMenuItem,
};
const MAX_EVENT_COUNT = 200;
@@ -97,6 +97,7 @@ export const ENTITY_LABELS: Record<SyncableEntity, string> = {
[SyncableEntity.Field]: 'Fields',
[SyncableEntity.LogicFunction]: 'Logic functions',
[SyncableEntity.FrontComponent]: 'Front components',
[SyncableEntity.CommandMenuItem]: 'Command menu items',
[SyncableEntity.Role]: 'Roles',
[SyncableEntity.Skill]: 'Skills',
[SyncableEntity.View]: 'Views',
@@ -0,0 +1,14 @@
import { v4 as uuidv4 } from 'uuid';
export const getCommandMenuItemBaseFile = ({ name }: { name: string }) => {
return `import { defineCommandMenuItem } from 'twenty-sdk/define';
export default defineCommandMenuItem({
universalIdentifier: '${uuidv4()}',
frontComponentUniversalIdentifier:
'replace-with-existing-front-component-uuid',
label: '${name}',
availabilityType: 'GLOBAL',
});
`;
};
@@ -0,0 +1,68 @@
import { describe, expect, it } from 'vitest';
import { defineCommandMenuItem } from '@/sdk/define';
const baseValidConfig = {
universalIdentifier: '11111111-1111-4111-8111-111111111111',
label: 'Open dashboard',
frontComponentUniversalIdentifier: '22222222-2222-4222-8222-222222222222',
};
describe('defineCommandMenuItem', () => {
it('returns success for a valid config', () => {
const result = defineCommandMenuItem(baseValidConfig);
expect(result.success).toBe(true);
expect(result.errors).toEqual([]);
});
it('reports a missing universalIdentifier', () => {
const result = defineCommandMenuItem({
...baseValidConfig,
universalIdentifier: '',
});
expect(result.success).toBe(false);
expect(result.errors).toContain(
'CommandMenuItem must have a universalIdentifier',
);
});
it('reports a missing label', () => {
const result = defineCommandMenuItem({
...baseValidConfig,
label: '',
});
expect(result.success).toBe(false);
expect(result.errors).toContain('CommandMenuItem must have a label');
});
it('reports a missing frontComponentUniversalIdentifier', () => {
const result = defineCommandMenuItem({
...baseValidConfig,
frontComponentUniversalIdentifier: '',
});
expect(result.success).toBe(false);
expect(
result.errors.some((error) =>
error.includes('frontComponentUniversalIdentifier'),
),
).toBe(true);
});
it('passes through optional fields', () => {
const result = defineCommandMenuItem({
...baseValidConfig,
icon: 'IconRocket',
shortLabel: 'Open',
isPinned: true,
availabilityType: 'GLOBAL',
});
expect(result.success).toBe(true);
expect(result.config.icon).toBe('IconRocket');
expect(result.config.isPinned).toBe(true);
});
});
@@ -0,0 +1,8 @@
import { type CommandMenuItemManifest } from 'twenty-shared/application';
export type CommandMenuItemConfig = Omit<
CommandMenuItemManifest,
'conditionalAvailabilityExpression'
> & {
conditionalAvailabilityExpression?: boolean | string;
};
@@ -0,0 +1,25 @@
import { type CommandMenuItemConfig } from '@/sdk/define/command-menu-items/command-menu-item-config';
import { type DefineEntity } from '@/sdk/define/common/types/define-entity.type';
import { createValidationResult } from '@/sdk/define/common/utils/create-validation-result';
export const defineCommandMenuItem: DefineEntity<CommandMenuItemConfig> = (
config,
) => {
const errors: string[] = [];
if (!config.universalIdentifier) {
errors.push('CommandMenuItem must have a universalIdentifier');
}
if (!config.label) {
errors.push('CommandMenuItem must have a label');
}
if (!config.frontComponentUniversalIdentifier) {
errors.push(
'CommandMenuItem must have a frontComponentUniversalIdentifier (the universalIdentifier of the front component this command opens)',
);
}
return createValidationResult({ config, errors });
};
@@ -1,4 +1,5 @@
import { type ApplicationConfig } from '@/sdk/define/application/application-config';
import { type CommandMenuItemConfig } from '@/sdk/define/command-menu-items/command-menu-item-config';
import { type FrontComponentConfig } from '@/sdk/define/front-component/front-component-config';
import { type LogicFunctionConfig } from '@/sdk/define/logic-functions/logic-function-config';
import { type ObjectConfig } from '@/sdk/define/objects/object-config';
@@ -37,7 +38,8 @@ export type DefinableEntity =
| ViewConfig
| NavigationMenuItemManifest
| PageLayoutConfig
| PageLayoutTabConfig;
| PageLayoutTabConfig
| CommandMenuItemConfig;
export type DefineEntity<T extends DefinableEntity = DefinableEntity> = (
config: T,
@@ -19,16 +19,6 @@ export const defineFrontComponent: DefineEntity<FrontComponentConfig> = (
errors.push('Front component component must be a React component');
}
if (config.command) {
if (!config.command.universalIdentifier) {
errors.push('Command must have a universalIdentifier');
}
if (!config.command.label) {
errors.push('Command must have a label');
}
}
return createValidationResult({
config,
errors,
@@ -1,17 +1,7 @@
import {
type FrontComponentCommandManifest,
type FrontComponentManifest,
} from 'twenty-shared/application';
import { type FrontComponentManifest } from 'twenty-shared/application';
export type FrontComponentType = React.ComponentType<any>;
export type FrontComponentCommandConfig = Omit<
FrontComponentCommandManifest,
'conditionalAvailabilityExpression'
> & {
conditionalAvailabilityExpression?: boolean | string;
};
export type FrontComponentConfig = Omit<
FrontComponentManifest,
| 'sourceComponentPath'
@@ -19,8 +9,6 @@ export type FrontComponentConfig = Omit<
| 'builtComponentChecksum'
| 'componentName'
| 'usesSdkClient'
| 'command'
> & {
component: FrontComponentType;
command?: FrontComponentCommandConfig;
};
+4 -1
View File
@@ -27,9 +27,12 @@ export { OnDeleteAction } from '@/sdk/define/fields/on-delete-action';
export { RelationType } from '@/sdk/define/fields/relation-type';
export { validateFields } from '@/sdk/define/fields/validate-fields';
export { defineCommandMenuItem } from '@/sdk/define/command-menu-items/define-command-menu-item';
export type { CommandMenuItemConfig } from '@/sdk/define/command-menu-items/command-menu-item-config';
export type { CommandMenuItemManifest } from 'twenty-shared/application';
export { defineFrontComponent } from '@/sdk/define/front-component/define-front-component';
export type {
FrontComponentCommandConfig,
FrontComponentConfig,
FrontComponentType,
} from '@/sdk/define/front-component/front-component-config';
@@ -79,6 +79,7 @@ export class ApplicationManifestMigrationService {
navigationMenuItems: [],
pageLayouts: [],
pageLayoutTabs: [],
commandMenuItems: [],
};
const now = new Date().toISOString();
@@ -158,23 +158,6 @@ export const computeApplicationManifestAllUniversalFlatEntityMaps = ({
universalFlatEntityMapsToMutate:
allUniversalFlatEntityMaps.flatFrontComponentMaps,
});
if (frontComponentManifest.command) {
addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow({
universalFlatEntity:
fromCommandMenuItemManifestToUniversalFlatCommandMenuItem({
commandMenuItemManifest: {
...frontComponentManifest.command,
frontComponentUniversalIdentifier:
frontComponentManifest.universalIdentifier,
},
applicationUniversalIdentifier,
now,
}),
universalFlatEntityMapsToMutate:
allUniversalFlatEntityMaps.flatCommandMenuItemMaps,
});
}
}
for (const connectionProviderManifest of manifest.connectionProviders ?? []) {
@@ -448,5 +431,24 @@ export const computeApplicationManifestAllUniversalFlatEntityMaps = ({
}
}
for (const commandMenuItemManifest of manifest.commandMenuItems ?? []) {
if (!isDefined(commandMenuItemManifest.frontComponentUniversalIdentifier)) {
throw new Error(
`Top-level commandMenuItem "${commandMenuItemManifest.universalIdentifier}" is missing required frontComponentUniversalIdentifier`,
);
}
addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow({
universalFlatEntity:
fromCommandMenuItemManifestToUniversalFlatCommandMenuItem({
commandMenuItemManifest,
applicationUniversalIdentifier,
now,
}),
universalFlatEntityMapsToMutate:
allUniversalFlatEntityMaps.flatCommandMenuItemMaps,
});
}
return allUniversalFlatEntityMaps;
};
@@ -26,6 +26,7 @@ const buildMinimalManifest = (
navigationMenuItems: [],
pageLayouts: [],
pageLayoutTabs: [],
commandMenuItems: [],
});
describe('resolveManifestAssetUrls', () => {
@@ -20,6 +20,7 @@ import { ApplicationRegistrationSourceType } from 'src/engine/core-modules/appli
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
import { ApplicationVariableEntity } from 'src/engine/core-modules/application/application-variable/application-variable.entity';
import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity';
import { CommandMenuItemEntity } from 'src/engine/metadata-modules/command-menu-item/entities/command-menu-item.entity';
import { FrontComponentEntity } from 'src/engine/metadata-modules/front-component/entities/front-component.entity';
import { LogicFunctionEntity } from 'src/engine/metadata-modules/logic-function/logic-function.entity';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
@@ -142,6 +143,15 @@ export class ApplicationEntity extends WorkspaceRelatedEntity {
)
frontComponents: Relation<FrontComponentEntity[]>;
@OneToMany(
() => CommandMenuItemEntity,
(commandMenuItem) => commandMenuItem.application,
{
onDelete: 'CASCADE',
},
)
commandMenuItems: Relation<CommandMenuItemEntity[]>;
@OneToMany(
() => ApplicationVariableEntity,
(applicationVariable) => applicationVariable.application,
@@ -9,6 +9,7 @@ import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-
import { TwentyConfigModule } from 'src/engine/core-modules/twenty-config/twenty-config.module';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity';
import { CommandMenuItemEntity } from 'src/engine/metadata-modules/command-menu-item/entities/command-menu-item.entity';
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
import { FrontComponentEntity } from 'src/engine/metadata-modules/front-component/entities/front-component.entity';
import { LogicFunctionEntity } from 'src/engine/metadata-modules/logic-function/logic-function.entity';
@@ -23,6 +24,7 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache
LogicFunctionEntity,
AgentEntity,
FrontComponentEntity,
CommandMenuItemEntity,
ObjectMetadataEntity,
ApplicationVariableEntity,
]),
@@ -17,6 +17,7 @@ import { type FlatApplication } from 'src/engine/core-modules/application/types/
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity';
import { CommandMenuItemEntity } from 'src/engine/metadata-modules/command-menu-item/entities/command-menu-item.entity';
import { ALL_FLAT_ENTITY_MAPS_PROPERTIES } from 'src/engine/metadata-modules/flat-entity/constant/all-flat-entity-maps-properties.constant';
import { FrontComponentEntity } from 'src/engine/metadata-modules/front-component/entities/front-component.entity';
import { LogicFunctionEntity } from 'src/engine/metadata-modules/logic-function/logic-function.entity';
@@ -40,6 +41,8 @@ export class ApplicationService {
private readonly agentRepository: Repository<AgentEntity>,
@InjectRepository(FrontComponentEntity)
private readonly frontComponentRepository: Repository<FrontComponentEntity>,
@InjectRepository(CommandMenuItemEntity)
private readonly commandMenuItemRepository: Repository<CommandMenuItemEntity>,
@InjectRepository(ObjectMetadataEntity)
private readonly objectMetadataRepository: Repository<ObjectMetadataEntity>,
@InjectRepository(ApplicationVariableEntity)
@@ -187,6 +190,7 @@ export class ApplicationService {
logicFunctions,
agents,
frontComponents,
commandMenuItems,
objects,
applicationVariables,
] = await Promise.all([
@@ -199,6 +203,9 @@ export class ApplicationService {
this.frontComponentRepository.find({
where: { applicationId: application.id, workspaceId },
}),
this.commandMenuItemRepository.find({
where: { applicationId: application.id, workspaceId },
}),
this.objectMetadataRepository.find({
where: { applicationId: application.id, workspaceId },
}),
@@ -210,6 +217,7 @@ export class ApplicationService {
application.logicFunctions = logicFunctions;
application.agents = agents;
application.frontComponents = frontComponents;
application.commandMenuItems = commandMenuItems;
application.objects = objects;
application.applicationVariables = applicationVariables;
@@ -4,6 +4,7 @@ export const APPLICATION_ENTITY_RELATION_PROPERTIES = [
'workspace',
'agents',
'frontComponents',
'commandMenuItems',
'logicFunctions',
'objects',
'applicationVariables',
@@ -13,6 +13,7 @@ import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/
import { ApplicationRegistrationSummaryDTO } from 'src/engine/core-modules/application/application-registration/dtos/application-registration-summary.dto';
import { ApplicationVariableEntityDTO } from 'src/engine/core-modules/application/application-variable/dtos/application-variable.dto';
import { AgentDTO } from 'src/engine/metadata-modules/ai/ai-agent/dtos/agent.dto';
import { CommandMenuItemDTO } from 'src/engine/metadata-modules/command-menu-item/dtos/command-menu-item.dto';
import { FrontComponentDTO } from 'src/engine/metadata-modules/front-component/dtos/front-component.dto';
import { LogicFunctionDTO } from 'src/engine/metadata-modules/logic-function/dtos/logic-function.dto';
import { ObjectMetadataDTO } from 'src/engine/metadata-modules/object-metadata/dtos/object-metadata.dto';
@@ -100,6 +101,9 @@ export class ApplicationDTO {
@Field(() => [FrontComponentDTO])
frontComponents?: FrontComponentDTO[];
@Field(() => [CommandMenuItemDTO])
commandMenuItems?: CommandMenuItemDTO[];
@Field(() => [LogicFunctionDTO])
logicFunctions?: LogicFunctionDTO[];
@@ -99,6 +99,9 @@ export class CommandMenuItemDTO {
@HideField()
workspaceId: string;
@Field(() => UUIDScalarType, { nullable: true })
universalIdentifier?: string;
@Field(() => UUIDScalarType, { nullable: true })
applicationId?: string;
@@ -36,5 +36,6 @@ export const buildBaseManifest = ({
navigationMenuItems: [],
pageLayouts: [],
pageLayoutTabs: [],
commandMenuItems: [],
...overrides,
});
@@ -11,4 +11,5 @@ export enum SyncableEntity {
NavigationMenuItem = 'navigationMenuItem',
PageLayout = 'pageLayout',
PageLayoutTab = 'pageLayoutTab',
CommandMenuItem = 'commandMenuItem',
}
@@ -15,11 +15,6 @@ export type CommandMenuItemManifest = SyncableEntityOptions & {
conditionalAvailabilityExpression?: string;
};
export type FrontComponentCommandManifest = Omit<
CommandMenuItemManifest,
'frontComponentUniversalIdentifier'
>;
export type FrontComponentManifest = {
universalIdentifier: string;
name?: string;
@@ -30,5 +25,4 @@ export type FrontComponentManifest = {
componentName: string;
isHeadless?: boolean;
usesSdkClient?: boolean;
command?: FrontComponentCommandManifest;
};
@@ -31,7 +31,6 @@ export type {
} from './fieldManifestType';
export type {
CommandMenuItemManifest,
FrontComponentCommandManifest,
FrontComponentManifest,
} from './frontComponentManifestType';
export type {
@@ -3,7 +3,10 @@ import { type ApplicationManifest } from './applicationType';
import { type AssetManifest } from './assetManifestType';
import { type ConnectionProviderManifest } from './connectionProviderManifestType';
import { type FieldManifest } from './fieldManifestType';
import { type FrontComponentManifest } from './frontComponentManifestType';
import {
type CommandMenuItemManifest,
type FrontComponentManifest,
} from './frontComponentManifestType';
import { type LogicFunctionManifest } from './logicFunctionManifestType';
import { type NavigationMenuItemManifest } from './navigationMenuItemManifestType';
import { type ObjectManifest } from './objectManifestType';
@@ -30,4 +33,5 @@ export type Manifest = {
navigationMenuItems: NavigationMenuItemManifest[];
pageLayouts: PageLayoutManifest[];
pageLayoutTabs: PageLayoutTabManifest[];
commandMenuItems: CommandMenuItemManifest[];
};
@@ -44,6 +44,7 @@ export enum SettingsPath {
ApplicationDetail = 'applications/:applicationId',
ApplicationLogicFunctionDetail = 'applications/:applicationId/logicFunctions/:logicFunctionId',
ApplicationFrontComponentDetail = 'applications/:applicationId/frontComponents/:frontComponentId',
ApplicationCommandMenuItemDetail = 'applications/:applicationId/commandMenuItems/:commandMenuItemId',
ApplicationViewDetail = 'applications/:applicationId/views/:viewUniversalIdentifier',
ApplicationPageLayoutDetail = 'applications/:applicationId/pageLayouts/:pageLayoutUniversalIdentifier',
AvailableApplicationDetail = 'applications/available/:availableApplicationId',