From 56812cce535c429f3da6a11d8dfc932c5a010c14 Mon Sep 17 00:00:00 2001 From: Jeremy Lim Date: Fri, 18 Jul 2025 21:17:29 +0800 Subject: [PATCH] Add Create related records to Record standard actions (#13095) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #12924 Add Create related records to Record standard actions - add the "Create related records" option to the standard Record actions in the command menu. - apply to one-to-many relations. - command should open a side panel with an empty record for the selected object. Screenshot --------- Co-authored-by: Raphaƫl Bosi <71827178+bosiraphael@users.noreply.github.com> Co-authored-by: bosiraphael --- .../components/CreateRelatedRecordAction.tsx | 120 +++++++++ .../useRelatedRecordActions.test.tsx | 253 ++++++++++++++++++ .../hooks/useRelatedRecordActions.ts | 107 ++++++++ .../action-menu/actions/types/ActionScope.ts | 1 + .../types/ShouldBeRegisteredFunctionParams.ts | 3 + .../actions/utils/getActionConfig.ts | 3 +- .../action-menu/hooks/useRegisteredActions.ts | 11 + .../useShouldActionBeRegisteredParams.ts | 15 ++ .../states/objectPermissionsFamilySelector.ts | 2 + .../command-menu/components/CommandMenu.tsx | 5 + .../hooks/useCommandMenuActions.tsx | 7 + .../hooks/useMatchingCommandMenuActions.ts | 9 +- ...etObjectMetadataItemBySingularName.test.ts | 15 -- .../getObjectMetadataItemBySingularName.ts | 22 -- .../twenty-orm/custom.workspace-entity.ts | 1 + .../company.workspace-entity.ts | 1 + .../standard-objects/note.workspace-entity.ts | 3 + .../opportunity.workspace-entity.ts | 2 + .../person.workspace-entity.ts | 3 +- .../standard-objects/task.workspace-entity.ts | 3 + .../workspace-member.workspace-entity.ts | 1 + 21 files changed, 547 insertions(+), 40 deletions(-) create mode 100644 packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/components/CreateRelatedRecordAction.tsx create mode 100644 packages/twenty-front/src/modules/action-menu/actions/record-agnostic-actions/hooks/__tests__/useRelatedRecordActions.test.tsx create mode 100644 packages/twenty-front/src/modules/action-menu/actions/record-agnostic-actions/hooks/useRelatedRecordActions.ts delete mode 100644 packages/twenty-front/src/modules/object-metadata/utils/__tests__/getObjectMetadataItemBySingularName.test.ts delete mode 100644 packages/twenty-front/src/modules/object-metadata/utils/getObjectMetadataItemBySingularName.ts diff --git a/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/components/CreateRelatedRecordAction.tsx b/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/components/CreateRelatedRecordAction.tsx new file mode 100644 index 000000000..6c2585299 --- /dev/null +++ b/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/components/CreateRelatedRecordAction.tsx @@ -0,0 +1,120 @@ +import { Action } from '@/action-menu/actions/components/Action'; +import { useSelectedRecordIdOrThrow } from '@/action-menu/actions/record-actions/single-record/hooks/useSelectedRecordIdOrThrow'; +import { useOpenRecordInCommandMenu } from '@/command-menu/hooks/useOpenRecordInCommandMenu'; +import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; +import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; +import { FieldMetadataItemRelation } from '@/object-metadata/types/FieldMetadataItemRelation'; +import { getLabelIdentifierFieldMetadataItem } from '@/object-metadata/utils/getLabelIdentifierFieldMetadataItem'; +import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord'; +import { useRecordTitleCell } from '@/object-record/record-title-cell/hooks/useRecordTitleCell'; +import { RecordTitleCellContainerType } from '@/object-record/record-title-cell/types/RecordTitleCellContainerType'; +import { ObjectRecord } from '@/object-record/types/ObjectRecord'; +import { getForeignKeyNameFromRelationFieldName } from '@/object-record/utils/getForeignKeyNameFromRelationFieldName'; +import { isDefined } from 'twenty-shared/utils'; + +interface CreateRelatedRecordActionProps { + targetFieldMetadataItemRelation: FieldMetadataItemRelation; +} + +export const CreateRelatedRecordAction = ({ + targetFieldMetadataItemRelation, +}: CreateRelatedRecordActionProps) => { + const sourceRecordId = useSelectedRecordIdOrThrow(); + + const { objectMetadataItem: targetObjectMetadataItem } = + useObjectMetadataItem({ + objectNameSingular: + targetFieldMetadataItemRelation.targetObjectMetadata.nameSingular, + }); + + const { objectMetadataItem: taskObjectMetadataItem } = useObjectMetadataItem({ + objectNameSingular: CoreObjectNameSingular.Task, + }); + + const { objectMetadataItem: noteObjectMetadataItem } = useObjectMetadataItem({ + objectNameSingular: CoreObjectNameSingular.Note, + }); + + const { openRecordInCommandMenu } = useOpenRecordInCommandMenu(); + + const { createOneRecord: createOneTaskTarget } = useCreateOneRecord({ + objectNameSingular: CoreObjectNameSingular.TaskTarget, + }); + + const { createOneRecord: createOneNoteTarget } = useCreateOneRecord({ + objectNameSingular: CoreObjectNameSingular.NoteTarget, + }); + + const { openRecordTitleCell } = useRecordTitleCell(); + + const targetObject = + targetObjectMetadataItem.nameSingular === CoreObjectNameSingular.TaskTarget + ? taskObjectMetadataItem + : targetObjectMetadataItem.nameSingular === + CoreObjectNameSingular.NoteTarget + ? noteObjectMetadataItem + : targetObjectMetadataItem; + + const { createOneRecord } = useCreateOneRecord({ + objectNameSingular: targetObject.nameSingular, + }); + + const handleCreateRelatedRecord = async () => { + const foreignKeyFieldName = + targetFieldMetadataItemRelation.targetFieldMetadata.name; + const foreignKeyIdFieldName = + getForeignKeyNameFromRelationFieldName(foreignKeyFieldName); + + let createdRecord: ObjectRecord; + + switch (targetObjectMetadataItem.nameSingular) { + case CoreObjectNameSingular.TaskTarget: { + createdRecord = await createOneRecord({}); + + await createOneTaskTarget({ + taskId: createdRecord.id, + [foreignKeyIdFieldName]: sourceRecordId, + }); + break; + } + case CoreObjectNameSingular.NoteTarget: { + createdRecord = await createOneRecord({}); + + await createOneNoteTarget({ + noteId: createdRecord.id, + [foreignKeyIdFieldName]: sourceRecordId, + }); + break; + } + default: + createdRecord = await createOneRecord({ + [foreignKeyIdFieldName]: sourceRecordId, + }); + break; + } + + openRecordInCommandMenu({ + recordId: createdRecord.id, + objectNameSingular: targetObject.nameSingular, + isNewRecord: true, + }); + + const labelIdentifierFieldMetadataItem = + getLabelIdentifierFieldMetadataItem(targetObject); + + if (isDefined(labelIdentifierFieldMetadataItem)) { + openRecordTitleCell({ + recordId: createdRecord.id, + fieldName: labelIdentifierFieldMetadataItem.name, + containerType: RecordTitleCellContainerType.ShowPage, + }); + } + }; + + return ( + + ); +}; diff --git a/packages/twenty-front/src/modules/action-menu/actions/record-agnostic-actions/hooks/__tests__/useRelatedRecordActions.test.tsx b/packages/twenty-front/src/modules/action-menu/actions/record-agnostic-actions/hooks/__tests__/useRelatedRecordActions.test.tsx new file mode 100644 index 000000000..6355824c7 --- /dev/null +++ b/packages/twenty-front/src/modules/action-menu/actions/record-agnostic-actions/hooks/__tests__/useRelatedRecordActions.test.tsx @@ -0,0 +1,253 @@ +import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; +import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { renderHook } from '@testing-library/react'; +import { ReactNode } from 'react'; +import { RecoilRoot } from 'recoil'; +import { useRelatedRecordActions } from '../useRelatedRecordActions'; + +jest.mock('@/object-metadata/hooks/useObjectMetadataItems', () => ({ + useObjectMetadataItems: () => ({ + objectMetadataItems: [ + { + id: 'person-id', + nameSingular: CoreObjectNameSingular.Person, + namePlural: 'People', + labelSingular: 'Person', + }, + { + id: 'company-id', + nameSingular: CoreObjectNameSingular.Company, + namePlural: 'Companies', + labelSingular: 'Company', + }, + ], + }), +})); + +const Wrapper = ({ children }: { children: ReactNode }) => ( + {children} +); + +describe('useRelatedRecordActions', () => { + const mockGetIcon = jest.fn(); + + beforeEach(() => { + mockGetIcon.mockClear(); + }); + + it('should return empty object when objectMetadataItem has no fields', () => { + const objectMetadataItem = { + fields: [], + } as unknown as ObjectMetadataItem; + + const { result } = renderHook( + () => + useRelatedRecordActions({ + sourceObjectMetadataItem: objectMetadataItem, + getIcon: mockGetIcon, + startPosition: 18, + }), + { wrapper: Wrapper }, + ); + + expect(result.current).toEqual({}); + }); + + it('should return empty object when objectMetadataItem is undefined', () => { + const objectMetadataItem = undefined as unknown as ObjectMetadataItem; + + const { result } = renderHook( + () => + useRelatedRecordActions({ + sourceObjectMetadataItem: objectMetadataItem, + getIcon: mockGetIcon, + startPosition: 18, + }), + { wrapper: Wrapper }, + ); + + expect(result.current).toEqual({}); + }); + + it('should generate actions for one-to-many relations', () => { + const objectMetadataItem = { + fields: [ + { + type: 'RELATION', + relation: { + type: 'ONE_TO_MANY', + targetObjectMetadata: { + nameSingular: CoreObjectNameSingular.Person, + namePlural: 'People', + }, + }, + label: 'person', + isSystem: false, + }, + { + type: 'RELATION', + relation: { + type: 'ONE_TO_MANY', + targetObjectMetadata: { + nameSingular: CoreObjectNameSingular.Company, + namePlural: 'Companies', + }, + }, + label: 'company', + isSystem: false, + }, + ], + } as unknown as ObjectMetadataItem; + + const { result } = renderHook( + () => + useRelatedRecordActions({ + sourceObjectMetadataItem: objectMetadataItem, + getIcon: mockGetIcon, + startPosition: 18, + }), + { wrapper: Wrapper }, + ); + + expect(Object.keys(result.current)).toHaveLength(2); + expect(result.current['create-related-person']).toBeDefined(); + expect(result.current['create-related-company']).toBeDefined(); + }); + + it('should filter out non-one-to-many relations', () => { + const objectMetadataItem = { + fields: [ + { + type: 'RELATION', + relation: { + type: 'MANY_TO_ONE', + targetObjectMetadata: { + nameSingular: CoreObjectNameSingular.Person, + namePlural: 'People', + }, + }, + label: 'person', + isSystem: false, + }, + { + type: 'TEXT', + }, + { + type: 'RELATION', + relation: { + type: 'ONE_TO_MANY', + targetObjectMetadata: { + nameSingular: CoreObjectNameSingular.Company, + namePlural: 'Companies', + }, + }, + label: 'company', + isSystem: false, + }, + ], + } as unknown as ObjectMetadataItem; + + const { result } = renderHook( + () => + useRelatedRecordActions({ + sourceObjectMetadataItem: objectMetadataItem, + getIcon: mockGetIcon, + startPosition: 18, + }), + { wrapper: Wrapper }, + ); + + expect(Object.keys(result.current)).toHaveLength(1); + expect(result.current['create-related-company']).toBeDefined(); + expect(result.current['create-related-person']).toBeUndefined(); + }); + + it('should assign correct positions to each action', () => { + const objectMetadataItem = { + fields: [ + { + type: 'RELATION', + relation: { + type: 'ONE_TO_MANY', + targetObjectMetadata: { + nameSingular: CoreObjectNameSingular.Person, + namePlural: 'People', + }, + }, + label: 'person', + isSystem: false, + }, + { + type: 'RELATION', + relation: { + type: 'ONE_TO_MANY', + targetObjectMetadata: { + nameSingular: CoreObjectNameSingular.Company, + namePlural: 'Companies', + }, + }, + label: 'company', + isSystem: false, + }, + ], + } as unknown as ObjectMetadataItem; + + const { result } = renderHook( + () => + useRelatedRecordActions({ + sourceObjectMetadataItem: objectMetadataItem, + getIcon: mockGetIcon, + startPosition: 18, + }), + { wrapper: Wrapper }, + ); + + expect(result.current['create-related-person'].position).toBe(18); + expect(result.current['create-related-company'].position).toBe(19); + }); + + it('should filter out system fields', () => { + const objectMetadataItem = { + fields: [ + { + type: 'RELATION', + relation: { + type: 'ONE_TO_MANY', + targetObjectMetadata: { + nameSingular: CoreObjectNameSingular.Person, + namePlural: 'People', + }, + }, + label: 'person', + isSystem: true, + }, + { + type: 'RELATION', + relation: { + type: 'ONE_TO_MANY', + targetObjectMetadata: { + nameSingular: CoreObjectNameSingular.Company, + namePlural: 'Companies', + }, + }, + label: 'company', + isSystem: false, + }, + ], + } as unknown as ObjectMetadataItem; + + const { result } = renderHook( + () => + useRelatedRecordActions({ + sourceObjectMetadataItem: objectMetadataItem, + getIcon: mockGetIcon, + startPosition: 18, + }), + { wrapper: Wrapper }, + ); + + expect(Object.keys(result.current)).toHaveLength(1); + expect(result.current['create-related-company']).toBeDefined(); + expect(result.current['create-related-person']).toBeUndefined(); + }); +}); diff --git a/packages/twenty-front/src/modules/action-menu/actions/record-agnostic-actions/hooks/useRelatedRecordActions.ts b/packages/twenty-front/src/modules/action-menu/actions/record-agnostic-actions/hooks/useRelatedRecordActions.ts new file mode 100644 index 000000000..ea0d229ee --- /dev/null +++ b/packages/twenty-front/src/modules/action-menu/actions/record-agnostic-actions/hooks/useRelatedRecordActions.ts @@ -0,0 +1,107 @@ +import { CreateRelatedRecordAction } from '@/action-menu/actions/record-actions/single-record/components/CreateRelatedRecordAction'; +import { ActionConfig } from '@/action-menu/actions/types/ActionConfig'; +import { ActionScope } from '@/action-menu/actions/types/ActionScope'; +import { ActionType } from '@/action-menu/actions/types/ActionType'; +import { ActionViewType } from '@/action-menu/actions/types/ActionViewType'; +import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems'; +import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; +import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { msg } from '@lingui/core/macro'; +import React from 'react'; +import { isDefined } from 'twenty-shared/utils'; +import { IconComponent, IconPlus } from 'twenty-ui/display'; + +interface GenerateRelatedRecordActionsParams { + sourceObjectMetadataItem?: ObjectMetadataItem; + getIcon: (iconKey: string) => IconComponent; + startPosition: number; +} + +export const useRelatedRecordActions = ({ + sourceObjectMetadataItem, + getIcon, + startPosition, +}: GenerateRelatedRecordActionsParams): Record => { + const relatedActions: Record = {}; + + const { objectMetadataItems } = useObjectMetadataItems(); + + if (!sourceObjectMetadataItem?.fields) { + return relatedActions; + } + + const oneToManyFields = sourceObjectMetadataItem.fields.filter( + (field) => + field.type === 'RELATION' && + field.relation?.type === 'ONE_TO_MANY' && + !field.isSystem, + ); + + let currentPosition = startPosition; + + for (const field of oneToManyFields) { + if (!isDefined(field.relation)) { + throw new Error(`Field relation is undefined for field: ${field.id}`); + } + + const targetObjectName = field.relation.targetObjectMetadata.nameSingular; + + const targetObjectMetadataItem = objectMetadataItems.find( + (item) => item.nameSingular === targetObjectName, + ); + + if (!isDefined(targetObjectMetadataItem)) { + throw new Error( + `Target object metadata item is undefined for field: ${field.id}`, + ); + } + + const targetObjectNameSingular = targetObjectMetadataItem.nameSingular; + const targetObjectLabelSingular = + targetObjectNameSingular === CoreObjectNameSingular.TaskTarget + ? 'task' + : targetObjectNameSingular === CoreObjectNameSingular.NoteTarget + ? 'note' + : targetObjectMetadataItem.labelSingular.toLowerCase(); + + const actionKey = `create-related-${targetObjectLabelSingular}`; + + relatedActions[actionKey] = { + type: ActionType.Standard, + scope: ActionScope.CreateRelatedRecord, + key: actionKey, + label: msg`Create ${targetObjectLabelSingular}`, + shortLabel: msg`Create ${targetObjectLabelSingular}`, + position: currentPosition, + Icon: field.icon ? getIcon(field.icon) : IconPlus, + accent: 'default', + isPinned: false, + shouldBeRegistered: ({ + selectedRecord, + objectPermissions, + getTargetObjectWritePermission, + }) => + isDefined(selectedRecord) && + !selectedRecord.isRemote && + objectPermissions.canUpdateObjectRecords && + getTargetObjectWritePermission( + targetObjectNameSingular === CoreObjectNameSingular.TaskTarget + ? CoreObjectNameSingular.Task + : targetObjectNameSingular === CoreObjectNameSingular.NoteTarget + ? CoreObjectNameSingular.Note + : targetObjectNameSingular, + ), + availableOn: [ + ActionViewType.SHOW_PAGE, + ActionViewType.INDEX_PAGE_SINGLE_RECORD_SELECTION, + ], + component: React.createElement(CreateRelatedRecordAction, { + targetFieldMetadataItemRelation: field.relation, + }), + }; + + currentPosition++; + } + + return relatedActions; +}; diff --git a/packages/twenty-front/src/modules/action-menu/actions/types/ActionScope.ts b/packages/twenty-front/src/modules/action-menu/actions/types/ActionScope.ts index c179087e8..259a35e26 100644 --- a/packages/twenty-front/src/modules/action-menu/actions/types/ActionScope.ts +++ b/packages/twenty-front/src/modules/action-menu/actions/types/ActionScope.ts @@ -1,5 +1,6 @@ export enum ActionScope { Global = 'Global', RecordSelection = 'RecordSelection', + CreateRelatedRecord = 'CreateRelatedRecord', Object = 'Object', } diff --git a/packages/twenty-front/src/modules/action-menu/actions/types/ShouldBeRegisteredFunctionParams.ts b/packages/twenty-front/src/modules/action-menu/actions/types/ShouldBeRegisteredFunctionParams.ts index 7ed44f7e7..147bfa5ac 100644 --- a/packages/twenty-front/src/modules/action-menu/actions/types/ShouldBeRegisteredFunctionParams.ts +++ b/packages/twenty-front/src/modules/action-menu/actions/types/ShouldBeRegisteredFunctionParams.ts @@ -22,4 +22,7 @@ export type ShouldBeRegisteredFunctionParams = { getTargetObjectReadPermission: ( objectMetadataItemNameSingular: string, ) => boolean; + getTargetObjectWritePermission: ( + objectMetadataItemNameSingular: string, + ) => boolean; }; diff --git a/packages/twenty-front/src/modules/action-menu/actions/utils/getActionConfig.ts b/packages/twenty-front/src/modules/action-menu/actions/utils/getActionConfig.ts index 30dc8644c..4eb6728fb 100644 --- a/packages/twenty-front/src/modules/action-menu/actions/utils/getActionConfig.ts +++ b/packages/twenty-front/src/modules/action-menu/actions/utils/getActionConfig.ts @@ -26,7 +26,8 @@ export const getActionConfig = ({ case CoreObjectNameSingular.WorkflowRun: { return WORKFLOW_RUNS_ACTIONS_CONFIG; } - default: + default: { return DEFAULT_RECORD_ACTIONS_CONFIG; + } } }; diff --git a/packages/twenty-front/src/modules/action-menu/hooks/useRegisteredActions.ts b/packages/twenty-front/src/modules/action-menu/hooks/useRegisteredActions.ts index 53d46a459..73cad9319 100644 --- a/packages/twenty-front/src/modules/action-menu/hooks/useRegisteredActions.ts +++ b/packages/twenty-front/src/modules/action-menu/hooks/useRegisteredActions.ts @@ -1,4 +1,5 @@ import { useRecordAgnosticActions } from '@/action-menu/actions/record-agnostic-actions/hooks/useRecordAgnosticActions'; +import { useRelatedRecordActions } from '@/action-menu/actions/record-agnostic-actions/hooks/useRelatedRecordActions'; import { ActionViewType } from '@/action-menu/actions/types/ActionViewType'; import { ShouldBeRegisteredFunctionParams } from '@/action-menu/actions/types/ShouldBeRegisteredFunctionParams'; import { getActionConfig } from '@/action-menu/actions/utils/getActionConfig'; @@ -7,12 +8,15 @@ import { contextStoreCurrentViewTypeComponentState } from '@/context-store/state import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState'; import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; import { isDefined } from 'twenty-shared/utils'; +import { useIcons } from 'twenty-ui/display'; export const useRegisteredActions = ( shouldBeRegisteredParams: ShouldBeRegisteredFunctionParams, ) => { const { objectMetadataItem } = shouldBeRegisteredParams; + const { getIcon } = useIcons(); + const contextStoreTargetedRecordsRule = useRecoilComponentValueV2( contextStoreTargetedRecordsRuleComponentState, ); @@ -30,10 +34,17 @@ export const useRegisteredActions = ( objectMetadataItem, }); + const relatedRecordActionConfig = useRelatedRecordActions({ + sourceObjectMetadataItem: objectMetadataItem, + getIcon, + startPosition: Object.keys(recordActionConfig).length + 1, + }); + const recordAgnosticActionConfig = useRecordAgnosticActions(); const actionsConfig = { ...recordActionConfig, + ...relatedRecordActionConfig, ...recordAgnosticActionConfig, }; diff --git a/packages/twenty-front/src/modules/action-menu/hooks/useShouldActionBeRegisteredParams.ts b/packages/twenty-front/src/modules/action-menu/hooks/useShouldActionBeRegisteredParams.ts index 4766c8e96..b2ea38ac3 100644 --- a/packages/twenty-front/src/modules/action-menu/hooks/useShouldActionBeRegisteredParams.ts +++ b/packages/twenty-front/src/modules/action-menu/hooks/useShouldActionBeRegisteredParams.ts @@ -86,6 +86,20 @@ export const useShouldActionBeRegisteredParams = ({ [], ); + const getObjectWritePermission = useRecoilCallback( + ({ snapshot }) => + (objectMetadataNameSingular: string) => { + return snapshot + .getLoadable( + objectPermissionsFamilySelector({ + objectNameSingular: objectMetadataNameSingular, + }), + ) + .getValue().canUpdate; + }, + [], + ); + return { objectMetadataItem, isFavorite, @@ -98,5 +112,6 @@ export const useShouldActionBeRegisteredParams = ({ numberOfSelectedRecords, viewType: viewType ?? undefined, getTargetObjectReadPermission: getObjectReadPermission, + getTargetObjectWritePermission: getObjectWritePermission, }; }; diff --git a/packages/twenty-front/src/modules/auth/states/objectPermissionsFamilySelector.ts b/packages/twenty-front/src/modules/auth/states/objectPermissionsFamilySelector.ts index 8a292a7d8..a87a9aa24 100644 --- a/packages/twenty-front/src/modules/auth/states/objectPermissionsFamilySelector.ts +++ b/packages/twenty-front/src/modules/auth/states/objectPermissionsFamilySelector.ts @@ -5,6 +5,7 @@ import { selectorFamily } from 'recoil'; export const objectPermissionsFamilySelector = selectorFamily< { canRead: boolean; + canUpdate: boolean; }, { objectNameSingular: string } >({ @@ -32,6 +33,7 @@ export const objectPermissionsFamilySelector = selectorFamily< return { canRead: objectPermissions?.canReadObjectRecords ?? false, + canUpdate: objectPermissions?.canUpdateObjectRecords ?? false, }; }, }); diff --git a/packages/twenty-front/src/modules/command-menu/components/CommandMenu.tsx b/packages/twenty-front/src/modules/command-menu/components/CommandMenu.tsx index 350bc4565..ab4b86117 100644 --- a/packages/twenty-front/src/modules/command-menu/components/CommandMenu.tsx +++ b/packages/twenty-front/src/modules/command-menu/components/CommandMenu.tsx @@ -32,6 +32,7 @@ export const CommandMenu = () => { matchingWorkflowRunGlobalActions, matchingNavigateActions, fallbackActions, + matchingCreateRelatedRecordActions, } = useMatchingCommandMenuActions({ commandMenuSearch, }); @@ -56,6 +57,10 @@ export const CommandMenu = () => { matchingWorkflowRunRecordSelectionActions, ), }, + { + heading: t`Create Related Record`, + items: matchingCreateRelatedRecordActions, + }, { heading: currentObjectMetadataItem?.labelPlural ?? t`Object`, items: matchingStandardActionObjectActions, diff --git a/packages/twenty-front/src/modules/command-menu/hooks/useCommandMenuActions.tsx b/packages/twenty-front/src/modules/command-menu/hooks/useCommandMenuActions.tsx index ddbc155f0..d76d2faa0 100644 --- a/packages/twenty-front/src/modules/command-menu/hooks/useCommandMenuActions.tsx +++ b/packages/twenty-front/src/modules/command-menu/hooks/useCommandMenuActions.tsx @@ -45,6 +45,12 @@ export const useCommandMenuActions = () => { (action) => action.type === ActionType.Fallback, ); + const createRelatedRecordActions: ActionConfig[] = actions?.filter( + (action) => + action.type === ActionType.Standard && + action.scope === ActionScope.CreateRelatedRecord, + ); + return { navigateActions, actionRecordSelectionActions, @@ -53,5 +59,6 @@ export const useCommandMenuActions = () => { workflowRunRecordSelectionActions, workflowRunGlobalActions, fallbackActions, + createRelatedRecordActions, }; }; diff --git a/packages/twenty-front/src/modules/command-menu/hooks/useMatchingCommandMenuActions.ts b/packages/twenty-front/src/modules/command-menu/hooks/useMatchingCommandMenuActions.ts index 8e9d35b4c..1bb6b9f29 100644 --- a/packages/twenty-front/src/modules/command-menu/hooks/useMatchingCommandMenuActions.ts +++ b/packages/twenty-front/src/modules/command-menu/hooks/useMatchingCommandMenuActions.ts @@ -19,6 +19,7 @@ export const useMatchingCommandMenuActions = ({ workflowRunRecordSelectionActions, workflowRunGlobalActions, fallbackActions, + createRelatedRecordActions, } = useCommandMenuActions(); const matchingNavigateActions = @@ -40,13 +41,18 @@ export const useMatchingCommandMenuActions = ({ workflowRunGlobalActions, ); + const matchingCreateRelatedRecordActions = filterActionsWithCommandMenuSearch( + createRelatedRecordActions, + ); + const noResults = !matchingStandardActionRecordSelectionActions.length && !matchingWorkflowRunRecordSelectionActions.length && !matchingStandardActionGlobalActions.length && !matchingWorkflowRunGlobalActions.length && !matchingStandardActionObjectActions.length && - !matchingNavigateActions.length; + !matchingNavigateActions.length && + !matchingCreateRelatedRecordActions.length; return { noResults, @@ -56,6 +62,7 @@ export const useMatchingCommandMenuActions = ({ matchingStandardActionGlobalActions, matchingWorkflowRunGlobalActions, matchingNavigateActions, + matchingCreateRelatedRecordActions, fallbackActions: noResults ? fallbackActions : [], }; }; diff --git a/packages/twenty-front/src/modules/object-metadata/utils/__tests__/getObjectMetadataItemBySingularName.test.ts b/packages/twenty-front/src/modules/object-metadata/utils/__tests__/getObjectMetadataItemBySingularName.test.ts deleted file mode 100644 index 92a7e414b..000000000 --- a/packages/twenty-front/src/modules/object-metadata/utils/__tests__/getObjectMetadataItemBySingularName.test.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { getObjectMetadataItemByNameSingular } from '@/object-metadata/utils/getObjectMetadataItemBySingularName'; -import { generatedMockObjectMetadataItems } from '~/testing/mock-data/generatedMockObjectMetadataItems'; - -describe('getObjectMetadataItemBySingularName', () => { - it('should work as expected', () => { - const firstObjectMetadataItem = generatedMockObjectMetadataItems[0]; - - const foundObjectMetadataItem = getObjectMetadataItemByNameSingular({ - objectMetadataItems: generatedMockObjectMetadataItems, - objectNameSingular: firstObjectMetadataItem.nameSingular, - }); - - expect(foundObjectMetadataItem.id).toEqual(firstObjectMetadataItem.id); - }); -}); diff --git a/packages/twenty-front/src/modules/object-metadata/utils/getObjectMetadataItemBySingularName.ts b/packages/twenty-front/src/modules/object-metadata/utils/getObjectMetadataItemBySingularName.ts deleted file mode 100644 index fcc77b9e0..000000000 --- a/packages/twenty-front/src/modules/object-metadata/utils/getObjectMetadataItemBySingularName.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; - -export const getObjectMetadataItemByNameSingular = ({ - objectMetadataItems, - objectNameSingular, -}: { - objectMetadataItems: ObjectMetadataItem[]; - objectNameSingular: string; -}) => { - const foundObjectMetadataItem = objectMetadataItems.find( - (objectMetadataItem) => - objectMetadataItem.nameSingular === objectNameSingular, - ); - - if (!foundObjectMetadataItem) { - throw new Error( - `Could not find object metadata item with singular name ${objectNameSingular}`, - ); - } - - return foundObjectMetadataItem; -}; diff --git a/packages/twenty-server/src/engine/twenty-orm/custom.workspace-entity.ts b/packages/twenty-server/src/engine/twenty-orm/custom.workspace-entity.ts index 4adb8cf6f..131604f7a 100644 --- a/packages/twenty-server/src/engine/twenty-orm/custom.workspace-entity.ts +++ b/packages/twenty-server/src/engine/twenty-orm/custom.workspace-entity.ts @@ -126,6 +126,7 @@ export class CustomWorkspaceEntity extends BaseWorkspaceEntity { onDelete: RelationOnDeleteAction.CASCADE, }) @WorkspaceIsNullable() + @WorkspaceIsSystem() attachments: AttachmentWorkspaceEntity[]; @WorkspaceRelation({ diff --git a/packages/twenty-server/src/modules/company/standard-objects/company.workspace-entity.ts b/packages/twenty-server/src/modules/company/standard-objects/company.workspace-entity.ts index cf475e679..c8f029573 100644 --- a/packages/twenty-server/src/modules/company/standard-objects/company.workspace-entity.ts +++ b/packages/twenty-server/src/modules/company/standard-objects/company.workspace-entity.ts @@ -245,6 +245,7 @@ export class CompanyWorkspaceEntity extends BaseWorkspaceEntity { onDelete: RelationOnDeleteAction.CASCADE, }) @WorkspaceIsNullable() + @WorkspaceIsSystem() attachments: Relation; @WorkspaceRelation({ diff --git a/packages/twenty-server/src/modules/note/standard-objects/note.workspace-entity.ts b/packages/twenty-server/src/modules/note/standard-objects/note.workspace-entity.ts index 249e88d26..9e934562c 100644 --- a/packages/twenty-server/src/modules/note/standard-objects/note.workspace-entity.ts +++ b/packages/twenty-server/src/modules/note/standard-objects/note.workspace-entity.ts @@ -108,6 +108,7 @@ export class NoteWorkspaceEntity extends BaseWorkspaceEntity { onDelete: RelationOnDeleteAction.SET_NULL, }) @WorkspaceIsNullable() + @WorkspaceIsSystem() noteTargets: Relation; @WorkspaceRelation({ @@ -120,6 +121,7 @@ export class NoteWorkspaceEntity extends BaseWorkspaceEntity { onDelete: RelationOnDeleteAction.SET_NULL, }) @WorkspaceIsNullable() + @WorkspaceIsSystem() attachments: Relation; @WorkspaceRelation({ @@ -132,6 +134,7 @@ export class NoteWorkspaceEntity extends BaseWorkspaceEntity { onDelete: RelationOnDeleteAction.SET_NULL, }) @WorkspaceIsNullable() + @WorkspaceIsSystem() timelineActivities: Relation; @WorkspaceRelation({ diff --git a/packages/twenty-server/src/modules/opportunity/standard-objects/opportunity.workspace-entity.ts b/packages/twenty-server/src/modules/opportunity/standard-objects/opportunity.workspace-entity.ts index 889457c77..1bb9ef2a8 100644 --- a/packages/twenty-server/src/modules/opportunity/standard-objects/opportunity.workspace-entity.ts +++ b/packages/twenty-server/src/modules/opportunity/standard-objects/opportunity.workspace-entity.ts @@ -201,6 +201,7 @@ export class OpportunityWorkspaceEntity extends BaseWorkspaceEntity { onDelete: RelationOnDeleteAction.CASCADE, }) @WorkspaceIsNullable() + @WorkspaceIsSystem() attachments: Relation; @WorkspaceRelation({ @@ -213,6 +214,7 @@ export class OpportunityWorkspaceEntity extends BaseWorkspaceEntity { onDelete: RelationOnDeleteAction.SET_NULL, }) @WorkspaceIsNullable() + @WorkspaceIsSystem() timelineActivities: Relation; @WorkspaceField({ diff --git a/packages/twenty-server/src/modules/person/standard-objects/person.workspace-entity.ts b/packages/twenty-server/src/modules/person/standard-objects/person.workspace-entity.ts index 019a11219..3db0f4cf0 100644 --- a/packages/twenty-server/src/modules/person/standard-objects/person.workspace-entity.ts +++ b/packages/twenty-server/src/modules/person/standard-objects/person.workspace-entity.ts @@ -196,7 +196,7 @@ export class PersonWorkspaceEntity extends BaseWorkspaceEntity { @WorkspaceRelation({ standardId: PERSON_STANDARD_FIELD_IDS.pointOfContactForOpportunities, type: RelationType.ONE_TO_MANY, - label: msg`Linked Opportunities`, + label: msg`Opportunities`, description: msg`List of opportunities for which that person is the point of contact`, icon: 'IconTargetArrow', inverseSideTarget: () => OpportunityWorkspaceEntity, @@ -248,6 +248,7 @@ export class PersonWorkspaceEntity extends BaseWorkspaceEntity { inverseSideTarget: () => AttachmentWorkspaceEntity, onDelete: RelationOnDeleteAction.CASCADE, }) + @WorkspaceIsSystem() attachments: Relation; @WorkspaceRelation({ diff --git a/packages/twenty-server/src/modules/task/standard-objects/task.workspace-entity.ts b/packages/twenty-server/src/modules/task/standard-objects/task.workspace-entity.ts index e4c5192d9..adc63cd7d 100644 --- a/packages/twenty-server/src/modules/task/standard-objects/task.workspace-entity.ts +++ b/packages/twenty-server/src/modules/task/standard-objects/task.workspace-entity.ts @@ -147,6 +147,7 @@ export class TaskWorkspaceEntity extends BaseWorkspaceEntity { onDelete: RelationOnDeleteAction.SET_NULL, }) @WorkspaceIsNullable() + @WorkspaceIsSystem() taskTargets: Relation; @WorkspaceRelation({ @@ -159,6 +160,7 @@ export class TaskWorkspaceEntity extends BaseWorkspaceEntity { onDelete: RelationOnDeleteAction.SET_NULL, }) @WorkspaceIsNullable() + @WorkspaceIsSystem() attachments: Relation; @WorkspaceRelation({ @@ -187,6 +189,7 @@ export class TaskWorkspaceEntity extends BaseWorkspaceEntity { onDelete: RelationOnDeleteAction.SET_NULL, }) @WorkspaceIsNullable() + @WorkspaceIsSystem() timelineActivities: Relation; @WorkspaceRelation({ diff --git a/packages/twenty-server/src/modules/workspace-member/standard-objects/workspace-member.workspace-entity.ts b/packages/twenty-server/src/modules/workspace-member/standard-objects/workspace-member.workspace-entity.ts index 678d55a21..821caf972 100644 --- a/packages/twenty-server/src/modules/workspace-member/standard-objects/workspace-member.workspace-entity.ts +++ b/packages/twenty-server/src/modules/workspace-member/standard-objects/workspace-member.workspace-entity.ts @@ -279,6 +279,7 @@ export class WorkspaceMemberWorkspaceEntity extends BaseWorkspaceEntity { inverseSideFieldKey: 'author', onDelete: RelationOnDeleteAction.SET_NULL, }) + @WorkspaceIsSystem() authoredAttachments: Relation; @WorkspaceRelation({