Add Create related records to Record standard actions (#13095)
#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. <img width="518" alt="Screenshot" src="https://github.com/user-attachments/assets/0388aaf9-b974-4ae1-85bf-2966d89cbbec" /> --------- Co-authored-by: Raphaël Bosi <71827178+bosiraphael@users.noreply.github.com> Co-authored-by: bosiraphael <raphael.bosi@gmail.com>
This commit is contained in:
@ -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 (
|
||||||
|
<Action
|
||||||
|
onClick={handleCreateRelatedRecord}
|
||||||
|
closeSidePanelOnCommandMenuListActionExecution={false}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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 }) => (
|
||||||
|
<RecoilRoot>{children}</RecoilRoot>
|
||||||
|
);
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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<string, ActionConfig> => {
|
||||||
|
const relatedActions: Record<string, ActionConfig> = {};
|
||||||
|
|
||||||
|
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;
|
||||||
|
};
|
||||||
@ -1,5 +1,6 @@
|
|||||||
export enum ActionScope {
|
export enum ActionScope {
|
||||||
Global = 'Global',
|
Global = 'Global',
|
||||||
RecordSelection = 'RecordSelection',
|
RecordSelection = 'RecordSelection',
|
||||||
|
CreateRelatedRecord = 'CreateRelatedRecord',
|
||||||
Object = 'Object',
|
Object = 'Object',
|
||||||
}
|
}
|
||||||
|
|||||||
@ -22,4 +22,7 @@ export type ShouldBeRegisteredFunctionParams = {
|
|||||||
getTargetObjectReadPermission: (
|
getTargetObjectReadPermission: (
|
||||||
objectMetadataItemNameSingular: string,
|
objectMetadataItemNameSingular: string,
|
||||||
) => boolean;
|
) => boolean;
|
||||||
|
getTargetObjectWritePermission: (
|
||||||
|
objectMetadataItemNameSingular: string,
|
||||||
|
) => boolean;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -26,7 +26,8 @@ export const getActionConfig = ({
|
|||||||
case CoreObjectNameSingular.WorkflowRun: {
|
case CoreObjectNameSingular.WorkflowRun: {
|
||||||
return WORKFLOW_RUNS_ACTIONS_CONFIG;
|
return WORKFLOW_RUNS_ACTIONS_CONFIG;
|
||||||
}
|
}
|
||||||
default:
|
default: {
|
||||||
return DEFAULT_RECORD_ACTIONS_CONFIG;
|
return DEFAULT_RECORD_ACTIONS_CONFIG;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { useRecordAgnosticActions } from '@/action-menu/actions/record-agnostic-actions/hooks/useRecordAgnosticActions';
|
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 { ActionViewType } from '@/action-menu/actions/types/ActionViewType';
|
||||||
import { ShouldBeRegisteredFunctionParams } from '@/action-menu/actions/types/ShouldBeRegisteredFunctionParams';
|
import { ShouldBeRegisteredFunctionParams } from '@/action-menu/actions/types/ShouldBeRegisteredFunctionParams';
|
||||||
import { getActionConfig } from '@/action-menu/actions/utils/getActionConfig';
|
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 { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
|
||||||
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
|
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
|
||||||
import { isDefined } from 'twenty-shared/utils';
|
import { isDefined } from 'twenty-shared/utils';
|
||||||
|
import { useIcons } from 'twenty-ui/display';
|
||||||
|
|
||||||
export const useRegisteredActions = (
|
export const useRegisteredActions = (
|
||||||
shouldBeRegisteredParams: ShouldBeRegisteredFunctionParams,
|
shouldBeRegisteredParams: ShouldBeRegisteredFunctionParams,
|
||||||
) => {
|
) => {
|
||||||
const { objectMetadataItem } = shouldBeRegisteredParams;
|
const { objectMetadataItem } = shouldBeRegisteredParams;
|
||||||
|
|
||||||
|
const { getIcon } = useIcons();
|
||||||
|
|
||||||
const contextStoreTargetedRecordsRule = useRecoilComponentValueV2(
|
const contextStoreTargetedRecordsRule = useRecoilComponentValueV2(
|
||||||
contextStoreTargetedRecordsRuleComponentState,
|
contextStoreTargetedRecordsRuleComponentState,
|
||||||
);
|
);
|
||||||
@ -30,10 +34,17 @@ export const useRegisteredActions = (
|
|||||||
objectMetadataItem,
|
objectMetadataItem,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const relatedRecordActionConfig = useRelatedRecordActions({
|
||||||
|
sourceObjectMetadataItem: objectMetadataItem,
|
||||||
|
getIcon,
|
||||||
|
startPosition: Object.keys(recordActionConfig).length + 1,
|
||||||
|
});
|
||||||
|
|
||||||
const recordAgnosticActionConfig = useRecordAgnosticActions();
|
const recordAgnosticActionConfig = useRecordAgnosticActions();
|
||||||
|
|
||||||
const actionsConfig = {
|
const actionsConfig = {
|
||||||
...recordActionConfig,
|
...recordActionConfig,
|
||||||
|
...relatedRecordActionConfig,
|
||||||
...recordAgnosticActionConfig,
|
...recordAgnosticActionConfig,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -86,6 +86,20 @@ export const useShouldActionBeRegisteredParams = ({
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const getObjectWritePermission = useRecoilCallback(
|
||||||
|
({ snapshot }) =>
|
||||||
|
(objectMetadataNameSingular: string) => {
|
||||||
|
return snapshot
|
||||||
|
.getLoadable(
|
||||||
|
objectPermissionsFamilySelector({
|
||||||
|
objectNameSingular: objectMetadataNameSingular,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.getValue().canUpdate;
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
objectMetadataItem,
|
objectMetadataItem,
|
||||||
isFavorite,
|
isFavorite,
|
||||||
@ -98,5 +112,6 @@ export const useShouldActionBeRegisteredParams = ({
|
|||||||
numberOfSelectedRecords,
|
numberOfSelectedRecords,
|
||||||
viewType: viewType ?? undefined,
|
viewType: viewType ?? undefined,
|
||||||
getTargetObjectReadPermission: getObjectReadPermission,
|
getTargetObjectReadPermission: getObjectReadPermission,
|
||||||
|
getTargetObjectWritePermission: getObjectWritePermission,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { selectorFamily } from 'recoil';
|
|||||||
export const objectPermissionsFamilySelector = selectorFamily<
|
export const objectPermissionsFamilySelector = selectorFamily<
|
||||||
{
|
{
|
||||||
canRead: boolean;
|
canRead: boolean;
|
||||||
|
canUpdate: boolean;
|
||||||
},
|
},
|
||||||
{ objectNameSingular: string }
|
{ objectNameSingular: string }
|
||||||
>({
|
>({
|
||||||
@ -32,6 +33,7 @@ export const objectPermissionsFamilySelector = selectorFamily<
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
canRead: objectPermissions?.canReadObjectRecords ?? false,
|
canRead: objectPermissions?.canReadObjectRecords ?? false,
|
||||||
|
canUpdate: objectPermissions?.canUpdateObjectRecords ?? false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@ -32,6 +32,7 @@ export const CommandMenu = () => {
|
|||||||
matchingWorkflowRunGlobalActions,
|
matchingWorkflowRunGlobalActions,
|
||||||
matchingNavigateActions,
|
matchingNavigateActions,
|
||||||
fallbackActions,
|
fallbackActions,
|
||||||
|
matchingCreateRelatedRecordActions,
|
||||||
} = useMatchingCommandMenuActions({
|
} = useMatchingCommandMenuActions({
|
||||||
commandMenuSearch,
|
commandMenuSearch,
|
||||||
});
|
});
|
||||||
@ -56,6 +57,10 @@ export const CommandMenu = () => {
|
|||||||
matchingWorkflowRunRecordSelectionActions,
|
matchingWorkflowRunRecordSelectionActions,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
heading: t`Create Related Record`,
|
||||||
|
items: matchingCreateRelatedRecordActions,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
heading: currentObjectMetadataItem?.labelPlural ?? t`Object`,
|
heading: currentObjectMetadataItem?.labelPlural ?? t`Object`,
|
||||||
items: matchingStandardActionObjectActions,
|
items: matchingStandardActionObjectActions,
|
||||||
|
|||||||
@ -45,6 +45,12 @@ export const useCommandMenuActions = () => {
|
|||||||
(action) => action.type === ActionType.Fallback,
|
(action) => action.type === ActionType.Fallback,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const createRelatedRecordActions: ActionConfig[] = actions?.filter(
|
||||||
|
(action) =>
|
||||||
|
action.type === ActionType.Standard &&
|
||||||
|
action.scope === ActionScope.CreateRelatedRecord,
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
navigateActions,
|
navigateActions,
|
||||||
actionRecordSelectionActions,
|
actionRecordSelectionActions,
|
||||||
@ -53,5 +59,6 @@ export const useCommandMenuActions = () => {
|
|||||||
workflowRunRecordSelectionActions,
|
workflowRunRecordSelectionActions,
|
||||||
workflowRunGlobalActions,
|
workflowRunGlobalActions,
|
||||||
fallbackActions,
|
fallbackActions,
|
||||||
|
createRelatedRecordActions,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@ -19,6 +19,7 @@ export const useMatchingCommandMenuActions = ({
|
|||||||
workflowRunRecordSelectionActions,
|
workflowRunRecordSelectionActions,
|
||||||
workflowRunGlobalActions,
|
workflowRunGlobalActions,
|
||||||
fallbackActions,
|
fallbackActions,
|
||||||
|
createRelatedRecordActions,
|
||||||
} = useCommandMenuActions();
|
} = useCommandMenuActions();
|
||||||
|
|
||||||
const matchingNavigateActions =
|
const matchingNavigateActions =
|
||||||
@ -40,13 +41,18 @@ export const useMatchingCommandMenuActions = ({
|
|||||||
workflowRunGlobalActions,
|
workflowRunGlobalActions,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const matchingCreateRelatedRecordActions = filterActionsWithCommandMenuSearch(
|
||||||
|
createRelatedRecordActions,
|
||||||
|
);
|
||||||
|
|
||||||
const noResults =
|
const noResults =
|
||||||
!matchingStandardActionRecordSelectionActions.length &&
|
!matchingStandardActionRecordSelectionActions.length &&
|
||||||
!matchingWorkflowRunRecordSelectionActions.length &&
|
!matchingWorkflowRunRecordSelectionActions.length &&
|
||||||
!matchingStandardActionGlobalActions.length &&
|
!matchingStandardActionGlobalActions.length &&
|
||||||
!matchingWorkflowRunGlobalActions.length &&
|
!matchingWorkflowRunGlobalActions.length &&
|
||||||
!matchingStandardActionObjectActions.length &&
|
!matchingStandardActionObjectActions.length &&
|
||||||
!matchingNavigateActions.length;
|
!matchingNavigateActions.length &&
|
||||||
|
!matchingCreateRelatedRecordActions.length;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
noResults,
|
noResults,
|
||||||
@ -56,6 +62,7 @@ export const useMatchingCommandMenuActions = ({
|
|||||||
matchingStandardActionGlobalActions,
|
matchingStandardActionGlobalActions,
|
||||||
matchingWorkflowRunGlobalActions,
|
matchingWorkflowRunGlobalActions,
|
||||||
matchingNavigateActions,
|
matchingNavigateActions,
|
||||||
|
matchingCreateRelatedRecordActions,
|
||||||
fallbackActions: noResults ? fallbackActions : [],
|
fallbackActions: noResults ? fallbackActions : [],
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -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;
|
|
||||||
};
|
|
||||||
@ -126,6 +126,7 @@ export class CustomWorkspaceEntity extends BaseWorkspaceEntity {
|
|||||||
onDelete: RelationOnDeleteAction.CASCADE,
|
onDelete: RelationOnDeleteAction.CASCADE,
|
||||||
})
|
})
|
||||||
@WorkspaceIsNullable()
|
@WorkspaceIsNullable()
|
||||||
|
@WorkspaceIsSystem()
|
||||||
attachments: AttachmentWorkspaceEntity[];
|
attachments: AttachmentWorkspaceEntity[];
|
||||||
|
|
||||||
@WorkspaceRelation({
|
@WorkspaceRelation({
|
||||||
|
|||||||
@ -245,6 +245,7 @@ export class CompanyWorkspaceEntity extends BaseWorkspaceEntity {
|
|||||||
onDelete: RelationOnDeleteAction.CASCADE,
|
onDelete: RelationOnDeleteAction.CASCADE,
|
||||||
})
|
})
|
||||||
@WorkspaceIsNullable()
|
@WorkspaceIsNullable()
|
||||||
|
@WorkspaceIsSystem()
|
||||||
attachments: Relation<AttachmentWorkspaceEntity[]>;
|
attachments: Relation<AttachmentWorkspaceEntity[]>;
|
||||||
|
|
||||||
@WorkspaceRelation({
|
@WorkspaceRelation({
|
||||||
|
|||||||
@ -108,6 +108,7 @@ export class NoteWorkspaceEntity extends BaseWorkspaceEntity {
|
|||||||
onDelete: RelationOnDeleteAction.SET_NULL,
|
onDelete: RelationOnDeleteAction.SET_NULL,
|
||||||
})
|
})
|
||||||
@WorkspaceIsNullable()
|
@WorkspaceIsNullable()
|
||||||
|
@WorkspaceIsSystem()
|
||||||
noteTargets: Relation<NoteTargetWorkspaceEntity[]>;
|
noteTargets: Relation<NoteTargetWorkspaceEntity[]>;
|
||||||
|
|
||||||
@WorkspaceRelation({
|
@WorkspaceRelation({
|
||||||
@ -120,6 +121,7 @@ export class NoteWorkspaceEntity extends BaseWorkspaceEntity {
|
|||||||
onDelete: RelationOnDeleteAction.SET_NULL,
|
onDelete: RelationOnDeleteAction.SET_NULL,
|
||||||
})
|
})
|
||||||
@WorkspaceIsNullable()
|
@WorkspaceIsNullable()
|
||||||
|
@WorkspaceIsSystem()
|
||||||
attachments: Relation<AttachmentWorkspaceEntity[]>;
|
attachments: Relation<AttachmentWorkspaceEntity[]>;
|
||||||
|
|
||||||
@WorkspaceRelation({
|
@WorkspaceRelation({
|
||||||
@ -132,6 +134,7 @@ export class NoteWorkspaceEntity extends BaseWorkspaceEntity {
|
|||||||
onDelete: RelationOnDeleteAction.SET_NULL,
|
onDelete: RelationOnDeleteAction.SET_NULL,
|
||||||
})
|
})
|
||||||
@WorkspaceIsNullable()
|
@WorkspaceIsNullable()
|
||||||
|
@WorkspaceIsSystem()
|
||||||
timelineActivities: Relation<TimelineActivityWorkspaceEntity[]>;
|
timelineActivities: Relation<TimelineActivityWorkspaceEntity[]>;
|
||||||
|
|
||||||
@WorkspaceRelation({
|
@WorkspaceRelation({
|
||||||
|
|||||||
@ -201,6 +201,7 @@ export class OpportunityWorkspaceEntity extends BaseWorkspaceEntity {
|
|||||||
onDelete: RelationOnDeleteAction.CASCADE,
|
onDelete: RelationOnDeleteAction.CASCADE,
|
||||||
})
|
})
|
||||||
@WorkspaceIsNullable()
|
@WorkspaceIsNullable()
|
||||||
|
@WorkspaceIsSystem()
|
||||||
attachments: Relation<AttachmentWorkspaceEntity[]>;
|
attachments: Relation<AttachmentWorkspaceEntity[]>;
|
||||||
|
|
||||||
@WorkspaceRelation({
|
@WorkspaceRelation({
|
||||||
@ -213,6 +214,7 @@ export class OpportunityWorkspaceEntity extends BaseWorkspaceEntity {
|
|||||||
onDelete: RelationOnDeleteAction.SET_NULL,
|
onDelete: RelationOnDeleteAction.SET_NULL,
|
||||||
})
|
})
|
||||||
@WorkspaceIsNullable()
|
@WorkspaceIsNullable()
|
||||||
|
@WorkspaceIsSystem()
|
||||||
timelineActivities: Relation<TimelineActivityWorkspaceEntity[]>;
|
timelineActivities: Relation<TimelineActivityWorkspaceEntity[]>;
|
||||||
|
|
||||||
@WorkspaceField({
|
@WorkspaceField({
|
||||||
|
|||||||
@ -196,7 +196,7 @@ export class PersonWorkspaceEntity extends BaseWorkspaceEntity {
|
|||||||
@WorkspaceRelation({
|
@WorkspaceRelation({
|
||||||
standardId: PERSON_STANDARD_FIELD_IDS.pointOfContactForOpportunities,
|
standardId: PERSON_STANDARD_FIELD_IDS.pointOfContactForOpportunities,
|
||||||
type: RelationType.ONE_TO_MANY,
|
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`,
|
description: msg`List of opportunities for which that person is the point of contact`,
|
||||||
icon: 'IconTargetArrow',
|
icon: 'IconTargetArrow',
|
||||||
inverseSideTarget: () => OpportunityWorkspaceEntity,
|
inverseSideTarget: () => OpportunityWorkspaceEntity,
|
||||||
@ -248,6 +248,7 @@ export class PersonWorkspaceEntity extends BaseWorkspaceEntity {
|
|||||||
inverseSideTarget: () => AttachmentWorkspaceEntity,
|
inverseSideTarget: () => AttachmentWorkspaceEntity,
|
||||||
onDelete: RelationOnDeleteAction.CASCADE,
|
onDelete: RelationOnDeleteAction.CASCADE,
|
||||||
})
|
})
|
||||||
|
@WorkspaceIsSystem()
|
||||||
attachments: Relation<AttachmentWorkspaceEntity[]>;
|
attachments: Relation<AttachmentWorkspaceEntity[]>;
|
||||||
|
|
||||||
@WorkspaceRelation({
|
@WorkspaceRelation({
|
||||||
|
|||||||
@ -147,6 +147,7 @@ export class TaskWorkspaceEntity extends BaseWorkspaceEntity {
|
|||||||
onDelete: RelationOnDeleteAction.SET_NULL,
|
onDelete: RelationOnDeleteAction.SET_NULL,
|
||||||
})
|
})
|
||||||
@WorkspaceIsNullable()
|
@WorkspaceIsNullable()
|
||||||
|
@WorkspaceIsSystem()
|
||||||
taskTargets: Relation<TaskTargetWorkspaceEntity[]>;
|
taskTargets: Relation<TaskTargetWorkspaceEntity[]>;
|
||||||
|
|
||||||
@WorkspaceRelation({
|
@WorkspaceRelation({
|
||||||
@ -159,6 +160,7 @@ export class TaskWorkspaceEntity extends BaseWorkspaceEntity {
|
|||||||
onDelete: RelationOnDeleteAction.SET_NULL,
|
onDelete: RelationOnDeleteAction.SET_NULL,
|
||||||
})
|
})
|
||||||
@WorkspaceIsNullable()
|
@WorkspaceIsNullable()
|
||||||
|
@WorkspaceIsSystem()
|
||||||
attachments: Relation<AttachmentWorkspaceEntity[]>;
|
attachments: Relation<AttachmentWorkspaceEntity[]>;
|
||||||
|
|
||||||
@WorkspaceRelation({
|
@WorkspaceRelation({
|
||||||
@ -187,6 +189,7 @@ export class TaskWorkspaceEntity extends BaseWorkspaceEntity {
|
|||||||
onDelete: RelationOnDeleteAction.SET_NULL,
|
onDelete: RelationOnDeleteAction.SET_NULL,
|
||||||
})
|
})
|
||||||
@WorkspaceIsNullable()
|
@WorkspaceIsNullable()
|
||||||
|
@WorkspaceIsSystem()
|
||||||
timelineActivities: Relation<TimelineActivityWorkspaceEntity[]>;
|
timelineActivities: Relation<TimelineActivityWorkspaceEntity[]>;
|
||||||
|
|
||||||
@WorkspaceRelation({
|
@WorkspaceRelation({
|
||||||
|
|||||||
@ -279,6 +279,7 @@ export class WorkspaceMemberWorkspaceEntity extends BaseWorkspaceEntity {
|
|||||||
inverseSideFieldKey: 'author',
|
inverseSideFieldKey: 'author',
|
||||||
onDelete: RelationOnDeleteAction.SET_NULL,
|
onDelete: RelationOnDeleteAction.SET_NULL,
|
||||||
})
|
})
|
||||||
|
@WorkspaceIsSystem()
|
||||||
authoredAttachments: Relation<AttachmentWorkspaceEntity[]>;
|
authoredAttachments: Relation<AttachmentWorkspaceEntity[]>;
|
||||||
|
|
||||||
@WorkspaceRelation({
|
@WorkspaceRelation({
|
||||||
|
|||||||
Reference in New Issue
Block a user