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

View File

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

View File

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

View File

@ -16,14 +16,6 @@ export const getLabelIdentifierFieldValue = (
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)) {
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 { useFieldFocus } from '@/object-record/record-field/hooks/useFieldFocus';
import { useRelationFromManyFieldDisplay } from '@/object-record/record-field/meta-types/hooks/useRelationFromManyFieldDisplay';
@ -7,24 +11,74 @@ export const RelationFromManyFieldDisplay = () => {
const { fieldValue, fieldDefinition } = useRelationFromManyFieldDisplay();
const { isFocused } = useFieldFocus();
const { fieldName, objectMetadataNameSingular } = fieldDefinition.metadata;
const relationObjectNameSingular =
fieldDefinition?.metadata.relationObjectMetadataNameSingular;
const { activityTargetObjectRecords } = useActivityTargetObjectRecords(
undefined,
fieldValue as NoteTarget[] | TaskTarget[],
);
if (!fieldValue || !relationObjectNameSingular) {
return null;
}
return (
<ExpandableList isChipCountDisplayed={isFocused}>
{fieldValue.map((record) => {
return (
const isRelationFromActivityTargets =
(fieldName === 'noteTargets' &&
objectMetadataNameSingular === CoreObjectNameSingular.Note) ||
(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
key={record.id}
objectNameSingular={relationObjectNameSingular}
record={record}
/>
);
})}
</ExpandableList>
);
))}
</ExpandableList>
);
}
};

View File

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

View File

@ -1,7 +1,10 @@
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 { 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 { isDefined } from '~/utils/isDefined';
@ -27,6 +30,16 @@ export const useRecordTableRecordGqlFields = ({
identifierQueryFields[imageIdentifierFieldMetadataItem.name] = true;
}
const { objectMetadataItem: noteTargetObjectMetadataItem } =
useObjectMetadataItem({
objectNameSingular: CoreObjectNameSingular.NoteTarget,
});
const { objectMetadataItem: taskTargetObjectMetadataItem } =
useObjectMetadataItem({
objectNameSingular: CoreObjectNameSingular.TaskTarget,
});
const recordGqlFields: Record<string, any> = {
id: true,
...Object.fromEntries(
@ -34,18 +47,12 @@ export const useRecordTableRecordGqlFields = ({
),
...identifierQueryFields,
position: true,
noteTargets: {
note: {
id: true,
title: true,
},
},
taskTargets: {
task: {
id: true,
title: true,
},
},
noteTargets: generateDepthOneRecordGqlFields({
objectMetadataItem: noteTargetObjectMetadataItem,
}),
taskTargets: generateDepthOneRecordGqlFields({
objectMetadataItem: taskTargetObjectMetadataItem,
}),
};
return recordGqlFields;

View File

@ -30,7 +30,7 @@ export const notesAllView = async (
{
fieldMetadataId:
objectMetadataMap[STANDARD_OBJECT_IDS.note].fields[
NOTE_STANDARD_FIELD_IDS.body
NOTE_STANDARD_FIELD_IDS.noteTargets
],
position: 1,
isVisible: true,
@ -39,7 +39,7 @@ export const notesAllView = async (
{
fieldMetadataId:
objectMetadataMap[STANDARD_OBJECT_IDS.note].fields[
NOTE_STANDARD_FIELD_IDS.createdBy
NOTE_STANDARD_FIELD_IDS.body
],
position: 2,
isVisible: true,
@ -48,12 +48,21 @@ export const notesAllView = async (
{
fieldMetadataId:
objectMetadataMap[STANDARD_OBJECT_IDS.note].fields[
BASE_OBJECT_STANDARD_FIELD_IDS.createdAt
NOTE_STANDARD_FIELD_IDS.createdBy
],
position: 3,
isVisible: true,
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?
{

View File

@ -49,7 +49,7 @@ export const tasksAllView = async (
{
fieldMetadataId:
objectMetadataMap[STANDARD_OBJECT_IDS.task].fields[
TASK_STANDARD_FIELD_IDS.createdBy
TASK_STANDARD_FIELD_IDS.taskTargets
],
position: 3,
isVisible: true,
@ -58,7 +58,7 @@ export const tasksAllView = async (
{
fieldMetadataId:
objectMetadataMap[STANDARD_OBJECT_IDS.task].fields[
TASK_STANDARD_FIELD_IDS.dueAt
TASK_STANDARD_FIELD_IDS.createdBy
],
position: 4,
isVisible: true,
@ -67,7 +67,7 @@ export const tasksAllView = async (
{
fieldMetadataId:
objectMetadataMap[STANDARD_OBJECT_IDS.task].fields[
TASK_STANDARD_FIELD_IDS.assignee
TASK_STANDARD_FIELD_IDS.dueAt
],
position: 5,
isVisible: true,
@ -76,7 +76,7 @@ export const tasksAllView = async (
{
fieldMetadataId:
objectMetadataMap[STANDARD_OBJECT_IDS.task].fields[
TASK_STANDARD_FIELD_IDS.body
TASK_STANDARD_FIELD_IDS.assignee
],
position: 6,
isVisible: true,
@ -85,12 +85,21 @@ export const tasksAllView = async (
{
fieldMetadataId:
objectMetadataMap[STANDARD_OBJECT_IDS.task].fields[
BASE_OBJECT_STANDARD_FIELD_IDS.createdAt
TASK_STANDARD_FIELD_IDS.body
],
position: 7,
isVisible: true,
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?
{

View File

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

View File

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

View File

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