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({