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 {
|
||||
Global = 'Global',
|
||||
RecordSelection = 'RecordSelection',
|
||||
CreateRelatedRecord = 'CreateRelatedRecord',
|
||||
Object = 'Object',
|
||||
}
|
||||
|
||||
@ -22,4 +22,7 @@ export type ShouldBeRegisteredFunctionParams = {
|
||||
getTargetObjectReadPermission: (
|
||||
objectMetadataItemNameSingular: string,
|
||||
) => boolean;
|
||||
getTargetObjectWritePermission: (
|
||||
objectMetadataItemNameSingular: string,
|
||||
) => boolean;
|
||||
};
|
||||
|
||||
@ -26,7 +26,8 @@ export const getActionConfig = ({
|
||||
case CoreObjectNameSingular.WorkflowRun: {
|
||||
return WORKFLOW_RUNS_ACTIONS_CONFIG;
|
||||
}
|
||||
default:
|
||||
default: {
|
||||
return DEFAULT_RECORD_ACTIONS_CONFIG;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@ -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,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@ -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 : [],
|
||||
};
|
||||
};
|
||||
|
||||
@ -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,
|
||||
})
|
||||
@WorkspaceIsNullable()
|
||||
@WorkspaceIsSystem()
|
||||
attachments: AttachmentWorkspaceEntity[];
|
||||
|
||||
@WorkspaceRelation({
|
||||
|
||||
@ -245,6 +245,7 @@ export class CompanyWorkspaceEntity extends BaseWorkspaceEntity {
|
||||
onDelete: RelationOnDeleteAction.CASCADE,
|
||||
})
|
||||
@WorkspaceIsNullable()
|
||||
@WorkspaceIsSystem()
|
||||
attachments: Relation<AttachmentWorkspaceEntity[]>;
|
||||
|
||||
@WorkspaceRelation({
|
||||
|
||||
@ -108,6 +108,7 @@ export class NoteWorkspaceEntity extends BaseWorkspaceEntity {
|
||||
onDelete: RelationOnDeleteAction.SET_NULL,
|
||||
})
|
||||
@WorkspaceIsNullable()
|
||||
@WorkspaceIsSystem()
|
||||
noteTargets: Relation<NoteTargetWorkspaceEntity[]>;
|
||||
|
||||
@WorkspaceRelation({
|
||||
@ -120,6 +121,7 @@ export class NoteWorkspaceEntity extends BaseWorkspaceEntity {
|
||||
onDelete: RelationOnDeleteAction.SET_NULL,
|
||||
})
|
||||
@WorkspaceIsNullable()
|
||||
@WorkspaceIsSystem()
|
||||
attachments: Relation<AttachmentWorkspaceEntity[]>;
|
||||
|
||||
@WorkspaceRelation({
|
||||
@ -132,6 +134,7 @@ export class NoteWorkspaceEntity extends BaseWorkspaceEntity {
|
||||
onDelete: RelationOnDeleteAction.SET_NULL,
|
||||
})
|
||||
@WorkspaceIsNullable()
|
||||
@WorkspaceIsSystem()
|
||||
timelineActivities: Relation<TimelineActivityWorkspaceEntity[]>;
|
||||
|
||||
@WorkspaceRelation({
|
||||
|
||||
@ -201,6 +201,7 @@ export class OpportunityWorkspaceEntity extends BaseWorkspaceEntity {
|
||||
onDelete: RelationOnDeleteAction.CASCADE,
|
||||
})
|
||||
@WorkspaceIsNullable()
|
||||
@WorkspaceIsSystem()
|
||||
attachments: Relation<AttachmentWorkspaceEntity[]>;
|
||||
|
||||
@WorkspaceRelation({
|
||||
@ -213,6 +214,7 @@ export class OpportunityWorkspaceEntity extends BaseWorkspaceEntity {
|
||||
onDelete: RelationOnDeleteAction.SET_NULL,
|
||||
})
|
||||
@WorkspaceIsNullable()
|
||||
@WorkspaceIsSystem()
|
||||
timelineActivities: Relation<TimelineActivityWorkspaceEntity[]>;
|
||||
|
||||
@WorkspaceField({
|
||||
|
||||
@ -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<AttachmentWorkspaceEntity[]>;
|
||||
|
||||
@WorkspaceRelation({
|
||||
|
||||
@ -147,6 +147,7 @@ export class TaskWorkspaceEntity extends BaseWorkspaceEntity {
|
||||
onDelete: RelationOnDeleteAction.SET_NULL,
|
||||
})
|
||||
@WorkspaceIsNullable()
|
||||
@WorkspaceIsSystem()
|
||||
taskTargets: Relation<TaskTargetWorkspaceEntity[]>;
|
||||
|
||||
@WorkspaceRelation({
|
||||
@ -159,6 +160,7 @@ export class TaskWorkspaceEntity extends BaseWorkspaceEntity {
|
||||
onDelete: RelationOnDeleteAction.SET_NULL,
|
||||
})
|
||||
@WorkspaceIsNullable()
|
||||
@WorkspaceIsSystem()
|
||||
attachments: Relation<AttachmentWorkspaceEntity[]>;
|
||||
|
||||
@WorkspaceRelation({
|
||||
@ -187,6 +189,7 @@ export class TaskWorkspaceEntity extends BaseWorkspaceEntity {
|
||||
onDelete: RelationOnDeleteAction.SET_NULL,
|
||||
})
|
||||
@WorkspaceIsNullable()
|
||||
@WorkspaceIsSystem()
|
||||
timelineActivities: Relation<TimelineActivityWorkspaceEntity[]>;
|
||||
|
||||
@WorkspaceRelation({
|
||||
|
||||
@ -279,6 +279,7 @@ export class WorkspaceMemberWorkspaceEntity extends BaseWorkspaceEntity {
|
||||
inverseSideFieldKey: 'author',
|
||||
onDelete: RelationOnDeleteAction.SET_NULL,
|
||||
})
|
||||
@WorkspaceIsSystem()
|
||||
authoredAttachments: Relation<AttachmentWorkspaceEntity[]>;
|
||||
|
||||
@WorkspaceRelation({
|
||||
|
||||
Reference in New Issue
Block a user