From 36a655828943572d41e05c68685ddb1adfda667e Mon Sep 17 00:00:00 2001 From: Lucas Bordeau Date: Tue, 20 Feb 2024 14:20:45 +0100 Subject: [PATCH] Feat/activity optimistic activities (#4009) * Fix naming * Fixed cache.evict bug for relation target deletion * Fixed cascade delete activity targets * Working version * Fix * fix * WIP * Fixed optimistic effect target inline cell * Removed openCreateActivityDrawer v1 * Ok for timeline * Removed console.log * Fix update record optimistic effect * Refactored activity queries into useActivities for everything * Fixed bugs * Cleaned * Fix lint --------- Co-authored-by: Charles Bochet --- .../effect-components/PageChangeEffect.tsx | 3 +- .../components/ActivityBodyEditor.tsx | 91 ++++++++---- .../activities/components/ActivityEditor.tsx | 63 ++++++-- .../components/ActivityTargetChips.tsx | 8 +- .../activities/components/ActivityTitle.tsx | 26 +++- .../modules/activities/hooks/useActivities.ts | 120 +++++++++++++++ .../activities/hooks/useActivityById.ts | 20 +-- .../hooks/useActivityTargetObjectRecords.ts | 11 +- .../useActivityTargetsForTargetableObject.ts | 3 + .../useActivityTargetsForTargetableObjects.ts | 42 ++++++ .../hooks/useCreateActivityInCache.ts | 41 ++---- .../useInjectIntoActivitiesQuery.ts} | 85 ++++++----- .../hooks/useOpenActivityRightDrawer.ts | 23 ++- .../hooks/useOpenCreateActivityDrawer.ts | 127 ++++++---------- ...enCreateActivityDrawerForSelectedRowIds.ts | 3 +- .../hooks/useOpenCreateActivityDrawerV2.ts | 61 -------- .../hooks/useRemoveFromActivitiesQueries.ts | 134 +++++++++++++++++ .../activities/hooks/useUpsertActivity.ts | 66 +++++++-- .../ActivityTargetInlineCellEditMode.tsx | 135 +++++++++-------- .../components/ActivityTargetsInlineCell.tsx | 2 +- .../activities/notes/components/NoteCard.tsx | 2 +- .../activities/notes/components/NoteList.tsx | 2 +- .../activities/notes/components/Notes.tsx | 6 +- .../activities/notes/hooks/useNotes.ts | 34 ++--- .../components/ActivityActionBar.tsx | 68 +++++++-- .../components/RightDrawerActivity.tsx | 4 +- .../states/activityInDrawerState.ts | 8 + .../states/canCreateActivityState.ts | 6 + .../states/isActivityInCreateModeState.ts | 6 + .../states/isCreatingActivityInDBState.ts | 6 + .../states/isCreatingActivityState.ts | 6 - .../states/targetableObjectsInDrawerState.ts | 8 + .../tasks/components/PageAddTaskButton.tsx | 21 +-- .../tasks/components/TaskGroups.tsx | 7 +- .../activities/tasks/components/TaskList.tsx | 5 +- .../activities/tasks/components/TaskRow.tsx | 10 +- .../activities/tasks/hooks/useTasks.ts | 80 +++------- .../timeline/components/Timeline.tsx | 24 ++- .../timeline/components/TimelineActivity.tsx | 27 ++-- .../components/TimelineCreateButtonGroup.tsx | 6 +- .../FIND_MANY_TIMELINE_ACTIVITIES_ORDER_BY.ts | 5 + .../useInjectIntoTimelineActivitiesQueries.ts | 32 ++++ .../useRemoveFromTimelineActivitiesQueries.ts | 138 ++++++++++++++++++ .../timeline/hooks/useTimelineActivities.ts | 60 ++++++-- .../states/timelineTargetableObjectState.ts | 9 ++ .../makeTimelineActivitiesQueryVariables.ts | 3 +- .../activities/types/ActivityTargetObject.ts | 7 +- .../utils/getActivityTargetsFilter.ts | 25 ++++ .../utils/useActivityConnectionUtils.ts | 14 +- .../utils/isObjectRecordConnection.ts | 6 +- .../triggerDetachRelationOptimisticEffect.ts | 36 ++--- .../triggerUpdateRecordOptimisticEffect.ts | 68 ++++----- .../triggerUpdateRelationsOptimisticEffect.ts | 12 +- ...coreObjectNamesToDeleteOnRelationDetach.ts | 2 + .../command-menu/components/CommandMenu.tsx | 10 +- ...eGenerateObjectRecordOptimisticResponse.ts | 7 + .../useUpsertFindManyRecordsQueryInCache.ts | 5 +- .../utils/getRecordConnectionFromRecords.ts | 1 + .../hooks/useDeleteManyRecords.ts | 51 ++++--- .../object-record/hooks/useFindOneRecord.ts | 1 + .../types/ObjectRecordQueryFilter.ts | 3 +- .../utils/sortByObjectRecordId.ts | 5 + .../utils/sortObjectRecordByDateField.test.ts | 77 ++++++++++ .../utils/sortObjectRecordByDateField.ts | 68 +++++++++ .../right-drawer/hooks/useRightDrawer.ts | 9 +- .../components/ShowPageAddButton.tsx | 6 +- .../twenty-front/src/pages/tasks/Tasks.tsx | 2 +- .../src/utils/array/sortByAscString.ts | 3 + 68 files changed, 1435 insertions(+), 630 deletions(-) create mode 100644 packages/twenty-front/src/modules/activities/hooks/useActivities.ts create mode 100644 packages/twenty-front/src/modules/activities/hooks/useActivityTargetsForTargetableObjects.ts rename packages/twenty-front/src/modules/activities/{timeline/hooks/useInjectIntoTimelineActivitiesQueryAfterDrawerMount.ts => hooks/useInjectIntoActivitiesQuery.ts} (56%) delete mode 100644 packages/twenty-front/src/modules/activities/hooks/useOpenCreateActivityDrawerV2.ts create mode 100644 packages/twenty-front/src/modules/activities/hooks/useRemoveFromActivitiesQueries.ts create mode 100644 packages/twenty-front/src/modules/activities/states/activityInDrawerState.ts create mode 100644 packages/twenty-front/src/modules/activities/states/canCreateActivityState.ts create mode 100644 packages/twenty-front/src/modules/activities/states/isActivityInCreateModeState.ts create mode 100644 packages/twenty-front/src/modules/activities/states/isCreatingActivityInDBState.ts delete mode 100644 packages/twenty-front/src/modules/activities/states/isCreatingActivityState.ts create mode 100644 packages/twenty-front/src/modules/activities/states/targetableObjectsInDrawerState.ts create mode 100644 packages/twenty-front/src/modules/activities/timeline/constants/FIND_MANY_TIMELINE_ACTIVITIES_ORDER_BY.ts create mode 100644 packages/twenty-front/src/modules/activities/timeline/hooks/useInjectIntoTimelineActivitiesQueries.ts create mode 100644 packages/twenty-front/src/modules/activities/timeline/hooks/useRemoveFromTimelineActivitiesQueries.ts create mode 100644 packages/twenty-front/src/modules/activities/timeline/states/timelineTargetableObjectState.ts create mode 100644 packages/twenty-front/src/modules/activities/utils/getActivityTargetsFilter.ts create mode 100644 packages/twenty-front/src/modules/object-record/utils/sortByObjectRecordId.ts create mode 100644 packages/twenty-front/src/modules/object-record/utils/sortObjectRecordByDateField.test.ts create mode 100644 packages/twenty-front/src/modules/object-record/utils/sortObjectRecordByDateField.ts create mode 100644 packages/twenty-front/src/utils/array/sortByAscString.ts diff --git a/packages/twenty-front/src/effect-components/PageChangeEffect.tsx b/packages/twenty-front/src/effect-components/PageChangeEffect.tsx index 80ae45b00..0db11f40f 100644 --- a/packages/twenty-front/src/effect-components/PageChangeEffect.tsx +++ b/packages/twenty-front/src/effect-components/PageChangeEffect.tsx @@ -218,7 +218,8 @@ export const PageChangeEffect = () => { label: 'Create Task', type: CommandType.Create, Icon: IconCheckbox, - onCommandClick: () => openCreateActivity({ type: 'Task' }), + onCommandClick: () => + openCreateActivity({ type: 'Task', targetableObjects: [] }), }, ]); }, [addToCommandMenu, setToInitialCommandMenu, openCreateActivity]); diff --git a/packages/twenty-front/src/modules/activities/components/ActivityBodyEditor.tsx b/packages/twenty-front/src/modules/activities/components/ActivityBodyEditor.tsx index ece81af3c..e472caaed 100644 --- a/packages/twenty-front/src/modules/activities/components/ActivityBodyEditor.tsx +++ b/packages/twenty-front/src/modules/activities/components/ActivityBodyEditor.tsx @@ -1,20 +1,22 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback } from 'react'; import { BlockNoteEditor } from '@blocknote/core'; import { useBlockNote } from '@blocknote/react'; import styled from '@emotion/styled'; import { isArray, isNonEmptyString } from '@sniptt/guards'; -import { useRecoilState } from 'recoil'; +import { useRecoilCallback, useRecoilState } from 'recoil'; import { Key } from 'ts-key-enum'; import { useDebouncedCallback } from 'use-debounce'; import { v4 } from 'uuid'; import { useUpsertActivity } from '@/activities/hooks/useUpsertActivity'; import { activityTitleHasBeenSetFamilyState } from '@/activities/states/activityTitleHasBeenSetFamilyState'; +import { canCreateActivityState } from '@/activities/states/canCreateActivityState'; import { Activity } from '@/activities/types/Activity'; import { ActivityEditorHotkeyScope } from '@/activities/types/ActivityEditorHotkeyScope'; import { useObjectMetadataItemOnly } from '@/object-metadata/hooks/useObjectMetadataItemOnly'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { useModifyRecordFromCache } from '@/object-record/cache/hooks/useModifyRecordFromCache'; +import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; import { BlockEditor } from '@/ui/input/editor/components/BlockEditor'; import { RightDrawerHotkeyScope } from '@/ui/layout/right-drawer/types/RightDrawerHotkeyScope'; import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope'; @@ -42,10 +44,6 @@ export const ActivityBodyEditor = ({ activity, fillTitleFromBody, }: ActivityBodyEditorProps) => { - const [stringifiedBodyFromEditor, setStringifiedBodyFromEditor] = useState< - string | null - >(activity.body); - const [activityTitleHasBeenSet, setActivityTitleHasBeenSet] = useRecoilState( activityTitleHasBeenSetFamilyState({ activityId: activity.id, @@ -96,19 +94,21 @@ export const ActivityBodyEditor = ({ const blockBody = JSON.parse(newStringifiedBody); const newTitleFromBody = blockBody[0]?.content?.[0]?.text; - modifyActivityFromCache(activity.id, { - title: () => { - return newTitleFromBody; - }, - }); - persistTitleAndBodyDebounced(newTitleFromBody, newStringifiedBody); }, - [activity.id, modifyActivityFromCache, persistTitleAndBodyDebounced], + [persistTitleAndBodyDebounced], + ); + + const [canCreateActivity, setCanCreateActivity] = useRecoilState( + canCreateActivityState, ); const handleBodyChange = useCallback( (activityBody: string) => { + if (!canCreateActivity) { + setCanCreateActivity(true); + } + if (!activityTitleHasBeenSet && fillTitleFromBody) { updateTitleAndBody(activityBody); } else { @@ -120,18 +120,11 @@ export const ActivityBodyEditor = ({ persistBodyDebounced, activityTitleHasBeenSet, updateTitleAndBody, + setCanCreateActivity, + canCreateActivity, ], ); - useEffect(() => { - if ( - isNonEmptyString(stringifiedBodyFromEditor) && - activity.body !== stringifiedBodyFromEditor - ) { - handleBodyChange(stringifiedBodyFromEditor); - } - }, [stringifiedBodyFromEditor, handleBodyChange, activity]); - const slashMenuItems = getSlashMenu(); const [uploadFile] = useUploadFileMutation(); @@ -160,9 +153,57 @@ export const ActivityBodyEditor = ({ ? JSON.parse(activity.body) : undefined, domAttributes: { editor: { class: 'editor' } }, - onEditorContentChange: (editor: BlockNoteEditor) => { - setStringifiedBodyFromEditor(JSON.stringify(editor.topLevelBlocks) ?? ''); - }, + onEditorContentChange: useRecoilCallback( + ({ snapshot, set }) => + (editor: BlockNoteEditor) => { + const newStringifiedBody = + JSON.stringify(editor.topLevelBlocks) ?? ''; + + set(recordStoreFamilyState(activity.id), (oldActivity) => { + return { + ...oldActivity, + id: activity.id, + body: newStringifiedBody, + }; + }); + + modifyActivityFromCache(activity.id, { + body: () => { + return newStringifiedBody; + }, + }); + + const activityTitleHasBeenSet = snapshot + .getLoadable( + activityTitleHasBeenSetFamilyState({ + activityId: activity.id, + }), + ) + .getValue(); + + const blockBody = JSON.parse(newStringifiedBody); + const newTitleFromBody = blockBody[0]?.content?.[0]?.text as string; + + if (!activityTitleHasBeenSet && fillTitleFromBody) { + set(recordStoreFamilyState(activity.id), (oldActivity) => { + return { + ...oldActivity, + id: activity.id, + title: newTitleFromBody, + }; + }); + + modifyActivityFromCache(activity.id, { + title: () => { + return newTitleFromBody; + }, + }); + } + + handleBodyChange(newStringifiedBody); + }, + [activity, fillTitleFromBody, modifyActivityFromCache, handleBodyChange], + ), slashMenuItems, blockSpecs: blockSpecs, uploadFile: handleUploadAttachment, diff --git a/packages/twenty-front/src/modules/activities/components/ActivityEditor.tsx b/packages/twenty-front/src/modules/activities/components/ActivityEditor.tsx index 86a7e489d..0c47707a0 100644 --- a/packages/twenty-front/src/modules/activities/components/ActivityEditor.tsx +++ b/packages/twenty-front/src/modules/activities/components/ActivityEditor.tsx @@ -8,7 +8,9 @@ import { ActivityTypeDropdown } from '@/activities/components/ActivityTypeDropdo import { useDeleteActivityFromCache } from '@/activities/hooks/useDeleteActivityFromCache'; import { useUpsertActivity } from '@/activities/hooks/useUpsertActivity'; import { ActivityTargetsInlineCell } from '@/activities/inline-cell/components/ActivityTargetsInlineCell'; -import { isCreatingActivityState } from '@/activities/states/isCreatingActivityState'; +import { canCreateActivityState } from '@/activities/states/canCreateActivityState'; +import { isActivityInCreateModeState } from '@/activities/states/isActivityInCreateModeState'; +import { isUpsertingActivityInDBState } from '@/activities/states/isCreatingActivityInDBState'; import { Activity } from '@/activities/types/Activity'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { useFieldContext } from '@/object-record/hooks/useFieldContext'; @@ -18,6 +20,7 @@ import { } from '@/object-record/record-field/contexts/FieldContext'; import { RecordInlineCell } from '@/object-record/record-inline-cell/components/RecordInlineCell'; import { PropertyBox } from '@/object-record/record-inline-cell/property-box/components/PropertyBox'; +import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; import { RIGHT_DRAWER_CLICK_OUTSIDE_LISTENER_ID } from '@/ui/layout/right-drawer/constants/RightDrawerClickOutsideListener'; import { useClickOutsideListener } from '@/ui/utilities/pointer-event/hooks/useClickOutsideListener'; import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile'; @@ -78,8 +81,10 @@ export const ActivityEditor = ({ const { deleteActivityFromCache } = useDeleteActivityFromCache(); const useUpsertOneActivityMutation: RecordUpdateHook = () => { - const upsertActivityMutation = ({ variables }: RecordUpdateHookParams) => { - upsertActivity({ activity, input: variables.updateOneRecordInput }); + const upsertActivityMutation = async ({ + variables, + }: RecordUpdateHookParams) => { + await upsertActivity({ activity, input: variables.updateOneRecordInput }); }; return [upsertActivityMutation, { loading: false }]; @@ -104,6 +109,20 @@ export const ActivityEditor = ({ customUseUpdateOneObjectHook: useUpsertOneActivityMutation, }); + const [isActivityInCreateMode, setIsActivityInCreateMode] = useRecoilState( + isActivityInCreateModeState, + ); + + const [isUpsertingActivityInDB] = useRecoilState( + isUpsertingActivityInDBState, + ); + + const [canCreateActivity] = useRecoilState(canCreateActivityState); + + const [activityFromStore] = useRecoilState( + recordStoreFamilyState(activity.id), + ); + const { FieldContextProvider: ActivityTargetsContextProvider } = useFieldContext({ objectNameSingular: CoreObjectNameSingular.Activity, @@ -112,16 +131,40 @@ export const ActivityEditor = ({ fieldPosition: 2, }); - const [isCreatingActivity, setIsCreatingActivity] = useRecoilState( - isCreatingActivityState, - ); - useRegisterClickOutsideListenerCallback({ callbackId: 'activity-editor', callbackFunction: () => { - if (isCreatingActivity) { - setIsCreatingActivity(false); - deleteActivityFromCache(activity); + if (isUpsertingActivityInDB || !activityFromStore) { + return; + } + + if (isActivityInCreateMode) { + if (canCreateActivity) { + upsertActivity({ + activity, + input: { + title: activityFromStore.title, + body: activityFromStore.body, + }, + }); + } else { + deleteActivityFromCache(activity); + } + + setIsActivityInCreateMode(false); + } else { + if ( + activityFromStore.title !== activity.title || + activityFromStore.body !== activity.body + ) { + upsertActivity({ + activity, + input: { + title: activityFromStore.title, + body: activityFromStore.body, + }, + }); + } } }, }); diff --git a/packages/twenty-front/src/modules/activities/components/ActivityTargetChips.tsx b/packages/twenty-front/src/modules/activities/components/ActivityTargetChips.tsx index 9d0920149..c88d5f5e6 100644 --- a/packages/twenty-front/src/modules/activities/components/ActivityTargetChips.tsx +++ b/packages/twenty-front/src/modules/activities/components/ActivityTargetChips.tsx @@ -1,6 +1,6 @@ import styled from '@emotion/styled'; -import { ActivityTargetObjectRecord } from '@/activities/types/ActivityTargetObject'; +import { ActivityTargetWithTargetRecord } from '@/activities/types/ActivityTargetObject'; import { RecordChip } from '@/object-record/components/RecordChip'; const StyledContainer = styled.div` @@ -12,14 +12,14 @@ const StyledContainer = styled.div` export const ActivityTargetChips = ({ activityTargetObjectRecords, }: { - activityTargetObjectRecords: ActivityTargetObjectRecord[]; + activityTargetObjectRecords: ActivityTargetWithTargetRecord[]; }) => { return ( {activityTargetObjectRecords?.map((activityTargetObjectRecord) => ( { - const [internalTitle, setInternalTitle] = useState(activity.title); + const [activityInStore, setActivityInStore] = useRecoilState( + recordStoreFamilyState(activity.id), + ); + + const [canCreateActivity, setCanCreateActivity] = useRecoilState( + canCreateActivityState, + ); const { upsertActivity } = useUpsertActivity(); @@ -115,7 +123,17 @@ export const ActivityTitle = ({ activity }: ActivityTitleProps) => { }, 500); const handleTitleChange = (newTitle: string) => { - setInternalTitle(newTitle); + setActivityInStore((currentActivity) => { + return { + ...currentActivity, + id: activity.id, + title: newTitle, + }; + }); + + if (isNonEmptyString(newTitle) && !canCreateActivity) { + setCanCreateActivity(true); + } modifyActivityFromCache(activity.id, { title: () => { @@ -153,7 +171,7 @@ export const ActivityTitle = ({ activity }: ActivityTitleProps) => { ref={titleInputRef} placeholder={`${activity.type} title`} onChange={(event) => handleTitleChange(event.target.value)} - value={internalTitle} + value={activityInStore?.title ?? ''} completed={completed} onBlur={handleBlur} onFocus={handleFocus} diff --git a/packages/twenty-front/src/modules/activities/hooks/useActivities.ts b/packages/twenty-front/src/modules/activities/hooks/useActivities.ts new file mode 100644 index 000000000..804426cab --- /dev/null +++ b/packages/twenty-front/src/modules/activities/hooks/useActivities.ts @@ -0,0 +1,120 @@ +import { useEffect, useState } from 'react'; +import { isNonEmptyArray, isNonEmptyString } from '@sniptt/guards'; +import { useRecoilCallback } from 'recoil'; + +import { useActivityTargetsForTargetableObjects } from '@/activities/hooks/useActivityTargetsForTargetableObjects'; +import { Activity } from '@/activities/types/Activity'; +import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity'; +import { useActivityConnectionUtils } from '@/activities/utils/useActivityConnectionUtils'; +import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; +import { OrderByField } from '@/object-metadata/types/OrderByField'; +import { getRecordsFromRecordConnection } from '@/object-record/cache/utils/getRecordsFromRecordConnection'; +import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords'; +import { ObjectRecordQueryFilter } from '@/object-record/record-filter/types/ObjectRecordQueryFilter'; +import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; +import { sortByAscString } from '~/utils/array/sortByAscString'; + +export const useActivities = ({ + targetableObjects, + activitiesFilters, + activitiesOrderByVariables, + skip, + skipActivityTargets, +}: { + targetableObjects: ActivityTargetableObject[]; + activitiesFilters: ObjectRecordQueryFilter; + activitiesOrderByVariables: OrderByField; + skip?: boolean; + skipActivityTargets?: boolean; +}) => { + const [initialized, setInitialized] = useState(false); + + const { makeActivityWithoutConnection } = useActivityConnectionUtils(); + + const { + activityTargets, + loadingActivityTargets, + initialized: initializedActivityTargets, + } = useActivityTargetsForTargetableObjects({ + targetableObjects, + skip: skipActivityTargets || skip, + }); + + const activityIds = activityTargets + ?.map((activityTarget) => activityTarget.activityId) + .filter(isNonEmptyString) + .toSorted(sortByAscString); + + const activityTargetsFound = + initializedActivityTargets && isNonEmptyArray(activityTargets); + + const filter: ObjectRecordQueryFilter = { + id: activityTargetsFound + ? { + in: activityIds, + } + : undefined, + ...activitiesFilters, + }; + + const skipActivities = + skip || + (!skipActivityTargets && + (!initializedActivityTargets || !activityTargetsFound)); + + const { records: activitiesWithConnection, loading: loadingActivities } = + useFindManyRecords({ + skip: skipActivities, + objectNameSingular: CoreObjectNameSingular.Activity, + filter, + orderBy: activitiesOrderByVariables, + onCompleted: useRecoilCallback( + ({ set }) => + (data) => { + if (!initialized) { + setInitialized(true); + } + + const activities = getRecordsFromRecordConnection({ + recordConnection: data, + }); + + for (const activity of activities) { + set(recordStoreFamilyState(activity.id), activity); + } + }, + [initialized], + ), + }); + + const loading = loadingActivities || loadingActivityTargets; + + // TODO: fix connection in relation => automatically change to an array + const activities = activitiesWithConnection + ?.map(makeActivityWithoutConnection as any) + .map(({ activity }: any) => activity); + + const noActivities = + (!activityTargetsFound && !skipActivityTargets && initialized) || + (initialized && !loading && !isNonEmptyArray(activities)); + + useEffect(() => { + if (skipActivities || noActivities) { + setInitialized(true); + } + }, [ + activities, + initialized, + loading, + noActivities, + skipActivities, + skipActivityTargets, + ]); + + return { + activities, + loading, + initialized, + noActivities, + }; +}; diff --git a/packages/twenty-front/src/modules/activities/hooks/useActivityById.ts b/packages/twenty-front/src/modules/activities/hooks/useActivityById.ts index 4e8ba8063..547d902dc 100644 --- a/packages/twenty-front/src/modules/activities/hooks/useActivityById.ts +++ b/packages/twenty-front/src/modules/activities/hooks/useActivityById.ts @@ -1,34 +1,26 @@ -import { useSetRecoilState } from 'recoil'; - import { useActivityConnectionUtils } from '@/activities/utils/useActivityConnectionUtils'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord'; -import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; const QUERY_DEPTH_TO_GET_ACTIVITY_TARGET_RELATIONS = 3; export const useActivityById = ({ activityId }: { activityId: string }) => { - const setEntityFields = useSetRecoilState(recordStoreFamilyState(activityId)); - const { makeActivityWithoutConnection } = useActivityConnectionUtils(); - const { record: activityWithConnections } = useFindOneRecord({ + // TODO: fix connection in relation => automatically change to an array + const { record: activityWithConnections, loading } = useFindOneRecord({ objectNameSingular: CoreObjectNameSingular.Activity, objectRecordId: activityId, skip: !activityId, - onCompleted: (activityWithConnections: any) => { - const { activity } = makeActivityWithoutConnection( - activityWithConnections, - ); - - setEntityFields(activity); - }, depth: QUERY_DEPTH_TO_GET_ACTIVITY_TARGET_RELATIONS, }); - const { activity } = makeActivityWithoutConnection(activityWithConnections); + const { activity } = activityWithConnections + ? makeActivityWithoutConnection(activityWithConnections as any) + : { activity: null }; return { activity, + loading, }; }; diff --git a/packages/twenty-front/src/modules/activities/hooks/useActivityTargetObjectRecords.ts b/packages/twenty-front/src/modules/activities/hooks/useActivityTargetObjectRecords.ts index 12b050b02..359b1c755 100644 --- a/packages/twenty-front/src/modules/activities/hooks/useActivityTargetObjectRecords.ts +++ b/packages/twenty-front/src/modules/activities/hooks/useActivityTargetObjectRecords.ts @@ -1,7 +1,8 @@ import { isNonEmptyString } from '@sniptt/guards'; import { useRecoilValue } from 'recoil'; -import { ActivityTargetObjectRecord } from '@/activities/types/ActivityTargetObject'; +import { ActivityTarget } from '@/activities/types/ActivityTarget'; +import { ActivityTargetWithTargetRecord } 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'; @@ -16,7 +17,7 @@ export const useActivityTargetObjectRecords = ({ const objectMetadataItems = useRecoilValue(objectMetadataItemsState); const { records: activityTargets, loading: loadingActivityTargets } = - useFindManyRecords({ + useFindManyRecords({ objectNameSingular: CoreObjectNameSingular.ActivityTarget, skip: !isNonEmptyString(activityId), filter: { @@ -27,7 +28,7 @@ export const useActivityTargetObjectRecords = ({ }); const activityTargetObjectRecords = activityTargets - .map>((activityTarget) => { + .map>((activityTarget) => { const correspondingObjectMetadataItem = objectMetadataItems.find( (objectMetadataItem) => isDefined(activityTarget[objectMetadataItem.nameSingular]) && @@ -39,8 +40,8 @@ export const useActivityTargetObjectRecords = ({ } return { - activityTargetRecord: activityTarget, - targetObjectRecord: + activityTarget: activityTarget, + targetObject: activityTarget[correspondingObjectMetadataItem.nameSingular], targetObjectMetadataItem: correspondingObjectMetadataItem, targetObjectNameSingular: correspondingObjectMetadataItem.nameSingular, diff --git a/packages/twenty-front/src/modules/activities/hooks/useActivityTargetsForTargetableObject.ts b/packages/twenty-front/src/modules/activities/hooks/useActivityTargetsForTargetableObject.ts index 7cdf18e20..a48dec8f8 100644 --- a/packages/twenty-front/src/modules/activities/hooks/useActivityTargetsForTargetableObject.ts +++ b/packages/twenty-front/src/modules/activities/hooks/useActivityTargetsForTargetableObject.ts @@ -22,6 +22,9 @@ export const useActivityTargetsForTargetableObject = ({ const skipRequest = !isNonEmptyString(targetableObjectId); + // TODO: We want to optimistically remove from this request + // If we are on a show page and we remove the current show page object corresponding activity target + // See also if we need to update useTimelineActivities const { records: activityTargets, loading: loadingActivityTargets } = useFindManyRecords({ objectNameSingular: CoreObjectNameSingular.ActivityTarget, diff --git a/packages/twenty-front/src/modules/activities/hooks/useActivityTargetsForTargetableObjects.ts b/packages/twenty-front/src/modules/activities/hooks/useActivityTargetsForTargetableObjects.ts new file mode 100644 index 000000000..db4a06f71 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/hooks/useActivityTargetsForTargetableObjects.ts @@ -0,0 +1,42 @@ +import { useState } from 'react'; + +import { ActivityTarget } from '@/activities/types/ActivityTarget'; +import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity'; +import { getActivityTargetsFilter } from '@/activities/utils/getActivityTargetsFilter'; +import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; +import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords'; + +export const useActivityTargetsForTargetableObjects = ({ + targetableObjects, + skip, +}: { + targetableObjects: ActivityTargetableObject[]; + skip?: boolean; +}) => { + const activityTargetsFilter = getActivityTargetsFilter({ + targetableObjects: targetableObjects, + }); + + const [initialized, setInitialized] = useState(false); + + // TODO: We want to optimistically remove from this request + // If we are on a show page and we remove the current show page object corresponding activity target + // See also if we need to update useTimelineActivities + const { records: activityTargets, loading: loadingActivityTargets } = + useFindManyRecords({ + skip, + objectNameSingular: CoreObjectNameSingular.ActivityTarget, + filter: activityTargetsFilter, + onCompleted: () => { + if (!initialized) { + setInitialized(true); + } + }, + }); + + return { + activityTargets: activityTargets as ActivityTarget[], + loadingActivityTargets, + initialized, + }; +}; diff --git a/packages/twenty-front/src/modules/activities/hooks/useCreateActivityInCache.ts b/packages/twenty-front/src/modules/activities/hooks/useCreateActivityInCache.ts index f03babb84..cbead3e30 100644 --- a/packages/twenty-front/src/modules/activities/hooks/useCreateActivityInCache.ts +++ b/packages/twenty-front/src/modules/activities/hooks/useCreateActivityInCache.ts @@ -1,10 +1,8 @@ -import { isNonEmptyString } from '@sniptt/guards'; import { useRecoilValue } from 'recoil'; import { v4 } from 'uuid'; import { useAttachRelationInBothDirections } from '@/activities/hooks/useAttachRelationInBothDirections'; import { useInjectIntoActivityTargetInlineCellCache } from '@/activities/inline-cell/hooks/useInjectIntoActivityTargetInlineCellCache'; -import { useInjectIntoTimelineActivitiesQueryAfterDrawerMount } from '@/activities/timeline/hooks/useInjectIntoTimelineActivitiesQueryAfterDrawerMount'; import { Activity, ActivityType } from '@/activities/types/Activity'; import { ActivityTarget } from '@/activities/types/ActivityTarget'; import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity'; @@ -14,6 +12,7 @@ import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSi import { useCreateManyRecordsInCache } from '@/object-record/hooks/useCreateManyRecordsInCache'; import { useCreateOneRecordInCache } from '@/object-record/hooks/useCreateOneRecordInCache'; import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord'; +import { WorkspaceMember } from '@/workspace-member/types/WorkspaceMember'; export const useCreateActivityInCache = () => { const { createManyRecordsInCache: createManyActivityTargetsInCache } = @@ -28,46 +27,36 @@ export const useCreateActivityInCache = () => { const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState); - const { record: workspaceMemberRecord } = useFindOneRecord({ + const { record: currentWorkspaceMemberRecord } = useFindOneRecord({ objectNameSingular: CoreObjectNameSingular.WorkspaceMember, objectRecordId: currentWorkspaceMember?.id, depth: 3, }); - const { injectIntoTimelineActivitiesQueryAfterDrawerMount } = - useInjectIntoTimelineActivitiesQueryAfterDrawerMount(); - const { injectIntoActivityTargetInlineCellCache } = useInjectIntoActivityTargetInlineCellCache(); - const { - attachRelationInBothDirections: - attachRelationSourceRecordToItsRelationTargetRecordsAndViceVersaInCache, - } = useAttachRelationInBothDirections(); + const { attachRelationInBothDirections } = + useAttachRelationInBothDirections(); const createActivityInCache = ({ type, targetableObjects, - timelineTargetableObject, - assigneeId, + customAssignee, }: { type: ActivityType; targetableObjects: ActivityTargetableObject[]; - timelineTargetableObject: ActivityTargetableObject; - assigneeId?: string; + customAssignee?: WorkspaceMember; }) => { const activityId = v4(); const createdActivityInCache = createOneActivityInCache({ id: activityId, - author: workspaceMemberRecord, - authorId: workspaceMemberRecord?.id, - assignee: !assigneeId ? workspaceMemberRecord : undefined, - assigneeId: - assigneeId ?? isNonEmptyString(workspaceMemberRecord?.id) - ? workspaceMemberRecord?.id - : undefined, - type: type, + author: currentWorkspaceMemberRecord, + authorId: currentWorkspaceMemberRecord?.id, + assignee: customAssignee ?? currentWorkspaceMemberRecord, + assigneeId: customAssignee?.id ?? currentWorkspaceMemberRecord?.id, + type, }); const activityTargetsToCreate = @@ -80,18 +69,12 @@ export const useCreateActivityInCache = () => { activityTargetsToCreate, ); - injectIntoTimelineActivitiesQueryAfterDrawerMount({ - activityToInject: createdActivityInCache, - activityTargetsToInject: createdActivityTargetsInCache, - timelineTargetableObject, - }); - injectIntoActivityTargetInlineCellCache({ activityId, activityTargetsToInject: createdActivityTargetsInCache, }); - attachRelationSourceRecordToItsRelationTargetRecordsAndViceVersaInCache({ + attachRelationInBothDirections({ sourceRecord: createdActivityInCache, fieldNameOnSourceRecord: 'activityTargets', sourceObjectNameSingular: CoreObjectNameSingular.Activity, diff --git a/packages/twenty-front/src/modules/activities/timeline/hooks/useInjectIntoTimelineActivitiesQueryAfterDrawerMount.ts b/packages/twenty-front/src/modules/activities/hooks/useInjectIntoActivitiesQuery.ts similarity index 56% rename from packages/twenty-front/src/modules/activities/timeline/hooks/useInjectIntoTimelineActivitiesQueryAfterDrawerMount.ts rename to packages/twenty-front/src/modules/activities/hooks/useInjectIntoActivitiesQuery.ts index 2aeac4d5b..7522844bb 100644 --- a/packages/twenty-front/src/modules/activities/timeline/hooks/useInjectIntoTimelineActivitiesQueryAfterDrawerMount.ts +++ b/packages/twenty-front/src/modules/activities/hooks/useInjectIntoActivitiesQuery.ts @@ -1,16 +1,19 @@ import { isNonEmptyString } from '@sniptt/guards'; -import { makeTimelineActivitiesQueryVariables } from '@/activities/timeline/utils/makeTimelineActivitiesQueryVariables'; import { Activity } from '@/activities/types/Activity'; import { ActivityTarget } from '@/activities/types/ActivityTarget'; import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity'; -import { getActivityTargetObjectFieldIdName } from '@/activities/utils/getTargetObjectFilterFieldName'; +import { getActivityTargetsFilter } from '@/activities/utils/getActivityTargetsFilter'; import { useObjectMetadataItemOnly } from '@/object-metadata/hooks/useObjectMetadataItemOnly'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; +import { OrderByField } from '@/object-metadata/types/OrderByField'; import { useReadFindManyRecordsQueryInCache } from '@/object-record/cache/hooks/useReadFindManyRecordsQueryInCache'; import { useUpsertFindManyRecordsQueryInCache } from '@/object-record/cache/hooks/useUpsertFindManyRecordsQueryInCache'; +import { ObjectRecordQueryFilter } from '@/object-record/record-filter/types/ObjectRecordQueryFilter'; +import { sortByAscString } from '~/utils/array/sortByAscString'; -export const useInjectIntoTimelineActivitiesQueryAfterDrawerMount = () => { +// TODO: create a generic hook from this +export const useInjectIntoActivitiesQuery = () => { const { objectMetadataItem: objectMetadataItemActivity } = useObjectMetadataItemOnly({ objectNameSingular: CoreObjectNameSingular.Activity, @@ -46,79 +49,85 @@ export const useInjectIntoTimelineActivitiesQueryAfterDrawerMount = () => { objectMetadataItem: objectMetadataItemActivityTarget, }); - const injectIntoTimelineActivitiesQueryAfterDrawerMount = ({ + const injectActivitiesQueries = ({ activityToInject, activityTargetsToInject, - timelineTargetableObject, + targetableObjects, + activitiesFilters, + activitiesOrderByVariables, }: { activityToInject: Activity; activityTargetsToInject: ActivityTarget[]; - timelineTargetableObject: ActivityTargetableObject; + targetableObjects: ActivityTargetableObject[]; + activitiesFilters: ObjectRecordQueryFilter; + activitiesOrderByVariables: OrderByField; }) => { const newActivity = { ...activityToInject, __typename: 'Activity', }; - const targetObjectFieldName = getActivityTargetObjectFieldIdName({ - nameSingular: timelineTargetableObject.targetObjectNameSingular, + const findManyActivitiyTargetsQueryFilter = getActivityTargetsFilter({ + targetableObjects, }); - const activitiyTargetsForTargetableObjectQueryVariables = { - filter: { - [targetObjectFieldName]: { - eq: timelineTargetableObject.id, - }, - }, + const findManyActivitiyTargetsQueryVariables = { + filter: findManyActivitiyTargetsQueryFilter, }; - const existingActivityTargetsForTargetableObject = - readFindManyActivityTargetsQueryInCache({ - queryVariables: activitiyTargetsForTargetableObjectQueryVariables, - }); + const existingActivityTargets = readFindManyActivityTargetsQueryInCache({ + queryVariables: findManyActivitiyTargetsQueryVariables, + }); - const newActivityTargetsForTargetableObject = [ - ...existingActivityTargetsForTargetableObject, + const newActivityTargets = [ + ...existingActivityTargets, ...activityTargetsToInject, ]; - const existingActivityIds = existingActivityTargetsForTargetableObject + const existingActivityIds = existingActivityTargets ?.map((activityTarget) => activityTarget.activityId) .filter(isNonEmptyString); - const timelineActivitiesQueryVariablesBeforeDrawerMount = - makeTimelineActivitiesQueryVariables({ - activityIds: existingActivityIds, - }); + const currentFindManyActivitiesQueryVariables = { + filter: { + id: { + in: existingActivityIds.toSorted(sortByAscString), + }, + ...activitiesFilters, + }, + orderBy: activitiesOrderByVariables, + }; const existingActivities = readFindManyActivitiesQueryInCache({ - queryVariables: timelineActivitiesQueryVariablesBeforeDrawerMount, + queryVariables: currentFindManyActivitiesQueryVariables, }); - const activityIdsAfterDrawerMount = [ - ...existingActivityIds, - newActivity.id, - ]; + const nextActivityIds = [...existingActivityIds, newActivity.id]; - const timelineActivitiesQueryVariablesAfterDrawerMount = - makeTimelineActivitiesQueryVariables({ - activityIds: activityIdsAfterDrawerMount, - }); + const nextFindManyActivitiesQueryVariables = { + filter: { + id: { + in: nextActivityIds.toSorted(sortByAscString), + }, + ...activitiesFilters, + }, + orderBy: activitiesOrderByVariables, + }; overwriteFindManyActivityTargetsQueryInCache({ - objectRecordsToOverwrite: newActivityTargetsForTargetableObject, - queryVariables: activitiyTargetsForTargetableObjectQueryVariables, + objectRecordsToOverwrite: newActivityTargets, + queryVariables: findManyActivitiyTargetsQueryVariables, }); const newActivities = [newActivity, ...existingActivities]; overwriteFindManyActivitiesInCache({ objectRecordsToOverwrite: newActivities, - queryVariables: timelineActivitiesQueryVariablesAfterDrawerMount, + queryVariables: nextFindManyActivitiesQueryVariables, }); }; return { - injectIntoTimelineActivitiesQueryAfterDrawerMount, + injectActivitiesQueries, }; }; diff --git a/packages/twenty-front/src/modules/activities/hooks/useOpenActivityRightDrawer.ts b/packages/twenty-front/src/modules/activities/hooks/useOpenActivityRightDrawer.ts index 75c30eb9b..237201aba 100644 --- a/packages/twenty-front/src/modules/activities/hooks/useOpenActivityRightDrawer.ts +++ b/packages/twenty-front/src/modules/activities/hooks/useOpenActivityRightDrawer.ts @@ -1,5 +1,7 @@ import { useRecoilState } from 'recoil'; +import { activityInDrawerState } from '@/activities/states/activityInDrawerState'; +import { Activity } from '@/activities/types/Activity'; import { useRightDrawer } from '@/ui/layout/right-drawer/hooks/useRightDrawer'; import { RightDrawerHotkeyScope } from '@/ui/layout/right-drawer/types/RightDrawerHotkeyScope'; import { RightDrawerPages } from '@/ui/layout/right-drawer/types/RightDrawerPages'; @@ -8,13 +10,26 @@ import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope import { viewableActivityIdState } from '../states/viewableActivityIdState'; export const useOpenActivityRightDrawer = () => { - const { openRightDrawer } = useRightDrawer(); - const [, setViewableActivityId] = useRecoilState(viewableActivityIdState); + const { openRightDrawer, isRightDrawerOpen, rightDrawerPage } = + useRightDrawer(); + const [viewableActivityId, setViewableActivityId] = useRecoilState( + viewableActivityIdState, + ); + const [, setActivityInDrawer] = useRecoilState(activityInDrawerState); const setHotkeyScope = useSetHotkeyScope(); - return (activityId: string) => { + return (activity: Activity) => { + if ( + isRightDrawerOpen && + rightDrawerPage === RightDrawerPages.EditActivity && + viewableActivityId === activity.id + ) { + return; + } + setHotkeyScope(RightDrawerHotkeyScope.RightDrawer, { goto: false }); - setViewableActivityId(activityId); + setViewableActivityId(activity.id); + setActivityInDrawer(activity); openRightDrawer(RightDrawerPages.EditActivity); }; }; diff --git a/packages/twenty-front/src/modules/activities/hooks/useOpenCreateActivityDrawer.ts b/packages/twenty-front/src/modules/activities/hooks/useOpenCreateActivityDrawer.ts index f55331f0a..24131b80f 100644 --- a/packages/twenty-front/src/modules/activities/hooks/useOpenCreateActivityDrawer.ts +++ b/packages/twenty-front/src/modules/activities/hooks/useOpenCreateActivityDrawer.ts @@ -1,102 +1,69 @@ -import { useCallback } from 'react'; -import { isNonEmptyString } from '@sniptt/guards'; -import { useRecoilState, useRecoilValue } from 'recoil'; +import { useRecoilState, useSetRecoilState } 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 { useCreateActivityInCache } from '@/activities/hooks/useCreateActivityInCache'; +import { activityInDrawerState } from '@/activities/states/activityInDrawerState'; +import { activityTargetableEntityArrayState } from '@/activities/states/activityTargetableEntityArrayState'; +import { isActivityInCreateModeState } from '@/activities/states/isActivityInCreateModeState'; +import { isUpsertingActivityInDBState } from '@/activities/states/isCreatingActivityInDBState'; +import { temporaryActivityForEditorState } from '@/activities/states/temporaryActivityForEditorState'; +import { viewableActivityIdState } from '@/activities/states/viewableActivityIdState'; +import { ActivityType } from '@/activities/types/Activity'; import { useRightDrawer } from '@/ui/layout/right-drawer/hooks/useRightDrawer'; import { RightDrawerHotkeyScope } from '@/ui/layout/right-drawer/types/RightDrawerHotkeyScope'; import { RightDrawerPages } from '@/ui/layout/right-drawer/types/RightDrawerPages'; import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope'; -import { isNonEmptyArray } from '~/utils/isNonEmptyArray'; +import { WorkspaceMember } from '@/workspace-member/types/WorkspaceMember'; -import { activityTargetableEntityArrayState } from '../states/activityTargetableEntityArrayState'; -import { viewableActivityIdState } from '../states/viewableActivityIdState'; import { ActivityTargetableObject } from '../types/ActivityTargetableEntity'; -import { flattenTargetableObjectsAndTheirRelatedTargetableObjects } from '../utils/flattenTargetableObjectsAndTheirRelatedTargetableObjects'; export const useOpenCreateActivityDrawer = () => { const { openRightDrawer } = useRightDrawer(); - const { createManyRecords: createManyActivityTargets } = - useCreateManyRecords({ - objectNameSingular: CoreObjectNameSingular.ActivityTarget, - }); - const { createOneRecord: createOneActivity } = useCreateOneRecord({ - objectNameSingular: CoreObjectNameSingular.Activity, - }); - const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState); + const setHotkeyScope = useSetHotkeyScope(); + const { createActivityInCache } = useCreateActivityInCache(); + const [, setActivityTargetableEntityArray] = useRecoilState( activityTargetableEntityArrayState, ); const [, setViewableActivityId] = useRecoilState(viewableActivityIdState); - return useCallback( - async ({ + const setIsCreatingActivity = useSetRecoilState(isActivityInCreateModeState); + + const setTemporaryActivityForEditor = useSetRecoilState( + temporaryActivityForEditorState, + ); + + const setActivityInDrawer = useSetRecoilState(activityInDrawerState); + + const [, setIsUpsertingActivityInDB] = useRecoilState( + isUpsertingActivityInDBState, + ); + + const openCreateActivityDrawer = async ({ + type, + targetableObjects, + customAssignee, + }: { + type: ActivityType; + targetableObjects: ActivityTargetableObject[]; + customAssignee?: WorkspaceMember; + }) => { + const { createdActivityInCache } = createActivityInCache({ type, targetableObjects, - assigneeId, - }: { - type: ActivityType; - targetableObjects?: ActivityTargetableObject[]; - assigneeId?: string; - }) => { - const flattenedTargetableObjects = targetableObjects - ? flattenTargetableObjectsAndTheirRelatedTargetableObjects( - targetableObjects, - ) - : []; + customAssignee, + }); - const createdActivity = await createOneActivity?.({ - authorId: currentWorkspaceMember?.id, - assigneeId: - assigneeId ?? isNonEmptyString(currentWorkspaceMember?.id) - ? currentWorkspaceMember?.id - : undefined, - type: type, - }); + setActivityInDrawer(createdActivityInCache); + setTemporaryActivityForEditor(createdActivityInCache); + setIsCreatingActivity(true); + setHotkeyScope(RightDrawerHotkeyScope.RightDrawer, { goto: false }); + setViewableActivityId(createdActivityInCache.id); + setActivityTargetableEntityArray(targetableObjects ?? []); + openRightDrawer(RightDrawerPages.CreateActivity); + setIsUpsertingActivityInDB(false); + }; - if (!createdActivity) { - return; - } - - const activityTargetsToCreate = flattenedTargetableObjects.map( - (targetableObject) => { - const targetableObjectFieldIdName = - getActivityTargetObjectFieldIdName({ - nameSingular: targetableObject.targetObjectNameSingular, - }); - - return { - [targetableObjectFieldIdName]: targetableObject.id, - activityId: createdActivity.id, - }; - }, - ); - - if (isNonEmptyArray(activityTargetsToCreate)) { - await createManyActivityTargets(activityTargetsToCreate); - } - - setHotkeyScope(RightDrawerHotkeyScope.RightDrawer, { goto: false }); - setViewableActivityId(createdActivity.id); - setActivityTargetableEntityArray(targetableObjects ?? []); - openRightDrawer(RightDrawerPages.CreateActivity); - }, - [ - openRightDrawer, - setActivityTargetableEntityArray, - setHotkeyScope, - setViewableActivityId, - createOneActivity, - createManyActivityTargets, - currentWorkspaceMember, - ], - ); + return openCreateActivityDrawer; }; diff --git a/packages/twenty-front/src/modules/activities/hooks/useOpenCreateActivityDrawerForSelectedRowIds.ts b/packages/twenty-front/src/modules/activities/hooks/useOpenCreateActivityDrawerForSelectedRowIds.ts index c141649cf..f4a6210e1 100644 --- a/packages/twenty-front/src/modules/activities/hooks/useOpenCreateActivityDrawerForSelectedRowIds.ts +++ b/packages/twenty-front/src/modules/activities/hooks/useOpenCreateActivityDrawerForSelectedRowIds.ts @@ -1,5 +1,6 @@ import { useRecoilCallback } from 'recoil'; +import { useOpenCreateActivityDrawer } from '@/activities/hooks/useOpenCreateActivityDrawer'; import { ActivityType } from '@/activities/types/Activity'; import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; import { useRecordTableStates } from '@/object-record/record-table/hooks/internal/useRecordTableStates'; @@ -8,8 +9,6 @@ import { isDefined } from '~/utils/isDefined'; import { ActivityTargetableObject } from '../types/ActivityTargetableEntity'; -import { useOpenCreateActivityDrawer } from './useOpenCreateActivityDrawer'; - export const useOpenCreateActivityDrawerForSelectedRowIds = ( recordTableId: string, ) => { diff --git a/packages/twenty-front/src/modules/activities/hooks/useOpenCreateActivityDrawerV2.ts b/packages/twenty-front/src/modules/activities/hooks/useOpenCreateActivityDrawerV2.ts deleted file mode 100644 index 1b1dff71a..000000000 --- a/packages/twenty-front/src/modules/activities/hooks/useOpenCreateActivityDrawerV2.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { useRecoilState, useSetRecoilState } from 'recoil'; - -import { useCreateActivityInCache } from '@/activities/hooks/useCreateActivityInCache'; -import { activityTargetableEntityArrayState } from '@/activities/states/activityTargetableEntityArrayState'; -import { isCreatingActivityState } from '@/activities/states/isCreatingActivityState'; -import { temporaryActivityForEditorState } from '@/activities/states/temporaryActivityForEditorState'; -import { viewableActivityIdState } from '@/activities/states/viewableActivityIdState'; -import { ActivityType } from '@/activities/types/Activity'; -import { useRightDrawer } from '@/ui/layout/right-drawer/hooks/useRightDrawer'; -import { RightDrawerHotkeyScope } from '@/ui/layout/right-drawer/types/RightDrawerHotkeyScope'; -import { RightDrawerPages } from '@/ui/layout/right-drawer/types/RightDrawerPages'; -import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope'; - -import { ActivityTargetableObject } from '../types/ActivityTargetableEntity'; - -export const useOpenCreateActivityDrawerV2 = () => { - const { openRightDrawer } = useRightDrawer(); - - const setHotkeyScope = useSetHotkeyScope(); - - const { createActivityInCache } = useCreateActivityInCache(); - - const [, setActivityTargetableEntityArray] = useRecoilState( - activityTargetableEntityArrayState, - ); - const [, setViewableActivityId] = useRecoilState(viewableActivityIdState); - - const setIsCreatingActivity = useSetRecoilState(isCreatingActivityState); - - const setTemporaryActivityForEditor = useSetRecoilState( - temporaryActivityForEditorState, - ); - - const openCreateActivityDrawer = async ({ - type, - targetableObjects, - timelineTargetableObject, - assigneeId, - }: { - type: ActivityType; - targetableObjects: ActivityTargetableObject[]; - timelineTargetableObject: ActivityTargetableObject; - assigneeId?: string; - }) => { - const { createdActivityInCache } = createActivityInCache({ - type, - targetableObjects, - timelineTargetableObject, - assigneeId, - }); - - setTemporaryActivityForEditor(createdActivityInCache); - setIsCreatingActivity(true); - setHotkeyScope(RightDrawerHotkeyScope.RightDrawer, { goto: false }); - setViewableActivityId(createdActivityInCache.id); - setActivityTargetableEntityArray(targetableObjects ?? []); - openRightDrawer(RightDrawerPages.CreateActivity); - }; - - return openCreateActivityDrawer; -}; diff --git a/packages/twenty-front/src/modules/activities/hooks/useRemoveFromActivitiesQueries.ts b/packages/twenty-front/src/modules/activities/hooks/useRemoveFromActivitiesQueries.ts new file mode 100644 index 000000000..13c765656 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/hooks/useRemoveFromActivitiesQueries.ts @@ -0,0 +1,134 @@ +import { isNonEmptyArray, isNonEmptyString } from '@sniptt/guards'; + +import { ActivityTarget } from '@/activities/types/ActivityTarget'; +import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity'; +import { getActivityTargetsFilter } from '@/activities/utils/getActivityTargetsFilter'; +import { useObjectMetadataItemOnly } from '@/object-metadata/hooks/useObjectMetadataItemOnly'; +import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; +import { OrderByField } from '@/object-metadata/types/OrderByField'; +import { useReadFindManyRecordsQueryInCache } from '@/object-record/cache/hooks/useReadFindManyRecordsQueryInCache'; +import { useUpsertFindManyRecordsQueryInCache } from '@/object-record/cache/hooks/useUpsertFindManyRecordsQueryInCache'; +import { ObjectRecordQueryFilter } from '@/object-record/record-filter/types/ObjectRecordQueryFilter'; +import { sortByAscString } from '~/utils/array/sortByAscString'; + +export const useRemoveFromActivitiesQueries = () => { + const { objectMetadataItem: objectMetadataItemActivity } = + useObjectMetadataItemOnly({ + objectNameSingular: CoreObjectNameSingular.Activity, + }); + + const { + upsertFindManyRecordsQueryInCache: overwriteFindManyActivitiesInCache, + } = useUpsertFindManyRecordsQueryInCache({ + objectMetadataItem: objectMetadataItemActivity, + }); + + const { objectMetadataItem: objectMetadataItemActivityTarget } = + useObjectMetadataItemOnly({ + objectNameSingular: CoreObjectNameSingular.ActivityTarget, + }); + + const { + readFindManyRecordsQueryInCache: readFindManyActivityTargetsQueryInCache, + } = useReadFindManyRecordsQueryInCache({ + objectMetadataItem: objectMetadataItemActivityTarget, + }); + + const { + readFindManyRecordsQueryInCache: readFindManyActivitiesQueryInCache, + } = useReadFindManyRecordsQueryInCache({ + objectMetadataItem: objectMetadataItemActivity, + }); + + const { + upsertFindManyRecordsQueryInCache: + overwriteFindManyActivityTargetsQueryInCache, + } = useUpsertFindManyRecordsQueryInCache({ + objectMetadataItem: objectMetadataItemActivityTarget, + }); + + const removeFromActivitiesQueries = ({ + activityIdToRemove, + activityTargetsToRemove, + targetableObjects, + activitiesFilters, + activitiesOrderByVariables, + }: { + activityIdToRemove: string; + activityTargetsToRemove: ActivityTarget[]; + targetableObjects: ActivityTargetableObject[]; + activitiesFilters?: ObjectRecordQueryFilter; + activitiesOrderByVariables?: OrderByField; + }) => { + const findManyActivitiyTargetsQueryFilter = getActivityTargetsFilter({ + targetableObjects, + }); + + const existingActivityTargetsForTargetableObject = + readFindManyActivityTargetsQueryInCache({ + queryVariables: findManyActivitiyTargetsQueryFilter, + }); + + const newActivityTargetsForTargetableObject = isNonEmptyArray( + activityTargetsToRemove, + ) + ? existingActivityTargetsForTargetableObject.filter( + (existingActivityTarget) => + activityTargetsToRemove.some( + (activityTargetToRemove) => + activityTargetToRemove.id !== existingActivityTarget.id, + ), + ) + : existingActivityTargetsForTargetableObject; + + overwriteFindManyActivityTargetsQueryInCache({ + objectRecordsToOverwrite: newActivityTargetsForTargetableObject, + queryVariables: findManyActivitiyTargetsQueryFilter, + }); + + const existingActivityIds = existingActivityTargetsForTargetableObject + ?.map((activityTarget) => activityTarget.activityId) + .filter(isNonEmptyString); + + const currentFindManyActivitiesQueryVariables = { + filter: { + id: { + in: existingActivityIds.toSorted(sortByAscString), + }, + ...activitiesFilters, + }, + orderBy: activitiesOrderByVariables, + }; + + const existingActivities = readFindManyActivitiesQueryInCache({ + queryVariables: currentFindManyActivitiesQueryVariables, + }); + + const activityIdsAfterRemoval = existingActivityIds.filter( + (existingActivityId) => existingActivityId !== activityIdToRemove, + ); + + const nextFindManyActivitiesQueryVariables = { + filter: { + id: { + in: activityIdsAfterRemoval.toSorted(sortByAscString), + }, + ...activitiesFilters, + }, + orderBy: activitiesOrderByVariables, + }; + + const newActivities = existingActivities.filter( + (existingActivity) => existingActivity.id !== activityIdToRemove, + ); + + overwriteFindManyActivitiesInCache({ + objectRecordsToOverwrite: newActivities, + queryVariables: nextFindManyActivitiesQueryVariables, + }); + }; + + return { + removeFromActivitiesQueries, + }; +}; diff --git a/packages/twenty-front/src/modules/activities/hooks/useUpsertActivity.ts b/packages/twenty-front/src/modules/activities/hooks/useUpsertActivity.ts index 2310ba7fb..1156ff001 100644 --- a/packages/twenty-front/src/modules/activities/hooks/useUpsertActivity.ts +++ b/packages/twenty-front/src/modules/activities/hooks/useUpsertActivity.ts @@ -1,14 +1,21 @@ -import { useRecoilState } from 'recoil'; +import { useApolloClient } from '@apollo/client'; +import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil'; import { useCreateActivityInDB } from '@/activities/hooks/useCreateActivityInDB'; -import { isCreatingActivityState } from '@/activities/states/isCreatingActivityState'; +import { activityInDrawerState } from '@/activities/states/activityInDrawerState'; +import { isActivityInCreateModeState } from '@/activities/states/isActivityInCreateModeState'; +import { isUpsertingActivityInDBState } from '@/activities/states/isCreatingActivityInDBState'; +import { useInjectIntoTimelineActivitiesQueries } from '@/activities/timeline/hooks/useInjectIntoTimelineActivitiesQueries'; +import { timelineTargetableObjectState } from '@/activities/timeline/states/timelineTargetableObjectState'; import { Activity } from '@/activities/types/Activity'; +import { useActivityConnectionUtils } from '@/activities/utils/useActivityConnectionUtils'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord'; +// TODO: create a generic way to have records only in cache for create mode and delete them afterwards ? export const useUpsertActivity = () => { - const [isCreatingActivity, setIsCreatingActivity] = useRecoilState( - isCreatingActivityState, + const [isActivityInCreateMode, setIsActivityInCreateMode] = useRecoilState( + isActivityInCreateModeState, ); const { updateOneRecord: updateOneActivity } = useUpdateOneRecord({ @@ -17,26 +24,67 @@ export const useUpsertActivity = () => { const { createActivityInDB } = useCreateActivityInDB(); - const upsertActivity = ({ + const [, setIsUpsertingActivityInDB] = useRecoilState( + isUpsertingActivityInDBState, + ); + + const setActivityInDrawer = useSetRecoilState(activityInDrawerState); + + const timelineTargetableObject = useRecoilValue( + timelineTargetableObjectState, + ); + + const { injectIntoTimelineActivitiesQueries } = + useInjectIntoTimelineActivitiesQueries(); + + const { makeActivityWithConnection } = useActivityConnectionUtils(); + + const apolloClient = useApolloClient(); + + const upsertActivity = async ({ activity, input, }: { activity: Activity; input: Partial; }) => { - if (isCreatingActivity) { - createActivityInDB({ + setIsUpsertingActivityInDB(true); + + if (isActivityInCreateMode) { + const activityToCreate: Activity = { ...activity, ...input, + }; + + const { activityWithConnection } = + makeActivityWithConnection(activityToCreate); + + // Call optimistic effects + if (timelineTargetableObject) { + injectIntoTimelineActivitiesQueries({ + timelineTargetableObject: timelineTargetableObject, + activityToInject: activityWithConnection, + activityTargetsToInject: activityToCreate.activityTargets, + }); + } + + await createActivityInDB(activityToCreate); + + await apolloClient.refetchQueries({ + include: ['FindManyActivities', 'FindManyActivityTargets'], }); - setIsCreatingActivity(false); + setActivityInDrawer(activityToCreate); + + setIsActivityInCreateMode(false); } else { - updateOneActivity?.({ + await updateOneActivity?.({ idToUpdate: activity.id, updateOneRecordInput: input, }); } + + setIsUpsertingActivityInDB(false); }; return { 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 0d0ba14b3..3275f0923 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 @@ -5,10 +5,10 @@ import { v4 } from 'uuid'; import { useUpsertActivity } from '@/activities/hooks/useUpsertActivity'; import { useInjectIntoActivityTargetInlineCellCache } from '@/activities/inline-cell/hooks/useInjectIntoActivityTargetInlineCellCache'; -import { isCreatingActivityState } from '@/activities/states/isCreatingActivityState'; +import { isActivityInCreateModeState } from '@/activities/states/isActivityInCreateModeState'; import { Activity } from '@/activities/types/Activity'; import { ActivityTarget } from '@/activities/types/ActivityTarget'; -import { ActivityTargetObjectRecord } from '@/activities/types/ActivityTargetObject'; +import { ActivityTargetWithTargetRecord } from '@/activities/types/ActivityTargetObject'; import { getActivityTargetObjectFieldIdName } from '@/activities/utils/getTargetObjectFilterFieldName'; import { useObjectMetadataItemOnly } from '@/object-metadata/hooks/useObjectMetadataItemOnly'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; @@ -27,19 +27,19 @@ const StyledSelectContainer = styled.div` type ActivityTargetInlineCellEditModeProps = { activity: Activity; - activityTargetObjectRecords: ActivityTargetObjectRecord[]; + activityTargetWithTargetRecords: ActivityTargetWithTargetRecord[]; }; export const ActivityTargetInlineCellEditMode = ({ activity, - activityTargetObjectRecords, + activityTargetWithTargetRecords, }: ActivityTargetInlineCellEditModeProps) => { - const [isCreatingActivity] = useRecoilState(isCreatingActivityState); + const [isActivityInCreateMode] = useRecoilState(isActivityInCreateModeState); - const selectedObjectRecordIds = activityTargetObjectRecords.map( + const selectedTargetObjectIds = activityTargetWithTargetRecords.map( (activityTarget) => ({ objectNameSingular: activityTarget.targetObjectNameSingular, - id: activityTarget.targetObjectRecord.id, + id: activityTarget.targetObject.id, }), ); @@ -73,90 +73,89 @@ export const ActivityTargetInlineCellEditMode = ({ const handleSubmit = async (selectedRecords: ObjectRecordForSelect[]) => { closeEditableField(); - const activityTargetRecordsToDelete = activityTargetObjectRecords.filter( + + const activityTargetsToDelete = activityTargetWithTargetRecords.filter( (activityTargetObjectRecord) => !selectedRecords.some( (selectedRecord) => selectedRecord.recordIdentifier.id === - activityTargetObjectRecord.targetObjectRecord.id, + activityTargetObjectRecord.targetObject.id, ), ); - const activityTargetRecordsToCreate = selectedRecords.filter( + const selectedTargetObjectsToCreate = selectedRecords.filter( (selectedRecord) => - !activityTargetObjectRecords.some( - (activityTargetObjectRecord) => - activityTargetObjectRecord.targetObjectRecord.id === + !activityTargetWithTargetRecords.some( + (activityTargetWithTargetRecord) => + activityTargetWithTargetRecord.targetObject.id === selectedRecord.recordIdentifier.id, ), ); - if (isCreatingActivity) { - let activityTargetsForCreation = activity.activityTargets; + const existingActivityTargets = activityTargetWithTargetRecords.map( + (activityTargetObjectRecord) => activityTargetObjectRecord.activityTarget, + ); - if (isNonEmptyArray(activityTargetsForCreation)) { - const generatedActivityTargets = activityTargetRecordsToCreate.map( - (selectedRecord) => { - const emptyActivityTarget = - generateObjectRecordOptimisticResponse({ - id: v4(), - activityId: activity.id, - activity, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - [getActivityTargetObjectFieldIdName({ - nameSingular: selectedRecord.objectMetadataItem.nameSingular, - })]: selectedRecord.recordIdentifier.id, - }); + let activityTargetsAfterUpdate = Array.from(existingActivityTargets); - return emptyActivityTarget; - }, - ); - - activityTargetsForCreation.push(...generatedActivityTargets); - } - - if (isNonEmptyArray(activityTargetRecordsToDelete)) { - activityTargetsForCreation = activityTargetsForCreation.filter( - (activityTarget) => - !activityTargetRecordsToDelete.some( - (activityTargetObjectRecord) => - activityTargetObjectRecord.targetObjectRecord.id === - activityTarget.id, - ), - ); - } - - injectIntoActivityTargetInlineCellCache({ - activityId: activity.id, - activityTargetsToInject: activityTargetsForCreation, - }); - - upsertActivity({ - activity, - input: { - activityTargets: activityTargetsForCreation, - }, - }); - } else { - if (activityTargetRecordsToCreate.length > 0) { - await createManyActivityTargets( - activityTargetRecordsToCreate.map((selectedRecord) => ({ + const activityTargetsToCreate = selectedTargetObjectsToCreate.map( + (selectedRecord) => { + const emptyActivityTarget = + generateObjectRecordOptimisticResponse({ id: v4(), activityId: activity.id, + activity, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), [getActivityTargetObjectFieldIdName({ nameSingular: selectedRecord.objectMetadataItem.nameSingular, })]: selectedRecord.recordIdentifier.id, - })), - ); + }); + + return emptyActivityTarget; + }, + ); + + activityTargetsAfterUpdate.push(...activityTargetsToCreate); + + if (isNonEmptyArray(activityTargetsToDelete)) { + activityTargetsAfterUpdate = activityTargetsAfterUpdate.filter( + (activityTarget) => + !activityTargetsToDelete.some( + (activityTargetToDelete) => + activityTargetToDelete.activityTarget.id === activityTarget.id, + ), + ); + } + + injectIntoActivityTargetInlineCellCache({ + activityId: activity.id, + activityTargetsToInject: activityTargetsAfterUpdate, + }); + + if (isActivityInCreateMode) { + upsertActivity({ + activity, + input: { + activityTargets: activityTargetsAfterUpdate, + }, + }); + } else { + if (activityTargetsToCreate.length > 0) { + await createManyActivityTargets(activityTargetsToCreate, { + skipOptimisticEffect: true, + }); } - if (activityTargetRecordsToDelete.length > 0) { + if (activityTargetsToDelete.length > 0) { await deleteManyActivityTargets( - activityTargetRecordsToDelete.map( + activityTargetsToDelete.map( (activityTargetObjectRecord) => - activityTargetObjectRecord.activityTargetRecord.id, + activityTargetObjectRecord.activityTarget.id, ), + { + skipOptimisticEffect: true, + }, ); } } @@ -169,7 +168,7 @@ 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 13a6b0236..3b8b473a9 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 @@ -42,7 +42,7 @@ export const ActivityTargetsInlineCell = ({ editModeContent={ } label="Relations" diff --git a/packages/twenty-front/src/modules/activities/notes/components/NoteCard.tsx b/packages/twenty-front/src/modules/activities/notes/components/NoteCard.tsx index 323546127..74a9633cf 100644 --- a/packages/twenty-front/src/modules/activities/notes/components/NoteCard.tsx +++ b/packages/twenty-front/src/modules/activities/notes/components/NoteCard.tsx @@ -94,7 +94,7 @@ export const NoteCard = ({ openActivityRightDrawer(note.id)} + onClick={() => openActivityRightDrawer(note)} > {note.title ?? 'Task Title'} {body} diff --git a/packages/twenty-front/src/modules/activities/notes/components/NoteList.tsx b/packages/twenty-front/src/modules/activities/notes/components/NoteList.tsx index fcf8d81e7..4993d9ffc 100644 --- a/packages/twenty-front/src/modules/activities/notes/components/NoteList.tsx +++ b/packages/twenty-front/src/modules/activities/notes/components/NoteList.tsx @@ -29,7 +29,7 @@ const StyledTitleBar = styled.h3` width: 100%; `; -const StyledTitle = styled.h3` +const StyledTitle = styled.span` color: ${({ theme }) => theme.font.color.primary}; font-weight: ${({ theme }) => theme.font.weight.semiBold}; `; 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 c55bd2fb7..120ac4c63 100644 --- a/packages/twenty-front/src/modules/activities/notes/components/Notes.tsx +++ b/packages/twenty-front/src/modules/activities/notes/components/Notes.tsx @@ -27,10 +27,14 @@ export const Notes = ({ }: { targetableObject: ActivityTargetableObject; }) => { - const { notes } = useNotes(targetableObject); + const { notes, initialized } = useNotes(targetableObject); const openCreateActivity = useOpenCreateActivityDrawer(); + if (!initialized) { + return <>; + } + if (notes?.length === 0) { return ( 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 546f97c81..4a5c9b6d7 100644 --- a/packages/twenty-front/src/modules/activities/notes/hooks/useNotes.ts +++ b/packages/twenty-front/src/modules/activities/notes/hooks/useNotes.ts @@ -1,35 +1,21 @@ -import { useActivityTargetsForTargetableObject } from '@/activities/hooks/useActivityTargetsForTargetableObject'; +import { useActivities } from '@/activities/hooks/useActivities'; +import { FIND_MANY_TIMELINE_ACTIVITIES_ORDER_BY } from '@/activities/timeline/constants/FIND_MANY_TIMELINE_ACTIVITIES_ORDER_BY'; 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 { ActivityTargetableObject } from '../../types/ActivityTargetableEntity'; export const useNotes = (targetableObject: ActivityTargetableObject) => { - const { activityTargets } = useActivityTargetsForTargetableObject({ - targetableObject, - }); - - const filter = { - id: { - in: activityTargets?.map((activityTarget) => activityTarget.activityId), + const { activities, initialized, loading } = useActivities({ + activitiesFilters: { + type: { eq: 'Note' }, }, - type: { eq: 'Note' }, - }; - - const orderBy = { - createdAt: 'AscNullsFirst', - } as OrderByField; - - const { records: notes } = useFindManyRecords({ - skip: !activityTargets?.length, - objectNameSingular: CoreObjectNameSingular.Activity, - filter, - orderBy, + activitiesOrderByVariables: FIND_MANY_TIMELINE_ACTIVITIES_ORDER_BY, + targetableObjects: [targetableObject], }); return { - notes: notes as Note[], + notes: activities as Note[], + initialized, + loading, }; }; diff --git a/packages/twenty-front/src/modules/activities/right-drawer/components/ActivityActionBar.tsx b/packages/twenty-front/src/modules/activities/right-drawer/components/ActivityActionBar.tsx index a4e28f580..2dc31905d 100644 --- a/packages/twenty-front/src/modules/activities/right-drawer/components/ActivityActionBar.tsx +++ b/packages/twenty-front/src/modules/activities/right-drawer/components/ActivityActionBar.tsx @@ -1,17 +1,22 @@ -import { useApolloClient } from '@apollo/client'; import styled from '@emotion/styled'; -import { isNonEmptyString } from '@sniptt/guards'; +import { isNonEmptyArray } from '@sniptt/guards'; import { useRecoilState, useRecoilValue } from 'recoil'; import { useDeleteActivityFromCache } from '@/activities/hooks/useDeleteActivityFromCache'; import { useOpenCreateActivityDrawer } from '@/activities/hooks/useOpenCreateActivityDrawer'; +import { activityInDrawerState } from '@/activities/states/activityInDrawerState'; import { activityTargetableEntityArrayState } from '@/activities/states/activityTargetableEntityArrayState'; -import { isCreatingActivityState } from '@/activities/states/isCreatingActivityState'; +import { isActivityInCreateModeState } from '@/activities/states/isActivityInCreateModeState'; +import { isUpsertingActivityInDBState } from '@/activities/states/isCreatingActivityInDBState'; import { temporaryActivityForEditorState } from '@/activities/states/temporaryActivityForEditorState'; import { viewableActivityIdState } from '@/activities/states/viewableActivityIdState'; +import { useRemoveFromTimelineActivitiesQueries } from '@/activities/timeline/hooks/useRemoveFromTimelineActivitiesQueries'; +import { timelineTargetableObjectState } from '@/activities/timeline/states/timelineTargetableObjectState'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; +import { useDeleteManyRecords } from '@/object-record/hooks/useDeleteManyRecords'; import { useDeleteOneRecord } from '@/object-record/hooks/useDeleteOneRecord'; import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; +import { mapToRecordId } from '@/object-record/utils/mapToObjectId'; import { IconPlus, IconTrash } from '@/ui/display/icon'; import { IconButton } from '@/ui/input/button/components/IconButton'; import { isRightDrawerOpenState } from '@/ui/layout/right-drawer/states/isRightDrawerOpenState'; @@ -24,6 +29,8 @@ const StyledButtonContainer = styled.div` export const ActivityActionBar = () => { const viewableActivityId = useRecoilValue(viewableActivityIdState); + const activityInDrawer = useRecoilValue(activityInDrawerState); + const activityTargetableEntityArray = useRecoilValue( activityTargetableEntityArrayState, ); @@ -33,27 +40,52 @@ export const ActivityActionBar = () => { refetchFindManyQuery: true, }); + const { deleteManyRecords: deleteManyActivityTargets } = useDeleteManyRecords( + { + objectNameSingular: CoreObjectNameSingular.ActivityTarget, + refetchFindManyQuery: true, + }, + ); + const [temporaryActivityForEditor, setTemporaryActivityForEditor] = useRecoilState(temporaryActivityForEditorState); const { deleteActivityFromCache } = useDeleteActivityFromCache(); - const [isCreatingActivity] = useRecoilState(isCreatingActivityState); - - const apolloClient = useApolloClient(); + const [isActivityInCreateMode] = useRecoilState(isActivityInCreateModeState); + const [isUpsertingActivityInDB] = useRecoilState( + isUpsertingActivityInDBState, + ); + const timelineTargetableObject = useRecoilValue( + timelineTargetableObjectState, + ); const openCreateActivity = useOpenCreateActivityDrawer(); + const { removeFromTimelineActivitiesQueries } = + useRemoveFromTimelineActivitiesQueries(); + const deleteActivity = () => { if (viewableActivityId) { - if (isCreatingActivity && isDefined(temporaryActivityForEditor)) { + if (isActivityInCreateMode && isDefined(temporaryActivityForEditor)) { deleteActivityFromCache(temporaryActivityForEditor); setTemporaryActivityForEditor(null); } else { - deleteOneActivity?.(viewableActivityId); - // TODO: find a better way to do this with custom optimistic rendering for activities - apolloClient.refetchQueries({ - include: ['FindManyActivities'], - }); + if (activityInDrawer) { + const activityTargetIdsToDelete = + activityInDrawer?.activityTargets.map(mapToRecordId) ?? []; + + if (isDefined(timelineTargetableObject)) { + removeFromTimelineActivitiesQueries({ + activityTargetsToRemove: activityInDrawer?.activityTargets ?? [], + activityIdToRemove: viewableActivityId, + }); + } + + if (isNonEmptyArray(activityTargetIdsToDelete)) { + deleteManyActivityTargets(activityTargetIdsToDelete); + } + deleteOneActivity?.(viewableActivityId); + } } } @@ -66,17 +98,19 @@ export const ActivityActionBar = () => { const addActivity = () => { setIsRightDrawerOpen(false); - if (record) { + if (record && timelineTargetableObject) { openCreateActivity({ type: record.type, - assigneeId: isNonEmptyString(record.assigneeId) - ? record.assigneeId - : undefined, + customAssignee: record.assignee, targetableObjects: activityTargetableEntityArray, }); } }; + const actionsAreDisabled = isUpsertingActivityInDB; + + const isCreateActionDisabled = isActivityInCreateMode; + return ( { onClick={addActivity} size="medium" variant="secondary" + disabled={actionsAreDisabled || isCreateActionDisabled} /> ); diff --git a/packages/twenty-front/src/modules/activities/right-drawer/components/RightDrawerActivity.tsx b/packages/twenty-front/src/modules/activities/right-drawer/components/RightDrawerActivity.tsx index 812222b42..bb179446a 100644 --- a/packages/twenty-front/src/modules/activities/right-drawer/components/RightDrawerActivity.tsx +++ b/packages/twenty-front/src/modules/activities/right-drawer/components/RightDrawerActivity.tsx @@ -24,11 +24,11 @@ export const RightDrawerActivity = ({ showComment = true, fillTitleFromBody = false, }: RightDrawerActivityProps) => { - const { activity } = useActivityById({ + const { activity, loading } = useActivityById({ activityId, }); - if (!activity) { + if (!activity || loading) { return <>; } diff --git a/packages/twenty-front/src/modules/activities/states/activityInDrawerState.ts b/packages/twenty-front/src/modules/activities/states/activityInDrawerState.ts new file mode 100644 index 000000000..115dd9381 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/states/activityInDrawerState.ts @@ -0,0 +1,8 @@ +import { atom } from 'recoil'; + +import { Activity } from '@/activities/types/Activity'; + +export const activityInDrawerState = atom({ + key: 'activityInDrawerState', + default: null, +}); diff --git a/packages/twenty-front/src/modules/activities/states/canCreateActivityState.ts b/packages/twenty-front/src/modules/activities/states/canCreateActivityState.ts new file mode 100644 index 000000000..6d1062371 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/states/canCreateActivityState.ts @@ -0,0 +1,6 @@ +import { atom } from 'recoil'; + +export const canCreateActivityState = atom({ + key: 'canCreateActivityState', + default: false, +}); diff --git a/packages/twenty-front/src/modules/activities/states/isActivityInCreateModeState.ts b/packages/twenty-front/src/modules/activities/states/isActivityInCreateModeState.ts new file mode 100644 index 000000000..d0f32a483 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/states/isActivityInCreateModeState.ts @@ -0,0 +1,6 @@ +import { atom } from 'recoil'; + +export const isActivityInCreateModeState = atom({ + key: 'isActivityInCreateModeState', + default: false, +}); diff --git a/packages/twenty-front/src/modules/activities/states/isCreatingActivityInDBState.ts b/packages/twenty-front/src/modules/activities/states/isCreatingActivityInDBState.ts new file mode 100644 index 000000000..315b623bb --- /dev/null +++ b/packages/twenty-front/src/modules/activities/states/isCreatingActivityInDBState.ts @@ -0,0 +1,6 @@ +import { atom } from 'recoil'; + +export const isUpsertingActivityInDBState = atom({ + key: 'isUpsertingActivityInDBState', + default: false, +}); diff --git a/packages/twenty-front/src/modules/activities/states/isCreatingActivityState.ts b/packages/twenty-front/src/modules/activities/states/isCreatingActivityState.ts deleted file mode 100644 index 91b039373..000000000 --- a/packages/twenty-front/src/modules/activities/states/isCreatingActivityState.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { atom } from 'recoil'; - -export const isCreatingActivityState = atom({ - key: 'isCreatingActivityState', - default: false, -}); diff --git a/packages/twenty-front/src/modules/activities/states/targetableObjectsInDrawerState.ts b/packages/twenty-front/src/modules/activities/states/targetableObjectsInDrawerState.ts new file mode 100644 index 000000000..553c75110 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/states/targetableObjectsInDrawerState.ts @@ -0,0 +1,8 @@ +import { atom } from 'recoil'; + +import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity'; + +export const targetableObjectsInDrawerState = atom({ + key: 'targetableObjectsInDrawerState', + default: [], +}); diff --git a/packages/twenty-front/src/modules/activities/tasks/components/PageAddTaskButton.tsx b/packages/twenty-front/src/modules/activities/tasks/components/PageAddTaskButton.tsx index 510e8a642..8d669c7ba 100644 --- a/packages/twenty-front/src/modules/activities/tasks/components/PageAddTaskButton.tsx +++ b/packages/twenty-front/src/modules/activities/tasks/components/PageAddTaskButton.tsx @@ -1,28 +1,15 @@ -import { isNonEmptyString } from '@sniptt/guards'; - import { useOpenCreateActivityDrawer } from '@/activities/hooks/useOpenCreateActivityDrawer'; -import { useFilterDropdown } from '@/object-record/object-filter-dropdown/hooks/useFilterDropdown'; import { PageAddButton } from '@/ui/layout/page/PageAddButton'; -type PageAddTaskButtonProps = { - filterDropdownId: string; -}; - -export const PageAddTaskButton = ({ - filterDropdownId, -}: PageAddTaskButtonProps) => { - const { selectedFilter } = useFilterDropdown({ - filterDropdownId: filterDropdownId, - }); - +export const PageAddTaskButton = () => { const openCreateActivity = useOpenCreateActivityDrawer(); + // TODO: fetch workspace member from filter here + const handleClick = () => { openCreateActivity({ type: 'Task', - assigneeId: isNonEmptyString(selectedFilter?.value) - ? selectedFilter?.value - : undefined, + targetableObjects: [], }); }; 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 f078e1645..657fce8bc 100644 --- a/packages/twenty-front/src/modules/activities/tasks/components/TaskGroups.tsx +++ b/packages/twenty-front/src/modules/activities/tasks/components/TaskGroups.tsx @@ -40,6 +40,7 @@ export const TaskGroups = ({ upcomingTasks, unscheduledTasks, completedTasks, + initialized, } = useTasks({ filterDropdownId: filterDropdownId, targetableObjects: targetableObjects ?? [], @@ -50,6 +51,10 @@ export const TaskGroups = ({ const { getActiveTabIdState } = useTabList(TASKS_TAB_LIST_COMPONENT_ID); const activeTabId = useRecoilValue(getActiveTabIdState()); + if (!initialized) { + return <>; + } + if ( (activeTabId !== 'done' && todayOrPreviousTasks?.length === 0 && @@ -73,7 +78,7 @@ export const TaskGroups = ({ onClick={() => openCreateActivity({ type: 'Task', - targetableObjects, + targetableObjects: targetableObjects ?? [], }) } /> diff --git a/packages/twenty-front/src/modules/activities/tasks/components/TaskList.tsx b/packages/twenty-front/src/modules/activities/tasks/components/TaskList.tsx index 962d5af5a..0a8b3b76c 100644 --- a/packages/twenty-front/src/modules/activities/tasks/components/TaskList.tsx +++ b/packages/twenty-front/src/modules/activities/tasks/components/TaskList.tsx @@ -2,13 +2,12 @@ import { ReactElement } from 'react'; import styled from '@emotion/styled'; import { Activity } from '@/activities/types/Activity'; -import { GraphQLActivity } from '@/activities/types/GraphQLActivity'; import { TaskRow } from './TaskRow'; type TaskListProps = { title?: string; - tasks: Omit[]; + tasks: Activity[]; button?: ReactElement | false; }; @@ -61,7 +60,7 @@ export const TaskList = ({ title, tasks, button }: TaskListProps) => ( {tasks.map((task) => ( - + ))} 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 2f6e51eac..9c00fb080 100644 --- a/packages/twenty-front/src/modules/activities/tasks/components/TaskRow.tsx +++ b/packages/twenty-front/src/modules/activities/tasks/components/TaskRow.tsx @@ -4,7 +4,7 @@ import styled from '@emotion/styled'; import { ActivityTargetChips } from '@/activities/components/ActivityTargetChips'; import { useActivityTargetObjectRecords } from '@/activities/hooks/useActivityTargetObjectRecords'; import { useOpenActivityRightDrawer } from '@/activities/hooks/useOpenActivityRightDrawer'; -import { GraphQLActivity } from '@/activities/types/GraphQLActivity'; +import { Activity } from '@/activities/types/Activity'; import { getActivitySummary } from '@/activities/utils/getActivitySummary'; import { IconCalendar, IconComment } from '@/ui/display/icon'; import { OverflowingTextWithTooltip } from '@/ui/display/tooltip/OverflowingTextWithTooltip'; @@ -71,11 +71,7 @@ const StyledPlaceholder = styled.div` color: ${({ theme }) => theme.font.color.light}; `; -export const TaskRow = ({ - task, -}: { - task: Omit; -}) => { +export const TaskRow = ({ task }: { task: Activity }) => { const theme = useTheme(); const openActivityRightDrawer = useOpenActivityRightDrawer(); @@ -89,7 +85,7 @@ export const TaskRow = ({ return ( { - openActivityRightDrawer(task.id); + openActivityRightDrawer(task); }} >
( - (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: targetableObjectsFilter, - skip: !isTargettingObjectRecords, - }); - - const skipRequest = !isNonEmptyArray(activityTargets) && !selectedFilter; - - const idFilter = isTargettingObjectRecords - ? { - id: { - in: activityTargets.map( - (activityTarget) => activityTarget.activityId, - ), - }, - } - : { id: {} }; - const assigneeIdFilter = selectedFilter ? { assigneeId: { @@ -68,32 +29,34 @@ export const useTasks = ({ } : undefined; - const { records: completeTasksData } = useFindManyRecords({ - objectNameSingular: CoreObjectNameSingular.Activity, - skip: skipRequest, - filter: { + const skipActivityTargets = !isNonEmptyArray(targetableObjects); + + const { + activities: completeTasksData, + initialized: initializedCompleteTasks, + } = useActivities({ + targetableObjects, + activitiesFilters: { completedAt: { is: 'NOT_NULL' }, - ...idFilter, type: { eq: 'Task' }, ...assigneeIdFilter, }, - orderBy: { - createdAt: 'DescNullsFirst', - }, + activitiesOrderByVariables: FIND_MANY_TIMELINE_ACTIVITIES_ORDER_BY, + skipActivityTargets, }); - const { records: incompleteTaskData } = useFindManyRecords({ - objectNameSingular: CoreObjectNameSingular.Activity, - skip: skipRequest, - filter: { + const { + activities: incompleteTaskData, + initialized: initializedIncompleteTasks, + } = useActivities({ + targetableObjects, + activitiesFilters: { completedAt: { is: 'NULL' }, - ...idFilter, type: { eq: 'Task' }, ...assigneeIdFilter, }, - orderBy: { - createdAt: 'DescNullsFirst', - }, + activitiesOrderByVariables: FIND_MANY_TIMELINE_ACTIVITIES_ORDER_BY, + skipActivityTargets, }); const todayOrPreviousTasks = incompleteTaskData?.filter((task) => { @@ -125,5 +88,6 @@ export const useTasks = ({ upcomingTasks: (upcomingTasks ?? []) as Activity[], unscheduledTasks: (unscheduledTasks ?? []) as Activity[], completedTasks: (completedTasks ?? []) as Activity[], + initialized: initializedCompleteTasks && initializedIncompleteTasks, }; }; 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 4e3d085f2..e9c6c27cb 100644 --- a/packages/twenty-front/src/modules/activities/timeline/components/Timeline.tsx +++ b/packages/twenty-front/src/modules/activities/timeline/components/Timeline.tsx @@ -1,7 +1,11 @@ +import { useEffect } from 'react'; import styled from '@emotion/styled'; +import { useSetRecoilState } from 'recoil'; +import { useActivities } from '@/activities/hooks/useActivities'; import { TimelineCreateButtonGroup } from '@/activities/timeline/components/TimelineCreateButtonGroup'; -import { useTimelineActivities } from '@/activities/timeline/hooks/useTimelineActivities'; +import { FIND_MANY_TIMELINE_ACTIVITIES_ORDER_BY } from '@/activities/timeline/constants/FIND_MANY_TIMELINE_ACTIVITIES_ORDER_BY'; +import { timelineTargetableObjectState } from '@/activities/timeline/states/timelineTargetableObjectState'; import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity'; import AnimatedPlaceholder from '@/ui/layout/animated-placeholder/components/AnimatedPlaceholder'; import { @@ -11,6 +15,7 @@ import { AnimatedPlaceholderEmptyTitle, } from '@/ui/layout/animated-placeholder/components/EmptyPlaceholderStyled'; import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile'; +import { isDefined } from '~/utils/isDefined'; import { TimelineItemsContainer } from './TimelineItemsContainer'; @@ -31,11 +36,22 @@ export const Timeline = ({ }: { targetableObject: ActivityTargetableObject; }) => { - const { activities, initialized } = useTimelineActivities({ - targetableObject, + const { activities, initialized, noActivities } = useActivities({ + targetableObjects: [targetableObject], + activitiesFilters: {}, + activitiesOrderByVariables: FIND_MANY_TIMELINE_ACTIVITIES_ORDER_BY, + skip: !isDefined(targetableObject), }); - const showEmptyState = initialized && activities.length === 0; + const setTimelineTargetableObject = useSetRecoilState( + timelineTargetableObjectState, + ); + + useEffect(() => { + setTimelineTargetableObject(targetableObject); + }, [targetableObject, setTimelineTargetableObject]); + + const showEmptyState = noActivities; const showLoadingState = !initialized; 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 435aaafd0..fbff3cf8d 100644 --- a/packages/twenty-front/src/modules/activities/timeline/components/TimelineActivity.tsx +++ b/packages/twenty-front/src/modules/activities/timeline/components/TimelineActivity.tsx @@ -1,13 +1,14 @@ import { Tooltip } from 'react-tooltip'; import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; +import { useRecoilValue } from 'recoil'; import { useOpenActivityRightDrawer } from '@/activities/hooks/useOpenActivityRightDrawer'; import { Activity } from '@/activities/types/Activity'; +import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; import { IconCheckbox, IconNotes } from '@/ui/display/icon'; import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile'; import { Avatar } from '@/users/components/Avatar'; -import { WorkspaceMember } from '@/workspace-member/types/WorkspaceMember'; import { beautifyExactDateTime, beautifyPastDateRelativeToNow, @@ -135,19 +136,7 @@ const StyledTimelineItemContainer = styled.div<{ isGap?: boolean }>` `; type TimelineActivityProps = { - activity: Pick< - Activity, - | 'id' - | 'title' - | 'body' - | 'createdAt' - | 'completedAt' - | 'type' - | 'comments' - | 'dueAt' - > & { author?: Pick } & { - assignee?: Pick | null; - }; + activity: Activity; isLastActivity?: boolean; }; @@ -160,6 +149,8 @@ export const TimelineActivity = ({ const openActivityRightDrawer = useOpenActivityRightDrawer(); const theme = useTheme(); + const activityFromStore = useRecoilValue(recordStoreFamilyState(activity.id)); + return ( <> @@ -191,11 +182,13 @@ export const TimelineActivity = ({ {(activity.type === 'Note' || activity.type === 'Task') && ( openActivityRightDrawer(activity.id)} + onClick={() => openActivityRightDrawer(activity)} > “ - - {activity.title ?? '(No Title)'} + + {activityFromStore?.title ?? '(No Title)'} diff --git a/packages/twenty-front/src/modules/activities/timeline/components/TimelineCreateButtonGroup.tsx b/packages/twenty-front/src/modules/activities/timeline/components/TimelineCreateButtonGroup.tsx index e92623a28..b8542cd19 100644 --- a/packages/twenty-front/src/modules/activities/timeline/components/TimelineCreateButtonGroup.tsx +++ b/packages/twenty-front/src/modules/activities/timeline/components/TimelineCreateButtonGroup.tsx @@ -1,7 +1,7 @@ import { useSetRecoilState } from 'recoil'; import { Button, ButtonGroup } from 'tsup.ui.index'; -import { useOpenCreateActivityDrawerV2 } from '@/activities/hooks/useOpenCreateActivityDrawerV2'; +import { useOpenCreateActivityDrawer } from '@/activities/hooks/useOpenCreateActivityDrawer'; import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity'; import { IconCheckbox, @@ -19,7 +19,7 @@ export const TimelineCreateButtonGroup = ({ const { getActiveTabIdState } = useTabList(TAB_LIST_COMPONENT_ID); const setActiveTabId = useSetRecoilState(getActiveTabIdState()); - const openCreateActivity = useOpenCreateActivityDrawerV2(); + const openCreateActivity = useOpenCreateActivityDrawer(); return ( @@ -30,7 +30,6 @@ export const TimelineCreateButtonGroup = ({ openCreateActivity({ type: 'Note', targetableObjects: [targetableObject], - timelineTargetableObject: targetableObject, }) } /> @@ -41,7 +40,6 @@ export const TimelineCreateButtonGroup = ({ openCreateActivity({ type: 'Task', targetableObjects: [targetableObject], - timelineTargetableObject: targetableObject, }) } /> diff --git a/packages/twenty-front/src/modules/activities/timeline/constants/FIND_MANY_TIMELINE_ACTIVITIES_ORDER_BY.ts b/packages/twenty-front/src/modules/activities/timeline/constants/FIND_MANY_TIMELINE_ACTIVITIES_ORDER_BY.ts new file mode 100644 index 000000000..47de19c1e --- /dev/null +++ b/packages/twenty-front/src/modules/activities/timeline/constants/FIND_MANY_TIMELINE_ACTIVITIES_ORDER_BY.ts @@ -0,0 +1,5 @@ +import { OrderByField } from '@/object-metadata/types/OrderByField'; + +export const FIND_MANY_TIMELINE_ACTIVITIES_ORDER_BY: OrderByField = { + createdAt: 'DescNullsFirst', +}; diff --git a/packages/twenty-front/src/modules/activities/timeline/hooks/useInjectIntoTimelineActivitiesQueries.ts b/packages/twenty-front/src/modules/activities/timeline/hooks/useInjectIntoTimelineActivitiesQueries.ts new file mode 100644 index 000000000..ee637aac1 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/timeline/hooks/useInjectIntoTimelineActivitiesQueries.ts @@ -0,0 +1,32 @@ +import { useInjectIntoActivitiesQuery } from '@/activities/hooks/useInjectIntoActivitiesQuery'; +import { Activity } from '@/activities/types/Activity'; +import { ActivityTarget } from '@/activities/types/ActivityTarget'; +import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity'; + +export const useInjectIntoTimelineActivitiesQueries = () => { + const { injectActivitiesQueries } = useInjectIntoActivitiesQuery(); + + const injectIntoTimelineActivitiesQueries = ({ + activityToInject, + activityTargetsToInject, + timelineTargetableObject, + }: { + activityToInject: Activity; + activityTargetsToInject: ActivityTarget[]; + timelineTargetableObject: ActivityTargetableObject; + }) => { + injectActivitiesQueries({ + activitiesFilters: {}, + activitiesOrderByVariables: { + createdAt: 'DescNullsFirst', + }, + activityTargetsToInject, + activityToInject, + targetableObjects: [timelineTargetableObject], + }); + }; + + return { + injectIntoTimelineActivitiesQueries, + }; +}; diff --git a/packages/twenty-front/src/modules/activities/timeline/hooks/useRemoveFromTimelineActivitiesQueries.ts b/packages/twenty-front/src/modules/activities/timeline/hooks/useRemoveFromTimelineActivitiesQueries.ts new file mode 100644 index 000000000..f5572f0f8 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/timeline/hooks/useRemoveFromTimelineActivitiesQueries.ts @@ -0,0 +1,138 @@ +import { useRecoilValue } from 'recoil'; + +import { useRemoveFromActivitiesQueries } from '@/activities/hooks/useRemoveFromActivitiesQueries'; +import { FIND_MANY_TIMELINE_ACTIVITIES_ORDER_BY } from '@/activities/timeline/constants/FIND_MANY_TIMELINE_ACTIVITIES_ORDER_BY'; +import { timelineTargetableObjectState } from '@/activities/timeline/states/timelineTargetableObjectState'; +import { ActivityTarget } from '@/activities/types/ActivityTarget'; + +export const useRemoveFromTimelineActivitiesQueries = () => { + const timelineTargetableObject = useRecoilValue( + timelineTargetableObjectState, + ); + + // const { objectMetadataItem: objectMetadataItemActivity } = + // useObjectMetadataItemOnly({ + // objectNameSingular: CoreObjectNameSingular.Activity, + // }); + + // const { + // upsertFindManyRecordsQueryInCache: overwriteFindManyActivitiesInCache, + // } = useUpsertFindManyRecordsQueryInCache({ + // objectMetadataItem: objectMetadataItemActivity, + // }); + + // const { objectMetadataItem: objectMetadataItemActivityTarget } = + // useObjectMetadataItemOnly({ + // objectNameSingular: CoreObjectNameSingular.ActivityTarget, + // }); + + // const { + // readFindManyRecordsQueryInCache: readFindManyActivityTargetsQueryInCache, + // } = useReadFindManyRecordsQueryInCache({ + // objectMetadataItem: objectMetadataItemActivityTarget, + // }); + + // const { + // readFindManyRecordsQueryInCache: readFindManyActivitiesQueryInCache, + // } = useReadFindManyRecordsQueryInCache({ + // objectMetadataItem: objectMetadataItemActivity, + // }); + + // const { + // upsertFindManyRecordsQueryInCache: + // overwriteFindManyActivityTargetsQueryInCache, + // } = useUpsertFindManyRecordsQueryInCache({ + // objectMetadataItem: objectMetadataItemActivityTarget, + // }); + + const { removeFromActivitiesQueries } = useRemoveFromActivitiesQueries(); + + const removeFromTimelineActivitiesQueries = ({ + activityIdToRemove, + activityTargetsToRemove, + }: { + activityIdToRemove: string; + activityTargetsToRemove: ActivityTarget[]; + }) => { + if (!timelineTargetableObject) { + throw new Error('Timeline targetable object is not defined'); + } + + removeFromActivitiesQueries({ + activityIdToRemove, + activityTargetsToRemove, + targetableObjects: [timelineTargetableObject], + activitiesFilters: {}, + activitiesOrderByVariables: FIND_MANY_TIMELINE_ACTIVITIES_ORDER_BY, + }); + + // const targetObjectFieldName = getActivityTargetObjectFieldIdName({ + // nameSingular: timelineTargetableObject.targetObjectNameSingular, + // }); + + // const activitiyTargetsForTargetableObjectQueryVariables = { + // filter: { + // [targetObjectFieldName]: { + // eq: timelineTargetableObject.id, + // }, + // }, + // }; + + // const existingActivityTargetsForTargetableObject = + // readFindManyActivityTargetsQueryInCache({ + // queryVariables: activitiyTargetsForTargetableObjectQueryVariables, + // }); + + // const newActivityTargetsForTargetableObject = isNonEmptyArray( + // activityTargetsToRemove, + // ) + // ? existingActivityTargetsForTargetableObject.filter( + // (existingActivityTarget) => + // activityTargetsToRemove.some( + // (activityTargetToRemove) => + // activityTargetToRemove.id !== existingActivityTarget.id, + // ), + // ) + // : existingActivityTargetsForTargetableObject; + + // overwriteFindManyActivityTargetsQueryInCache({ + // objectRecordsToOverwrite: newActivityTargetsForTargetableObject, + // queryVariables: activitiyTargetsForTargetableObjectQueryVariables, + // }); + + // const existingActivityIds = existingActivityTargetsForTargetableObject + // ?.map((activityTarget) => activityTarget.activityId) + // .filter(isNonEmptyString); + + // const timelineActivitiesQueryVariablesBeforeDrawerMount = + // makeTimelineActivitiesQueryVariables({ + // activityIds: existingActivityIds, + // }); + + // const existingActivities = readFindManyActivitiesQueryInCache({ + // queryVariables: timelineActivitiesQueryVariablesBeforeDrawerMount, + // }); + + // const activityIdsAfterRemoval = existingActivityIds.filter( + // (existingActivityId) => existingActivityId !== activityIdToRemove, + // ); + + // const timelineActivitiesQueryVariablesAfterRemoval = + // makeTimelineActivitiesQueryVariables({ + // activityIds: activityIdsAfterRemoval, + // }); + + // const newActivities = existingActivities + // .filter((existingActivity) => existingActivity.id !== activityIdToRemove) + // .toSorted(sortObjectRecordByDateField('createdAt', 'DescNullsFirst')); + + // overwriteFindManyActivitiesInCache({ + // objectRecordsToOverwrite: newActivities, + // queryVariables: timelineActivitiesQueryVariablesAfterRemoval, + // }); + }; + + return { + removeFromTimelineActivitiesQueries, + }; +}; diff --git a/packages/twenty-front/src/modules/activities/timeline/hooks/useTimelineActivities.ts b/packages/twenty-front/src/modules/activities/timeline/hooks/useTimelineActivities.ts index ee0df89cb..348f693e0 100644 --- a/packages/twenty-front/src/modules/activities/timeline/hooks/useTimelineActivities.ts +++ b/packages/twenty-front/src/modules/activities/timeline/hooks/useTimelineActivities.ts @@ -1,18 +1,37 @@ import { useEffect, useState } from 'react'; import { isNonEmptyArray, isNonEmptyString } from '@sniptt/guards'; +import { useRecoilCallback, useRecoilState } from 'recoil'; import { useActivityTargetsForTargetableObject } from '@/activities/hooks/useActivityTargetsForTargetableObject'; +import { timelineTargetableObjectState } from '@/activities/timeline/states/timelineTargetableObjectState'; import { makeTimelineActivitiesQueryVariables } from '@/activities/timeline/utils/makeTimelineActivitiesQueryVariables'; import { Activity } from '@/activities/types/Activity'; import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity'; +import { useActivityConnectionUtils } from '@/activities/utils/useActivityConnectionUtils'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; +import { getRecordsFromRecordConnection } from '@/object-record/cache/utils/getRecordsFromRecordConnection'; import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords'; +import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; +import { sortByAscString } from '~/utils/array/sortByAscString'; +import { isDefined } from '~/utils/isDefined'; export const useTimelineActivities = ({ targetableObject, }: { targetableObject: ActivityTargetableObject; }) => { + const { makeActivityWithoutConnection } = useActivityConnectionUtils(); + + const [, setTimelineTargetableObject] = useRecoilState( + timelineTargetableObjectState, + ); + + useEffect(() => { + if (isDefined(targetableObject)) { + setTimelineTargetableObject(targetableObject); + } + }, [targetableObject, setTimelineTargetableObject]); + const { activityTargets, loadingActivityTargets, @@ -23,9 +42,14 @@ export const useTimelineActivities = ({ const [initialized, setInitialized] = useState(false); - const activityIds = activityTargets - ?.map((activityTarget) => activityTarget.activityId) - .filter(isNonEmptyString); + const activityIds = Array.from( + new Set( + activityTargets + ?.map((activityTarget) => activityTarget.activityId) + .filter(isNonEmptyString) + .toSorted(sortByAscString), + ), + ); const timelineActivitiesQueryVariables = makeTimelineActivitiesQueryVariables( { @@ -33,17 +57,30 @@ export const useTimelineActivities = ({ }, ); - const { records: activities, loading: loadingActivities } = + const { records: activitiesWithConnection, loading: loadingActivities } = useFindManyRecords({ skip: loadingActivityTargets || !isNonEmptyArray(activityTargets), objectNameSingular: CoreObjectNameSingular.Activity, filter: timelineActivitiesQueryVariables.filter, orderBy: timelineActivitiesQueryVariables.orderBy, - onCompleted: () => { - if (!initialized) { - setInitialized(true); - } - }, + onCompleted: useRecoilCallback( + ({ set }) => + (data) => { + if (!initialized) { + setInitialized(true); + } + + const activities = getRecordsFromRecordConnection({ + recordConnection: data, + }); + + for (const activity of activities) { + set(recordStoreFamilyState(activity.id), activity); + } + }, + [initialized], + ), + depth: 3, }); const noActivityTargets = @@ -57,6 +94,11 @@ export const useTimelineActivities = ({ const loading = loadingActivities || loadingActivityTargets; + const activities = activitiesWithConnection + ?.map(makeActivityWithoutConnection as any) + .map(({ activity }: any) => activity as any) + .filter(isDefined); + return { activities, loading, diff --git a/packages/twenty-front/src/modules/activities/timeline/states/timelineTargetableObjectState.ts b/packages/twenty-front/src/modules/activities/timeline/states/timelineTargetableObjectState.ts new file mode 100644 index 000000000..ad328f150 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/timeline/states/timelineTargetableObjectState.ts @@ -0,0 +1,9 @@ +import { atom } from 'recoil'; + +import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity'; + +export const timelineTargetableObjectState = + atom({ + key: 'timelineTargetableObjectState', + default: null, + }); diff --git a/packages/twenty-front/src/modules/activities/timeline/utils/makeTimelineActivitiesQueryVariables.ts b/packages/twenty-front/src/modules/activities/timeline/utils/makeTimelineActivitiesQueryVariables.ts index ae39da0bd..86af23487 100644 --- a/packages/twenty-front/src/modules/activities/timeline/utils/makeTimelineActivitiesQueryVariables.ts +++ b/packages/twenty-front/src/modules/activities/timeline/utils/makeTimelineActivitiesQueryVariables.ts @@ -1,4 +1,5 @@ import { ObjectRecordQueryVariables } from '@/object-record/types/ObjectRecordQueryVariables'; +import { sortByAscString } from '~/utils/array/sortByAscString'; export const makeTimelineActivitiesQueryVariables = ({ activityIds, @@ -8,7 +9,7 @@ export const makeTimelineActivitiesQueryVariables = ({ return { filter: { id: { - in: activityIds, + in: activityIds.toSorted(sortByAscString), }, }, orderBy: { diff --git a/packages/twenty-front/src/modules/activities/types/ActivityTargetObject.ts b/packages/twenty-front/src/modules/activities/types/ActivityTargetObject.ts index d95d4f520..ad119af39 100644 --- a/packages/twenty-front/src/modules/activities/types/ActivityTargetObject.ts +++ b/packages/twenty-front/src/modules/activities/types/ActivityTargetObject.ts @@ -1,9 +1,10 @@ +import { ActivityTarget } from '@/activities/types/ActivityTarget'; import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { ObjectRecord } from '@/object-record/types/ObjectRecord'; -export type ActivityTargetObjectRecord = { +export type ActivityTargetWithTargetRecord = { targetObjectMetadataItem: ObjectMetadataItem; - activityTargetRecord: ObjectRecord; - targetObjectRecord: ObjectRecord; + activityTarget: ActivityTarget; + targetObject: ObjectRecord; targetObjectNameSingular: string; }; diff --git a/packages/twenty-front/src/modules/activities/utils/getActivityTargetsFilter.ts b/packages/twenty-front/src/modules/activities/utils/getActivityTargetsFilter.ts new file mode 100644 index 000000000..c6a53c39e --- /dev/null +++ b/packages/twenty-front/src/modules/activities/utils/getActivityTargetsFilter.ts @@ -0,0 +1,25 @@ +import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity'; +import { getActivityTargetObjectFieldIdName } from '@/activities/utils/getTargetObjectFilterFieldName'; + +export const getActivityTargetsFilter = ({ + targetableObjects, +}: { + targetableObjects: ActivityTargetableObject[]; +}) => { + const findManyActivitiyTargetsQueryFilter = Object.fromEntries( + targetableObjects.map((targetableObject) => { + const targetObjectFieldName = getActivityTargetObjectFieldIdName({ + nameSingular: targetableObject.targetObjectNameSingular, + }); + + return [ + targetObjectFieldName, + { + eq: targetableObject.id, + }, + ]; + }), + ); + + return findManyActivitiyTargetsQueryFilter; +}; diff --git a/packages/twenty-front/src/modules/activities/utils/useActivityConnectionUtils.ts b/packages/twenty-front/src/modules/activities/utils/useActivityConnectionUtils.ts index b0c6bacec..55cd0b401 100644 --- a/packages/twenty-front/src/modules/activities/utils/useActivityConnectionUtils.ts +++ b/packages/twenty-front/src/modules/activities/utils/useActivityConnectionUtils.ts @@ -13,9 +13,14 @@ import { isDefined } from '~/utils/isDefined'; export const useActivityConnectionUtils = () => { const mapConnectionToRecords = useMapConnectionToRecords(); - const makeActivityWithoutConnection = (activityWithConnections: any) => { + const makeActivityWithoutConnection = ( + activityWithConnections: Activity & { + activityTargets: ObjectRecordConnection; + comments: ObjectRecordConnection; + }, + ) => { if (!isDefined(activityWithConnections)) { - return { activity: null }; + throw new Error('Activity with connections is not defined'); } const hasActivityTargetsConnection = isObjectRecordConnection( @@ -77,11 +82,13 @@ export const useActivityConnectionUtils = () => { : []; const activityTargets = { + __typename: 'ActivityTargetConnection', edges: activityTargetEdges, pageInfo: getEmptyPageInfo(), } as ObjectRecordConnection; const comments = { + __typename: 'CommentConnection', edges: commentEdges, pageInfo: getEmptyPageInfo(), } as ObjectRecordConnection; @@ -90,6 +97,9 @@ export const useActivityConnectionUtils = () => { ...activity, activityTargets, comments, + } as Activity & { + activityTargets: ObjectRecordConnection; + comments: ObjectRecordConnection; }; return { activityWithConnection }; diff --git a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/isObjectRecordConnection.ts b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/isObjectRecordConnection.ts index 7017d15c0..c069a0a6c 100644 --- a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/isObjectRecordConnection.ts +++ b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/isObjectRecordConnection.ts @@ -11,17 +11,19 @@ export const isObjectRecordConnection = ( objectNameSingular, )}Connection`; const objectEdgeTypeName = `${capitalize(objectNameSingular)}Edge`; + const objectConnectionSchema = z.object({ - __typename: z.literal(objectConnectionTypeName), + __typename: z.literal(objectConnectionTypeName).optional(), edges: z.array( z.object({ - __typename: z.literal(objectEdgeTypeName), + __typename: z.literal(objectEdgeTypeName).optional(), node: z.object({ id: z.string().uuid(), }), }), ), }); + const connectionValidation = objectConnectionSchema.safeParse(value); return connectionValidation.success; diff --git a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerDetachRelationOptimisticEffect.ts b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerDetachRelationOptimisticEffect.ts index 7a1196cf4..3d0080526 100644 --- a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerDetachRelationOptimisticEffect.ts +++ b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerDetachRelationOptimisticEffect.ts @@ -32,29 +32,25 @@ export const triggerDetachRelationOptimisticEffect = ({ targetRecordFieldValue, { isReference, readField }, ) => { - const isRelationTargetFieldAnObjectRecordConnection = - isCachedObjectRecordConnection( - sourceObjectNameSingular, - targetRecordFieldValue, - ); - - if (isRelationTargetFieldAnObjectRecordConnection) { - const relationTargetFieldEdgesWithoutRelationSourceRecordToDetach = - targetRecordFieldValue.edges.filter( - ({ node }) => readField('id', node) !== sourceRecordId, - ); - - return { - ...targetRecordFieldValue, - edges: relationTargetFieldEdgesWithoutRelationSourceRecordToDetach, - }; - } - - const isRelationTargetFieldASingleObjectRecord = isReference( + const isRecordConnection = isCachedObjectRecordConnection( + sourceObjectNameSingular, targetRecordFieldValue, ); - if (isRelationTargetFieldASingleObjectRecord) { + if (isRecordConnection) { + const nextEdges = targetRecordFieldValue.edges.filter( + ({ node }) => readField('id', node) !== sourceRecordId, + ); + + return { + ...targetRecordFieldValue, + edges: nextEdges, + }; + } + + const isSingleReference = isReference(targetRecordFieldValue); + + if (isSingleReference) { return null; } diff --git a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerUpdateRecordOptimisticEffect.ts b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerUpdateRecordOptimisticEffect.ts index 11b69677b..96c5fa0cc 100644 --- a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerUpdateRecordOptimisticEffect.ts +++ b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerUpdateRecordOptimisticEffect.ts @@ -45,41 +45,35 @@ export const triggerUpdateRecordOptimisticEffect = ({ rootQueryCachedResponse, { DELETE, readField, storeFieldName, toReference }, ) => { - const rootQueryCachedResponseIsNotACachedObjectRecordConnection = - !isCachedObjectRecordConnection( - objectMetadataItem.nameSingular, - rootQueryCachedResponse, - ); + const shouldSkip = !isCachedObjectRecordConnection( + objectMetadataItem.nameSingular, + rootQueryCachedResponse, + ); - if (rootQueryCachedResponseIsNotACachedObjectRecordConnection) { + if (shouldSkip) { return rootQueryCachedResponse; } - const rootQueryCachedObjectRecordConnection = rootQueryCachedResponse; + const rootQueryConnection = rootQueryCachedResponse; const { fieldArguments: rootQueryVariables } = parseApolloStoreFieldName( storeFieldName, ); - const rootQueryCurrentCachedRecordEdges = - readField( - 'edges', - rootQueryCachedObjectRecordConnection, - ) ?? []; + const rootQueryCurrentEdges = + readField('edges', rootQueryConnection) ?? + []; - let rootQueryNextCachedRecordEdges = [ - ...rootQueryCurrentCachedRecordEdges, - ]; + let rootQueryNextEdges = [...rootQueryCurrentEdges]; const rootQueryFilter = rootQueryVariables?.filter; const rootQueryOrderBy = rootQueryVariables?.orderBy; const rootQueryLimit = rootQueryVariables?.first; - const shouldTestThatUpdatedRecordMatchesThisRootQueryFilter = - isDefined(rootQueryFilter); + const shouldTryToMatchFilter = isDefined(rootQueryFilter); - if (shouldTestThatUpdatedRecordMatchesThisRootQueryFilter) { + if (shouldTryToMatchFilter) { const updatedRecordMatchesThisRootQueryFilter = isRecordMatchingFilter({ record: updatedRecord, @@ -88,24 +82,27 @@ export const triggerUpdateRecordOptimisticEffect = ({ }); const updatedRecordIndexInRootQueryEdges = - rootQueryCurrentCachedRecordEdges.findIndex( + rootQueryCurrentEdges.findIndex( (cachedEdge) => readField('id', cachedEdge.node) === updatedRecord.id, ); + const updatedRecordFoundInRootQueryEdges = + updatedRecordIndexInRootQueryEdges > -1; + const updatedRecordShouldBeAddedToRootQueryEdges = updatedRecordMatchesThisRootQueryFilter && - updatedRecordIndexInRootQueryEdges === -1; + !updatedRecordFoundInRootQueryEdges; const updatedRecordShouldBeRemovedFromRootQueryEdges = - updatedRecordMatchesThisRootQueryFilter && - updatedRecordIndexInRootQueryEdges === -1; + !updatedRecordMatchesThisRootQueryFilter && + updatedRecordFoundInRootQueryEdges; if (updatedRecordShouldBeAddedToRootQueryEdges) { const updatedRecordNodeReference = toReference(updatedRecord); if (isDefined(updatedRecordNodeReference)) { - rootQueryNextCachedRecordEdges.push({ + rootQueryNextEdges.push({ __typename: objectEdgeTypeName, node: updatedRecordNodeReference, cursor: '', @@ -114,18 +111,15 @@ export const triggerUpdateRecordOptimisticEffect = ({ } if (updatedRecordShouldBeRemovedFromRootQueryEdges) { - rootQueryNextCachedRecordEdges.splice( - updatedRecordIndexInRootQueryEdges, - 1, - ); + rootQueryNextEdges.splice(updatedRecordIndexInRootQueryEdges, 1); } } - const nextRootQueryEdgesShouldBeSorted = isDefined(rootQueryOrderBy); + const rootQueryNextEdgesShouldBeSorted = isDefined(rootQueryOrderBy); - if (nextRootQueryEdgesShouldBeSorted) { - rootQueryNextCachedRecordEdges = sortCachedObjectEdges({ - edges: rootQueryNextCachedRecordEdges, + if (rootQueryNextEdgesShouldBeSorted) { + rootQueryNextEdges = sortCachedObjectEdges({ + edges: rootQueryNextEdges, orderBy: rootQueryOrderBy, readCacheField: readField, }); @@ -158,12 +152,12 @@ export const triggerUpdateRecordOptimisticEffect = ({ // the query's result. // In this case, invalidate the cache entry so it can be re-fetched. const rootQueryCurrentCachedRecordEdgesLengthIsAtLimit = - rootQueryCurrentCachedRecordEdges.length === rootQueryLimit; + rootQueryCurrentEdges.length === rootQueryLimit; // If next edges length is under limit, then we can wait for the network response and merge the result // then in the merge function we could implement this mechanism to limit the number of edges in the cache const rootQueryNextCachedRecordEdgesLengthIsUnderLimit = - rootQueryNextCachedRecordEdges.length < rootQueryLimit; + rootQueryNextEdges.length < rootQueryLimit; const shouldDeleteRootQuerySoItCanBeRefetched = rootQueryCurrentCachedRecordEdgesLengthIsAtLimit && @@ -174,16 +168,16 @@ export const triggerUpdateRecordOptimisticEffect = ({ } const rootQueryNextCachedRecordEdgesLengthIsAboveRootQueryLimit = - rootQueryNextCachedRecordEdges.length > rootQueryLimit; + rootQueryNextEdges.length > rootQueryLimit; if (rootQueryNextCachedRecordEdgesLengthIsAboveRootQueryLimit) { - rootQueryNextCachedRecordEdges.splice(rootQueryLimit); + rootQueryNextEdges.splice(rootQueryLimit); } } return { - ...rootQueryCachedObjectRecordConnection, - edges: rootQueryNextCachedRecordEdges, + ...rootQueryConnection, + edges: rootQueryNextEdges, }; }, }, diff --git a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerUpdateRelationsOptimisticEffect.ts b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerUpdateRelationsOptimisticEffect.ts index 6610ba302..633fde736 100644 --- a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerUpdateRelationsOptimisticEffect.ts +++ b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerUpdateRelationsOptimisticEffect.ts @@ -6,7 +6,7 @@ import { triggerAttachRelationOptimisticEffect } from '@/apollo/optimistic-effec import { triggerDeleteRecordsOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerDeleteRecordsOptimisticEffect'; import { triggerDetachRelationOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerDetachRelationOptimisticEffect'; import { CachedObjectRecord } from '@/apollo/types/CachedObjectRecord'; -import { CORE_OBJECT_NAMES_TO_DELETE_ON_TRIGGER_RELATION_DETACH as CORE_OBJECT_NAMES_TO_DELETE_ON_OPTIMISTIC_RELATION_DETACH } from '@/apollo/types/coreObjectNamesToDeleteOnRelationDetach'; +import { CORE_OBJECT_NAMES_TO_DELETE_ON_TRIGGER_RELATION_DETACH } from '@/apollo/types/coreObjectNamesToDeleteOnRelationDetach'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { ObjectRecordConnection } from '@/object-record/types/ObjectRecordConnection'; @@ -74,6 +74,8 @@ export const triggerUpdateRelationsOptimisticEffect = ({ return; } + // TODO: replace this by a relation type check, if it's one to many, + // it's an object record connection (we can still check it though as a safeguard) const currentFieldValueOnSourceRecordIsARecordConnection = isObjectRecordConnection( targetObjectMetadataItem.nameSingular, @@ -104,12 +106,14 @@ export const triggerUpdateRelationsOptimisticEffect = ({ isDefined(currentSourceRecord) && targetRecordsToDetachFrom.length > 0; if (shouldDetachSourceFromAllTargets) { - const shouldStartByDeletingRelationTargetRecordsFromCache = - CORE_OBJECT_NAMES_TO_DELETE_ON_OPTIMISTIC_RELATION_DETACH.includes( + // TODO: see if we can de-hardcode this, put cascade delete in relation metadata item + // Instead of hardcoding it here + const shouldCascadeDeleteTargetRecords = + CORE_OBJECT_NAMES_TO_DELETE_ON_TRIGGER_RELATION_DETACH.includes( targetObjectMetadataItem.nameSingular as CoreObjectNameSingular, ); - if (shouldStartByDeletingRelationTargetRecordsFromCache) { + if (shouldCascadeDeleteTargetRecords) { triggerDeleteRecordsOptimisticEffect({ cache, objectMetadataItem: targetObjectMetadataItem, diff --git a/packages/twenty-front/src/modules/apollo/types/coreObjectNamesToDeleteOnRelationDetach.ts b/packages/twenty-front/src/modules/apollo/types/coreObjectNamesToDeleteOnRelationDetach.ts index f28caf779..90e91f138 100644 --- a/packages/twenty-front/src/modules/apollo/types/coreObjectNamesToDeleteOnRelationDetach.ts +++ b/packages/twenty-front/src/modules/apollo/types/coreObjectNamesToDeleteOnRelationDetach.ts @@ -2,4 +2,6 @@ import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSi export const CORE_OBJECT_NAMES_TO_DELETE_ON_TRIGGER_RELATION_DETACH = [ CoreObjectNameSingular.Favorite, + CoreObjectNameSingular.ActivityTarget, + CoreObjectNameSingular.Comment, ]; diff --git a/packages/twenty-front/src/modules/command-menu/components/CommandMenu.tsx b/packages/twenty-front/src/modules/command-menu/components/CommandMenu.tsx index d351e0a03..95a7e2e25 100644 --- a/packages/twenty-front/src/modules/command-menu/components/CommandMenu.tsx +++ b/packages/twenty-front/src/modules/command-menu/components/CommandMenu.tsx @@ -192,11 +192,11 @@ export const CommandMenu = () => { const activityCommands = useMemo( () => - activities.map(({ id, title }) => ({ - id, - label: title ?? '', + activities.map((activity) => ({ + id: activity.id, + label: activity.title ?? '', to: '', - onCommandClick: () => openActivityRightDrawer(id), + onCommandClick: () => openActivityRightDrawer(activity), })), [activities, openActivityRightDrawer], ); @@ -372,7 +372,7 @@ export const CommandMenu = () => { Icon={IconNotes} key={activity.id} label={activity.title ?? ''} - onClick={() => openActivityRightDrawer(activity.id)} + onClick={() => openActivityRightDrawer(activity)} /> ))} diff --git a/packages/twenty-front/src/modules/object-record/cache/hooks/useGenerateObjectRecordOptimisticResponse.ts b/packages/twenty-front/src/modules/object-record/cache/hooks/useGenerateObjectRecordOptimisticResponse.ts index f07a757a2..348c0cc54 100644 --- a/packages/twenty-front/src/modules/object-record/cache/hooks/useGenerateObjectRecordOptimisticResponse.ts +++ b/packages/twenty-front/src/modules/object-record/cache/hooks/useGenerateObjectRecordOptimisticResponse.ts @@ -43,12 +43,19 @@ export const useGenerateObjectRecordOptimisticResponse = ({ ); const relationRecordId = result[relationIdFieldName] as string | null; + const relationRecord = input[fieldMetadataItem.name] as + | ObjectRecord + | undefined; + return { ...result, [fieldMetadataItem.name]: relationRecordId ? { __typename: relationRecordTypeName, id: relationRecordId, + // TODO: there are too many bugs if we don't include the entire relation record + // See if we can find a way to work only with the id and typename + ...relationRecord, } : null, }; diff --git a/packages/twenty-front/src/modules/object-record/cache/hooks/useUpsertFindManyRecordsQueryInCache.ts b/packages/twenty-front/src/modules/object-record/cache/hooks/useUpsertFindManyRecordsQueryInCache.ts index d519428f4..210ecee55 100644 --- a/packages/twenty-front/src/modules/object-record/cache/hooks/useUpsertFindManyRecordsQueryInCache.ts +++ b/packages/twenty-front/src/modules/object-record/cache/hooks/useUpsertFindManyRecordsQueryInCache.ts @@ -1,6 +1,5 @@ import { useApolloClient } from '@apollo/client'; -import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { MAX_QUERY_DEPTH_FOR_CACHE_INJECTION } from '@/object-record/cache/constants/MaxQueryDepthForCacheInjection'; import { getRecordConnectionFromRecords } from '@/object-record/cache/utils/getRecordConnectionFromRecords'; @@ -28,11 +27,11 @@ export const useUpsertFindManyRecordsQueryInCache = ({ }) => { const findManyRecordsQueryForCacheOverwrite = generateFindManyRecordsQuery({ objectMetadataItem, - depth: MAX_QUERY_DEPTH_FOR_CACHE_INJECTION, + depth: MAX_QUERY_DEPTH_FOR_CACHE_INJECTION, // TODO: fix this }); const newObjectRecordConnection = getRecordConnectionFromRecords({ - objectNameSingular: CoreObjectNameSingular.ActivityTarget, + objectNameSingular: objectMetadataItem.nameSingular, records: objectRecordsToOverwrite, }); diff --git a/packages/twenty-front/src/modules/object-record/cache/utils/getRecordConnectionFromRecords.ts b/packages/twenty-front/src/modules/object-record/cache/utils/getRecordConnectionFromRecords.ts index 18fa0c6de..affe038e9 100644 --- a/packages/twenty-front/src/modules/object-record/cache/utils/getRecordConnectionFromRecords.ts +++ b/packages/twenty-front/src/modules/object-record/cache/utils/getRecordConnectionFromRecords.ts @@ -20,5 +20,6 @@ export const getRecordConnectionFromRecords = ({ }); }), pageInfo: getEmptyPageInfo(), + totalCount: records.length, } as ObjectRecordConnection; }; diff --git a/packages/twenty-front/src/modules/object-record/hooks/useDeleteManyRecords.ts b/packages/twenty-front/src/modules/object-record/hooks/useDeleteManyRecords.ts index b055e0dca..b3236cc5d 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useDeleteManyRecords.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useDeleteManyRecords.ts @@ -12,6 +12,10 @@ type useDeleteOneRecordProps = { refetchFindManyQuery?: boolean; }; +type DeleteManyRecordsOptions = { + skipOptimisticEffect?: boolean; +}; + export const useDeleteManyRecords = ({ objectNameSingular, }: useDeleteOneRecordProps) => { @@ -26,34 +30,41 @@ export const useDeleteManyRecords = ({ objectMetadataItem.namePlural, ); - const deleteManyRecords = async (idsToDelete: string[]) => { + const deleteManyRecords = async ( + idsToDelete: string[], + options?: DeleteManyRecordsOptions, + ) => { const deletedRecords = await apolloClient.mutate({ mutation: deleteManyRecordsMutation, variables: { filter: { id: { in: idsToDelete } }, }, - optimisticResponse: { - [mutationResponseField]: idsToDelete.map((idToDelete) => ({ - __typename: capitalize(objectNameSingular), - id: idToDelete, - })), - }, - update: (cache, { data }) => { - const records = data?.[mutationResponseField]; + optimisticResponse: options?.skipOptimisticEffect + ? undefined + : { + [mutationResponseField]: idsToDelete.map((idToDelete) => ({ + __typename: capitalize(objectNameSingular), + id: idToDelete, + })), + }, + update: options?.skipOptimisticEffect + ? undefined + : (cache, { data }) => { + const records = data?.[mutationResponseField]; - if (!records?.length) return; + if (!records?.length) return; - const cachedRecords = records - .map((record) => getRecordFromCache(record.id, cache)) - .filter(isDefined); + const cachedRecords = records + .map((record) => getRecordFromCache(record.id, cache)) + .filter(isDefined); - triggerDeleteRecordsOptimisticEffect({ - cache, - objectMetadataItem, - recordsToDelete: cachedRecords, - objectMetadataItems, - }); - }, + triggerDeleteRecordsOptimisticEffect({ + cache, + objectMetadataItem, + recordsToDelete: cachedRecords, + objectMetadataItems, + }); + }, }); return deletedRecords.data?.[mutationResponseField] ?? null; diff --git a/packages/twenty-front/src/modules/object-record/hooks/useFindOneRecord.ts b/packages/twenty-front/src/modules/object-record/hooks/useFindOneRecord.ts index 7b8e81d11..ade2c951e 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useFindOneRecord.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useFindOneRecord.ts @@ -4,6 +4,7 @@ import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadata import { ObjectMetadataItemIdentifier } from '@/object-metadata/types/ObjectMetadataItemIdentifier'; import { ObjectRecord } from '@/object-record/types/ObjectRecord'; +// TODO: fix connection in relation => automatically change to an array export const useFindOneRecord = ({ objectNameSingular, objectRecordId = '', diff --git a/packages/twenty-front/src/modules/object-record/record-filter/types/ObjectRecordQueryFilter.ts b/packages/twenty-front/src/modules/object-record/record-filter/types/ObjectRecordQueryFilter.ts index 19c40d85b..e86ac4da6 100644 --- a/packages/twenty-front/src/modules/object-record/record-filter/types/ObjectRecordQueryFilter.ts +++ b/packages/twenty-front/src/modules/object-record/record-filter/types/ObjectRecordQueryFilter.ts @@ -79,7 +79,8 @@ export type LeafFilter = | CurrencyFilter | URLFilter | FullNameFilter - | BooleanFilter; + | BooleanFilter + | undefined; export type AndObjectRecordFilter = { and?: ObjectRecordQueryFilter[]; diff --git a/packages/twenty-front/src/modules/object-record/utils/sortByObjectRecordId.ts b/packages/twenty-front/src/modules/object-record/utils/sortByObjectRecordId.ts new file mode 100644 index 000000000..0594de216 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/utils/sortByObjectRecordId.ts @@ -0,0 +1,5 @@ +import { ObjectRecord } from '@/object-record/types/ObjectRecord'; + +export const sortByObjectRecordId = (a: ObjectRecord, b: ObjectRecord) => { + return a.id.localeCompare(b.id); +}; diff --git a/packages/twenty-front/src/modules/object-record/utils/sortObjectRecordByDateField.test.ts b/packages/twenty-front/src/modules/object-record/utils/sortObjectRecordByDateField.test.ts new file mode 100644 index 000000000..46a03aae8 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/utils/sortObjectRecordByDateField.test.ts @@ -0,0 +1,77 @@ +import { OrderBy } from '@/object-metadata/types/OrderBy'; + +import { sortObjectRecordByDateField } from './sortObjectRecordByDateField'; + +describe('sortByObjectRecordByCreatedAt', () => { + const recordOldest = { id: '', createdAt: '2022-01-01T00:00:00.000Z' }; + const recordNewest = { id: '', createdAt: '2022-01-02T00:00:00.000Z' }; + const recordNull1 = { id: '', createdAt: null }; + const recordNull2 = { id: '', createdAt: null }; + + it('should sort in ascending order with null values first', () => { + const sortDirection = 'AscNullsFirst' satisfies OrderBy; + const sortedArray = [ + recordNull2, + recordNewest, + recordNull1, + recordOldest, + ].sort(sortObjectRecordByDateField('createdAt', sortDirection)); + + expect(sortedArray).toEqual([ + recordNull1, + recordNull2, + recordOldest, + recordNewest, + ]); + }); + + it('should sort in descending order with null values first', () => { + const sortDirection = 'DescNullsFirst' satisfies OrderBy; + const sortedArray = [ + recordNull2, + recordOldest, + recordNewest, + recordNull1, + ].sort(sortObjectRecordByDateField('createdAt', sortDirection)); + + expect(sortedArray).toEqual([ + recordNull2, + recordNull1, + recordNewest, + recordOldest, + ]); + }); + it('should sort in ascending order with null values last', () => { + const sortDirection = 'AscNullsLast' satisfies OrderBy; + const sortedArray = [ + recordOldest, + recordNull2, + recordNewest, + recordNull1, + ].sort(sortObjectRecordByDateField('createdAt', sortDirection)); + + expect(sortedArray).toEqual([ + recordOldest, + recordNewest, + recordNull1, + recordNull2, + ]); + }); + + it('should sort in descending order with null values last', () => { + const sortDirection = 'DescNullsLast' satisfies OrderBy; + const sortedArray = [ + recordNull1, + recordOldest, + recordNewest, + recordNull2, + ].sort(sortObjectRecordByDateField('createdAt', sortDirection)); + + expect(sortedArray).toEqual([ + recordNewest, + recordOldest, + recordNull1, + recordNull2, + ]); + }); +}); diff --git a/packages/twenty-front/src/modules/object-record/utils/sortObjectRecordByDateField.ts b/packages/twenty-front/src/modules/object-record/utils/sortObjectRecordByDateField.ts new file mode 100644 index 000000000..10b0b3269 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/utils/sortObjectRecordByDateField.ts @@ -0,0 +1,68 @@ +import { DateTime } from 'luxon'; + +import { OrderBy } from '@/object-metadata/types/OrderBy'; +import { ObjectRecord } from '@/object-record/types/ObjectRecord'; +import { isDefined } from '~/utils/isDefined'; + +const SORT_BEFORE = -1; +const SORT_AFTER = 1; +const SORT_EQUAL = 0; + +export const sortObjectRecordByDateField = + (dateField: keyof T, sortDirection: OrderBy) => + (a: T, b: T) => { + const aDate = a[dateField]; + const bDate = b[dateField]; + + if (!isDefined(aDate) && !isDefined(bDate)) { + return SORT_EQUAL; + } + + if (!isDefined(aDate)) { + if (sortDirection === 'AscNullsFirst') { + return SORT_BEFORE; + } else if (sortDirection === 'DescNullsFirst') { + return SORT_BEFORE; + } else if (sortDirection === 'AscNullsLast') { + return SORT_AFTER; + } else if (sortDirection === 'DescNullsLast') { + return SORT_AFTER; + } + + throw new Error(`Invalid sortDirection: ${sortDirection}`); + } + + if (!isDefined(bDate)) { + if (sortDirection === 'AscNullsFirst') { + return SORT_AFTER; + } else if (sortDirection === 'DescNullsFirst') { + return SORT_AFTER; + } else if (sortDirection === 'AscNullsLast') { + return SORT_BEFORE; + } else if (sortDirection === 'DescNullsLast') { + return SORT_BEFORE; + } + + throw new Error(`Invalid sortDirection: ${sortDirection}`); + } + + const differenceInMs = DateTime.fromISO(aDate) + .diff(DateTime.fromISO(bDate)) + .as('milliseconds'); + + if (differenceInMs === 0) { + return SORT_EQUAL; + } else if ( + sortDirection === 'AscNullsFirst' || + sortDirection === 'AscNullsLast' + ) { + return differenceInMs > 0 ? SORT_AFTER : SORT_BEFORE; + } else if ( + sortDirection === 'DescNullsFirst' || + sortDirection === 'DescNullsLast' + ) { + return differenceInMs > 0 ? SORT_BEFORE : SORT_AFTER; + } + + throw new Error(`Invalid sortDirection: ${sortDirection}`); + }; diff --git a/packages/twenty-front/src/modules/ui/layout/right-drawer/hooks/useRightDrawer.ts b/packages/twenty-front/src/modules/ui/layout/right-drawer/hooks/useRightDrawer.ts index cb5110960..08cb391cc 100644 --- a/packages/twenty-front/src/modules/ui/layout/right-drawer/hooks/useRightDrawer.ts +++ b/packages/twenty-front/src/modules/ui/layout/right-drawer/hooks/useRightDrawer.ts @@ -6,12 +6,15 @@ import { rightDrawerPageState } from '../states/rightDrawerPageState'; import { RightDrawerPages } from '../types/RightDrawerPages'; export const useRightDrawer = () => { - const [, setIsRightDrawerOpen] = useRecoilState(isRightDrawerOpenState); + const [isRightDrawerOpen, setIsRightDrawerOpen] = useRecoilState( + isRightDrawerOpenState, + ); const [, setIsRightDrawerExpanded] = useRecoilState( isRightDrawerExpandedState, ); - const [, setRightDrawerPage] = useRecoilState(rightDrawerPageState); + const [rightDrawerPage, setRightDrawerPage] = + useRecoilState(rightDrawerPageState); const openRightDrawer = (rightDrawerPage: RightDrawerPages) => { setRightDrawerPage(rightDrawerPage); @@ -25,6 +28,8 @@ export const useRightDrawer = () => { }; return { + rightDrawerPage, + isRightDrawerOpen, openRightDrawer, closeRightDrawer, }; 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 08812ccbb..1b3f27383 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 @@ -1,6 +1,6 @@ import styled from '@emotion/styled'; -import { useOpenCreateActivityDrawerV2 } from '@/activities/hooks/useOpenCreateActivityDrawerV2'; +import { useOpenCreateActivityDrawer } from '@/activities/hooks/useOpenCreateActivityDrawer'; import { ActivityType } from '@/activities/types/Activity'; import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity'; import { PageHotkeyScope } from '@/types/PageHotkeyScope'; @@ -24,14 +24,14 @@ export const ShowPageAddButton = ({ activityTargetObject: ActivityTargetableObject; }) => { const { closeDropdown, toggleDropdown } = useDropdown('add-show-page'); - const openCreateActivity = useOpenCreateActivityDrawerV2(); + const openCreateActivity = useOpenCreateActivityDrawer(); const handleSelect = (type: ActivityType) => { openCreateActivity({ type, targetableObjects: [activityTargetObject], - timelineTargetableObject: activityTargetObject, }); + closeDropdown(); }; diff --git a/packages/twenty-front/src/pages/tasks/Tasks.tsx b/packages/twenty-front/src/pages/tasks/Tasks.tsx index 61a550ad6..33b2a4ff1 100644 --- a/packages/twenty-front/src/pages/tasks/Tasks.tsx +++ b/packages/twenty-front/src/pages/tasks/Tasks.tsx @@ -52,7 +52,7 @@ export const Tasks = () => { - + diff --git a/packages/twenty-front/src/utils/array/sortByAscString.ts b/packages/twenty-front/src/utils/array/sortByAscString.ts new file mode 100644 index 000000000..3c4b1dca1 --- /dev/null +++ b/packages/twenty-front/src/utils/array/sortByAscString.ts @@ -0,0 +1,3 @@ +export const sortByAscString = (a: string, b: string) => { + return a.localeCompare(b); +};