From b112b74022b4139d5f29da277ac17772cc9cd085 Mon Sep 17 00:00:00 2001 From: Lucas Bordeau Date: Fri, 5 Jan 2024 09:08:33 +0100 Subject: [PATCH] Feat/activities custom objects (#3213) * WIP * WIP - MultiObjectSearch * WIP * WIP * Finished working version * Fix * Fixed and cleaned * Fix * Disabled files and emails for custom objects * Cleaned console.log * Fixed attachment * Fixed * fix lint --------- Co-authored-by: Charles Bochet --- .../components/ActivityTargetChips.tsx | 47 ++-- .../activities/emails/components/Threads.tsx | 10 +- .../__stories__/Threads.stories.tsx | 2 +- .../files/components/Attachments.tsx | 29 +-- .../activities/files/hooks/useAttachments.tsx | 14 +- .../hooks/useActivityTargetObjectRecords.ts | 55 +++++ .../activities/hooks/useActivityTargets.ts | 28 +++ .../hooks/useOpenCreateActivityDrawer.ts | 46 ++-- ...enCreateActivityDrawerForSelectedRowIds.ts | 18 +- .../ActivityTargetInlineCellEditMode.tsx | 202 ++++++----------- .../components/ActivityTargetsInlineCell.tsx | 21 +- .../activities/notes/components/Notes.tsx | 16 +- .../activities/notes/hooks/useNotes.ts | 13 +- .../activityTargetableEntityArrayState.ts | 4 +- .../tasks/__stories__/TaskGroups.stories.tsx | 11 +- .../tasks/components/AddTaskButton.tsx | 12 +- .../{EntityTasks.tsx => ObjectTasks.tsx} | 10 +- .../tasks/components/TaskGroups.tsx | 39 ++-- .../activities/tasks/components/TaskRow.tsx | 18 +- .../activities/tasks/hooks/useTasks.ts | 92 ++++---- .../timeline/components/Timeline.tsx | 29 ++- .../timeline/components/TimelineActivity.tsx | 9 +- .../activities/types/ActivityTargetObject.ts | 9 + .../types/ActivityTargetableEntity.ts | 8 +- .../ActivityTargetableEntityForSelect.ts | 7 - .../getTargetableEntitiesWithParents.test.ts | 25 ++- ...ObjectsAndTheirRelatedTargetableObjects.ts | 21 ++ .../utils/getTargetObjectFilterFieldName.ts | 15 ++ .../utils/getTargetableEntitiesWithParents.ts | 16 -- .../hooks/useSpreadsheetCompanyImport.ts | 71 +++--- ...eFilterOutUnexistingObjectMetadataItems.ts | 22 ++ .../hooks/useMapToObjectRecordIdentifier.ts | 8 +- .../hooks/useObjectMetadataItem.ts | 5 +- ...ectMetadataItemsByNamePluralMapSelector.ts | 20 ++ ...tMetadataItemsByNameSingularMapSelector.ts | 20 ++ .../utils/getBasePathToShowPage.ts | 11 + .../getLabelIdentifierFieldMetadataItem.ts | 12 + .../utils/getObjectOrderByField.ts | 10 +- .../utils/getObjectRecordIdentifier.ts | 47 ++-- .../object-record/components/RecordChip.tsx | 28 +++ .../components/RecordShowPage.tsx | 23 +- .../display/components/ChipFieldDisplay.tsx | 1 + .../hooks/useCreateManyRecords.ts | 14 +- .../object-record/hooks/useCreateOneRecord.ts | 2 +- .../hooks/useGenerateEmptyRecord.ts | 9 +- ...anyRecordsForMultipleMetadataItemsQuery.ts | 84 +++++++ .../hooks/useGenerateFindManyRecordsQuery.ts | 5 - .../useRecordTableContextMenuEntries.tsx | 11 +- .../components/MultipleObjectRecordSelect.tsx | 212 ++++++++++++++++++ .../hooks/useLimitPerMetadataItem.ts | 24 ++ ...ltFormattedAsObjectRecordForSelectArray.ts | 50 +++++ .../hooks/useMultiObjectSearch.ts | 72 ++++++ ...atchesSearchFilterAndSelectedItemsQuery.ts | 113 ++++++++++ ...archMatchesSearchFilterAndToSelectQuery.ts | 130 +++++++++++ .../useMultiObjectSearchSelectedItemsQuery.ts | 88 ++++++++ .../hooks/useOrderByFieldPerMetadataItem.ts | 29 +++ .../hooks/useSearchFilterPerMetadataItem.ts | 67 ++++++ .../object-record/types/ObjectRecord.ts | 1 + .../types/ObjectRecordConnection.ts | 11 + .../object-record/types/ObjectRecordEdge.ts | 6 + .../types/ObjectRecordIdentifier.ts | 1 + .../hooks/useSpreadsheetPersonImport.ts | 58 ++--- .../ui/display/chip/components/EntityChip.tsx | 3 +- .../modules/ui/input/components/Checkbox.tsx | 4 +- .../components/ShowPageAddButton.tsx | 6 +- .../components/ShowPageRightContainer.tsx | 39 ++-- .../components/MenuItemMultiSelectAvatar.tsx | 4 +- .../components/StyledMenuItemBase.tsx | 3 +- .../src/modules/users/components/Avatar.tsx | 3 +- .../src/testing/mock-data/activities.ts | 5 + .../factories/relation-field-alias.factory.ts | 3 +- packages/twenty-server/test/utils/reset-db.ts | 1 - 72 files changed, 1611 insertions(+), 551 deletions(-) create mode 100644 packages/twenty-front/src/modules/activities/hooks/useActivityTargetObjectRecords.ts create mode 100644 packages/twenty-front/src/modules/activities/hooks/useActivityTargets.ts rename packages/twenty-front/src/modules/activities/tasks/components/{EntityTasks.tsx => ObjectTasks.tsx} (78%) create mode 100644 packages/twenty-front/src/modules/activities/types/ActivityTargetObject.ts delete mode 100644 packages/twenty-front/src/modules/activities/types/ActivityTargetableEntityForSelect.ts create mode 100644 packages/twenty-front/src/modules/activities/utils/flattenTargetableObjectsAndTheirRelatedTargetableObjects.ts create mode 100644 packages/twenty-front/src/modules/activities/utils/getTargetObjectFilterFieldName.ts delete mode 100644 packages/twenty-front/src/modules/activities/utils/getTargetableEntitiesWithParents.ts create mode 100644 packages/twenty-front/src/modules/object-metadata/hooks/useFilterOutUnexistingObjectMetadataItems.ts create mode 100644 packages/twenty-front/src/modules/object-metadata/states/objectMetadataItemsByNamePluralMapSelector.ts create mode 100644 packages/twenty-front/src/modules/object-metadata/states/objectMetadataItemsByNameSingularMapSelector.ts create mode 100644 packages/twenty-front/src/modules/object-metadata/utils/getBasePathToShowPage.ts create mode 100644 packages/twenty-front/src/modules/object-metadata/utils/getLabelIdentifierFieldMetadataItem.ts create mode 100644 packages/twenty-front/src/modules/object-record/components/RecordChip.tsx create mode 100644 packages/twenty-front/src/modules/object-record/hooks/useGenerateFindManyRecordsForMultipleMetadataItemsQuery.ts create mode 100644 packages/twenty-front/src/modules/object-record/relation-picker/components/MultipleObjectRecordSelect.tsx create mode 100644 packages/twenty-front/src/modules/object-record/relation-picker/hooks/useLimitPerMetadataItem.ts create mode 100644 packages/twenty-front/src/modules/object-record/relation-picker/hooks/useMultiObjectRecordsQueryResultFormattedAsObjectRecordForSelectArray.ts create mode 100644 packages/twenty-front/src/modules/object-record/relation-picker/hooks/useMultiObjectSearch.ts create mode 100644 packages/twenty-front/src/modules/object-record/relation-picker/hooks/useMultiObjectSearchMatchesSearchFilterAndSelectedItemsQuery.ts create mode 100644 packages/twenty-front/src/modules/object-record/relation-picker/hooks/useMultiObjectSearchMatchesSearchFilterAndToSelectQuery.ts create mode 100644 packages/twenty-front/src/modules/object-record/relation-picker/hooks/useMultiObjectSearchSelectedItemsQuery.ts create mode 100644 packages/twenty-front/src/modules/object-record/relation-picker/hooks/useOrderByFieldPerMetadataItem.ts create mode 100644 packages/twenty-front/src/modules/object-record/relation-picker/hooks/useSearchFilterPerMetadataItem.ts create mode 100644 packages/twenty-front/src/modules/object-record/types/ObjectRecord.ts create mode 100644 packages/twenty-front/src/modules/object-record/types/ObjectRecordConnection.ts create mode 100644 packages/twenty-front/src/modules/object-record/types/ObjectRecordEdge.ts diff --git a/packages/twenty-front/src/modules/activities/components/ActivityTargetChips.tsx b/packages/twenty-front/src/modules/activities/components/ActivityTargetChips.tsx index 6b69c22d6..b6e69cc7b 100644 --- a/packages/twenty-front/src/modules/activities/components/ActivityTargetChips.tsx +++ b/packages/twenty-front/src/modules/activities/components/ActivityTargetChips.tsx @@ -1,8 +1,7 @@ import styled from '@emotion/styled'; -import { CompanyChip } from '@/companies/components/CompanyChip'; -import { PersonChip } from '@/people/components/PersonChip'; -import { getLogoUrlFromDomainName } from '~/utils'; +import { ActivityTargetObjectRecord } from '@/activities/types/ActivityTargetObject'; +import { RecordChip } from '@/object-record/components/RecordChip'; const StyledContainer = styled.div` display: flex; @@ -10,37 +9,21 @@ const StyledContainer = styled.div` gap: ${({ theme }) => theme.spacing(1)}; `; -// TODO: fix edges pagination formatting on n+N -export const ActivityTargetChips = ({ targets }: { targets?: any }) => { - if (!targets) { - return null; - } - +export const ActivityTargetChips = ({ + activityTargetObjectRecords, +}: { + activityTargetObjectRecords: ActivityTargetObjectRecord[]; +}) => { return ( - {targets?.map(({ company, person }: any) => { - if (company) { - return ( - - ); - } - if (person) { - return ( - - ); - } - return <>; - })} + {activityTargetObjectRecords?.map((activityTargetObjectRecord) => ( + + ))} ); }; diff --git a/packages/twenty-front/src/modules/activities/emails/components/Threads.tsx b/packages/twenty-front/src/modules/activities/emails/components/Threads.tsx index 952e7958c..bb9fc06b1 100644 --- a/packages/twenty-front/src/modules/activities/emails/components/Threads.tsx +++ b/packages/twenty-front/src/modules/activities/emails/components/Threads.tsx @@ -4,7 +4,7 @@ import styled from '@emotion/styled'; import { ThreadPreview } from '@/activities/emails/components/ThreadPreview'; import { getTimelineThreadsFromCompanyId } from '@/activities/emails/queries/getTimelineThreadsFromCompanyId'; import { getTimelineThreadsFromPersonId } from '@/activities/emails/queries/getTimelineThreadsFromPersonId'; -import { ActivityTargetableEntity } from '@/activities/types/ActivityTargetableEntity'; +import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity'; import { H1Title, H1TitleFontColor, @@ -29,14 +29,14 @@ const StyledEmailCount = styled.span` color: ${({ theme }) => theme.font.color.light}; `; -export const Threads = ({ entity }: { entity: ActivityTargetableEntity }) => { +export const Threads = ({ entity }: { entity: ActivityTargetableObject }) => { const threadQuery = - entity.type === 'Person' + entity.targetObjectNameSingular === 'person' ? getTimelineThreadsFromPersonId : getTimelineThreadsFromCompanyId; const threadQueryVariables = - entity.type === 'Person' + entity.targetObjectNameSingular === 'person' ? { personId: entity.id } : { companyId: entity.id }; @@ -50,7 +50,7 @@ export const Threads = ({ entity }: { entity: ActivityTargetableEntity }) => { const timelineThreads: TimelineThread[] = threads.data[ - entity.type === 'Person' + entity.targetObjectNameSingular === 'Person' ? 'getTimelineThreadsFromPersonId' : 'getTimelineThreadsFromCompanyId' ]; diff --git a/packages/twenty-front/src/modules/activities/emails/components/__stories__/Threads.stories.tsx b/packages/twenty-front/src/modules/activities/emails/components/__stories__/Threads.stories.tsx index a4c4d04d4..92fbf44e5 100644 --- a/packages/twenty-front/src/modules/activities/emails/components/__stories__/Threads.stories.tsx +++ b/packages/twenty-front/src/modules/activities/emails/components/__stories__/Threads.stories.tsx @@ -7,7 +7,7 @@ const meta: Meta = { component: Threads, args: { entity: { - type: 'Person', + targetObjectNameSingular: 'person', id: '52ba3fd0-c723-4482-8b11-5fc24a587c71', }, }, diff --git a/packages/twenty-front/src/modules/activities/files/components/Attachments.tsx b/packages/twenty-front/src/modules/activities/files/components/Attachments.tsx index b7b8dbd77..27225559c 100644 --- a/packages/twenty-front/src/modules/activities/files/components/Attachments.tsx +++ b/packages/twenty-front/src/modules/activities/files/components/Attachments.tsx @@ -1,12 +1,14 @@ import { ChangeEvent, useRef } from 'react'; import styled from '@emotion/styled'; +import { isNonEmptyArray } from '@sniptt/guards'; import { useRecoilValue } from 'recoil'; import { AttachmentList } from '@/activities/files/components/AttachmentList'; import { useAttachments } from '@/activities/files/hooks/useAttachments'; import { Attachment } from '@/activities/files/types/Attachment'; import { getFileType } from '@/activities/files/utils/getFileType'; -import { ActivityTargetableEntity } from '@/activities/types/ActivityTargetableEntity'; +import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity'; +import { getActivityTargetObjectFieldIdName } from '@/activities/utils/getTargetObjectFilterFieldName'; import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord'; @@ -56,13 +58,13 @@ const StyledFileInput = styled.input` `; export const Attachments = ({ - targetableEntity, + targetableObject, }: { - targetableEntity: ActivityTargetableEntity; + targetableObject: ActivityTargetableObject; }) => { const inputFileRef = useRef(null); const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState); - const { attachments } = useAttachments(targetableEntity); + const { attachments } = useAttachments(targetableObject); const [uploadFile] = useUploadFileMutation(); @@ -92,22 +94,23 @@ export const Attachments = ({ if (!attachmentUrl) { return; } - if (!createOneAttachment) { - return; - } - await createOneAttachment({ + const targetableObjectFieldIdName = getActivityTargetObjectFieldIdName({ + nameSingular: targetableObject.targetObjectNameSingular, + }); + + const attachmentToCreate = { authorId: currentWorkspaceMember?.id, name: file.name, fullPath: attachmentUrl, type: getFileType(file.name), - companyId: - targetableEntity.type === 'Company' ? targetableEntity.id : null, - personId: targetableEntity.type === 'Person' ? targetableEntity.id : null, - }); + [targetableObjectFieldIdName]: targetableObject.id, + }; + + await createOneAttachment(attachmentToCreate); }; - if (attachments?.length === 0 && targetableEntity.type !== 'Custom') { + if (!isNonEmptyArray(attachments)) { return ( { +export const useAttachments = (targetableObject: ActivityTargetableObject) => { + const targetableObjectFieldIdName = getActivityTargetObjectFieldIdName({ + nameSingular: targetableObject.targetObjectNameSingular, + }); + const { records: attachments } = useFindManyRecords({ objectNameSingular: CoreObjectNameSingular.Attachment, filter: { - [entity.type === 'Company' ? 'companyId' : 'personId']: { eq: entity.id }, + [targetableObjectFieldIdName]: { + eq: targetableObject.id, + }, }, orderBy: { createdAt: 'DescNullsFirst', diff --git a/packages/twenty-front/src/modules/activities/hooks/useActivityTargetObjectRecords.ts b/packages/twenty-front/src/modules/activities/hooks/useActivityTargetObjectRecords.ts new file mode 100644 index 000000000..b3ef7be3d --- /dev/null +++ b/packages/twenty-front/src/modules/activities/hooks/useActivityTargetObjectRecords.ts @@ -0,0 +1,55 @@ +import { useMemo } from 'react'; +import { useRecoilValue } from 'recoil'; + +import { ActivityTargetObjectRecord } from '@/activities/types/ActivityTargetObject'; +import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; +import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; +import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords'; +import { Nullable } from '~/types/Nullable'; +import { isDefined } from '~/utils/isDefined'; + +export const useActivityTargetObjectRecords = ({ + activityId, +}: { + activityId: string; +}) => { + const objectMetadataItems = useRecoilValue(objectMetadataItemsState); + + const { records: activityTargets } = useFindManyRecords({ + objectNameSingular: CoreObjectNameSingular.ActivityTarget, + filter: { + activityId: { + eq: activityId, + }, + }, + }); + + const activityTargetObjectRecords = useMemo(() => { + return activityTargets + .map>((activityTarget) => { + const correspondingObjectMetadataItem = objectMetadataItems.find( + (objectMetadataItem) => + isDefined(activityTarget[objectMetadataItem.nameSingular]) && + !objectMetadataItem.isSystem, + ); + + if (!correspondingObjectMetadataItem) { + return null; + } + + return { + activityTargetRecord: activityTarget, + targetObjectRecord: + activityTarget[correspondingObjectMetadataItem.nameSingular], + targetObjectMetadataItem: correspondingObjectMetadataItem, + targetObjectNameSingular: + correspondingObjectMetadataItem.nameSingular, + }; + }) + .filter(isDefined); + }, [activityTargets, objectMetadataItems]); + + return { + activityTargetObjectRecords, + }; +}; diff --git a/packages/twenty-front/src/modules/activities/hooks/useActivityTargets.ts b/packages/twenty-front/src/modules/activities/hooks/useActivityTargets.ts new file mode 100644 index 000000000..eb8845073 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/hooks/useActivityTargets.ts @@ -0,0 +1,28 @@ +import { ActivityTarget } from '@/activities/types/ActivityTarget'; +import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity'; +import { getActivityTargetObjectFieldIdName } from '@/activities/utils/getTargetObjectFilterFieldName'; +import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; +import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords'; + +export const useActivityTargets = ({ + targetableObject, +}: { + targetableObject: ActivityTargetableObject; +}) => { + const targetObjectFieldName = getActivityTargetObjectFieldIdName({ + nameSingular: targetableObject.targetObjectNameSingular, + }); + + const { records: activityTargets } = useFindManyRecords({ + objectNameSingular: CoreObjectNameSingular.ActivityTarget, + filter: { + [targetObjectFieldName]: { + eq: targetableObject.id, + }, + }, + }); + + return { + activityTargets: activityTargets as ActivityTarget[], + }; +}; diff --git a/packages/twenty-front/src/modules/activities/hooks/useOpenCreateActivityDrawer.ts b/packages/twenty-front/src/modules/activities/hooks/useOpenCreateActivityDrawer.ts index 1567231f7..b0581ec5c 100644 --- a/packages/twenty-front/src/modules/activities/hooks/useOpenCreateActivityDrawer.ts +++ b/packages/twenty-front/src/modules/activities/hooks/useOpenCreateActivityDrawer.ts @@ -4,8 +4,10 @@ import { useRecoilState, useRecoilValue } from 'recoil'; import { Activity, ActivityType } from '@/activities/types/Activity'; import { ActivityTarget } from '@/activities/types/ActivityTarget'; +import { getActivityTargetObjectFieldIdName } from '@/activities/utils/getTargetObjectFilterFieldName'; import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; +import { useCreateManyRecords } from '@/object-record/hooks/useCreateManyRecords'; import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord'; import { useRightDrawer } from '@/ui/layout/right-drawer/hooks/useRightDrawer'; import { RightDrawerHotkeyScope } from '@/ui/layout/right-drawer/types/RightDrawerHotkeyScope'; @@ -14,13 +16,13 @@ import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope import { activityTargetableEntityArrayState } from '../states/activityTargetableEntityArrayState'; import { viewableActivityIdState } from '../states/viewableActivityIdState'; -import { ActivityTargetableEntity } from '../types/ActivityTargetableEntity'; -import { getTargetableEntitiesWithParents } from '../utils/getTargetableEntitiesWithParents'; +import { ActivityTargetableObject } from '../types/ActivityTargetableEntity'; +import { flattenTargetableObjectsAndTheirRelatedTargetableObjects } from '../utils/flattenTargetableObjectsAndTheirRelatedTargetableObjects'; export const useOpenCreateActivityDrawer = () => { const { openRightDrawer } = useRightDrawer(); - const { createOneRecord: createOneActivityTarget } = - useCreateOneRecord({ + const { createManyRecords: createManyActivityTargets } = + useCreateManyRecords({ objectNameSingular: CoreObjectNameSingular.ActivityTarget, }); const { createOneRecord: createOneActivity } = useCreateOneRecord({ @@ -37,15 +39,17 @@ export const useOpenCreateActivityDrawer = () => { return useCallback( async ({ type, - targetableEntities, + targetableObjects, assigneeId, }: { type: ActivityType; - targetableEntities?: ActivityTargetableEntity[]; + targetableObjects?: ActivityTargetableObject[]; assigneeId?: string; }) => { - const targetableEntitiesWithRelations = targetableEntities - ? getTargetableEntitiesWithParents(targetableEntities) + const flattenedTargetableObjects = targetableObjects + ? flattenTargetableObjectsAndTheirRelatedTargetableObjects( + targetableObjects, + ) : []; const createdActivity = await createOneActivity?.({ @@ -61,21 +65,25 @@ export const useOpenCreateActivityDrawer = () => { return; } - await Promise.all( - targetableEntitiesWithRelations.map(async (targetableEntity) => { - await createOneActivityTarget?.({ - companyId: - targetableEntity.type === 'Company' ? targetableEntity.id : null, - personId: - targetableEntity.type === 'Person' ? targetableEntity.id : null, + const activityTargetsToCreate = flattenedTargetableObjects.map( + (targetableObject) => { + const targetableObjectFieldIdName = + getActivityTargetObjectFieldIdName({ + nameSingular: targetableObject.targetObjectNameSingular, + }); + + return { + [targetableObjectFieldIdName]: targetableObject.id, activityId: createdActivity.id, - }); - }), + }; + }, ); + await createManyActivityTargets(activityTargetsToCreate); + setHotkeyScope(RightDrawerHotkeyScope.RightDrawer, { goto: false }); setViewableActivityId(createdActivity.id); - setActivityTargetableEntityArray(targetableEntities ?? []); + setActivityTargetableEntityArray(targetableObjects ?? []); openRightDrawer(RightDrawerPages.CreateActivity); }, [ @@ -84,7 +92,7 @@ export const useOpenCreateActivityDrawer = () => { setHotkeyScope, setViewableActivityId, createOneActivity, - createOneActivityTarget, + createManyActivityTargets, currentWorkspaceMember, ], ); diff --git a/packages/twenty-front/src/modules/activities/hooks/useOpenCreateActivityDrawerForSelectedRowIds.ts b/packages/twenty-front/src/modules/activities/hooks/useOpenCreateActivityDrawerForSelectedRowIds.ts index b950b7a6f..d65e78eb2 100644 --- a/packages/twenty-front/src/modules/activities/hooks/useOpenCreateActivityDrawerForSelectedRowIds.ts +++ b/packages/twenty-front/src/modules/activities/hooks/useOpenCreateActivityDrawerForSelectedRowIds.ts @@ -4,10 +4,7 @@ import { ActivityType } from '@/activities/types/Activity'; import { useRecordTableScopedStates } from '@/object-record/record-table/hooks/internal/useRecordTableScopedStates'; import { getRecordTableScopeInjector } from '@/object-record/record-table/utils/getRecordTableScopeInjector'; -import { - ActivityTargetableEntity, - ActivityTargetableEntityType, -} from '../types/ActivityTargetableEntity'; +import { ActivityTargetableObject } from '../types/ActivityTargetableEntity'; import { useOpenCreateActivityDrawer } from './useOpenCreateActivityDrawer'; @@ -25,8 +22,8 @@ export const useOpenCreateActivityDrawerForSelectedRowIds = ( ({ snapshot }) => ( type: ActivityType, - entityType: ActivityTargetableEntityType, - relatedEntities?: ActivityTargetableEntity[], + objectNameSingular: string, + relatedEntities?: ActivityTargetableObject[], ) => { const selectedRowIds = injectSelectorSnapshotValueWithRecordTableScopeId( @@ -34,18 +31,21 @@ export const useOpenCreateActivityDrawerForSelectedRowIds = ( selectedRowIdsScopeInjector, ); - let activityTargetableEntityArray: ActivityTargetableEntity[] = + let activityTargetableEntityArray: ActivityTargetableObject[] = selectedRowIds.map((id: string) => ({ - type: entityType, + type: 'Custom', + targetObjectNameSingular: objectNameSingular, id, })); + if (relatedEntities) { activityTargetableEntityArray = activityTargetableEntityArray.concat(relatedEntities); } + openCreateActivityDrawer({ type, - targetableEntities: activityTargetableEntityArray, + targetableObjects: activityTargetableEntityArray, }); }, [ diff --git a/packages/twenty-front/src/modules/activities/inline-cell/components/ActivityTargetInlineCellEditMode.tsx b/packages/twenty-front/src/modules/activities/inline-cell/components/ActivityTargetInlineCellEditMode.tsx index 04e0c69f2..7a22cd4dd 100644 --- a/packages/twenty-front/src/modules/activities/inline-cell/components/ActivityTargetInlineCellEditMode.tsx +++ b/packages/twenty-front/src/modules/activities/inline-cell/components/ActivityTargetInlineCellEditMode.tsx @@ -1,22 +1,15 @@ -import { useCallback, useMemo, useState } from 'react'; -import { useQuery } from '@apollo/client'; import styled from '@emotion/styled'; +import { v4 } from 'uuid'; -import { useHandleCheckableActivityTargetChange } from '@/activities/hooks/useHandleCheckableActivityTargetChange'; import { ActivityTarget } from '@/activities/types/ActivityTarget'; -import { flatMapAndSortEntityForSelectArrayOfArrayByName } from '@/activities/utils/flatMapAndSortEntityForSelectArrayByName'; -import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; +import { ActivityTargetObjectRecord } from '@/activities/types/ActivityTargetObject'; +import { getActivityTargetObjectFieldIdName } from '@/activities/utils/getTargetObjectFilterFieldName'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; +import { useCreateManyRecords } from '@/object-record/hooks/useCreateManyRecords'; +import { useDeleteManyRecords } from '@/object-record/hooks/useDeleteManyRecords'; import { useInlineCell } from '@/object-record/record-inline-cell/hooks/useInlineCell'; -import { MultipleEntitySelect } from '@/object-record/relation-picker/components/MultipleEntitySelect'; -import { useRelationPicker } from '@/object-record/relation-picker/hooks/useRelationPicker'; -import { useFilteredSearchEntityQuery } from '@/search/hooks/useFilteredSearchEntityQuery'; -import { assertNotNull } from '~/utils/assert'; - -type ActivityTargetInlineCellEditModeProps = { - activityId: string; - activityTargets: Array>; -}; +import { MultipleObjectRecordSelect } from '@/object-record/relation-picker/components/MultipleObjectRecordSelect'; +import { ObjectRecordForSelect } from '@/object-record/relation-picker/hooks/useMultiObjectSearch'; const StyledSelectContainer = styled.div` left: 0px; @@ -24,125 +17,77 @@ const StyledSelectContainer = styled.div` top: -8px; `; +type ActivityTargetInlineCellEditModeProps = { + activityId: string; + activityTargetObjectRecords: ActivityTargetObjectRecord[]; +}; + export const ActivityTargetInlineCellEditMode = ({ activityId, - activityTargets, + activityTargetObjectRecords, }: ActivityTargetInlineCellEditModeProps) => { - const [searchFilter, setSearchFilter] = useState(''); - - const initialPeopleIds = useMemo( - () => - activityTargets - ?.filter(({ personId }) => personId !== null) - .map(({ personId }) => personId) - .filter(assertNotNull) ?? [], - [activityTargets], + const selectedObjectRecordIds = activityTargetObjectRecords.map( + (activityTarget) => ({ + objectNameSingular: activityTarget.targetObjectNameSingular, + id: activityTarget.targetObjectRecord.id, + }), ); - const initialCompanyIds = useMemo( - () => - activityTargets - ?.filter(({ companyId }) => companyId !== null) - .map(({ companyId }) => companyId) - .filter(assertNotNull) ?? [], - [activityTargets], - ); - - const initialSelectedEntityIds = useMemo( - () => - [...initialPeopleIds, ...initialCompanyIds].reduce< - Record - >((result, entityId) => ({ ...result, [entityId]: true }), {}), - [initialPeopleIds, initialCompanyIds], - ); - - const { findManyRecordsQuery: findManyPeopleQuery } = useObjectMetadataItem({ - objectNameSingular: CoreObjectNameSingular.Person, - }); - - const { findManyRecordsQuery: findManyCompaniesQuery } = - useObjectMetadataItem({ - objectNameSingular: CoreObjectNameSingular.Company, + const { createManyRecords: createManyActivityTargets } = + useCreateManyRecords({ + objectNameSingular: CoreObjectNameSingular.ActivityTarget, }); - const useFindManyPeopleQuery = (options: any) => - useQuery(findManyPeopleQuery, options); + const { deleteManyRecords: deleteManyActivityTargets } = useDeleteManyRecords( + { + objectNameSingular: CoreObjectNameSingular.ActivityTarget, + }, + ); - const useFindManyCompaniesQuery = (options: any) => - useQuery(findManyCompaniesQuery, options); - - const [selectedEntityIds, setSelectedEntityIds] = useState< - Record - >(initialSelectedEntityIds); - - const { identifiersMapper, searchQuery } = useRelationPicker(); - - const people = useFilteredSearchEntityQuery({ - queryHook: useFindManyPeopleQuery, - filters: [ - { - fieldNames: searchQuery?.computeFilterFields?.('person') ?? [], - filter: searchFilter, - }, - ], - orderByField: 'createdAt', - mappingFunction: (record: any) => identifiersMapper?.(record, 'person'), - selectedIds: initialPeopleIds, - objectNameSingular: CoreObjectNameSingular.Person, - limit: 3, - }); - - const companies = useFilteredSearchEntityQuery({ - queryHook: useFindManyCompaniesQuery, - filters: [ - { - fieldNames: searchQuery?.computeFilterFields?.('company') ?? [], - filter: searchFilter, - }, - ], - orderByField: 'createdAt', - mappingFunction: (record: any) => identifiersMapper?.(record, 'company'), - selectedIds: initialCompanyIds, - objectNameSingular: CoreObjectNameSingular.Company, - limit: 3, - }); - - const selectedEntities = flatMapAndSortEntityForSelectArrayOfArrayByName([ - people.selectedEntities, - companies.selectedEntities, - ]); - - const filteredSelectedEntities = - flatMapAndSortEntityForSelectArrayOfArrayByName([ - people.filteredSelectedEntities, - companies.filteredSelectedEntities, - ]); - - const entitiesToSelect = flatMapAndSortEntityForSelectArrayOfArrayByName([ - people.entitiesToSelect, - companies.entitiesToSelect, - ]); - - const handleCheckItemsChange = useHandleCheckableActivityTargetChange({ - activityId, - currentActivityTargets: activityTargets, - }); const { closeInlineCell: closeEditableField } = useInlineCell(); - const handleSubmit = useCallback(() => { - handleCheckItemsChange( - selectedEntityIds, - entitiesToSelect, - selectedEntities, - ); + const handleSubmit = async (selectedRecords: ObjectRecordForSelect[]) => { closeEditableField(); - }, [ - closeEditableField, - entitiesToSelect, - handleCheckItemsChange, - selectedEntities, - selectedEntityIds, - ]); + + const activityTargetRecordsToDelete = activityTargetObjectRecords.filter( + (activityTargetObjectRecord) => + !selectedRecords.some( + (selectedRecord) => + selectedRecord.recordIdentifier.id === + activityTargetObjectRecord.targetObjectRecord.id, + ), + ); + + const activityTargetRecordsToCreate = selectedRecords.filter( + (selectedRecord) => + !activityTargetObjectRecords.some( + (activityTargetObjectRecord) => + activityTargetObjectRecord.targetObjectRecord.id === + selectedRecord.recordIdentifier.id, + ), + ); + + if (activityTargetRecordsToCreate.length > 0) { + await createManyActivityTargets( + activityTargetRecordsToCreate.map((selectedRecord) => ({ + id: v4(), + activityId, + [getActivityTargetObjectFieldIdName({ + nameSingular: selectedRecord.objectMetadataItem.nameSingular, + })]: selectedRecord.recordIdentifier.id, + })), + ); + } + + if (activityTargetRecordsToDelete.length > 0) { + await deleteManyActivityTargets( + activityTargetRecordsToDelete.map( + (activityTargetObjectRecord) => + activityTargetObjectRecord.activityTargetRecord.id, + ), + ); + } + }; const handleCancel = () => { closeEditableField(); @@ -150,17 +95,8 @@ export const ActivityTargetInlineCellEditMode = ({ return ( - diff --git a/packages/twenty-front/src/modules/activities/inline-cell/components/ActivityTargetsInlineCell.tsx b/packages/twenty-front/src/modules/activities/inline-cell/components/ActivityTargetsInlineCell.tsx index cc94e6fee..fd062ed71 100644 --- a/packages/twenty-front/src/modules/activities/inline-cell/components/ActivityTargetsInlineCell.tsx +++ b/packages/twenty-front/src/modules/activities/inline-cell/components/ActivityTargetsInlineCell.tsx @@ -1,9 +1,8 @@ import { ActivityTargetChips } from '@/activities/components/ActivityTargetChips'; +import { useActivityTargetObjectRecords } from '@/activities/hooks/useActivityTargetObjectRecords'; import { ActivityTargetInlineCellEditMode } from '@/activities/inline-cell/components/ActivityTargetInlineCellEditMode'; import { ActivityTarget } from '@/activities/types/ActivityTarget'; import { GraphQLActivity } from '@/activities/types/GraphQLActivity'; -import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; -import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords'; import { RecordInlineCellContainer } from '@/object-record/record-inline-cell/components/RecordInlineCellContainer'; import { FieldRecoilScopeContext } from '@/object-record/record-inline-cell/states/recoil-scope-contexts/FieldRecoilScopeContext'; import { RelationPickerHotkeyScope } from '@/object-record/relation-picker/types/RelationPickerHotkeyScope'; @@ -23,14 +22,8 @@ type ActivityTargetsInlineCellProps = { export const ActivityTargetsInlineCell = ({ activity, }: ActivityTargetsInlineCellProps) => { - const activityTargetIds = - activity?.activityTargets?.edges?.map( - (activityTarget) => activityTarget.node.id, - ) ?? []; - - const { records: activityTargets } = useFindManyRecords({ - objectNameSingular: CoreObjectNameSingular.ActivityTarget, - filter: { id: { in: activityTargetIds } }, + const { activityTargetObjectRecords } = useActivityTargetObjectRecords({ + activityId: activity?.id ?? '', }); return ( @@ -44,11 +37,15 @@ export const ActivityTargetsInlineCell = ({ editModeContent={ } label="Relations" - displayModeContent={} + displayModeContent={ + + } isDisplayModeContentEmpty={ activity?.activityTargets?.edges?.length === 0 } diff --git a/packages/twenty-front/src/modules/activities/notes/components/Notes.tsx b/packages/twenty-front/src/modules/activities/notes/components/Notes.tsx index 800abcb88..24dbb45d4 100644 --- a/packages/twenty-front/src/modules/activities/notes/components/Notes.tsx +++ b/packages/twenty-front/src/modules/activities/notes/components/Notes.tsx @@ -3,7 +3,7 @@ import styled from '@emotion/styled'; import { useOpenCreateActivityDrawer } from '@/activities/hooks/useOpenCreateActivityDrawer'; import { NoteList } from '@/activities/notes/components/NoteList'; import { useNotes } from '@/activities/notes/hooks/useNotes'; -import { ActivityTargetableEntity } from '@/activities/types/ActivityTargetableEntity'; +import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity'; import { IconPlus } from '@/ui/display/icon'; import { Button } from '@/ui/input/button/components/Button'; @@ -44,12 +44,16 @@ const StyledNotesContainer = styled.div` overflow: auto; `; -export const Notes = ({ entity }: { entity: ActivityTargetableEntity }) => { - const { notes } = useNotes(entity); +export const Notes = ({ + targetableObject, +}: { + targetableObject: ActivityTargetableObject; +}) => { + const { notes } = useNotes(targetableObject); const openCreateActivity = useOpenCreateActivityDrawer(); - if (notes?.length === 0 && entity.type !== 'Custom') { + if (notes?.length === 0) { return ( No note yet @@ -61,7 +65,7 @@ export const Notes = ({ entity }: { entity: ActivityTargetableEntity }) => { onClick={() => openCreateActivity({ type: 'Note', - targetableEntities: [entity], + targetableObjects: [targetableObject], }) } /> @@ -83,7 +87,7 @@ export const Notes = ({ entity }: { entity: ActivityTargetableEntity }) => { onClick={() => openCreateActivity({ type: 'Note', - targetableEntities: [entity], + targetableObjects: [targetableObject], }) } > diff --git a/packages/twenty-front/src/modules/activities/notes/hooks/useNotes.ts b/packages/twenty-front/src/modules/activities/notes/hooks/useNotes.ts index 3c8e4f867..de0f8ef37 100644 --- a/packages/twenty-front/src/modules/activities/notes/hooks/useNotes.ts +++ b/packages/twenty-front/src/modules/activities/notes/hooks/useNotes.ts @@ -1,17 +1,13 @@ +import { useActivityTargets } from '@/activities/hooks/useActivityTargets'; import { Note } from '@/activities/types/Note'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { OrderByField } from '@/object-metadata/types/OrderByField'; import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords'; -import { ActivityTargetableEntity } from '../../types/ActivityTargetableEntity'; +import { ActivityTargetableObject } from '../../types/ActivityTargetableEntity'; -export const useNotes = (entity: ActivityTargetableEntity) => { - const { records: activityTargets } = useFindManyRecords({ - objectNameSingular: CoreObjectNameSingular.ActivityTarget, - filter: { - [entity.type === 'Company' ? 'companyId' : 'personId']: { eq: entity.id }, - }, - }); +export const useNotes = (targetableObject: ActivityTargetableObject) => { + const { activityTargets } = useActivityTargets({ targetableObject }); const filter = { id: { @@ -19,6 +15,7 @@ export const useNotes = (entity: ActivityTargetableEntity) => { }, type: { eq: 'Note' }, }; + const orderBy = { createdAt: 'AscNullsFirst', } as OrderByField; diff --git a/packages/twenty-front/src/modules/activities/states/activityTargetableEntityArrayState.ts b/packages/twenty-front/src/modules/activities/states/activityTargetableEntityArrayState.ts index 25e2da755..c3a06d48f 100644 --- a/packages/twenty-front/src/modules/activities/states/activityTargetableEntityArrayState.ts +++ b/packages/twenty-front/src/modules/activities/states/activityTargetableEntityArrayState.ts @@ -1,9 +1,9 @@ import { atom } from 'recoil'; -import { ActivityTargetableEntity } from '../types/ActivityTargetableEntity'; +import { ActivityTargetableObject } from '../types/ActivityTargetableEntity'; export const activityTargetableEntityArrayState = atom< - ActivityTargetableEntity[] + ActivityTargetableObject[] >({ key: 'activities/targetable-entity-array', default: [], diff --git a/packages/twenty-front/src/modules/activities/tasks/__stories__/TaskGroups.stories.tsx b/packages/twenty-front/src/modules/activities/tasks/__stories__/TaskGroups.stories.tsx index c369e267d..7404fcf19 100644 --- a/packages/twenty-front/src/modules/activities/tasks/__stories__/TaskGroups.stories.tsx +++ b/packages/twenty-front/src/modules/activities/tasks/__stories__/TaskGroups.stories.tsx @@ -2,6 +2,7 @@ import { Meta, StoryObj } from '@storybook/react'; import { TasksRecoilScopeContext } from '@/activities/states/recoil-scope-contexts/TasksRecoilScopeContext'; import { TaskGroups } from '@/activities/tasks/components/TaskGroups'; +import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity'; import { ObjectFilterDropdownScope } from '@/object-record/object-filter-dropdown/scopes/ObjectFilterDropdownScope'; import { ComponentWithRecoilScopeDecorator } from '~/testing/decorators/ComponentWithRecoilScopeDecorator'; import { ComponentWithRouterDecorator } from '~/testing/decorators/ComponentWithRouterDecorator'; @@ -35,9 +36,11 @@ export const Empty: Story = {}; export const WithTasks: Story = { args: { - entity: { - id: mockedTasks[0].authorId, - type: 'Person', - }, + targetableObjects: [ + { + id: mockedTasks[0].authorId, + targetObjectNameSingular: 'person', + }, + ] as ActivityTargetableObject[], }, }; diff --git a/packages/twenty-front/src/modules/activities/tasks/components/AddTaskButton.tsx b/packages/twenty-front/src/modules/activities/tasks/components/AddTaskButton.tsx index a3ee09979..3c02ddb29 100644 --- a/packages/twenty-front/src/modules/activities/tasks/components/AddTaskButton.tsx +++ b/packages/twenty-front/src/modules/activities/tasks/components/AddTaskButton.tsx @@ -1,16 +1,18 @@ +import { isNonEmptyArray } from '@sniptt/guards'; + import { useOpenCreateActivityDrawer } from '@/activities/hooks/useOpenCreateActivityDrawer'; -import { ActivityTargetableEntity } from '@/activities/types/ActivityTargetableEntity'; +import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity'; import { IconPlus } from '@/ui/display/icon'; import { Button } from '@/ui/input/button/components/Button'; export const AddTaskButton = ({ - activityTargetEntity, + activityTargetableObjects, }: { - activityTargetEntity?: ActivityTargetableEntity; + activityTargetableObjects?: ActivityTargetableObject[]; }) => { const openCreateActivity = useOpenCreateActivityDrawer(); - if (!activityTargetEntity) { + if (!isNonEmptyArray(activityTargetableObjects)) { return <>; } @@ -23,7 +25,7 @@ export const AddTaskButton = ({ onClick={() => openCreateActivity({ type: 'Task', - targetableEntities: [activityTargetEntity], + targetableObjects: activityTargetableObjects, }) } > diff --git a/packages/twenty-front/src/modules/activities/tasks/components/EntityTasks.tsx b/packages/twenty-front/src/modules/activities/tasks/components/ObjectTasks.tsx similarity index 78% rename from packages/twenty-front/src/modules/activities/tasks/components/EntityTasks.tsx rename to packages/twenty-front/src/modules/activities/tasks/components/ObjectTasks.tsx index f7ffd434b..fd54c2169 100644 --- a/packages/twenty-front/src/modules/activities/tasks/components/EntityTasks.tsx +++ b/packages/twenty-front/src/modules/activities/tasks/components/ObjectTasks.tsx @@ -2,7 +2,7 @@ import styled from '@emotion/styled'; import { TasksRecoilScopeContext } from '@/activities/states/recoil-scope-contexts/TasksRecoilScopeContext'; import { TaskGroups } from '@/activities/tasks/components/TaskGroups'; -import { ActivityTargetableEntity } from '@/activities/types/ActivityTargetableEntity'; +import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity'; import { ObjectFilterDropdownScope } from '@/object-record/object-filter-dropdown/scopes/ObjectFilterDropdownScope'; import { RecoilScope } from '@/ui/utilities/recoil-scope/components/RecoilScope'; @@ -14,16 +14,16 @@ const StyledContainer = styled.div` overflow: auto; `; -export const EntityTasks = ({ - entity, +export const ObjectTasks = ({ + targetableObject, }: { - entity: ActivityTargetableEntity; + targetableObject: ActivityTargetableObject; }) => { return ( - + diff --git a/packages/twenty-front/src/modules/activities/tasks/components/TaskGroups.tsx b/packages/twenty-front/src/modules/activities/tasks/components/TaskGroups.tsx index 246500a97..c9c8934aa 100644 --- a/packages/twenty-front/src/modules/activities/tasks/components/TaskGroups.tsx +++ b/packages/twenty-front/src/modules/activities/tasks/components/TaskGroups.tsx @@ -3,7 +3,7 @@ import styled from '@emotion/styled'; import { useOpenCreateActivityDrawer } from '@/activities/hooks/useOpenCreateActivityDrawer'; import { TasksRecoilScopeContext } from '@/activities/states/recoil-scope-contexts/TasksRecoilScopeContext'; import { useTasks } from '@/activities/tasks/hooks/useTasks'; -import { ActivityTargetableEntity } from '@/activities/types/ActivityTargetableEntity'; +import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity'; import { IconPlus } from '@/ui/display/icon'; import { Button } from '@/ui/input/button/components/Button'; import { activeTabIdScopedState } from '@/ui/layout/tab/states/activeTabIdScopedState'; @@ -12,12 +12,6 @@ import { useRecoilScopedState } from '@/ui/utilities/recoil-scope/hooks/useRecoi import { AddTaskButton } from './AddTaskButton'; import { TaskList } from './TaskList'; -type TaskGroupsProps = { - filterDropdownId?: string; - entity?: ActivityTargetableEntity; - showAddButton?: boolean; -}; - const StyledTaskGroupEmptyContainer = styled.div` align-items: center; align-self: stretch; @@ -52,9 +46,15 @@ const StyledContainer = styled.div` flex-direction: column; `; +type TaskGroupsProps = { + filterDropdownId?: string; + targetableObjects?: ActivityTargetableObject[]; + showAddButton?: boolean; +}; + export const TaskGroups = ({ filterDropdownId, - entity, + targetableObjects, showAddButton, }: TaskGroupsProps) => { const { @@ -62,7 +62,10 @@ export const TaskGroups = ({ upcomingTasks, unscheduledTasks, completedTasks, - } = useTasks({ filterDropdownId: filterDropdownId, entity }); + } = useTasks({ + filterDropdownId: filterDropdownId, + targetableObjects: targetableObjects ?? [], + }); const openCreateActivity = useOpenCreateActivityDrawer(); @@ -71,10 +74,6 @@ export const TaskGroups = ({ TasksRecoilScopeContext, ); - if (entity?.type === 'Custom') { - return <>; - } - if ( (activeTabId !== 'done' && todayOrPreviousTasks?.length === 0 && @@ -93,7 +92,7 @@ export const TaskGroups = ({ onClick={() => openCreateActivity({ type: 'Task', - targetableEntities: entity ? [entity] : undefined, + targetableObjects, }) } /> @@ -107,7 +106,9 @@ export const TaskGroups = ({ + showAddButton && ( + + ) } /> ) : ( @@ -116,7 +117,9 @@ export const TaskGroups = ({ title="Today" tasks={todayOrPreviousTasks ?? []} button={ - showAddButton && + showAddButton && ( + + ) } /> + ) } /> @@ -136,7 +139,7 @@ export const TaskGroups = ({ showAddButton && !todayOrPreviousTasks?.length && !upcomingTasks?.length && ( - + ) } /> diff --git a/packages/twenty-front/src/modules/activities/tasks/components/TaskRow.tsx b/packages/twenty-front/src/modules/activities/tasks/components/TaskRow.tsx index d5c0e1f1f..2bf25b01d 100644 --- a/packages/twenty-front/src/modules/activities/tasks/components/TaskRow.tsx +++ b/packages/twenty-front/src/modules/activities/tasks/components/TaskRow.tsx @@ -2,12 +2,10 @@ import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; import { ActivityTargetChips } from '@/activities/components/ActivityTargetChips'; +import { useActivityTargetObjectRecords } from '@/activities/hooks/useActivityTargetObjectRecords'; import { useOpenActivityRightDrawer } from '@/activities/hooks/useOpenActivityRightDrawer'; -import { ActivityTarget } from '@/activities/types/ActivityTarget'; import { GraphQLActivity } from '@/activities/types/GraphQLActivity'; import { getActivitySummary } from '@/activities/utils/getActivitySummary'; -import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; -import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords'; import { IconCalendar, IconComment } from '@/ui/display/icon'; import { OverflowingTextWithTooltip } from '@/ui/display/tooltip/OverflowingTextWithTooltip'; import { Checkbox, CheckboxShape } from '@/ui/input/components/Checkbox'; @@ -76,14 +74,8 @@ export const TaskRow = ({ const body = getActivitySummary(task.body); const { completeTask } = useCompleteTask(task); - const activityTargetIds = - task?.activityTargets?.edges?.map( - (activityTarget) => activityTarget.node.id, - ) ?? []; - - const { records: activityTargets } = useFindManyRecords({ - objectNameSingular: CoreObjectNameSingular.ActivityTarget, - filter: { id: { in: activityTargetIds } }, + const { activityTargetObjectRecords } = useActivityTargetObjectRecords({ + activityId: task.id, }); return ( @@ -115,7 +107,9 @@ export const TaskRow = ({ )} - + { - const { filterDropdownId, entity } = props ?? {}; - +export const useTasks = ({ + targetableObjects, + filterDropdownId, +}: UseTasksProps) => { const { selectedFilter } = useFilterDropdown({ - filterDropdownId: filterDropdownId, + filterDropdownId, }); + const targetableObjectsFilter = + targetableObjects.reduce( + (aggregateFilter, targetableObject) => { + const targetableObjectFieldName = getActivityTargetObjectFieldIdName({ + nameSingular: targetableObject.targetObjectNameSingular, + }); + + if (isNonEmptyString(targetableObject.id)) { + aggregateFilter[targetableObjectFieldName] = { + eq: targetableObject.id, + }; + } + + return aggregateFilter; + }, + {}, + ); + const { records: activityTargets } = useFindManyRecords({ objectNameSingular: CoreObjectNameSingular.ActivityTarget, - filter: isDefined(entity) - ? { - [entity?.type === 'Company' ? 'companyId' : 'personId']: { - eq: entity?.id, - }, - } - : undefined, + filter: targetableObjectsFilter, }); + const skipRequest = !isNonEmptyArray(activityTargets) && !selectedFilter; + + const idFilter = { + id: { + in: activityTargets.map((activityTarget) => activityTarget.activityId), + }, + }; + + const assigneeIdFilter = selectedFilter + ? { + assigneeId: { + in: JSON.parse(selectedFilter.value), + }, + } + : undefined; + const { records: completeTasksData } = useFindManyRecords({ objectNameSingular: CoreObjectNameSingular.Activity, - skip: !entity && !selectedFilter, + skip: skipRequest, filter: { completedAt: { is: 'NOT_NULL' }, - ...(isDefined(entity) && { - id: { - in: activityTargets?.map( - (activityTarget) => activityTarget.activityId, - ), - }, - }), + ...idFilter, type: { eq: 'Task' }, - ...(isNonEmptyString(selectedFilter?.value) && { - assigneeId: { - in: JSON.parse(selectedFilter?.value), - }, - }), + ...assigneeIdFilter, }, orderBy: { createdAt: 'DescNullsFirst', @@ -58,22 +78,12 @@ export const useTasks = (props?: UseTasksProps) => { const { records: incompleteTaskData } = useFindManyRecords({ objectNameSingular: CoreObjectNameSingular.Activity, - skip: !entity && !selectedFilter, + skip: skipRequest, filter: { completedAt: { is: 'NULL' }, - ...(isDefined(entity) && { - id: { - in: activityTargets?.map( - (activityTarget) => activityTarget.activityId, - ), - }, - }), + ...idFilter, type: { eq: 'Task' }, - ...(isNonEmptyString(selectedFilter?.value) && { - assigneeId: { - in: JSON.parse(selectedFilter?.value), - }, - }), + ...assigneeIdFilter, }, orderBy: { createdAt: 'DescNullsFirst', diff --git a/packages/twenty-front/src/modules/activities/timeline/components/Timeline.tsx b/packages/twenty-front/src/modules/activities/timeline/components/Timeline.tsx index 2f30431a0..cd5d749d4 100644 --- a/packages/twenty-front/src/modules/activities/timeline/components/Timeline.tsx +++ b/packages/twenty-front/src/modules/activities/timeline/components/Timeline.tsx @@ -1,10 +1,12 @@ import React from 'react'; import styled from '@emotion/styled'; +import { isNonEmptyString } from '@sniptt/guards'; import { ActivityCreateButton } from '@/activities/components/ActivityCreateButton'; +import { useActivityTargets } from '@/activities/hooks/useActivityTargets'; import { useOpenCreateActivityDrawer } from '@/activities/hooks/useOpenCreateActivityDrawer'; import { Activity } from '@/activities/types/Activity'; -import { ActivityTargetableEntity } from '@/activities/types/ActivityTargetableEntity'; +import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords'; import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile'; @@ -48,20 +50,21 @@ const StyledEmptyTimelineSubTitle = styled.div` margin-bottom: ${({ theme }) => theme.spacing(2)}; `; -export const Timeline = ({ entity }: { entity: ActivityTargetableEntity }) => { - const { records: activityTargets, loading } = useFindManyRecords({ - objectNameSingular: CoreObjectNameSingular.ActivityTarget, - filter: { - [entity.type === 'Company' ? 'companyId' : 'personId']: { eq: entity.id }, - }, - }); +export const Timeline = ({ + targetableObject, +}: { + targetableObject: ActivityTargetableObject; +}) => { + const { activityTargets } = useActivityTargets({ targetableObject }); const { records: activities } = useFindManyRecords({ skip: !activityTargets?.length, objectNameSingular: CoreObjectNameSingular.Activity, filter: { id: { - in: activityTargets?.map((activityTarget) => activityTarget.activityId), + in: activityTargets + ?.map((activityTarget) => activityTarget.activityId) + .filter(isNonEmptyString), }, }, orderBy: { @@ -71,10 +74,6 @@ export const Timeline = ({ entity }: { entity: ActivityTargetableEntity }) => { const openCreateActivity = useOpenCreateActivityDrawer(); - if (loading || entity.type === 'Custom') { - return <>; - } - if (!activities.length) { return ( @@ -84,13 +83,13 @@ export const Timeline = ({ entity }: { entity: ActivityTargetableEntity }) => { onNoteClick={() => openCreateActivity({ type: 'Note', - targetableEntities: [entity], + targetableObjects: [targetableObject], }) } onTaskClick={() => openCreateActivity({ type: 'Task', - targetableEntities: [entity], + targetableObjects: [targetableObject], }) } /> diff --git a/packages/twenty-front/src/modules/activities/timeline/components/TimelineActivity.tsx b/packages/twenty-front/src/modules/activities/timeline/components/TimelineActivity.tsx index 3c2bd4ceb..435aaafd0 100644 --- a/packages/twenty-front/src/modules/activities/timeline/components/TimelineActivity.tsx +++ b/packages/twenty-front/src/modules/activities/timeline/components/TimelineActivity.tsx @@ -145,7 +145,7 @@ type TimelineActivityProps = { | 'type' | 'comments' | 'dueAt' - > & { author: Pick } & { + > & { author?: Pick } & { assignee?: Pick | null; }; isLastActivity?: boolean; @@ -165,8 +165,8 @@ export const TimelineActivity = ({ @@ -175,7 +175,8 @@ export const TimelineActivity = ({ - {activity.author.name.firstName} {activity.author.name.lastName} + {activity.author?.name.firstName}{' '} + {activity.author?.name.lastName} created a {activity.type.toLowerCase()} diff --git a/packages/twenty-front/src/modules/activities/types/ActivityTargetObject.ts b/packages/twenty-front/src/modules/activities/types/ActivityTargetObject.ts new file mode 100644 index 000000000..d95d4f520 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/types/ActivityTargetObject.ts @@ -0,0 +1,9 @@ +import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { ObjectRecord } from '@/object-record/types/ObjectRecord'; + +export type ActivityTargetObjectRecord = { + targetObjectMetadataItem: ObjectMetadataItem; + activityTargetRecord: ObjectRecord; + targetObjectRecord: ObjectRecord; + targetObjectNameSingular: string; +}; diff --git a/packages/twenty-front/src/modules/activities/types/ActivityTargetableEntity.ts b/packages/twenty-front/src/modules/activities/types/ActivityTargetableEntity.ts index f474d9ac6..22d25858f 100644 --- a/packages/twenty-front/src/modules/activities/types/ActivityTargetableEntity.ts +++ b/packages/twenty-front/src/modules/activities/types/ActivityTargetableEntity.ts @@ -1,7 +1,5 @@ -export type ActivityTargetableEntityType = 'Person' | 'Company' | 'Custom'; - -export type ActivityTargetableEntity = { +export type ActivityTargetableObject = { id: string; - type: ActivityTargetableEntityType; - relatedEntities?: ActivityTargetableEntity[]; + targetObjectNameSingular: string; + relatedTargetableObjects?: ActivityTargetableObject[]; }; diff --git a/packages/twenty-front/src/modules/activities/types/ActivityTargetableEntityForSelect.ts b/packages/twenty-front/src/modules/activities/types/ActivityTargetableEntityForSelect.ts deleted file mode 100644 index 9d1b65874..000000000 --- a/packages/twenty-front/src/modules/activities/types/ActivityTargetableEntityForSelect.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { EntityForSelect } from '@/object-record/relation-picker/types/EntityForSelect'; - -import { ActivityTargetableEntityType } from './ActivityTargetableEntity'; - -export type ActivityTargetableEntityForSelect = EntityForSelect & { - entityType: ActivityTargetableEntityType; -}; diff --git a/packages/twenty-front/src/modules/activities/utils/__tests__/getTargetableEntitiesWithParents.test.ts b/packages/twenty-front/src/modules/activities/utils/__tests__/getTargetableEntitiesWithParents.test.ts index 4fd159560..489364a1e 100644 --- a/packages/twenty-front/src/modules/activities/utils/__tests__/getTargetableEntitiesWithParents.test.ts +++ b/packages/twenty-front/src/modules/activities/utils/__tests__/getTargetableEntitiesWithParents.test.ts @@ -1,40 +1,41 @@ -import { ActivityTargetableEntity } from '@/activities/types/ActivityTargetableEntity'; -import { getTargetableEntitiesWithParents } from '@/activities/utils/getTargetableEntitiesWithParents'; +import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity'; +import { flattenTargetableObjectsAndTheirRelatedTargetableObjects } from '@/activities/utils/flattenTargetableObjectsAndTheirRelatedTargetableObjects'; describe('getTargetableEntitiesWithParents', () => { it('should return the correct value', () => { - const entities: ActivityTargetableEntity[] = [ + const entities: ActivityTargetableObject[] = [ { id: '1', - type: 'Person', - relatedEntities: [ + targetObjectNameSingular: 'person', + relatedTargetableObjects: [ { id: '2', - type: 'Company', + targetObjectNameSingular: 'company', }, ], }, { id: '4', - type: 'Company', + targetObjectNameSingular: 'person', }, { id: '3', - type: 'Custom', - relatedEntities: [ + targetObjectNameSingular: 'car', + relatedTargetableObjects: [ { id: '6', - type: 'Person', + targetObjectNameSingular: 'person', }, { id: '5', - type: 'Company', + targetObjectNameSingular: 'company', }, ], }, ]; - const res = getTargetableEntitiesWithParents(entities); + const res = + flattenTargetableObjectsAndTheirRelatedTargetableObjects(entities); expect(res).toHaveLength(6); expect(res[0].id).toBe('1'); diff --git a/packages/twenty-front/src/modules/activities/utils/flattenTargetableObjectsAndTheirRelatedTargetableObjects.ts b/packages/twenty-front/src/modules/activities/utils/flattenTargetableObjectsAndTheirRelatedTargetableObjects.ts new file mode 100644 index 000000000..1f30e422a --- /dev/null +++ b/packages/twenty-front/src/modules/activities/utils/flattenTargetableObjectsAndTheirRelatedTargetableObjects.ts @@ -0,0 +1,21 @@ +import { ActivityTargetableObject } from '../types/ActivityTargetableEntity'; + +export const flattenTargetableObjectsAndTheirRelatedTargetableObjects = ( + targetableObjectsWithRelatedTargetableObjects: ActivityTargetableObject[], +): ActivityTargetableObject[] => { + const flattenedTargetableObjects: ActivityTargetableObject[] = []; + + for (const targetableObject of targetableObjectsWithRelatedTargetableObjects ?? + []) { + flattenedTargetableObjects.push(targetableObject); + + if (targetableObject.relatedTargetableObjects) { + for (const relatedEntity of targetableObject.relatedTargetableObjects ?? + []) { + flattenedTargetableObjects.push(relatedEntity); + } + } + } + + return flattenedTargetableObjects; +}; diff --git a/packages/twenty-front/src/modules/activities/utils/getTargetObjectFilterFieldName.ts b/packages/twenty-front/src/modules/activities/utils/getTargetObjectFilterFieldName.ts new file mode 100644 index 000000000..be1256229 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/utils/getTargetObjectFilterFieldName.ts @@ -0,0 +1,15 @@ +import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; + +export const getActivityTargetObjectFieldIdName = ({ + nameSingular, +}: { + nameSingular: string; +}) => { + const isCoreObject = + nameSingular === CoreObjectNameSingular.Company || + nameSingular === CoreObjectNameSingular.Person; + + const objectFieldIdName = `${!isCoreObject ? '_' : ''}${nameSingular}Id`; + + return objectFieldIdName; +}; diff --git a/packages/twenty-front/src/modules/activities/utils/getTargetableEntitiesWithParents.ts b/packages/twenty-front/src/modules/activities/utils/getTargetableEntitiesWithParents.ts deleted file mode 100644 index f97d51d4a..000000000 --- a/packages/twenty-front/src/modules/activities/utils/getTargetableEntitiesWithParents.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { ActivityTargetableEntity } from '../types/ActivityTargetableEntity'; - -export const getTargetableEntitiesWithParents = ( - entities: ActivityTargetableEntity[], -): ActivityTargetableEntity[] => { - const entitiesWithRelations: ActivityTargetableEntity[] = []; - for (const entity of entities ?? []) { - entitiesWithRelations.push(entity); - if (entity.relatedEntities) { - for (const relatedEntity of entity.relatedEntities ?? []) { - entitiesWithRelations.push(relatedEntity); - } - } - } - return entitiesWithRelations; -}; diff --git a/packages/twenty-front/src/modules/companies/hooks/useSpreadsheetCompanyImport.ts b/packages/twenty-front/src/modules/companies/hooks/useSpreadsheetCompanyImport.ts index 81db1f8bc..9b26fb0b5 100644 --- a/packages/twenty-front/src/modules/companies/hooks/useSpreadsheetCompanyImport.ts +++ b/packages/twenty-front/src/modules/companies/hooks/useSpreadsheetCompanyImport.ts @@ -28,39 +28,44 @@ export const useSpreadsheetCompanyImport = () => { ...options, onSubmit: async (data) => { // TODO: Add better type checking in spreadsheet import later - const createInputs = data.validData.map((company) => ({ - name: company.name as string | undefined, - domainName: company.domainName as string | undefined, - ...(company.linkedinUrl - ? { - linkedinLink: { - label: 'linkedinUrl', - url: company.linkedinUrl as string | undefined, - }, - } - : {}), - ...(company.annualRecurringRevenue - ? { - annualRecurringRevenue: { - amountMicros: Number(company.annualRecurringRevenue), - currencyCode: 'USD', - }, - } - : {}), - idealCustomerProfile: - company.idealCustomerProfile && - ['true', true].includes(company.idealCustomerProfile), - ...(company.xUrl - ? { - xLink: { - label: 'xUrl', - url: company.xUrl as string | undefined, - }, - } - : {}), - address: company.address as string | undefined, - employees: company.employees ? Number(company.employees) : undefined, - })); + const createInputs = data.validData.map( + (company) => + ({ + name: company.name as string | undefined, + domainName: company.domainName as string | undefined, + ...(company.linkedinUrl + ? { + linkedinLink: { + label: 'linkedinUrl', + url: company.linkedinUrl as string | undefined, + }, + } + : {}), + ...(company.annualRecurringRevenue + ? { + annualRecurringRevenue: { + amountMicros: Number(company.annualRecurringRevenue), + currencyCode: 'USD', + }, + } + : {}), + idealCustomerProfile: + company.idealCustomerProfile && + ['true', true].includes(company.idealCustomerProfile), + ...(company.xUrl + ? { + xLink: { + label: 'xUrl', + url: company.xUrl as string | undefined, + }, + } + : {}), + address: company.address as string | undefined, + employees: company.employees + ? Number(company.employees) + : undefined, + }) as Company, + ); // TODO: abstract this part for any object try { await createManyCompanies(createInputs); diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/useFilterOutUnexistingObjectMetadataItems.ts b/packages/twenty-front/src/modules/object-metadata/hooks/useFilterOutUnexistingObjectMetadataItems.ts new file mode 100644 index 000000000..8241aeffb --- /dev/null +++ b/packages/twenty-front/src/modules/object-metadata/hooks/useFilterOutUnexistingObjectMetadataItems.ts @@ -0,0 +1,22 @@ +import { useRecoilValue } from 'recoil'; + +import { objectMetadataItemsByNameSingularMapSelector } from '@/object-metadata/states/objectMetadataItemsByNameSingularMapSelector'; +import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { isDefined } from '~/utils/isDefined'; + +export const useFilterOutUnexistingObjectMetadataItems = () => { + const objectMetadataItemsByNameSingularMap = useRecoilValue( + objectMetadataItemsByNameSingularMapSelector, + ); + + const filterOutUnexistingObjectMetadataItems = ( + objectMetadatItem: ObjectMetadataItem, + ) => + isDefined( + objectMetadataItemsByNameSingularMap.get(objectMetadatItem.nameSingular), + ); + + return { + filterOutUnexistingObjectMetadataItems, + }; +}; diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/useMapToObjectRecordIdentifier.ts b/packages/twenty-front/src/modules/object-metadata/hooks/useMapToObjectRecordIdentifier.ts index 35a1c6d4e..53fcaec74 100644 --- a/packages/twenty-front/src/modules/object-metadata/hooks/useMapToObjectRecordIdentifier.ts +++ b/packages/twenty-front/src/modules/object-metadata/hooks/useMapToObjectRecordIdentifier.ts @@ -1,16 +1,16 @@ import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { getObjectRecordIdentifier } from '@/object-metadata/utils/getObjectRecordIdentifier'; +import { ObjectRecord } from '@/object-record/types/ObjectRecord'; import { ObjectRecordIdentifier } from '@/object-record/types/ObjectRecordIdentifier'; export const useMapToObjectRecordIdentifier = ({ objectMetadataItem, }: { objectMetadataItem: ObjectMetadataItem; -}) => { - return (record: any): ObjectRecordIdentifier => { - return getObjectRecordIdentifier({ +}): ((record: ObjectRecord) => ObjectRecordIdentifier) => { + return (record: ObjectRecord) => + getObjectRecordIdentifier({ objectMetadataItem, record, }); - }; }; diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/useObjectMetadataItem.ts b/packages/twenty-front/src/modules/object-metadata/hooks/useObjectMetadataItem.ts index fa35ff9d8..6baed042b 100644 --- a/packages/twenty-front/src/modules/object-metadata/hooks/useObjectMetadataItem.ts +++ b/packages/twenty-front/src/modules/object-metadata/hooks/useObjectMetadataItem.ts @@ -7,6 +7,7 @@ import { useGetObjectOrderByField } from '@/object-metadata/hooks/useGetObjectOr import { useMapToObjectRecordIdentifier } from '@/object-metadata/hooks/useMapToObjectRecordIdentifier'; import { objectMetadataItemFamilySelector } from '@/object-metadata/states/objectMetadataItemFamilySelector'; import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; +import { getBasePathToShowPage } from '@/object-metadata/utils/getBasePathToShowPage'; import { getObjectMetadataItemsMock } from '@/object-metadata/utils/getObjectMetadataItemsMock'; import { useGenerateCreateManyRecordMutation } from '@/object-record/hooks/useGenerateCreateManyRecordMutation'; import { useGenerateCreateOneRecordMutation } from '@/object-record/hooks/useGenerateCreateOneRecordMutation'; @@ -121,7 +122,9 @@ export const useObjectMetadataItem = ( ({ name }) => name === 'name', ); - const basePathToShowPage = `/object/${objectMetadataItem.nameSingular}/`; + const basePathToShowPage = getBasePathToShowPage({ + objectMetadataItem, + }); return { labelIdentifierFieldMetadata, diff --git a/packages/twenty-front/src/modules/object-metadata/states/objectMetadataItemsByNamePluralMapSelector.ts b/packages/twenty-front/src/modules/object-metadata/states/objectMetadataItemsByNamePluralMapSelector.ts new file mode 100644 index 000000000..30dfb115e --- /dev/null +++ b/packages/twenty-front/src/modules/object-metadata/states/objectMetadataItemsByNamePluralMapSelector.ts @@ -0,0 +1,20 @@ +import { selector } from 'recoil'; + +import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; +import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; + +export const objectMetadataItemsByNamePluralMapSelector = selector< + Map +>({ + key: 'objectMetadataItemsByNamePluralMapSelector', + get: ({ get }) => { + const objectMetadataItems = get(objectMetadataItemsState); + + return new Map( + objectMetadataItems.map((objectMetadataItem) => [ + objectMetadataItem.namePlural, + objectMetadataItem, + ]), + ); + }, +}); diff --git a/packages/twenty-front/src/modules/object-metadata/states/objectMetadataItemsByNameSingularMapSelector.ts b/packages/twenty-front/src/modules/object-metadata/states/objectMetadataItemsByNameSingularMapSelector.ts new file mode 100644 index 000000000..2eddad42b --- /dev/null +++ b/packages/twenty-front/src/modules/object-metadata/states/objectMetadataItemsByNameSingularMapSelector.ts @@ -0,0 +1,20 @@ +import { selector } from 'recoil'; + +import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; +import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; + +export const objectMetadataItemsByNameSingularMapSelector = selector< + Map +>({ + key: 'objectMetadataItemsByNameSingularMapSelector', + get: ({ get }) => { + const objectMetadataItems = get(objectMetadataItemsState); + + return new Map( + objectMetadataItems.map((objectMetadataItem) => [ + objectMetadataItem.nameSingular, + objectMetadataItem, + ]), + ); + }, +}); diff --git a/packages/twenty-front/src/modules/object-metadata/utils/getBasePathToShowPage.ts b/packages/twenty-front/src/modules/object-metadata/utils/getBasePathToShowPage.ts new file mode 100644 index 000000000..7a42595ac --- /dev/null +++ b/packages/twenty-front/src/modules/object-metadata/utils/getBasePathToShowPage.ts @@ -0,0 +1,11 @@ +import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; + +export const getBasePathToShowPage = ({ + objectMetadataItem, +}: { + objectMetadataItem: ObjectMetadataItem; +}) => { + const basePathToShowPage = `/object/${objectMetadataItem.nameSingular}/`; + + return basePathToShowPage; +}; diff --git a/packages/twenty-front/src/modules/object-metadata/utils/getLabelIdentifierFieldMetadataItem.ts b/packages/twenty-front/src/modules/object-metadata/utils/getLabelIdentifierFieldMetadataItem.ts new file mode 100644 index 000000000..98fc51134 --- /dev/null +++ b/packages/twenty-front/src/modules/object-metadata/utils/getLabelIdentifierFieldMetadataItem.ts @@ -0,0 +1,12 @@ +import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; +import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; + +export const getLabelIdentifierFieldMetadataItem = ( + objectMetadataItem: ObjectMetadataItem, +): FieldMetadataItem | undefined => { + return objectMetadataItem.fields.find( + (field) => + field.id === objectMetadataItem.labelIdentifierFieldMetadataId || + field.name === 'name', + ); +}; diff --git a/packages/twenty-front/src/modules/object-metadata/utils/getObjectOrderByField.ts b/packages/twenty-front/src/modules/object-metadata/utils/getObjectOrderByField.ts index 7164656c0..8fe573bb2 100644 --- a/packages/twenty-front/src/modules/object-metadata/utils/getObjectOrderByField.ts +++ b/packages/twenty-front/src/modules/object-metadata/utils/getObjectOrderByField.ts @@ -5,7 +5,7 @@ import { FieldMetadataType } from '~/generated-metadata/graphql'; export const getObjectOrderByField = ( objectMetadataItem: ObjectMetadataItem, - orderBy: OrderBy, + orderBy?: OrderBy | null, ): OrderByField => { const labelIdentifierFieldMetadata = objectMetadataItem.fields.find( (field) => @@ -18,18 +18,18 @@ export const getObjectOrderByField = ( case FieldMetadataType.FullName: return { [labelIdentifierFieldMetadata.name]: { - firstName: orderBy, - lastName: orderBy, + firstName: orderBy ?? 'AscNullsLast', + lastName: orderBy ?? 'AscNullsLast', }, }; default: return { - [labelIdentifierFieldMetadata.name]: orderBy, + [labelIdentifierFieldMetadata.name]: orderBy ?? 'AscNullsLast', }; } } else { return { - createdAt: orderBy, + createdAt: orderBy ?? 'DescNullsLast', }; } }; diff --git a/packages/twenty-front/src/modules/object-metadata/utils/getObjectRecordIdentifier.ts b/packages/twenty-front/src/modules/object-metadata/utils/getObjectRecordIdentifier.ts index e42a9de52..f706d9e63 100644 --- a/packages/twenty-front/src/modules/object-metadata/utils/getObjectRecordIdentifier.ts +++ b/packages/twenty-front/src/modules/object-metadata/utils/getObjectRecordIdentifier.ts @@ -1,7 +1,10 @@ import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { getBasePathToShowPage } from '@/object-metadata/utils/getBasePathToShowPage'; +import { getLabelIdentifierFieldMetadataItem } from '@/object-metadata/utils/getLabelIdentifierFieldMetadataItem'; +import { ObjectRecord } from '@/object-record/types/ObjectRecord'; import { ObjectRecordIdentifier } from '@/object-record/types/ObjectRecordIdentifier'; -import { FieldMetadataType } from '~/generated/graphql'; +import { FieldMetadataType } from '~/generated-metadata/graphql'; import { getLogoUrlFromDomainName } from '~/utils'; export const getObjectRecordIdentifier = ({ @@ -9,30 +12,24 @@ export const getObjectRecordIdentifier = ({ record, }: { objectMetadataItem: ObjectMetadataItem; - record: any; + record: ObjectRecord; }): ObjectRecordIdentifier => { - const basePathToShowPage = `/object/${objectMetadataItem.nameSingular}/`; - const linkToShowPage = `${basePathToShowPage}${record.id}`; - - if (objectMetadataItem.nameSingular === CoreObjectNameSingular.Opportunity) { - return { - id: record.id, - name: record?.company?.name, - avatarUrl: record.avatarUrl, - avatarType: 'rounded', - linkToShowPage, - }; + switch (objectMetadataItem.nameSingular) { + case CoreObjectNameSingular.Opportunity: + return { + id: record.id, + name: record?.company?.name, + avatarUrl: record.avatarUrl, + avatarType: 'rounded', + }; } - const labelIdentifierFieldMetadata = objectMetadataItem.fields.find( - (field) => - field.id === objectMetadataItem.labelIdentifierFieldMetadataId || - field.name === 'name', - ); + const labelIdentifierFieldMetadataItem = + getLabelIdentifierFieldMetadataItem(objectMetadataItem); let labelIdentifierFieldValue = ''; - switch (labelIdentifierFieldMetadata?.type) { + switch (labelIdentifierFieldMetadataItem?.type) { case FieldMetadataType.FullName: { labelIdentifierFieldValue = `${record.name?.firstName ?? ''} ${ record.name?.lastName ?? '' @@ -40,8 +37,8 @@ export const getObjectRecordIdentifier = ({ break; } default: - labelIdentifierFieldValue = labelIdentifierFieldMetadata - ? record[labelIdentifierFieldMetadata.name] + labelIdentifierFieldValue = labelIdentifierFieldMetadataItem + ? record[labelIdentifierFieldMetadataItem.name] : ''; } @@ -63,11 +60,17 @@ export const getObjectRecordIdentifier = ({ ? getLogoUrlFromDomainName(record['domainName'] ?? '') : imageIdentifierFieldValue ?? null; + const basePathToShowPage = getBasePathToShowPage({ + objectMetadataItem, + }); + + const linkToEntity = `${basePathToShowPage}${record.id}`; + return { id: record.id, name: labelIdentifierFieldValue, avatarUrl, avatarType, - linkToShowPage, + linkToEntity, }; }; diff --git a/packages/twenty-front/src/modules/object-record/components/RecordChip.tsx b/packages/twenty-front/src/modules/object-record/components/RecordChip.tsx new file mode 100644 index 000000000..aaad350f7 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/components/RecordChip.tsx @@ -0,0 +1,28 @@ +import * as React from 'react'; + +import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; +import { ObjectRecord } from '@/object-record/types/ObjectRecord'; +import { EntityChip } from '@/ui/display/chip/components/EntityChip'; + +export type RecordChipProps = { + objectNameSingular: string; + record: ObjectRecord; +}; + +export const RecordChip = ({ objectNameSingular, record }: RecordChipProps) => { + const { mapToObjectRecordIdentifier } = useObjectMetadataItem({ + objectNameSingular, + }); + + const objectRecordIdentifier = mapToObjectRecordIdentifier(record); + + return ( + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/components/RecordShowPage.tsx b/packages/twenty-front/src/modules/object-record/components/RecordShowPage.tsx index 5452b4e63..2ac72896e 100644 --- a/packages/twenty-front/src/modules/object-record/components/RecordShowPage.tsx +++ b/packages/twenty-front/src/modules/object-record/components/RecordShowPage.tsx @@ -77,13 +77,6 @@ export const RecordShowPage = () => { objectNameSingular, }); - const objectMetadataType = - objectMetadataItem?.nameSingular === 'company' - ? 'Company' - : objectMetadataItem?.nameSingular === 'person' - ? 'Person' - : 'Custom'; - const useUpdateOneObjectRecordMutation: RecordUpdateHook = () => { const updateEntity = ({ variables }: RecordUpdateHookParams) => { updateOneRecord?.({ @@ -171,7 +164,7 @@ export const RecordShowPage = () => { hasBackButton Icon={IconBuildingSkyscraper} > - {record && objectMetadataType !== 'Custom' && ( + {record && ( <> { key="add" entity={{ id: record.id, - type: objectMetadataType, + targetObjectNameSingular: objectMetadataItem?.nameSingular, }} /> { )} { basePathToShowPage, } = useChipField(); + // TODO: remove this and use ObjectRecordChip instead const identifiers = identifiersMapper?.(record, objectNameSingular ?? ''); return ( diff --git a/packages/twenty-front/src/modules/object-record/hooks/useCreateManyRecords.ts b/packages/twenty-front/src/modules/object-record/hooks/useCreateManyRecords.ts index 4bb189787..f8e7a77d5 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useCreateManyRecords.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useCreateManyRecords.ts @@ -5,11 +5,10 @@ import { useOptimisticEffect } from '@/apollo/optimistic-effect/hooks/useOptimis import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; import { ObjectMetadataItemIdentifier } from '@/object-metadata/types/ObjectMetadataItemIdentifier'; import { useGenerateEmptyRecord } from '@/object-record/hooks/useGenerateEmptyRecord'; +import { ObjectRecord } from '@/object-record/types/ObjectRecord'; import { capitalize } from '~/utils/string/capitalize'; -export const useCreateManyRecords = < - T extends Record & { id: string }, ->({ +export const useCreateManyRecords = ({ objectNameSingular, }: ObjectMetadataItemIdentifier) => { const { triggerOptimisticEffects } = useOptimisticEffect({ @@ -27,17 +26,16 @@ export const useCreateManyRecords = < const apolloClient = useApolloClient(); - const createManyRecords = async (data: Record[]) => { + const createManyRecords = async (data: Partial[]) => { const withIds = data.map((record) => ({ ...record, id: (record.id as string) ?? v4(), })); withIds.forEach((record) => { - const emptyRecord: Record | undefined = - generateEmptyRecord({ - id: record.id, - }); + const emptyRecord: T | undefined = generateEmptyRecord({ + id: record.id, + } as T); if (emptyRecord) { triggerOptimisticEffects({ diff --git a/packages/twenty-front/src/modules/object-record/hooks/useCreateOneRecord.ts b/packages/twenty-front/src/modules/object-record/hooks/useCreateOneRecord.ts index 141c1cff1..a2cc0d9d7 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useCreateOneRecord.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useCreateOneRecord.ts @@ -34,7 +34,7 @@ export const useCreateOneRecord = ({ const createOneRecord = async (input: Record) => { const recordId = v4(); - const generatedEmptyRecord = generateEmptyRecord>({ + const generatedEmptyRecord = generateEmptyRecord({ id: recordId, createdAt: new Date().toISOString(), ...input, diff --git a/packages/twenty-front/src/modules/object-record/hooks/useGenerateEmptyRecord.ts b/packages/twenty-front/src/modules/object-record/hooks/useGenerateEmptyRecord.ts index 1c04168ea..df33935b4 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useGenerateEmptyRecord.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useGenerateEmptyRecord.ts @@ -1,4 +1,5 @@ import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { ObjectRecord } from '@/object-record/types/ObjectRecord'; import { generateEmptyFieldValue } from '@/object-record/utils/generateEmptyFieldValue'; export const useGenerateEmptyRecord = ({ @@ -7,11 +8,11 @@ export const useGenerateEmptyRecord = ({ objectMetadataItem: ObjectMetadataItem; }) => { // Todo fix typing once we generate the return base on Metadata - const generateEmptyRecord = (input: Partial & { id: string }) => { + const generateEmptyRecord = (input: T) => { // Todo replace this by runtime typing - const validatedInput = input as { id: string } & { [key: string]: any }; + const validatedInput = input as T; - const emptyRecord = {} as Record; + const emptyRecord = {} as any; for (const fieldMetadataItem of objectMetadataItem.fields) { emptyRecord[fieldMetadataItem.name] = @@ -19,7 +20,7 @@ export const useGenerateEmptyRecord = ({ generateEmptyFieldValue(fieldMetadataItem); } - return emptyRecord; + return emptyRecord as T; }; return { diff --git a/packages/twenty-front/src/modules/object-record/hooks/useGenerateFindManyRecordsForMultipleMetadataItemsQuery.ts b/packages/twenty-front/src/modules/object-record/hooks/useGenerateFindManyRecordsForMultipleMetadataItemsQuery.ts new file mode 100644 index 000000000..2c8de3258 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/hooks/useGenerateFindManyRecordsForMultipleMetadataItemsQuery.ts @@ -0,0 +1,84 @@ +import { gql } from '@apollo/client'; + +import { useMapFieldMetadataToGraphQLQuery } from '@/object-metadata/hooks/useMapFieldMetadataToGraphQLQuery'; +import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { capitalize } from '~/utils/string/capitalize'; + +export const useGenerateFindManyRecordsForMultipleMetadataItemsQuery = ({ + objectMetadataItems, + depth, +}: { + objectMetadataItems: ObjectMetadataItem[]; + depth?: number; +}) => { + const mapFieldMetadataToGraphQLQuery = useMapFieldMetadataToGraphQLQuery(); + + const capitalizedObjectNameSingulars = objectMetadataItems.map( + ({ nameSingular }) => capitalize(nameSingular), + ); + + const filterPerMetadataItemArray = capitalizedObjectNameSingulars + .map( + (capitalizedObjectNameSingular) => + `$filter${capitalizedObjectNameSingular}: ${capitalizedObjectNameSingular}FilterInput`, + ) + .join(', '); + + const orderByPerMetadataItemArray = capitalizedObjectNameSingulars + .map( + (capitalizedObjectNameSingular) => + `$orderBy${capitalizedObjectNameSingular}: ${capitalizedObjectNameSingular}OrderByInput`, + ) + .join(', '); + + const lastCursorPerMetadataItemArray = capitalizedObjectNameSingulars + .map( + (capitalizedObjectNameSingular) => + `$lastCursor${capitalizedObjectNameSingular}: String`, + ) + .join(', '); + + const limitPerMetadataItemArray = capitalizedObjectNameSingulars + .map( + (capitalizedObjectNameSingular) => + `$limit${capitalizedObjectNameSingular}: Float = 5`, + ) + .join(', '); + + return gql` + query FindManyRecordsMultipleMetadataItems( + ${filterPerMetadataItemArray}, + ${orderByPerMetadataItemArray}, + ${lastCursorPerMetadataItemArray}, + ${limitPerMetadataItemArray} + ) { + ${objectMetadataItems + .map( + ({ namePlural, nameSingular, fields }) => + `${namePlural}(filter: $filter${capitalize( + nameSingular, + )}, orderBy: $orderBy${capitalize( + nameSingular, + )}, first: $limit${capitalize( + nameSingular, + )}, after: $lastCursor${capitalize(nameSingular)}){ + edges { + node { + id + ${fields + .map((field) => mapFieldMetadataToGraphQLQuery(field, depth)) + .join('\n')} + } + cursor + } + pageInfo { + hasNextPage + startCursor + endCursor + } + }`, + ) + .join('\n')} + } + `; +}; diff --git a/packages/twenty-front/src/modules/object-record/hooks/useGenerateFindManyRecordsQuery.ts b/packages/twenty-front/src/modules/object-record/hooks/useGenerateFindManyRecordsQuery.ts index c597506ad..d726f45e2 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useGenerateFindManyRecordsQuery.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useGenerateFindManyRecordsQuery.ts @@ -1,7 +1,6 @@ import { gql } from '@apollo/client'; import { useMapFieldMetadataToGraphQLQuery } from '@/object-metadata/hooks/useMapFieldMetadataToGraphQLQuery'; -import { EMPTY_QUERY } from '@/object-metadata/hooks/useObjectMetadataItem'; import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { capitalize } from '~/utils/string/capitalize'; @@ -14,10 +13,6 @@ export const useGenerateFindManyRecordsQuery = ({ }) => { const mapFieldMetadataToGraphQLQuery = useMapFieldMetadataToGraphQLQuery(); - if (!objectMetadataItem) { - return EMPTY_QUERY; - } - return gql` query FindMany${capitalize( objectMetadataItem.namePlural, diff --git a/packages/twenty-front/src/modules/object-record/hooks/useRecordTableContextMenuEntries.tsx b/packages/twenty-front/src/modules/object-record/hooks/useRecordTableContextMenuEntries.tsx index 70737f1f8..9fa8a8966 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useRecordTableContextMenuEntries.tsx +++ b/packages/twenty-front/src/modules/object-record/hooks/useRecordTableContextMenuEntries.tsx @@ -67,13 +67,6 @@ export const useRecordTableContextMenuEntries = ( const { createFavorite, favorites, deleteFavorite } = useFavorites(); - const objectMetadataType = - objectNameSingular === 'company' - ? 'Company' - : objectNameSingular === 'person' - ? 'Person' - : 'Custom'; - const handleFavoriteButtonClick = useRecoilCallback(({ snapshot }) => () => { const selectedRowIds = injectSelectorSnapshotValueWithRecordTableScopeId( snapshot, @@ -212,14 +205,14 @@ export const useRecordTableContextMenuEntries = ( label: 'Task', Icon: IconCheckbox, onClick: () => { - openCreateActivityDrawer('Task', objectMetadataType); + openCreateActivityDrawer('Task', objectNameSingular); }, }, { label: 'Note', Icon: IconNotes, onClick: () => { - openCreateActivityDrawer('Note', objectMetadataType); + openCreateActivityDrawer('Note', objectNameSingular); }, }, ...(dataExecuteQuickActionOnmentEnabled diff --git a/packages/twenty-front/src/modules/object-record/relation-picker/components/MultipleObjectRecordSelect.tsx b/packages/twenty-front/src/modules/object-record/relation-picker/components/MultipleObjectRecordSelect.tsx new file mode 100644 index 000000000..03cb9107b --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/relation-picker/components/MultipleObjectRecordSelect.tsx @@ -0,0 +1,212 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import styled from '@emotion/styled'; +import { isNonEmptyString } from '@sniptt/guards'; +import debounce from 'lodash.debounce'; +import { v4 } from 'uuid'; + +import { + ObjectRecordForSelect, + SelectedObjectRecordId, + useMultiObjectSearch, +} from '@/object-record/relation-picker/hooks/useMultiObjectSearch'; +import { RelationPickerHotkeyScope } from '@/object-record/relation-picker/types/RelationPickerHotkeyScope'; +import { DropdownMenu } from '@/ui/layout/dropdown/components/DropdownMenu'; +import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; +import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput'; +import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator'; +import { SelectableItem } from '@/ui/layout/selectable-list/components/SelectableItem'; +import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList'; +import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem'; +import { MenuItemMultiSelectAvatar } from '@/ui/navigation/menu-item/components/MenuItemMultiSelectAvatar'; +import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside'; +import { Avatar } from '@/users/components/Avatar'; + +export const StyledSelectableItem = styled(SelectableItem)` + height: 100%; + width: 100%; +`; + +export type EntitiesForMultipleObjectRecordSelect = { + filteredSelectedObjectRecords: ObjectRecordForSelect[]; + objectRecordsToSelect: ObjectRecordForSelect[]; + loading: boolean; +}; + +export const MultipleObjectRecordSelect = ({ + onChange, + onSubmit, + selectedObjectRecordIds, +}: { + onChange?: ( + changedRecordForSelect: ObjectRecordForSelect, + newSelectedValue: boolean, + ) => void; + onCancel?: (objectRecordsForSelect: ObjectRecordForSelect[]) => void; + onSubmit?: (objectRecordsForSelect: ObjectRecordForSelect[]) => void; + selectedObjectRecordIds: SelectedObjectRecordId[]; +}) => { + const containerRef = useRef(null); + + const [searchFilter, setSearchFilter] = useState(''); + + const { + filteredSelectedObjectRecords, + loading, + objectRecordsToSelect, + selectedObjectRecords, + } = useMultiObjectSearch({ + searchFilterValue: searchFilter, + selectedObjectRecordIds, + excludedObjectRecordIds: [], + limit: 10, + }); + + const selectedObjectRecordsForSelect = useMemo( + () => + selectedObjectRecords.filter((selectedObjectRecord) => + selectedObjectRecordIds.some( + (selectedObjectRecordId) => + selectedObjectRecordId.id === + selectedObjectRecord.recordIdentifier.id, + ), + ), + [selectedObjectRecords, selectedObjectRecordIds], + ); + + const [internalSelectedRecords, setInternalSelectedRecords] = useState< + ObjectRecordForSelect[] + >([]); + + useEffect(() => { + if (!loading) { + setInternalSelectedRecords(selectedObjectRecordsForSelect); + } + }, [selectedObjectRecordsForSelect, loading]); + + const debouncedSetSearchFilter = debounce(setSearchFilter, 100, { + leading: true, + }); + + const handleFilterChange = (event: React.ChangeEvent) => { + debouncedSetSearchFilter(event.currentTarget.value); + }; + + const handleSelectChange = ( + changedRecordForSelect: ObjectRecordForSelect, + newSelectedValue: boolean, + ) => { + const newSelectedRecords = newSelectedValue + ? [...internalSelectedRecords, changedRecordForSelect] + : internalSelectedRecords.filter( + (selectedRecord) => + selectedRecord.record.id !== changedRecordForSelect.record.id, + ); + + setInternalSelectedRecords(newSelectedRecords); + + onChange?.(changedRecordForSelect, newSelectedValue); + }; + + const entitiesInDropdown = useMemo( + () => + [ + ...(filteredSelectedObjectRecords ?? []), + ...(objectRecordsToSelect ?? []), + ].filter((entity) => isNonEmptyString(entity.recordIdentifier.id)), + [filteredSelectedObjectRecords, objectRecordsToSelect], + ); + + useListenClickOutside({ + refs: [containerRef], + callback: (event) => { + event.stopImmediatePropagation(); + event.stopPropagation(); + event.preventDefault(); + + onSubmit?.(internalSelectedRecords); + }, + }); + + const selectableItemIds = entitiesInDropdown.map( + (entity) => entity.record.id, + ); + + return ( + + + + + {loading ? ( + + ) : ( + <> + { + const recordIsSelected = internalSelectedRecords?.some( + (selectedRecord) => selectedRecord.record.id === recordId, + ); + + const correspondingRecordForSelect = entitiesInDropdown?.find( + (entity) => entity.record.id === recordId, + ); + + if (correspondingRecordForSelect) { + handleSelectChange( + correspondingRecordForSelect, + !recordIsSelected, + ); + } + }} + > + {entitiesInDropdown?.map((objectRecordForSelect) => ( + + { + return ( + selectedRecord.record.id === + objectRecordForSelect.record.id + ); + }, + )} + onSelectChange={(newCheckedValue) => + handleSelectChange(objectRecordForSelect, newCheckedValue) + } + avatar={ + + } + text={objectRecordForSelect.recordIdentifier.name} + /> + + ))} + + {entitiesInDropdown?.length === 0 && } + + )} + + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/relation-picker/hooks/useLimitPerMetadataItem.ts b/packages/twenty-front/src/modules/object-record/relation-picker/hooks/useLimitPerMetadataItem.ts new file mode 100644 index 000000000..1f1107ea4 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/relation-picker/hooks/useLimitPerMetadataItem.ts @@ -0,0 +1,24 @@ +import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { DEFAULT_SEARCH_REQUEST_LIMIT } from '@/search/hooks/useFilteredSearchEntityQuery'; +import { isDefined } from '~/utils/isDefined'; +import { capitalize } from '~/utils/string/capitalize'; + +export const useLimitPerMetadataItem = ({ + objectMetadataItems, + limit = DEFAULT_SEARCH_REQUEST_LIMIT, +}: { + objectMetadataItems: ObjectMetadataItem[]; + limit?: number; +}) => { + const limitPerMetadataItem = Object.fromEntries( + objectMetadataItems + .map(({ nameSingular }) => { + return [`limit${capitalize(nameSingular)}`, limit]; + }) + .filter(isDefined), + ); + + return { + limitPerMetadataItem, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/relation-picker/hooks/useMultiObjectRecordsQueryResultFormattedAsObjectRecordForSelectArray.ts b/packages/twenty-front/src/modules/object-record/relation-picker/hooks/useMultiObjectRecordsQueryResultFormattedAsObjectRecordForSelectArray.ts new file mode 100644 index 000000000..eedf25327 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/relation-picker/hooks/useMultiObjectRecordsQueryResultFormattedAsObjectRecordForSelectArray.ts @@ -0,0 +1,50 @@ +import { useMemo } from 'react'; +import { useRecoilValue } from 'recoil'; + +import { objectMetadataItemsByNamePluralMapSelector } from '@/object-metadata/states/objectMetadataItemsByNamePluralMapSelector'; +import { getObjectRecordIdentifier } from '@/object-metadata/utils/getObjectRecordIdentifier'; +import { ObjectRecordForSelect } from '@/object-record/relation-picker/hooks/useMultiObjectSearch'; +import { ObjectRecordConnection } from '@/object-record/types/ObjectRecordConnection'; +import { isDefined } from '~/utils/isDefined'; + +export type MultiObjectRecordQueryResult = { + [namePlural: string]: ObjectRecordConnection; +}; + +export const useMultiObjectRecordsQueryResultFormattedAsObjectRecordForSelectArray = + ({ + multiObjectRecordsQueryResult, + }: { + multiObjectRecordsQueryResult: + | MultiObjectRecordQueryResult + | null + | undefined; + }) => { + const objectMetadataItemsByNamePluralMap = useRecoilValue( + objectMetadataItemsByNamePluralMapSelector, + ); + + const objectRecordForSelectArray = useMemo(() => { + return Object.entries(multiObjectRecordsQueryResult ?? {}).flatMap( + ([namePlural, objectRecordConnection]) => { + const objectMetadataItem = + objectMetadataItemsByNamePluralMap.get(namePlural); + + if (!isDefined(objectMetadataItem)) return []; + + return objectRecordConnection.edges.map(({ node }) => ({ + objectMetadataItem, + record: node, + recordIdentifier: getObjectRecordIdentifier({ + objectMetadataItem, + record: node, + }), + })) as ObjectRecordForSelect[]; + }, + ); + }, [multiObjectRecordsQueryResult, objectMetadataItemsByNamePluralMap]); + + return { + objectRecordForSelectArray, + }; + }; diff --git a/packages/twenty-front/src/modules/object-record/relation-picker/hooks/useMultiObjectSearch.ts b/packages/twenty-front/src/modules/object-record/relation-picker/hooks/useMultiObjectSearch.ts new file mode 100644 index 000000000..01c740433 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/relation-picker/hooks/useMultiObjectSearch.ts @@ -0,0 +1,72 @@ +import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { useMultiObjectSearchMatchesSearchFilterAndSelectedItemsQuery } from '@/object-record/relation-picker/hooks/useMultiObjectSearchMatchesSearchFilterAndSelectedItemsQuery'; +import { useMultiObjectSearchMatchesSearchFilterAndToSelectQuery } from '@/object-record/relation-picker/hooks/useMultiObjectSearchMatchesSearchFilterAndToSelectQuery'; +import { useMultiObjectSearchSelectedItemsQuery } from '@/object-record/relation-picker/hooks/useMultiObjectSearchSelectedItemsQuery'; +import { ObjectRecord } from '@/object-record/types/ObjectRecord'; +import { ObjectRecordIdentifier } from '@/object-record/types/ObjectRecordIdentifier'; + +export const DEFAULT_SEARCH_REQUEST_LIMIT = 5; + +export type ObjectRecordForSelect = { + objectMetadataItem: ObjectMetadataItem; + record: ObjectRecord; + recordIdentifier: ObjectRecordIdentifier; +}; + +export type SelectedObjectRecordId = { + objectNameSingular: string; + id: string; +}; + +export type MultiObjectSearch = { + selectedObjectRecords: ObjectRecordForSelect[]; + filteredSelectedObjectRecords: ObjectRecordForSelect[]; + objectRecordsToSelect: ObjectRecordForSelect[]; + loading: boolean; +}; + +export const useMultiObjectSearch = ({ + searchFilterValue, + selectedObjectRecordIds, + limit, + excludedObjectRecordIds = [], +}: { + searchFilterValue: string; + selectedObjectRecordIds: SelectedObjectRecordId[]; + limit?: number; + excludedObjectRecordIds?: SelectedObjectRecordId[]; +}): MultiObjectSearch => { + const { selectedObjectRecords, selectedObjectRecordsLoading } = + useMultiObjectSearchSelectedItemsQuery({ + selectedObjectRecordIds, + }); + + const { + selectedAndMatchesSearchFilterObjectRecords, + selectedAndMatchesSearchFilterObjectRecordsLoading, + } = useMultiObjectSearchMatchesSearchFilterAndSelectedItemsQuery({ + searchFilterValue, + selectedObjectRecordIds, + limit, + }); + + const { + toSelectAndMatchesSearchFilterObjectRecords, + toSelectAndMatchesSearchFilterObjectRecordsLoading, + } = useMultiObjectSearchMatchesSearchFilterAndToSelectQuery({ + excludedObjectRecordIds, + searchFilterValue, + selectedObjectRecordIds, + limit, + }); + + return { + selectedObjectRecords, + filteredSelectedObjectRecords: selectedAndMatchesSearchFilterObjectRecords, + objectRecordsToSelect: toSelectAndMatchesSearchFilterObjectRecords, + loading: + selectedAndMatchesSearchFilterObjectRecordsLoading || + toSelectAndMatchesSearchFilterObjectRecordsLoading || + selectedObjectRecordsLoading, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/relation-picker/hooks/useMultiObjectSearchMatchesSearchFilterAndSelectedItemsQuery.ts b/packages/twenty-front/src/modules/object-record/relation-picker/hooks/useMultiObjectSearchMatchesSearchFilterAndSelectedItemsQuery.ts new file mode 100644 index 000000000..a3b5ac363 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/relation-picker/hooks/useMultiObjectSearchMatchesSearchFilterAndSelectedItemsQuery.ts @@ -0,0 +1,113 @@ +import { useQuery } from '@apollo/client'; +import { isNonEmptyArray } from '@sniptt/guards'; +import { useRecoilValue } from 'recoil'; + +import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; +import { useGenerateFindManyRecordsForMultipleMetadataItemsQuery } from '@/object-record/hooks/useGenerateFindManyRecordsForMultipleMetadataItemsQuery'; +import { useLimitPerMetadataItem } from '@/object-record/relation-picker/hooks/useLimitPerMetadataItem'; +import { + MultiObjectRecordQueryResult, + useMultiObjectRecordsQueryResultFormattedAsObjectRecordForSelectArray, +} from '@/object-record/relation-picker/hooks/useMultiObjectRecordsQueryResultFormattedAsObjectRecordForSelectArray'; +import { SelectedObjectRecordId } from '@/object-record/relation-picker/hooks/useMultiObjectSearch'; +import { useOrderByFieldPerMetadataItem } from '@/object-record/relation-picker/hooks/useOrderByFieldPerMetadataItem'; +import { useSearchFilterPerMetadataItem } from '@/object-record/relation-picker/hooks/useSearchFilterPerMetadataItem'; +import { isDefined } from '~/utils/isDefined'; +import { capitalize } from '~/utils/string/capitalize'; + +export const useMultiObjectSearchMatchesSearchFilterAndSelectedItemsQuery = ({ + selectedObjectRecordIds, + searchFilterValue, + limit, +}: { + selectedObjectRecordIds: SelectedObjectRecordId[]; + searchFilterValue: string; + limit?: number; +}) => { + const objectMetadataItems = useRecoilValue(objectMetadataItemsState); + + const { searchFilterPerMetadataItemNameSingular } = + useSearchFilterPerMetadataItem({ + objectMetadataItems, + searchFilterValue, + }); + + const objectMetadataItemsUsedInSelectedIdsQuery = objectMetadataItems.filter( + ({ nameSingular }) => { + return selectedObjectRecordIds.some(({ objectNameSingular }) => { + return objectNameSingular === nameSingular; + }); + }, + ); + + const selectedAndMatchesSearchFilterTextFilterPerMetadataItem = + Object.fromEntries( + objectMetadataItems + .map(({ nameSingular }) => { + const selectedIds = selectedObjectRecordIds + .filter( + ({ objectNameSingular }) => objectNameSingular === nameSingular, + ) + .map(({ id }) => id); + + if (!isNonEmptyArray(selectedIds)) return null; + + const searchFilter = + searchFilterPerMetadataItemNameSingular[nameSingular] ?? {}; + + return [ + `filter${capitalize(nameSingular)}`, + { + and: [ + { + ...searchFilter, + }, + { + id: { + in: selectedIds, + }, + }, + ], + }, + ]; + }) + .filter(isDefined), + ); + + const { orderByFieldPerMetadataItem } = useOrderByFieldPerMetadataItem({ + objectMetadataItems: objectMetadataItemsUsedInSelectedIdsQuery, + }); + + const { limitPerMetadataItem } = useLimitPerMetadataItem({ + objectMetadataItems: objectMetadataItemsUsedInSelectedIdsQuery, + limit, + }); + + const multiSelectQueryForSelectedIds = + useGenerateFindManyRecordsForMultipleMetadataItemsQuery({ + objectMetadataItems: objectMetadataItemsUsedInSelectedIdsQuery, + }); + + const { + loading: selectedAndMatchesSearchFilterObjectRecordsLoading, + data: selectedAndMatchesSearchFilterObjectRecordsQueryResult, + } = useQuery(multiSelectQueryForSelectedIds, { + variables: { + ...selectedAndMatchesSearchFilterTextFilterPerMetadataItem, + ...orderByFieldPerMetadataItem, + ...limitPerMetadataItem, + }, + }); + + const { + objectRecordForSelectArray: selectedAndMatchesSearchFilterObjectRecords, + } = useMultiObjectRecordsQueryResultFormattedAsObjectRecordForSelectArray({ + multiObjectRecordsQueryResult: + selectedAndMatchesSearchFilterObjectRecordsQueryResult, + }); + + return { + selectedAndMatchesSearchFilterObjectRecordsLoading, + selectedAndMatchesSearchFilterObjectRecords, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/relation-picker/hooks/useMultiObjectSearchMatchesSearchFilterAndToSelectQuery.ts b/packages/twenty-front/src/modules/object-record/relation-picker/hooks/useMultiObjectSearchMatchesSearchFilterAndToSelectQuery.ts new file mode 100644 index 000000000..00894cd56 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/relation-picker/hooks/useMultiObjectSearchMatchesSearchFilterAndToSelectQuery.ts @@ -0,0 +1,130 @@ +import { useQuery } from '@apollo/client'; +import { isNonEmptyArray } from '@sniptt/guards'; +import { useRecoilValue } from 'recoil'; + +import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; +import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; +import { useGenerateFindManyRecordsForMultipleMetadataItemsQuery } from '@/object-record/hooks/useGenerateFindManyRecordsForMultipleMetadataItemsQuery'; +import { useLimitPerMetadataItem } from '@/object-record/relation-picker/hooks/useLimitPerMetadataItem'; +import { + MultiObjectRecordQueryResult, + useMultiObjectRecordsQueryResultFormattedAsObjectRecordForSelectArray, +} from '@/object-record/relation-picker/hooks/useMultiObjectRecordsQueryResultFormattedAsObjectRecordForSelectArray'; +import { SelectedObjectRecordId } from '@/object-record/relation-picker/hooks/useMultiObjectSearch'; +import { useOrderByFieldPerMetadataItem } from '@/object-record/relation-picker/hooks/useOrderByFieldPerMetadataItem'; +import { useSearchFilterPerMetadataItem } from '@/object-record/relation-picker/hooks/useSearchFilterPerMetadataItem'; +import { isDeeplyEqual } from '~/utils/isDeeplyEqual'; +import { isDefined } from '~/utils/isDefined'; +import { capitalize } from '~/utils/string/capitalize'; + +export const useMultiObjectSearchMatchesSearchFilterAndToSelectQuery = ({ + selectedObjectRecordIds, + excludedObjectRecordIds, + searchFilterValue, + limit, +}: { + selectedObjectRecordIds: SelectedObjectRecordId[]; + excludedObjectRecordIds: SelectedObjectRecordId[]; + searchFilterValue: string; + limit?: number; +}) => { + const objectMetadataItems = useRecoilValue(objectMetadataItemsState); + + const nonSystemObjectMetadataItems = objectMetadataItems.filter( + ({ nameSingular, isSystem }) => + !isSystem && nameSingular !== CoreObjectNameSingular.Opportunity, + ); + + const { searchFilterPerMetadataItemNameSingular } = + useSearchFilterPerMetadataItem({ + objectMetadataItems: nonSystemObjectMetadataItems, + searchFilterValue, + }); + + const objectRecordsToSelectAndMatchesSearchFilterTextFilterPerMetadataItem = + Object.fromEntries( + nonSystemObjectMetadataItems + .map(({ nameSingular }) => { + const selectedIds = selectedObjectRecordIds + .filter( + ({ objectNameSingular }) => objectNameSingular === nameSingular, + ) + .map(({ id }) => id); + + const excludedIds = excludedObjectRecordIds + .filter( + ({ objectNameSingular }) => objectNameSingular === nameSingular, + ) + .map(({ id }) => id); + + const searchFilter = + searchFilterPerMetadataItemNameSingular[nameSingular] ?? {}; + + const excludedIdsUnion = [...selectedIds, ...excludedIds]; + + const noFilter = + !isNonEmptyArray(excludedIdsUnion) && + isDeeplyEqual(searchFilter, {}); + + return [ + `filter${capitalize(nameSingular)}`, + !noFilter + ? { + and: [ + { + ...searchFilter, + }, + isNonEmptyArray(excludedIdsUnion) + ? { + not: { + id: { + in: [...selectedIds, ...excludedIds], + }, + }, + } + : {}, + ], + } + : {}, + ]; + }) + .filter(isDefined), + ); + + const { orderByFieldPerMetadataItem } = useOrderByFieldPerMetadataItem({ + objectMetadataItems: nonSystemObjectMetadataItems, + }); + + const { limitPerMetadataItem } = useLimitPerMetadataItem({ + objectMetadataItems: nonSystemObjectMetadataItems, + limit, + }); + + const multiSelectQuery = + useGenerateFindManyRecordsForMultipleMetadataItemsQuery({ + objectMetadataItems: nonSystemObjectMetadataItems, + }); + + const { + loading: toSelectAndMatchesSearchFilterObjectRecordsLoading, + data: toSelectAndMatchesSearchFilterObjectRecordsQueryResult, + } = useQuery(multiSelectQuery, { + variables: { + ...objectRecordsToSelectAndMatchesSearchFilterTextFilterPerMetadataItem, + ...orderByFieldPerMetadataItem, + ...limitPerMetadataItem, + }, + }); + + const { + objectRecordForSelectArray: toSelectAndMatchesSearchFilterObjectRecords, + } = useMultiObjectRecordsQueryResultFormattedAsObjectRecordForSelectArray({ + multiObjectRecordsQueryResult: + toSelectAndMatchesSearchFilterObjectRecordsQueryResult, + }); + + return { + toSelectAndMatchesSearchFilterObjectRecordsLoading, + toSelectAndMatchesSearchFilterObjectRecords, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/relation-picker/hooks/useMultiObjectSearchSelectedItemsQuery.ts b/packages/twenty-front/src/modules/object-record/relation-picker/hooks/useMultiObjectSearchSelectedItemsQuery.ts new file mode 100644 index 000000000..8869c24b7 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/relation-picker/hooks/useMultiObjectSearchSelectedItemsQuery.ts @@ -0,0 +1,88 @@ +import { useQuery } from '@apollo/client'; +import { isNonEmptyArray } from '@sniptt/guards'; +import { useRecoilValue } from 'recoil'; + +import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; +import { useGenerateFindManyRecordsForMultipleMetadataItemsQuery } from '@/object-record/hooks/useGenerateFindManyRecordsForMultipleMetadataItemsQuery'; +import { useLimitPerMetadataItem } from '@/object-record/relation-picker/hooks/useLimitPerMetadataItem'; +import { + MultiObjectRecordQueryResult, + useMultiObjectRecordsQueryResultFormattedAsObjectRecordForSelectArray, +} from '@/object-record/relation-picker/hooks/useMultiObjectRecordsQueryResultFormattedAsObjectRecordForSelectArray'; +import { SelectedObjectRecordId } from '@/object-record/relation-picker/hooks/useMultiObjectSearch'; +import { useOrderByFieldPerMetadataItem } from '@/object-record/relation-picker/hooks/useOrderByFieldPerMetadataItem'; +import { isDefined } from '~/utils/isDefined'; +import { capitalize } from '~/utils/string/capitalize'; + +export const useMultiObjectSearchSelectedItemsQuery = ({ + selectedObjectRecordIds, +}: { + selectedObjectRecordIds: SelectedObjectRecordId[]; +}) => { + const objectMetadataItems = useRecoilValue(objectMetadataItemsState); + + const objectMetadataItemsUsedInSelectedIdsQuery = objectMetadataItems.filter( + ({ nameSingular }) => { + return selectedObjectRecordIds.some(({ objectNameSingular }) => { + return objectNameSingular === nameSingular; + }); + }, + ); + + const selectedIdFilterPerMetadataItem = Object.fromEntries( + objectMetadataItemsUsedInSelectedIdsQuery + .map(({ nameSingular }) => { + const selectedIds = selectedObjectRecordIds + .filter( + ({ objectNameSingular }) => objectNameSingular === nameSingular, + ) + .map(({ id }) => id); + + if (!isNonEmptyArray(selectedIds)) return null; + + return [ + `filter${capitalize(nameSingular)}`, + { + id: { + in: selectedIds, + }, + }, + ]; + }) + .filter(isDefined), + ); + + const { orderByFieldPerMetadataItem } = useOrderByFieldPerMetadataItem({ + objectMetadataItems: objectMetadataItemsUsedInSelectedIdsQuery, + }); + + const { limitPerMetadataItem } = useLimitPerMetadataItem({ + objectMetadataItems: objectMetadataItemsUsedInSelectedIdsQuery, + }); + + const multiSelectQueryForSelectedIds = + useGenerateFindManyRecordsForMultipleMetadataItemsQuery({ + objectMetadataItems: objectMetadataItemsUsedInSelectedIdsQuery, + }); + + const { + loading: selectedObjectRecordsLoading, + data: selectedObjectRecordsQueryResult, + } = useQuery(multiSelectQueryForSelectedIds, { + variables: { + ...selectedIdFilterPerMetadataItem, + ...orderByFieldPerMetadataItem, + ...limitPerMetadataItem, + }, + }); + + const { objectRecordForSelectArray: selectedObjectRecords } = + useMultiObjectRecordsQueryResultFormattedAsObjectRecordForSelectArray({ + multiObjectRecordsQueryResult: selectedObjectRecordsQueryResult, + }); + + return { + selectedObjectRecordsLoading, + selectedObjectRecords, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/relation-picker/hooks/useOrderByFieldPerMetadataItem.ts b/packages/twenty-front/src/modules/object-record/relation-picker/hooks/useOrderByFieldPerMetadataItem.ts new file mode 100644 index 000000000..42dd28f99 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/relation-picker/hooks/useOrderByFieldPerMetadataItem.ts @@ -0,0 +1,29 @@ +import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { getObjectOrderByField } from '@/object-metadata/utils/getObjectOrderByField'; +import { isDefined } from '~/utils/isDefined'; +import { capitalize } from '~/utils/string/capitalize'; + +export const useOrderByFieldPerMetadataItem = ({ + objectMetadataItems, +}: { + objectMetadataItems: ObjectMetadataItem[]; +}) => { + const orderByFieldPerMetadataItem = Object.fromEntries( + objectMetadataItems + .map((objectMetadataItem) => { + const orderByField = getObjectOrderByField(objectMetadataItem); + + return [ + `orderBy${capitalize(objectMetadataItem.nameSingular)}`, + { + ...orderByField, + }, + ]; + }) + .filter(isDefined), + ); + + return { + orderByFieldPerMetadataItem, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/relation-picker/hooks/useSearchFilterPerMetadataItem.ts b/packages/twenty-front/src/modules/object-record/relation-picker/hooks/useSearchFilterPerMetadataItem.ts new file mode 100644 index 000000000..b2f1d8c3f --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/relation-picker/hooks/useSearchFilterPerMetadataItem.ts @@ -0,0 +1,67 @@ +import { isNonEmptyString } from '@sniptt/guards'; + +import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { getLabelIdentifierFieldMetadataItem } from '@/object-metadata/utils/getLabelIdentifierFieldMetadataItem'; +import { ObjectRecordQueryFilter } from '@/object-record/record-filter/types/ObjectRecordQueryFilter'; +import { FieldMetadataType } from '~/generated/graphql'; +import { isDefined } from '~/utils/isDefined'; + +export const useSearchFilterPerMetadataItem = ({ + objectMetadataItems, + searchFilterValue, +}: { + objectMetadataItems: ObjectMetadataItem[]; + searchFilterValue: string; +}) => { + const searchFilterPerMetadataItemNameSingular = + Object.fromEntries( + objectMetadataItems + .map((objectMetadataItem) => { + if (!isNonEmptyString(searchFilterValue)) return null; + + const labelIdentifierFieldMetadataItem = + getLabelIdentifierFieldMetadataItem(objectMetadataItem); + + let searchFilter: ObjectRecordQueryFilter = {}; + + if (labelIdentifierFieldMetadataItem) { + switch (labelIdentifierFieldMetadataItem.type) { + case FieldMetadataType.FullName: { + searchFilter = { + or: [ + { + [labelIdentifierFieldMetadataItem.name]: { + firstName: { + ilike: `%${searchFilterValue}%`, + }, + }, + }, + { + [labelIdentifierFieldMetadataItem.name]: { + lastName: { + ilike: `%${searchFilterValue}%`, + }, + }, + }, + ], + }; + break; + } + default: + searchFilter = { + [labelIdentifierFieldMetadataItem.name]: { + ilike: `%${searchFilterValue}%`, + }, + }; + } + } + + return [objectMetadataItem.nameSingular, searchFilter] as const; + }) + .filter(isDefined), + ); + + return { + searchFilterPerMetadataItemNameSingular, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/types/ObjectRecord.ts b/packages/twenty-front/src/modules/object-record/types/ObjectRecord.ts new file mode 100644 index 000000000..7ffedb025 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/types/ObjectRecord.ts @@ -0,0 +1 @@ +export type ObjectRecord = Record & { id: string }; diff --git a/packages/twenty-front/src/modules/object-record/types/ObjectRecordConnection.ts b/packages/twenty-front/src/modules/object-record/types/ObjectRecordConnection.ts new file mode 100644 index 000000000..62bfc9164 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/types/ObjectRecordConnection.ts @@ -0,0 +1,11 @@ +import { ObjectRecordEdge } from '@/object-record/types/ObjectRecordEdge'; + +export type ObjectRecordConnection = { + edges: ObjectRecordEdge[]; + pageInfo: { + hasNextPage?: boolean; + hasPreviousPage?: boolean; + startCursor?: string; + endCursor?: string; + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/types/ObjectRecordEdge.ts b/packages/twenty-front/src/modules/object-record/types/ObjectRecordEdge.ts new file mode 100644 index 000000000..76581297a --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/types/ObjectRecordEdge.ts @@ -0,0 +1,6 @@ +import { ObjectRecord } from '@/object-record/types/ObjectRecord'; + +export type ObjectRecordEdge = { + node: ObjectRecord; + cursor: string; +}; diff --git a/packages/twenty-front/src/modules/object-record/types/ObjectRecordIdentifier.ts b/packages/twenty-front/src/modules/object-record/types/ObjectRecordIdentifier.ts index 50856064c..6e860d20b 100644 --- a/packages/twenty-front/src/modules/object-record/types/ObjectRecordIdentifier.ts +++ b/packages/twenty-front/src/modules/object-record/types/ObjectRecordIdentifier.ts @@ -5,5 +5,6 @@ export type ObjectRecordIdentifier = { name: string; avatarUrl?: string | null; avatarType?: AvatarType | null; + linkToEntity?: string; linkToShowPage?: string; }; diff --git a/packages/twenty-front/src/modules/people/hooks/useSpreadsheetPersonImport.ts b/packages/twenty-front/src/modules/people/hooks/useSpreadsheetPersonImport.ts index c92228e23..e21ee67b0 100644 --- a/packages/twenty-front/src/modules/people/hooks/useSpreadsheetPersonImport.ts +++ b/packages/twenty-front/src/modules/people/hooks/useSpreadsheetPersonImport.ts @@ -29,33 +29,37 @@ export const useSpreadsheetPersonImport = () => { ...options, onSubmit: async (data) => { // TODO: Add better type checking in spreadsheet import later - const createInputs = data.validData.map((person) => ({ - id: v4(), - name: { - firstName: person.firstName as string | undefined, - lastName: person.lastName as string | undefined, - }, - email: person.email as string | undefined, - ...(person.linkedinUrl - ? { - linkedinLink: { - label: 'linkedinUrl', - url: person.linkedinUrl as string | undefined, - }, - } - : {}), - ...(person.xUrl - ? { - xLink: { - label: 'xUrl', - url: person.xUrl as string | undefined, - }, - } - : {}), - jobTitle: person.jobTitle as string | undefined, - phone: person.phone as string | undefined, - city: person.city as string | undefined, - })); + const createInputs = data.validData.map( + (person) => + ({ + id: v4(), + name: { + firstName: person.firstName as string | undefined, + lastName: person.lastName as string | undefined, + }, + email: person.email as string | undefined, + ...(person.linkedinUrl + ? { + linkedinLink: { + label: 'linkedinUrl', + url: person.linkedinUrl as string | undefined, + }, + } + : {}), + ...(person.xUrl + ? { + xLink: { + label: 'xUrl', + url: person.xUrl as string | undefined, + }, + } + : {}), + jobTitle: person.jobTitle as string | undefined, + phone: person.phone as string | undefined, + city: person.city as string | undefined, + }) as Person, + ); + // TODO: abstract this part for any object try { await createManyPeople(createInputs); diff --git a/packages/twenty-front/src/modules/ui/display/chip/components/EntityChip.tsx b/packages/twenty-front/src/modules/ui/display/chip/components/EntityChip.tsx index 58f78e26e..a32bee0b3 100644 --- a/packages/twenty-front/src/modules/ui/display/chip/components/EntityChip.tsx +++ b/packages/twenty-front/src/modules/ui/display/chip/components/EntityChip.tsx @@ -5,6 +5,7 @@ import { isNonEmptyString } from '@sniptt/guards'; import { IconComponent } from '@/ui/display/icon/types/IconComponent'; import { Avatar, AvatarType } from '@/users/components/Avatar'; +import { Nullable } from '~/types/Nullable'; import { Chip, ChipVariant } from './Chip'; @@ -13,7 +14,7 @@ export type EntityChipProps = { entityId: string; name: string; avatarUrl?: string; - avatarType?: AvatarType; + avatarType?: Nullable; variant?: EntityChipVariant; LeftIcon?: IconComponent; className?: string; diff --git a/packages/twenty-front/src/modules/ui/input/components/Checkbox.tsx b/packages/twenty-front/src/modules/ui/input/components/Checkbox.tsx index 5fb33ddcf..1d7c49bc8 100644 --- a/packages/twenty-front/src/modules/ui/input/components/Checkbox.tsx +++ b/packages/twenty-front/src/modules/ui/input/components/Checkbox.tsx @@ -124,13 +124,13 @@ export const Checkbox = ({ React.useState(false); React.useEffect(() => { - setIsInternalChecked(checked); + setIsInternalChecked(checked ?? false); }, [checked]); const handleChange = (event: React.ChangeEvent) => { onChange?.(event); onCheckedChange?.(event.target.checked); - setIsInternalChecked(event.target.checked); + setIsInternalChecked(event.target.checked ?? false); }; const checkboxId = 'checkbox' + v4(); diff --git a/packages/twenty-front/src/modules/ui/layout/show-page/components/ShowPageAddButton.tsx b/packages/twenty-front/src/modules/ui/layout/show-page/components/ShowPageAddButton.tsx index 0bc688805..d6bb05d8b 100644 --- a/packages/twenty-front/src/modules/ui/layout/show-page/components/ShowPageAddButton.tsx +++ b/packages/twenty-front/src/modules/ui/layout/show-page/components/ShowPageAddButton.tsx @@ -2,7 +2,7 @@ import styled from '@emotion/styled'; import { useOpenCreateActivityDrawer } from '@/activities/hooks/useOpenCreateActivityDrawer'; import { ActivityType } from '@/activities/types/Activity'; -import { ActivityTargetableEntity } from '@/activities/types/ActivityTargetableEntity'; +import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity'; import { PageHotkeyScope } from '@/types/PageHotkeyScope'; import { IconCheckbox, IconNotes, IconPlus } from '@/ui/display/icon/index'; import { IconButton } from '@/ui/input/button/components/IconButton'; @@ -21,13 +21,13 @@ const StyledContainer = styled.div` export const ShowPageAddButton = ({ entity, }: { - entity: ActivityTargetableEntity; + entity: ActivityTargetableObject; }) => { const { closeDropdown, toggleDropdown } = useDropdown('add-show-page'); const openCreateActivity = useOpenCreateActivityDrawer(); const handleSelect = (type: ActivityType) => { - openCreateActivity({ type, targetableEntities: [entity] }); + openCreateActivity({ type, targetableObjects: [entity] }); closeDropdown(); }; diff --git a/packages/twenty-front/src/modules/ui/layout/show-page/components/ShowPageRightContainer.tsx b/packages/twenty-front/src/modules/ui/layout/show-page/components/ShowPageRightContainer.tsx index 6a82de0ce..9de7a4b73 100644 --- a/packages/twenty-front/src/modules/ui/layout/show-page/components/ShowPageRightContainer.tsx +++ b/packages/twenty-front/src/modules/ui/layout/show-page/components/ShowPageRightContainer.tsx @@ -3,9 +3,10 @@ import styled from '@emotion/styled'; import { Threads } from '@/activities/emails/components/Threads'; import { Attachments } from '@/activities/files/components/Attachments'; import { Notes } from '@/activities/notes/components/Notes'; -import { EntityTasks } from '@/activities/tasks/components/EntityTasks'; +import { ObjectTasks } from '@/activities/tasks/components/ObjectTasks'; import { Timeline } from '@/activities/timeline/components/Timeline'; -import { ActivityTargetableEntity } from '@/activities/types/ActivityTargetableEntity'; +import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity'; +import { isStandardObject } from '@/object-metadata/utils/isStandardObject'; import { IconCheckbox, IconMail, @@ -40,7 +41,7 @@ const StyledTabListContainer = styled.div` `; type ShowPageRightContainerProps = { - entity?: ActivityTargetableEntity; + targetableObject?: ActivityTargetableObject; timeline?: boolean; tasks?: boolean; notes?: boolean; @@ -48,7 +49,7 @@ type ShowPageRightContainerProps = { }; export const ShowPageRightContainer = ({ - entity, + targetableObject, timeline, tasks, notes, @@ -60,42 +61,44 @@ export const ShowPageRightContainer = ({ ShowPageRecoilScopeContext, ); - if (!entity) return <>; + if (!targetableObject) return <>; + + const targetableObjectIsStandardObject = isStandardObject( + targetableObject.targetObjectNameSingular, + ); + const TASK_TABS = [ { id: 'timeline', title: 'Timeline', Icon: IconTimelineEvent, hide: !timeline, - disabled: entity.type === 'Custom', }, { id: 'tasks', title: 'Tasks', Icon: IconCheckbox, hide: !tasks, - disabled: entity.type === 'Custom', }, { id: 'notes', title: 'Notes', Icon: IconNotes, hide: !notes, - disabled: entity.type === 'Custom', }, { id: 'files', title: 'Files', Icon: IconPaperclip, hide: !notes, - disabled: entity.type === 'Custom', + disabled: !targetableObjectIsStandardObject, }, { id: 'emails', title: 'Emails', Icon: IconMail, hide: !emails, - disabled: !isMessagingEnabled || entity.type === 'Custom', + disabled: !isMessagingEnabled || !targetableObjectIsStandardObject, }, ]; @@ -104,11 +107,17 @@ export const ShowPageRightContainer = ({ - {activeTabId === 'timeline' && } - {activeTabId === 'tasks' && } - {activeTabId === 'notes' && } - {activeTabId === 'files' && } - {activeTabId === 'emails' && } + {activeTabId === 'timeline' && ( + + )} + {activeTabId === 'tasks' && ( + + )} + {activeTabId === 'notes' && } + {activeTabId === 'files' && ( + + )} + {activeTabId === 'emails' && } ); }; diff --git a/packages/twenty-front/src/modules/ui/navigation/menu-item/components/MenuItemMultiSelectAvatar.tsx b/packages/twenty-front/src/modules/ui/navigation/menu-item/components/MenuItemMultiSelectAvatar.tsx index 369f7b205..60e32d628 100644 --- a/packages/twenty-front/src/modules/ui/navigation/menu-item/components/MenuItemMultiSelectAvatar.tsx +++ b/packages/twenty-front/src/modules/ui/navigation/menu-item/components/MenuItemMultiSelectAvatar.tsx @@ -1,6 +1,7 @@ import { ReactNode } from 'react'; import styled from '@emotion/styled'; +import { OverflowingTextWithTooltip } from '@/ui/display/tooltip/OverflowingTextWithTooltip'; import { Checkbox } from '@/ui/input/components/Checkbox'; import { @@ -14,6 +15,7 @@ const StyledLeftContentWithCheckboxContainer = styled.div` display: flex; flex-direction: row; gap: ${({ theme }) => theme.spacing(2)}; + width: 100%; `; type MenuItemMultiSelectAvatarProps = { @@ -48,7 +50,7 @@ export const MenuItemMultiSelectAvatar = ({ {avatar} - {text} + diff --git a/packages/twenty-front/src/modules/ui/navigation/menu-item/internals/components/StyledMenuItemBase.tsx b/packages/twenty-front/src/modules/ui/navigation/menu-item/internals/components/StyledMenuItemBase.tsx index 2291eec5f..7fc9e0b79 100644 --- a/packages/twenty-front/src/modules/ui/navigation/menu-item/internals/components/StyledMenuItemBase.tsx +++ b/packages/twenty-front/src/modules/ui/navigation/menu-item/internals/components/StyledMenuItemBase.tsx @@ -71,10 +71,11 @@ export const StyledMenuItemBase = styled.li` export const StyledMenuItemLabel = styled.div<{ hasLeftIcon: boolean }>` font-size: ${({ theme }) => theme.font.size.sm}; font-weight: ${({ theme }) => theme.font.weight.regular}; + overflow: hidden; padding-left: ${({ theme, hasLeftIcon }) => hasLeftIcon ? '' : theme.spacing(1)}; - text-overflow: ellipsis; + white-space: nowrap; `; diff --git a/packages/twenty-front/src/modules/users/components/Avatar.tsx b/packages/twenty-front/src/modules/users/components/Avatar.tsx index 782aedbcd..12dc109fd 100644 --- a/packages/twenty-front/src/modules/users/components/Avatar.tsx +++ b/packages/twenty-front/src/modules/users/components/Avatar.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'; import styled from '@emotion/styled'; import { isNonEmptyString } from '@sniptt/guards'; +import { Nullable } from '~/types/Nullable'; import { stringToHslColor } from '~/utils/string-to-hsl'; import { getImageAbsoluteURIOrBase64 } from '../utils/getProfilePictureAbsoluteURI'; @@ -16,7 +17,7 @@ export type AvatarProps = { size?: AvatarSize; placeholder: string | undefined; colorId?: string; - type?: AvatarType; + type?: Nullable; onClick?: () => void; }; diff --git a/packages/twenty-front/src/testing/mock-data/activities.ts b/packages/twenty-front/src/testing/mock-data/activities.ts index c911cf065..423580a2d 100644 --- a/packages/twenty-front/src/testing/mock-data/activities.ts +++ b/packages/twenty-front/src/testing/mock-data/activities.ts @@ -34,6 +34,7 @@ type MockedActivity = Pick< | 'activityId' | 'personId' | 'companyId' + | 'targetObjectNameSingular' > & { activity: Pick; person?: Pick | null; @@ -98,6 +99,7 @@ export const mockedActivities: Array = [ id: '89bb825c-171e-4bcc-9cf7-43448d6fb300', createdAt: '2023-04-26T10:12:42.33625+00:00', updatedAt: '2023-04-26T10:23:42.33625+00:00', + targetObjectNameSingular: 'company', personId: null, companyId: '89bb825c-171e-4bcc-9cf7-43448d6fb280', company: { @@ -118,6 +120,7 @@ export const mockedActivities: Array = [ id: '89bb825c-171e-4bcc-9cf7-43448d6fb301', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), + targetObjectNameSingular: 'company', personId: null, companyId: 'b396e6b9-dc5c-4643-bcff-61b6cf7523ae', company: { @@ -160,6 +163,7 @@ export const mockedActivities: Array = [ id: '89bb825c-171e-4bcc-9cf7-43448d6fb278t', createdAt: '2023-04-26T10:12:42.33625+00:00', updatedAt: '2023-04-26T10:23:42.33625+00:00', + targetObjectNameSingular: 'person', personId: '7dfbc3f7-6e5e-4128-957e-8d86808cdf6b', // Alexandre person: { id: '7dfbc3f7-6e5e-4128-957e-8d86808cdf6b', @@ -185,6 +189,7 @@ export const mockedActivities: Array = [ updatedAt: new Date().toISOString(), personId: '7dfbc3f7-6e5e-4128-957e-8d86808cdf6d', // Jean d'Eau companyId: null, + targetObjectNameSingular: 'person', company: null, person: { id: '7dfbc3f7-6e5e-4128-957e-8d86808cdf6d', diff --git a/packages/twenty-server/src/workspace/workspace-query-builder/factories/relation-field-alias.factory.ts b/packages/twenty-server/src/workspace/workspace-query-builder/factories/relation-field-alias.factory.ts index 101629597..d18f463fa 100644 --- a/packages/twenty-server/src/workspace/workspace-query-builder/factories/relation-field-alias.factory.ts +++ b/packages/twenty-server/src/workspace/workspace-query-builder/factories/relation-field-alias.factory.ts @@ -111,8 +111,9 @@ export class RelationFieldAliasFactory { } `; } + let relationAlias = fieldMetadata.isCustom - ? `${fieldKey}: ${fieldMetadata.targetColumnMap.value}` + ? `${fieldKey}: ${referencedObjectMetadata.targetTableName}` : fieldKey; // For one to one relations, pg_graphql use the targetTableName on the side that is not storing the foreign key diff --git a/packages/twenty-server/test/utils/reset-db.ts b/packages/twenty-server/test/utils/reset-db.ts index 2a7863eb3..f39b118ce 100644 --- a/packages/twenty-server/test/utils/reset-db.ts +++ b/packages/twenty-server/test/utils/reset-db.ts @@ -11,7 +11,6 @@ export default async () => { await prisma.$transaction( entities.map((entity) => { - console.log('entity: ', entity); return prisma[entity].deleteMany(); }), );