Add relations to notes/tasks list view (#6971)

<img width="664" alt="Screenshot 2024-09-10 at 17 00 11"
src="https://github.com/user-attachments/assets/37132805-ff67-4d28-b664-b03da680e166">

---------

Co-authored-by: Lucas Bordeau <bordeau.lucas@gmail.com>
This commit is contained in:
Félix Malfait
2024-09-12 10:50:49 +02:00
committed by GitHub
parent 725ee837f9
commit f8e5b333d9
12 changed files with 135 additions and 80 deletions

View File

@ -7,7 +7,6 @@ import { RecoilRoot, useSetRecoilState } from 'recoil';
import { useActivityTargetObjectRecords } from '@/activities/hooks/useActivityTargetObjectRecords'; import { useActivityTargetObjectRecords } from '@/activities/hooks/useActivityTargetObjectRecords';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { getObjectMetadataItemsMock } from '@/object-metadata/utils/getObjectMetadataItemsMock'; import { getObjectMetadataItemsMock } from '@/object-metadata/utils/getObjectMetadataItemsMock';
import { SnackBarProviderScope } from '@/ui/feedback/snack-bar-manager/scopes/SnackBarProviderScope'; import { SnackBarProviderScope } from '@/ui/feedback/snack-bar-manager/scopes/SnackBarProviderScope';
import { JestObjectMetadataItemSetter } from '~/testing/jest/JestObjectMetadataItemSetter'; import { JestObjectMetadataItemSetter } from '~/testing/jest/JestObjectMetadataItemSetter';
@ -128,10 +127,8 @@ describe('useActivityTargetObjectRecords', () => {
objectMetadataItemsState, objectMetadataItemsState,
); );
const { activityTargetObjectRecords } = useActivityTargetObjectRecords( const { activityTargetObjectRecords } =
task, useActivityTargetObjectRecords(task);
CoreObjectNameSingular.Task,
);
return { return {
activityTargetObjectRecords, activityTargetObjectRecords,

View File

@ -1,4 +1,3 @@
import { useApolloClient } from '@apollo/client';
import { useRecoilValue } from 'recoil'; import { useRecoilValue } from 'recoil';
import { Nullable } from 'twenty-ui'; import { Nullable } from 'twenty-ui';
@ -7,46 +6,37 @@ import { Note } from '@/activities/types/Note';
import { NoteTarget } from '@/activities/types/NoteTarget'; import { NoteTarget } from '@/activities/types/NoteTarget';
import { Task } from '@/activities/types/Task'; import { Task } from '@/activities/types/Task';
import { TaskTarget } from '@/activities/types/TaskTarget'; import { TaskTarget } from '@/activities/types/TaskTarget';
import { getJoinObjectNameSingular } from '@/activities/utils/getJoinObjectNameSingular';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useGetRecordFromCache } from '@/object-record/cache/hooks/useGetRecordFromCache';
import { isDefined } from '~/utils/isDefined'; import { isDefined } from '~/utils/isDefined';
export const useActivityTargetObjectRecords = ( export const useActivityTargetObjectRecords = (
activity: Task | Note, activity?: Task | Note,
objectNameSingular: CoreObjectNameSingular, activityTargets?: NoteTarget[] | TaskTarget[],
) => { ) => {
const objectMetadataItems = useRecoilValue(objectMetadataItemsState); const objectMetadataItems = useRecoilValue(objectMetadataItemsState);
const activityTargets = if (!isDefined(activity) && !isDefined(activityTargets)) {
'noteTargets' in activity && activity.noteTargets return { activityTargetObjectRecords: [] };
}
const targets = activityTargets
? activityTargets
: activity && 'noteTargets' in activity && activity.noteTargets
? activity.noteTargets ? activity.noteTargets
: 'taskTargets' in activity && activity.taskTargets : activity && 'taskTargets' in activity && activity.taskTargets
? activity.taskTargets ? activity.taskTargets
: []; : [];
const getRecordFromCache = useGetRecordFromCache({ const activityTargetObjectRecords = targets
objectNameSingular: getJoinObjectNameSingular(objectNameSingular),
});
const apolloClient = useApolloClient();
const activityTargetObjectRecords = activityTargets
.map<Nullable<ActivityTargetWithTargetRecord>>((activityTarget) => { .map<Nullable<ActivityTargetWithTargetRecord>>((activityTarget) => {
const activityTargetFromCache = getRecordFromCache< if (!isDefined(activityTarget)) {
NoteTarget | TaskTarget throw new Error(`Cannot find activity target`);
>(activityTarget.id, apolloClient.cache);
if (!isDefined(activityTargetFromCache)) {
throw new Error(
`Cannot find activity target ${activityTarget.id} in cache, this shouldn't happen.`,
);
} }
const correspondingObjectMetadataItem = objectMetadataItems.find( const correspondingObjectMetadataItem = objectMetadataItems.find(
(objectMetadataItem) => (objectMetadataItem) =>
isDefined(activityTargetFromCache[objectMetadataItem.nameSingular]) && isDefined(activityTarget[objectMetadataItem.nameSingular]) &&
![CoreObjectNameSingular.Note, CoreObjectNameSingular.Task].includes( ![CoreObjectNameSingular.Note, CoreObjectNameSingular.Task].includes(
objectMetadataItem.nameSingular as CoreObjectNameSingular, objectMetadataItem.nameSingular as CoreObjectNameSingular,
), ),
@ -57,7 +47,7 @@ export const useActivityTargetObjectRecords = (
} }
const targetObjectRecord = const targetObjectRecord =
activityTargetFromCache[correspondingObjectMetadataItem.nameSingular]; activityTarget[correspondingObjectMetadataItem.nameSingular];
if (!targetObjectRecord) { if (!targetObjectRecord) {
throw new Error( throw new Error(
@ -66,7 +56,7 @@ export const useActivityTargetObjectRecords = (
} }
return { return {
activityTarget: activityTargetFromCache ?? activityTarget, activityTarget,
targetObject: targetObjectRecord ?? undefined, targetObject: targetObjectRecord ?? undefined,
targetObjectMetadataItem: correspondingObjectMetadataItem, targetObjectMetadataItem: correspondingObjectMetadataItem,
}; };

View File

@ -35,10 +35,8 @@ export const ActivityTargetsInlineCell = ({
readonly, readonly,
activityObjectNameSingular, activityObjectNameSingular,
}: ActivityTargetsInlineCellProps) => { }: ActivityTargetsInlineCellProps) => {
const { activityTargetObjectRecords } = useActivityTargetObjectRecords( const { activityTargetObjectRecords } =
activity, useActivityTargetObjectRecords(activity);
activityObjectNameSingular,
);
const { closeInlineCell } = useInlineCell(); const { closeInlineCell } = useInlineCell();

View File

@ -16,14 +16,6 @@ export const getLabelIdentifierFieldValue = (
return `${record.name?.firstName ?? ''} ${record.name?.lastName ?? ''}`; return `${record.name?.firstName ?? ''} ${record.name?.lastName ?? ''}`;
} }
if (objectNameSingular === CoreObjectNameSingular.NoteTarget) {
return record.note?.title ?? '';
}
if (objectNameSingular === CoreObjectNameSingular.TaskTarget) {
return record.task?.title ?? '';
}
if (isDefined(labelIdentifierFieldMetadataItem?.name)) { if (isDefined(labelIdentifierFieldMetadataItem?.name)) {
return String(record[labelIdentifierFieldMetadataItem.name]); return String(record[labelIdentifierFieldMetadataItem.name]);
} }

View File

@ -1,3 +1,7 @@
import { useActivityTargetObjectRecords } from '@/activities/hooks/useActivityTargetObjectRecords';
import { NoteTarget } from '@/activities/types/NoteTarget';
import { TaskTarget } from '@/activities/types/TaskTarget';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { RecordChip } from '@/object-record/components/RecordChip'; import { RecordChip } from '@/object-record/components/RecordChip';
import { useFieldFocus } from '@/object-record/record-field/hooks/useFieldFocus'; import { useFieldFocus } from '@/object-record/record-field/hooks/useFieldFocus';
import { useRelationFromManyFieldDisplay } from '@/object-record/record-field/meta-types/hooks/useRelationFromManyFieldDisplay'; import { useRelationFromManyFieldDisplay } from '@/object-record/record-field/meta-types/hooks/useRelationFromManyFieldDisplay';
@ -7,24 +11,74 @@ export const RelationFromManyFieldDisplay = () => {
const { fieldValue, fieldDefinition } = useRelationFromManyFieldDisplay(); const { fieldValue, fieldDefinition } = useRelationFromManyFieldDisplay();
const { isFocused } = useFieldFocus(); const { isFocused } = useFieldFocus();
const { fieldName, objectMetadataNameSingular } = fieldDefinition.metadata;
const relationObjectNameSingular = const relationObjectNameSingular =
fieldDefinition?.metadata.relationObjectMetadataNameSingular; fieldDefinition?.metadata.relationObjectMetadataNameSingular;
const { activityTargetObjectRecords } = useActivityTargetObjectRecords(
undefined,
fieldValue as NoteTarget[] | TaskTarget[],
);
if (!fieldValue || !relationObjectNameSingular) { if (!fieldValue || !relationObjectNameSingular) {
return null; return null;
} }
return ( const isRelationFromActivityTargets =
<ExpandableList isChipCountDisplayed={isFocused}> (fieldName === 'noteTargets' &&
{fieldValue.map((record) => { objectMetadataNameSingular === CoreObjectNameSingular.Note) ||
return ( (fieldName === 'taskTargets' &&
objectMetadataNameSingular === CoreObjectNameSingular.Task);
const isRelationFromManyActivities =
(fieldName === 'noteTargets' &&
objectMetadataNameSingular !== CoreObjectNameSingular.Note) ||
(fieldName === 'taskTargets' &&
objectMetadataNameSingular !== CoreObjectNameSingular.Task);
if (isRelationFromManyActivities) {
const objectNameSingular =
fieldName === 'noteTargets'
? CoreObjectNameSingular.Note
: CoreObjectNameSingular.Task;
const relationFieldName = fieldName === 'noteTargets' ? 'note' : 'task';
return (
<ExpandableList isChipCountDisplayed={isFocused}>
{fieldValue.map((record) => (
<RecordChip
key={record.id}
objectNameSingular={objectNameSingular}
record={record[relationFieldName]}
/>
))}
</ExpandableList>
);
} else if (isRelationFromActivityTargets) {
return (
<ExpandableList isChipCountDisplayed={isFocused}>
{activityTargetObjectRecords.map((record) => (
<RecordChip
key={record.targetObject.id}
objectNameSingular={record.targetObjectMetadataItem.nameSingular}
record={record.targetObject}
/>
))}
</ExpandableList>
);
} else {
return (
<ExpandableList isChipCountDisplayed={isFocused}>
{fieldValue.map((record) => (
<RecordChip <RecordChip
key={record.id} key={record.id}
objectNameSingular={relationObjectNameSingular} objectNameSingular={relationObjectNameSingular}
record={record} record={record}
/> />
); ))}
})} </ExpandableList>
</ExpandableList> );
); }
}; };

View File

@ -1,5 +1,5 @@
import { useEffect } from 'react';
import { Meta, StoryObj } from '@storybook/react'; import { Meta, StoryObj } from '@storybook/react';
import { useEffect } from 'react';
import { useSetRecoilState } from 'recoil'; import { useSetRecoilState } from 'recoil';
import { ComponentDecorator } from 'twenty-ui'; import { ComponentDecorator } from 'twenty-ui';
@ -94,9 +94,10 @@ type Story = StoryObj<typeof RelationFromManyFieldDisplay>;
export const Default: Story = {}; export const Default: Story = {};
// TODO: optimize this component once we have morph many
export const Performance = getProfilingStory({ export const Performance = getProfilingStory({
componentName: 'RelationFromManyFieldDisplay', componentName: 'RelationFromManyFieldDisplay',
averageThresholdInMs: 0.5, averageThresholdInMs: 1,
numberOfRuns: 20, numberOfRuns: 20,
numberOfTestsPerRun: 100, numberOfTestsPerRun: 100,
}); });

View File

@ -1,7 +1,10 @@
import { useRecoilValue } from 'recoil'; import { useRecoilValue } from 'recoil';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { getObjectMetadataIdentifierFields } from '@/object-metadata/utils/getObjectMetadataIdentifierFields'; import { getObjectMetadataIdentifierFields } from '@/object-metadata/utils/getObjectMetadataIdentifierFields';
import { generateDepthOneRecordGqlFields } from '@/object-record/graphql/utils/generateDepthOneRecordGqlFields';
import { useRecordTableStates } from '@/object-record/record-table/hooks/internal/useRecordTableStates'; import { useRecordTableStates } from '@/object-record/record-table/hooks/internal/useRecordTableStates';
import { isDefined } from '~/utils/isDefined'; import { isDefined } from '~/utils/isDefined';
@ -27,6 +30,16 @@ export const useRecordTableRecordGqlFields = ({
identifierQueryFields[imageIdentifierFieldMetadataItem.name] = true; identifierQueryFields[imageIdentifierFieldMetadataItem.name] = true;
} }
const { objectMetadataItem: noteTargetObjectMetadataItem } =
useObjectMetadataItem({
objectNameSingular: CoreObjectNameSingular.NoteTarget,
});
const { objectMetadataItem: taskTargetObjectMetadataItem } =
useObjectMetadataItem({
objectNameSingular: CoreObjectNameSingular.TaskTarget,
});
const recordGqlFields: Record<string, any> = { const recordGqlFields: Record<string, any> = {
id: true, id: true,
...Object.fromEntries( ...Object.fromEntries(
@ -34,18 +47,12 @@ export const useRecordTableRecordGqlFields = ({
), ),
...identifierQueryFields, ...identifierQueryFields,
position: true, position: true,
noteTargets: { noteTargets: generateDepthOneRecordGqlFields({
note: { objectMetadataItem: noteTargetObjectMetadataItem,
id: true, }),
title: true, taskTargets: generateDepthOneRecordGqlFields({
}, objectMetadataItem: taskTargetObjectMetadataItem,
}, }),
taskTargets: {
task: {
id: true,
title: true,
},
},
}; };
return recordGqlFields; return recordGqlFields;

View File

@ -30,7 +30,7 @@ export const notesAllView = async (
{ {
fieldMetadataId: fieldMetadataId:
objectMetadataMap[STANDARD_OBJECT_IDS.note].fields[ objectMetadataMap[STANDARD_OBJECT_IDS.note].fields[
NOTE_STANDARD_FIELD_IDS.body NOTE_STANDARD_FIELD_IDS.noteTargets
], ],
position: 1, position: 1,
isVisible: true, isVisible: true,
@ -39,7 +39,7 @@ export const notesAllView = async (
{ {
fieldMetadataId: fieldMetadataId:
objectMetadataMap[STANDARD_OBJECT_IDS.note].fields[ objectMetadataMap[STANDARD_OBJECT_IDS.note].fields[
NOTE_STANDARD_FIELD_IDS.createdBy NOTE_STANDARD_FIELD_IDS.body
], ],
position: 2, position: 2,
isVisible: true, isVisible: true,
@ -48,12 +48,21 @@ export const notesAllView = async (
{ {
fieldMetadataId: fieldMetadataId:
objectMetadataMap[STANDARD_OBJECT_IDS.note].fields[ objectMetadataMap[STANDARD_OBJECT_IDS.note].fields[
BASE_OBJECT_STANDARD_FIELD_IDS.createdAt NOTE_STANDARD_FIELD_IDS.createdBy
], ],
position: 3, position: 3,
isVisible: true, isVisible: true,
size: 150, size: 150,
}, },
{
fieldMetadataId:
objectMetadataMap[STANDARD_OBJECT_IDS.note].fields[
BASE_OBJECT_STANDARD_FIELD_IDS.createdAt
],
position: 4,
isVisible: true,
size: 150,
},
/* /*
TODO: Add later, since we don't have real-time it probably doesn't work well? TODO: Add later, since we don't have real-time it probably doesn't work well?
{ {

View File

@ -49,7 +49,7 @@ export const tasksAllView = async (
{ {
fieldMetadataId: fieldMetadataId:
objectMetadataMap[STANDARD_OBJECT_IDS.task].fields[ objectMetadataMap[STANDARD_OBJECT_IDS.task].fields[
TASK_STANDARD_FIELD_IDS.createdBy TASK_STANDARD_FIELD_IDS.taskTargets
], ],
position: 3, position: 3,
isVisible: true, isVisible: true,
@ -58,7 +58,7 @@ export const tasksAllView = async (
{ {
fieldMetadataId: fieldMetadataId:
objectMetadataMap[STANDARD_OBJECT_IDS.task].fields[ objectMetadataMap[STANDARD_OBJECT_IDS.task].fields[
TASK_STANDARD_FIELD_IDS.dueAt TASK_STANDARD_FIELD_IDS.createdBy
], ],
position: 4, position: 4,
isVisible: true, isVisible: true,
@ -67,7 +67,7 @@ export const tasksAllView = async (
{ {
fieldMetadataId: fieldMetadataId:
objectMetadataMap[STANDARD_OBJECT_IDS.task].fields[ objectMetadataMap[STANDARD_OBJECT_IDS.task].fields[
TASK_STANDARD_FIELD_IDS.assignee TASK_STANDARD_FIELD_IDS.dueAt
], ],
position: 5, position: 5,
isVisible: true, isVisible: true,
@ -76,7 +76,7 @@ export const tasksAllView = async (
{ {
fieldMetadataId: fieldMetadataId:
objectMetadataMap[STANDARD_OBJECT_IDS.task].fields[ objectMetadataMap[STANDARD_OBJECT_IDS.task].fields[
TASK_STANDARD_FIELD_IDS.body TASK_STANDARD_FIELD_IDS.assignee
], ],
position: 6, position: 6,
isVisible: true, isVisible: true,
@ -85,12 +85,21 @@ export const tasksAllView = async (
{ {
fieldMetadataId: fieldMetadataId:
objectMetadataMap[STANDARD_OBJECT_IDS.task].fields[ objectMetadataMap[STANDARD_OBJECT_IDS.task].fields[
BASE_OBJECT_STANDARD_FIELD_IDS.createdAt TASK_STANDARD_FIELD_IDS.body
], ],
position: 7, position: 7,
isVisible: true, isVisible: true,
size: 150, size: 150,
}, },
{
fieldMetadataId:
objectMetadataMap[STANDARD_OBJECT_IDS.task].fields[
BASE_OBJECT_STANDARD_FIELD_IDS.createdAt
],
position: 8,
isVisible: true,
size: 150,
},
/* /*
TODO: Add later, since we don't have real-time it probably doesn't work well? TODO: Add later, since we don't have real-time it probably doesn't work well?
{ {

View File

@ -78,15 +78,14 @@ export class NoteWorkspaceEntity extends BaseWorkspaceEntity {
@WorkspaceRelation({ @WorkspaceRelation({
standardId: NOTE_STANDARD_FIELD_IDS.noteTargets, standardId: NOTE_STANDARD_FIELD_IDS.noteTargets,
label: 'Targets', label: 'Relations',
description: 'Note targets', description: 'Note targets',
icon: 'IconCheckbox', icon: 'IconArrowUpRight',
type: RelationMetadataType.ONE_TO_MANY, type: RelationMetadataType.ONE_TO_MANY,
inverseSideTarget: () => NoteTargetWorkspaceEntity, inverseSideTarget: () => NoteTargetWorkspaceEntity,
onDelete: RelationOnDeleteAction.SET_NULL, onDelete: RelationOnDeleteAction.SET_NULL,
}) })
@WorkspaceIsNullable() @WorkspaceIsNullable()
@WorkspaceIsSystem()
noteTargets: Relation<NoteTargetWorkspaceEntity[]>; noteTargets: Relation<NoteTargetWorkspaceEntity[]>;
@WorkspaceRelation({ @WorkspaceRelation({

View File

@ -116,15 +116,14 @@ export class TaskWorkspaceEntity extends BaseWorkspaceEntity {
@WorkspaceRelation({ @WorkspaceRelation({
standardId: TASK_STANDARD_FIELD_IDS.taskTargets, standardId: TASK_STANDARD_FIELD_IDS.taskTargets,
label: 'Targets', label: 'Relations',
description: 'Task targets', description: 'Task targets',
icon: 'IconCheckbox', icon: 'IconArrowUpRight',
type: RelationMetadataType.ONE_TO_MANY, type: RelationMetadataType.ONE_TO_MANY,
inverseSideTarget: () => TaskTargetWorkspaceEntity, inverseSideTarget: () => TaskTargetWorkspaceEntity,
onDelete: RelationOnDeleteAction.SET_NULL, onDelete: RelationOnDeleteAction.SET_NULL,
}) })
@WorkspaceIsNullable() @WorkspaceIsNullable()
@WorkspaceIsSystem()
taskTargets: Relation<TaskTargetWorkspaceEntity[]>; taskTargets: Relation<TaskTargetWorkspaceEntity[]>;
@WorkspaceRelation({ @WorkspaceRelation({

View File

@ -23,7 +23,7 @@ export class ViewService {
fieldId: string; fieldId: string;
viewsIds: string[]; viewsIds: string[];
positions?: { positions?: {
[key: string]: number; [viewId: string]: number;
}[]; }[];
size?: number; size?: number;
}) { }) {