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:
@ -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,
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -35,10 +35,8 @@ export const ActivityTargetsInlineCell = ({
|
||||
readonly,
|
||||
activityObjectNameSingular,
|
||||
}: ActivityTargetsInlineCellProps) => {
|
||||
const { activityTargetObjectRecords } = useActivityTargetObjectRecords(
|
||||
activity,
|
||||
activityObjectNameSingular,
|
||||
);
|
||||
const { activityTargetObjectRecords } =
|
||||
useActivityTargetObjectRecords(activity);
|
||||
|
||||
const { closeInlineCell } = useInlineCell();
|
||||
|
||||
|
||||
@ -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]);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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?
|
||||
{
|
||||
|
||||
@ -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?
|
||||
{
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -23,7 +23,7 @@ export class ViewService {
|
||||
fieldId: string;
|
||||
viewsIds: string[];
|
||||
positions?: {
|
||||
[key: string]: number;
|
||||
[viewId: string]: number;
|
||||
}[];
|
||||
size?: number;
|
||||
}) {
|
||||
|
||||
Reference in New Issue
Block a user