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 <charles@twenty.com>
This commit is contained in:
Lucas Bordeau
2024-02-20 14:20:45 +01:00
committed by GitHub
parent 6fb0099eb3
commit 36a6558289
68 changed files with 1435 additions and 630 deletions

View File

@@ -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]);

View File

@@ -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,

View File

@@ -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,
},
});
}
}
},
});

View File

@@ -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 (
<StyledContainer>
{activityTargetObjectRecords?.map((activityTargetObjectRecord) => (
<RecordChip
key={activityTargetObjectRecord.targetObjectRecord.id}
record={activityTargetObjectRecord.targetObjectRecord}
key={activityTargetObjectRecord.targetObject.id}
record={activityTargetObjectRecord.targetObject}
objectNameSingular={
activityTargetObjectRecord.targetObjectMetadataItem.nameSingular
}

View File

@@ -1,17 +1,19 @@
import { useRef } from 'react';
import { useState } from 'react';
import styled from '@emotion/styled';
import { isNonEmptyString } from '@sniptt/guards';
import { useRecoilState } from 'recoil';
import { Key } from 'ts-key-enum';
import { useDebouncedCallback } from 'use-debounce';
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 {
Checkbox,
CheckboxShape,
@@ -57,7 +59,13 @@ type ActivityTitleProps = {
};
export const ActivityTitle = ({ activity }: ActivityTitleProps) => {
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}

View File

@@ -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<Activity>({
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,
};
};

View File

@@ -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,
};
};

View File

@@ -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<ActivityTarget>({
objectNameSingular: CoreObjectNameSingular.ActivityTarget,
skip: !isNonEmptyString(activityId),
filter: {
@@ -27,7 +28,7 @@ export const useActivityTargetObjectRecords = ({
});
const activityTargetObjectRecords = activityTargets
.map<Nullable<ActivityTargetObjectRecord>>((activityTarget) => {
.map<Nullable<ActivityTargetWithTargetRecord>>((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,

View File

@@ -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,

View File

@@ -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,
};
};

View File

@@ -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,

View File

@@ -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,
};
};

View File

@@ -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);
};
};

View File

@@ -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<ActivityTarget>({
objectNameSingular: CoreObjectNameSingular.ActivityTarget,
});
const { createOneRecord: createOneActivity } = useCreateOneRecord<Activity>({
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;
};

View File

@@ -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,
) => {

View File

@@ -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;
};

View File

@@ -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,
};
};

View File

@@ -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<Activity>({
@@ -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<Activity>;
}) => {
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 {

View File

@@ -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<ActivityTarget>({
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<ActivityTarget>({
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 (
<StyledSelectContainer>
<MultipleObjectRecordSelect
selectedObjectRecordIds={selectedObjectRecordIds}
selectedObjectRecordIds={selectedTargetObjectIds}
onCancel={handleCancel}
onSubmit={handleSubmit}
/>

View File

@@ -42,7 +42,7 @@ export const ActivityTargetsInlineCell = ({
editModeContent={
<ActivityTargetInlineCellEditMode
activity={activity}
activityTargetObjectRecords={activityTargetObjectRecords}
activityTargetWithTargetRecords={activityTargetObjectRecords}
/>
}
label="Relations"

View File

@@ -94,7 +94,7 @@ export const NoteCard = ({
<FieldContext.Provider value={fieldContext as GenericFieldContextType}>
<StyledCard isSingleNote={isSingleNote}>
<StyledCardDetailsContainer
onClick={() => openActivityRightDrawer(note.id)}
onClick={() => openActivityRightDrawer(note)}
>
<StyledNoteTitle>{note.title ?? 'Task Title'}</StyledNoteTitle>
<StyledCardContent>{body}</StyledCardContent>

View File

@@ -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};
`;

View File

@@ -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 (
<AnimatedPlaceholderEmptyContainer>

View File

@@ -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,
};
};

View File

@@ -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 (
<StyledButtonContainer>
<IconButton
@@ -84,12 +118,14 @@ export const ActivityActionBar = () => {
onClick={addActivity}
size="medium"
variant="secondary"
disabled={actionsAreDisabled || isCreateActionDisabled}
/>
<IconButton
Icon={IconTrash}
onClick={deleteActivity}
size="medium"
variant="secondary"
disabled={actionsAreDisabled}
/>
</StyledButtonContainer>
);

View File

@@ -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 <></>;
}

View File

@@ -0,0 +1,8 @@
import { atom } from 'recoil';
import { Activity } from '@/activities/types/Activity';
export const activityInDrawerState = atom<Activity | null>({
key: 'activityInDrawerState',
default: null,
});

View File

@@ -0,0 +1,6 @@
import { atom } from 'recoil';
export const canCreateActivityState = atom<boolean>({
key: 'canCreateActivityState',
default: false,
});

View File

@@ -0,0 +1,6 @@
import { atom } from 'recoil';
export const isActivityInCreateModeState = atom<boolean>({
key: 'isActivityInCreateModeState',
default: false,
});

View File

@@ -0,0 +1,6 @@
import { atom } from 'recoil';
export const isUpsertingActivityInDBState = atom<boolean>({
key: 'isUpsertingActivityInDBState',
default: false,
});

View File

@@ -1,6 +0,0 @@
import { atom } from 'recoil';
export const isCreatingActivityState = atom<boolean>({
key: 'isCreatingActivityState',
default: false,
});

View File

@@ -0,0 +1,8 @@
import { atom } from 'recoil';
import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
export const targetableObjectsInDrawerState = atom<ActivityTargetableObject[]>({
key: 'targetableObjectsInDrawerState',
default: [],
});

View File

@@ -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: [],
});
};

View File

@@ -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 ?? [],
})
}
/>

View File

@@ -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<Activity, 'assigneeId'>[];
tasks: Activity[];
button?: ReactElement | false;
};
@@ -61,7 +60,7 @@ export const TaskList = ({ title, tasks, button }: TaskListProps) => (
</StyledTitleBar>
<StyledTaskRows>
{tasks.map((task) => (
<TaskRow key={task.id} task={task as unknown as GraphQLActivity} />
<TaskRow key={task.id} task={task} />
))}
</StyledTaskRows>
</StyledContainer>

View File

@@ -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<GraphQLActivity, 'assigneeId'>;
}) => {
export const TaskRow = ({ task }: { task: Activity }) => {
const theme = useTheme();
const openActivityRightDrawer = useOpenActivityRightDrawer();
@@ -89,7 +85,7 @@ export const TaskRow = ({
return (
<StyledContainer
onClick={() => {
openActivityRightDrawer(task.id);
openActivityRightDrawer(task);
}}
>
<div

View File

@@ -1,13 +1,11 @@
import { isNonEmptyArray, isNonEmptyString } from '@sniptt/guards';
import { isNonEmptyArray } from '@sniptt/guards';
import { DateTime } from 'luxon';
import { useActivities } from '@/activities/hooks/useActivities';
import { FIND_MANY_TIMELINE_ACTIVITIES_ORDER_BY } from '@/activities/timeline/constants/FIND_MANY_TIMELINE_ACTIVITIES_ORDER_BY';
import { Activity } from '@/activities/types/Activity';
import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
import { getActivityTargetObjectFieldIdName } from '@/activities/utils/getTargetObjectFilterFieldName';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { useFilterDropdown } from '@/object-record/object-filter-dropdown/hooks/useFilterDropdown';
import { LeafObjectRecordFilter } from '@/object-record/record-filter/types/ObjectRecordQueryFilter';
import { parseDate } from '~/utils/date-utils';
type UseTasksProps = {
@@ -23,43 +21,6 @@ export const useTasks = ({
filterDropdownId,
});
const isTargettingObjectRecords = isNonEmptyArray(targetableObjects);
const targetableObjectsFilter =
targetableObjects.reduce<LeafObjectRecordFilter>(
(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,
};
};

View File

@@ -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;

View File

@@ -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<WorkspaceMember, 'name' | 'avatarUrl'> } & {
assignee?: Pick<WorkspaceMember, 'id' | 'name' | 'avatarUrl'> | 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 (
<>
<StyledTimelineItemContainer>
@@ -191,11 +182,13 @@ export const TimelineActivity = ({
</StyledIconContainer>
{(activity.type === 'Note' || activity.type === 'Task') && (
<StyledActivityTitle
onClick={() => openActivityRightDrawer(activity.id)}
onClick={() => openActivityRightDrawer(activity)}
>
<StyledActivityLink title={activity.title ?? '(No Title)'}>
{activity.title ?? '(No Title)'}
<StyledActivityLink
title={activityFromStore?.title ?? '(No Title)'}
>
{activityFromStore?.title ?? '(No Title)'}
</StyledActivityLink>
</StyledActivityTitle>

View File

@@ -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 (
<ButtonGroup variant={'secondary'}>
@@ -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,
})
}
/>

View File

@@ -0,0 +1,5 @@
import { OrderByField } from '@/object-metadata/types/OrderByField';
export const FIND_MANY_TIMELINE_ACTIVITIES_ORDER_BY: OrderByField = {
createdAt: 'DescNullsFirst',
};

View File

@@ -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,
};
};

View File

@@ -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,
};
};

View File

@@ -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<Activity>({
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,

View File

@@ -0,0 +1,9 @@
import { atom } from 'recoil';
import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
export const timelineTargetableObjectState =
atom<ActivityTargetableObject | null>({
key: 'timelineTargetableObjectState',
default: null,
});

View File

@@ -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: {

View File

@@ -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;
};

View File

@@ -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;
};

View File

@@ -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<ActivityTarget>;
comments: ObjectRecordConnection<Comment>;
},
) => {
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<ActivityTarget>;
const comments = {
__typename: 'CommentConnection',
edges: commentEdges,
pageInfo: getEmptyPageInfo(),
} as ObjectRecordConnection<Comment>;
@@ -90,6 +97,9 @@ export const useActivityConnectionUtils = () => {
...activity,
activityTargets,
comments,
} as Activity & {
activityTargets: ObjectRecordConnection<ActivityTarget>;
comments: ObjectRecordConnection<Comment>;
};
return { activityWithConnection };

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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<CachedObjectRecordQueryVariables>(
storeFieldName,
);
const rootQueryCurrentCachedRecordEdges =
readField<CachedObjectRecordEdge[]>(
'edges',
rootQueryCachedObjectRecordConnection,
) ?? [];
const rootQueryCurrentEdges =
readField<CachedObjectRecordEdge[]>('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,
};
},
},

View File

@@ -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,

View File

@@ -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,
];

View File

@@ -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)}
/>
</SelectableItem>
))}

View File

@@ -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,
};

View File

@@ -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,
});

View File

@@ -20,5 +20,6 @@ export const getRecordConnectionFromRecords = <T extends ObjectRecord>({
});
}),
pageInfo: getEmptyPageInfo(),
totalCount: records.length,
} as ObjectRecordConnection<T>;
};

View File

@@ -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;

View File

@@ -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 = <T extends ObjectRecord = ObjectRecord>({
objectNameSingular,
objectRecordId = '',

View File

@@ -79,7 +79,8 @@ export type LeafFilter =
| CurrencyFilter
| URLFilter
| FullNameFilter
| BooleanFilter;
| BooleanFilter
| undefined;
export type AndObjectRecordFilter = {
and?: ObjectRecordQueryFilter[];

View File

@@ -0,0 +1,5 @@
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
export const sortByObjectRecordId = (a: ObjectRecord, b: ObjectRecord) => {
return a.id.localeCompare(b.id);
};

View File

@@ -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,
]);
});
});

View File

@@ -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 =
<T extends ObjectRecord>(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}`);
};

View File

@@ -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,
};

View File

@@ -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();
};

View File

@@ -52,7 +52,7 @@ export const Tasks = () => {
<RecoilScope CustomRecoilScopeContext={TasksRecoilScopeContext}>
<TasksEffect filterDropdownId={filterDropdownId} />
<PageHeader title="Tasks" Icon={IconCheckbox}>
<PageAddTaskButton filterDropdownId={filterDropdownId} />
<PageAddTaskButton />
</PageHeader>
<PageBody>
<StyledTasksContainer>

View File

@@ -0,0 +1,3 @@
export const sortByAscString = (a: string, b: string) => {
return a.localeCompare(b);
};