Activity cache injection (#3791)

* WIP

* Minor fixes

* Added TODO

* Fix post merge

* Fix

* Fixed warnings

* Fixed comments

* Fixed comments

* Fixed naming

* Removed comment

* WIP

* WIP 2

* Finished working version

* Fixes

* Fixed typing

* Fixes

* Fixes

* Fixes

* Naming fixes

* WIP

* Fix import

* WIP

* Working version on title

* Fixed create record id overwrite

* Removed unecessary callback

* Masterpiece

* Fixed delete on click outside drawer or delete

* Cleaned

* Cleaned

* Cleaned

* Minor fixes

* Fixes

* Fixed naming

* WIP

* Fix

* Fixed create from target inline cell

* Removed console.log

* Fixed delete activity optimistic effect

* Fixed no title

* Fixed debounce and title body creation

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Lucas Bordeau 2024-02-09 14:51:30 +01:00 committed by GitHub
parent 9ceff84bbf
commit cca72da708
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
87 changed files with 2195 additions and 1058 deletions

View File

@ -68,6 +68,7 @@
"danger-plugin-todos": "^1.3.1",
"dataloader": "^2.2.2",
"date-fns": "^2.30.0",
"debounce": "^2.0.0",
"deep-equal": "^2.2.2",
"docusaurus-node-polyfills": "^1.0.0",
"dotenv-cli": "^7.2.1",
@ -149,6 +150,7 @@
"tsup": "^8.0.1",
"type-fest": "^4.1.0",
"typeorm": "^0.3.17",
"use-debounce": "^10.0.0",
"uuid": "^9.0.0",
"vite-tsconfig-paths": "^4.2.1",
"xlsx-ugnis": "^0.19.3",

View File

@ -1,13 +1,17 @@
import { useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { BlockNoteEditor } from '@blocknote/core';
import { useBlockNote } from '@blocknote/react';
import styled from '@emotion/styled';
import { isNonEmptyString } from '@sniptt/guards';
import debounce from 'lodash.debounce';
import { useRecoilState } from 'recoil';
import { useDebouncedCallback } from 'use-debounce';
import { useUpsertActivity } from '@/activities/hooks/useUpsertActivity';
import { activityTitleHasBeenSetFamilyState } from '@/activities/states/activityTitleHasBeenSetFamilyState';
import { Activity } from '@/activities/types/Activity';
import { useObjectMetadataItemOnly } from '@/object-metadata/hooks/useObjectMetadataItemOnly';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { useModifyRecordFromCache } from '@/object-record/cache/hooks/useModifyRecordFromCache';
import { BlockEditor } from '@/ui/input/editor/components/BlockEditor';
import { REACT_APP_SERVER_BASE_URL } from '~/config';
import { FileFolder, useUploadFileMutation } from '~/generated/graphql';
@ -23,38 +27,99 @@ const StyledBlockNoteStyledContainer = styled.div`
`;
type ActivityBodyEditorProps = {
activity: Pick<Activity, 'id' | 'body'>;
onChange?: (activityBody: string) => void;
activity: Activity;
fillTitleFromBody: boolean;
};
export const ActivityBodyEditor = ({
activity,
onChange,
fillTitleFromBody,
}: ActivityBodyEditorProps) => {
const [body, setBody] = useState<string | null>(null);
const { updateOneRecord } = useUpdateOneRecord({
objectNameSingular: CoreObjectNameSingular.Activity,
const [stringifiedBodyFromEditor, setStringifiedBodyFromEditor] = useState<
string | null
>(activity.body);
const [activityTitleHasBeenSet, setActivityTitleHasBeenSet] = useRecoilState(
activityTitleHasBeenSetFamilyState({
activityId: activity.id,
}),
);
const { objectMetadataItem: objectMetadataItemActivity } =
useObjectMetadataItemOnly({
objectNameSingular: CoreObjectNameSingular.Activity,
});
const modifyActivityFromCache = useModifyRecordFromCache({
objectMetadataItem: objectMetadataItemActivity,
});
useEffect(() => {
if (body) {
onChange?.(body);
}
}, [body, onChange]);
const { upsertActivity } = useUpsertActivity();
const debounceOnChange = useMemo(() => {
const onInternalChange = (activityBody: string) => {
setBody(activityBody);
updateOneRecord?.({
idToUpdate: activity.id,
updateOneRecordInput: {
body: activityBody,
const persistBodyDebounced = useDebouncedCallback((newBody: string) => {
upsertActivity({
activity,
input: {
body: newBody,
},
});
}, 500);
const persistTitleAndBodyDebounced = useDebouncedCallback(
(newTitle: string, newBody: string) => {
upsertActivity({
activity,
input: {
title: newTitle,
body: newBody,
},
});
};
return debounce(onInternalChange, 200);
}, [updateOneRecord, activity.id]);
setActivityTitleHasBeenSet(true);
},
500,
);
const updateTitleAndBody = useCallback(
(newStringifiedBody: string) => {
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],
);
const handleBodyChange = useCallback(
(activityBody: string) => {
if (!activityTitleHasBeenSet && fillTitleFromBody) {
updateTitleAndBody(activityBody);
} else {
persistBodyDebounced(activityBody);
}
},
[
fillTitleFromBody,
persistBodyDebounced,
activityTitleHasBeenSet,
updateTitleAndBody,
],
);
useEffect(() => {
if (
isNonEmptyString(stringifiedBodyFromEditor) &&
activity.body !== stringifiedBodyFromEditor
) {
handleBodyChange(stringifiedBodyFromEditor);
}
}, [stringifiedBodyFromEditor, handleBodyChange, activity]);
const slashMenuItems = getSlashMenu();
@ -85,7 +150,7 @@ export const ActivityBodyEditor = ({
: undefined,
domAttributes: { editor: { class: 'editor' } },
onEditorContentChange: (editor: BlockNoteEditor) => {
debounceOnChange(JSON.stringify(editor.topLevelBlocks) ?? '');
setStringifiedBodyFromEditor(JSON.stringify(editor.topLevelBlocks) ?? '');
},
slashMenuItems,
blockSpecs: blockSpecs,

View File

@ -67,6 +67,7 @@ export const ActivityComments = ({
const { records: comments } = useFindManyRecords({
objectNameSingular: CoreObjectNameSingular.Comment,
skip: !isNonEmptyString(activity?.id),
filter: {
activityId: {
eq: activity?.id ?? '',

View File

@ -1,22 +1,26 @@
import React, { useCallback, useRef, useState } from 'react';
import { useRef } from 'react';
import styled from '@emotion/styled';
import { useRecoilState } from 'recoil';
import { ActivityBodyEditor } from '@/activities/components/ActivityBodyEditor';
import { ActivityComments } from '@/activities/components/ActivityComments';
import { ActivityTypeDropdown } from '@/activities/components/ActivityTypeDropdown';
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 { Activity } from '@/activities/types/Activity';
import { ActivityTarget } from '@/activities/types/ActivityTarget';
import { Comment } from '@/activities/types/Comment';
import { GraphQLActivity } from '@/activities/types/GraphQLActivity';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useFieldContext } from '@/object-record/hooks/useFieldContext';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import {
RecordUpdateHook,
RecordUpdateHookParams,
} 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 { 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';
import { WorkspaceMember } from '@/workspace-member/types/WorkspaceMember';
import { debounce } from '~/utils/debounce';
import { ActivityTitle } from './ActivityTitle';
@ -54,36 +58,32 @@ const StyledTopContainer = styled.div`
`;
type ActivityEditorProps = {
activity: Pick<
Activity,
'id' | 'title' | 'body' | 'type' | 'completedAt' | 'dueAt'
> & {
comments?: Array<Comment> | null;
} & {
assignee?: Pick<WorkspaceMember, 'id' | 'name' | 'avatarUrl'> | null;
} & {
activityTargets?: Array<
Pick<ActivityTarget, 'id' | 'companyId' | 'personId'>
> | null;
};
activity: Activity;
showComment?: boolean;
autoFillTitle?: boolean;
fillTitleFromBody?: boolean;
};
export const ActivityEditor = ({
activity,
showComment = true,
autoFillTitle = false,
fillTitleFromBody = false,
}: ActivityEditorProps) => {
const [hasUserManuallySetTitle, setHasUserManuallySetTitle] =
useState<boolean>(false);
const [title, setTitle] = useState<string | null>(activity.title ?? '');
const containerRef = useRef<HTMLDivElement>(null);
const { updateOneRecord: updateOneActivity } = useUpdateOneRecord<Activity>({
objectNameSingular: CoreObjectNameSingular.Activity,
});
const { useRegisterClickOutsideListenerCallback } = useClickOutsideListener(
RIGHT_DRAWER_CLICK_OUTSIDE_LISTENER_ID,
);
const { upsertActivity } = useUpsertActivity();
const { deleteActivityFromCache } = useDeleteActivityFromCache();
const useUpsertOneActivityMutation: RecordUpdateHook = () => {
const upsertActivityMutation = ({ variables }: RecordUpdateHookParams) => {
upsertActivity({ activity, input: variables.updateOneRecordInput });
};
return [upsertActivityMutation, { loading: false }];
};
const { FieldContextProvider: DueAtFieldContextProvider } = useFieldContext({
objectNameSingular: CoreObjectNameSingular.Activity,
@ -91,6 +91,7 @@ export const ActivityEditor = ({
fieldMetadataName: 'dueAt',
fieldPosition: 0,
clearable: true,
customUseUpdateOneObjectHook: useUpsertOneActivityMutation,
});
const { FieldContextProvider: AssigneeFieldContextProvider } =
@ -100,41 +101,24 @@ export const ActivityEditor = ({
fieldMetadataName: 'assignee',
fieldPosition: 1,
clearable: true,
customUseUpdateOneObjectHook: useUpsertOneActivityMutation,
});
const updateTitle = useCallback(
(newTitle: string) => {
updateOneActivity?.({
idToUpdate: activity.id,
updateOneRecordInput: {
title: newTitle ?? '',
},
});
},
[activity.id, updateOneActivity],
);
const handleActivityCompletionChange = useCallback(
(value: boolean) => {
updateOneActivity?.({
idToUpdate: activity.id,
updateOneRecordInput: {
completedAt: value ? new Date().toISOString() : null,
},
});
},
[activity.id, updateOneActivity],
const [isCreatingActivity, setIsCreatingActivity] = useRecoilState(
isCreatingActivityState,
);
const debouncedUpdateTitle = debounce(updateTitle, 200);
// TODO: remove
const updateTitleFromBody = (body: string) => {
const blockBody = JSON.parse(body);
const parsedTitle = blockBody[0]?.content?.[0]?.text;
if (!hasUserManuallySetTitle && autoFillTitle) {
setTitle(parsedTitle);
debouncedUpdateTitle(parsedTitle);
}
};
useRegisterClickOutsideListenerCallback({
callbackId: 'activity-editor',
callbackFunction: () => {
if (isCreatingActivity) {
setIsCreatingActivity(false);
deleteActivityFromCache(activity);
}
},
});
if (!activity) {
return <></>;
@ -145,17 +129,7 @@ export const ActivityEditor = ({
<StyledUpperPartContainer>
<StyledTopContainer>
<ActivityTypeDropdown activity={activity} />
<ActivityTitle
title={title ?? ''}
completed={!!activity.completedAt}
type={activity.type}
onTitleChange={(newTitle) => {
setTitle(newTitle);
setHasUserManuallySetTitle(true);
debouncedUpdateTitle(newTitle);
}}
onCompletionChange={handleActivityCompletionChange}
/>
<ActivityTitle activity={activity} />
<PropertyBox>
{activity.type === 'Task' &&
DueAtFieldContextProvider &&
@ -169,14 +143,12 @@ export const ActivityEditor = ({
</AssigneeFieldContextProvider>
</>
)}
<ActivityTargetsInlineCell
activity={activity as unknown as GraphQLActivity}
/>
<ActivityTargetsInlineCell activity={activity} />
</PropertyBox>
</StyledTopContainer>
<ActivityBodyEditor
activity={activity}
onChange={updateTitleFromBody}
fillTitleFromBody={fillTitleFromBody}
/>
</StyledUpperPartContainer>
{showComment && (

View File

@ -1,11 +1,20 @@
import { useState } from 'react';
import styled from '@emotion/styled';
import { useRecoilState } from 'recoil';
import { useDebouncedCallback } from 'use-debounce';
import { ActivityType } from '@/activities/types/Activity';
import { useUpsertActivity } from '@/activities/hooks/useUpsertActivity';
import { activityTitleHasBeenSetFamilyState } from '@/activities/states/activityTitleHasBeenSetFamilyState';
import { Activity } from '@/activities/types/Activity';
import { useObjectMetadataItemOnly } from '@/object-metadata/hooks/useObjectMetadataItemOnly';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useModifyRecordFromCache } from '@/object-record/cache/hooks/useModifyRecordFromCache';
import {
Checkbox,
CheckboxShape,
CheckboxSize,
} from '@/ui/input/components/Checkbox';
import { isDefined } from '~/utils/isDefined';
const StyledEditableTitleInput = styled.input<{
completed: boolean;
@ -39,36 +48,83 @@ const StyledContainer = styled.div`
`;
type ActivityTitleProps = {
title: string;
type: ActivityType;
completed: boolean;
onTitleChange: (title: string) => void;
onCompletionChange: (value: boolean) => void;
activity: Activity;
};
export const ActivityTitle = ({
title,
completed,
type,
onTitleChange,
onCompletionChange,
}: ActivityTitleProps) => (
<StyledContainer>
{type === 'Task' && (
<Checkbox
size={CheckboxSize.Large}
shape={CheckboxShape.Rounded}
checked={completed}
onCheckedChange={(value) => onCompletionChange(value)}
export const ActivityTitle = ({ activity }: ActivityTitleProps) => {
const [internalTitle, setInternalTitle] = useState(activity.title);
const { upsertActivity } = useUpsertActivity();
const [activityTitleHasBeenSet, setActivityTitleHasBeenSet] = useRecoilState(
activityTitleHasBeenSetFamilyState({
activityId: activity.id,
}),
);
const { objectMetadataItem: objectMetadataItemActivity } =
useObjectMetadataItemOnly({
objectNameSingular: CoreObjectNameSingular.Activity,
});
const modifyActivityFromCache = useModifyRecordFromCache({
objectMetadataItem: objectMetadataItemActivity,
});
const persistTitleDebounced = useDebouncedCallback((newTitle: string) => {
upsertActivity({
activity,
input: {
title: newTitle,
},
});
if (!activityTitleHasBeenSet) {
setActivityTitleHasBeenSet(true);
}
}, 500);
const handleTitleChange = (newTitle: string) => {
setInternalTitle(newTitle);
modifyActivityFromCache(activity.id, {
title: () => {
return newTitle;
},
});
persistTitleDebounced(newTitle);
};
const handleActivityCompletionChange = (value: boolean) => {
upsertActivity({
activity,
input: {
completedAt: value ? new Date().toISOString() : null,
},
});
};
const completed = isDefined(activity.completedAt);
return (
<StyledContainer>
{activity.type === 'Task' && (
<Checkbox
size={CheckboxSize.Large}
shape={CheckboxShape.Rounded}
checked={completed}
onCheckedChange={(value) => handleActivityCompletionChange(value)}
/>
)}
<StyledEditableTitleInput
autoComplete="off"
autoFocus
placeholder={`${activity.type} title`}
onChange={(event) => handleTitleChange(event.target.value)}
value={internalTitle}
completed={completed}
/>
)}
<StyledEditableTitleInput
autoComplete="off"
autoFocus
placeholder={`${type} title`}
onChange={(event) => onTitleChange(event.target.value)}
value={title}
completed={completed}
/>
</StyledContainer>
);
</StyledContainer>
);
};

View File

@ -0,0 +1,34 @@
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({
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);
return {
activity,
};
};

View File

@ -1,3 +1,4 @@
import { isNonEmptyString } from '@sniptt/guards';
import { useRecoilValue } from 'recoil';
import { ActivityTargetObjectRecord } from '@/activities/types/ActivityTargetObject';
@ -17,6 +18,7 @@ export const useActivityTargetObjectRecords = ({
const { records: activityTargets, loading: loadingActivityTargets } =
useFindManyRecords({
objectNameSingular: CoreObjectNameSingular.ActivityTarget,
skip: !isNonEmptyString(activityId),
filter: {
activityId: {
eq: activityId,

View File

@ -1,4 +1,5 @@
import { useState } from 'react';
import { isNonEmptyString } from '@sniptt/guards';
import { ActivityTarget } from '@/activities/types/ActivityTarget';
import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
@ -6,7 +7,7 @@ import { getActivityTargetObjectFieldIdName } from '@/activities/utils/getTarget
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
export const useActivityTargets = ({
export const useActivityTargetsForTargetableObject = ({
targetableObject,
}: {
targetableObject: ActivityTargetableObject;
@ -17,9 +18,14 @@ export const useActivityTargets = ({
const [initialized, setInitialized] = useState(false);
const targetableObjectId = targetableObject.id;
const skipRequest = !isNonEmptyString(targetableObjectId);
const { records: activityTargets, loading: loadingActivityTargets } =
useFindManyRecords({
objectNameSingular: CoreObjectNameSingular.ActivityTarget,
skip: skipRequest,
filter: {
[targetObjectFieldName]: {
eq: targetableObject.id,

View File

@ -0,0 +1,91 @@
import { useApolloClient } from '@apollo/client';
import { StringKeyOf } from 'type-fest';
import { getRelationDefinition } from '@/apollo/optimistic-effect/utils/getRelationDefinition';
import { triggerAttachRelationOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerAttachRelationOptimisticEffect';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { getObjectMetadataItemByNameSingular } from '@/object-metadata/utils/getObjectMetadataItemBySingularName';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { isDefined } from '~/utils/isDefined';
export const useAttachRelationInBothDirections = () => {
const { objectMetadataItems } = useObjectMetadataItems();
const apolloClient = useApolloClient();
const attachRelationInBothDirections = <
Source extends ObjectRecord = ObjectRecord,
Target extends ObjectRecord = ObjectRecord,
>({
sourceRecord,
targetRecords,
sourceObjectNameSingular,
targetObjectNameSingular,
fieldNameOnSourceRecord,
fieldNameOnTargetRecord,
}: {
sourceRecord: Source;
targetRecords: Target[];
sourceObjectNameSingular: string;
targetObjectNameSingular: string;
fieldNameOnSourceRecord: StringKeyOf<Source>;
fieldNameOnTargetRecord: StringKeyOf<Target>;
}) => {
const sourceObjectMetadataItem = getObjectMetadataItemByNameSingular({
objectMetadataItems,
objectNameSingular: sourceObjectNameSingular,
});
const targetObjectMetadataItem = getObjectMetadataItemByNameSingular({
objectMetadataItems,
objectNameSingular: targetObjectNameSingular,
});
const fieldMetadataItemOnSourceRecord =
sourceObjectMetadataItem.fields.find(
(field) => field.name === fieldNameOnSourceRecord,
);
if (!isDefined(fieldMetadataItemOnSourceRecord)) {
throw new Error(
`Field ${fieldNameOnSourceRecord} not found on object ${sourceObjectNameSingular}`,
);
}
const relationDefinition = getRelationDefinition({
fieldMetadataItemOnSourceRecord: fieldMetadataItemOnSourceRecord,
objectMetadataItems,
});
if (!isDefined(relationDefinition)) {
throw new Error(
`Relation metadata not found for field ${fieldNameOnSourceRecord} on object ${sourceObjectNameSingular}`,
);
}
// TODO: could we use triggerUpdateRelationsOptimisticEffect here?
targetRecords.forEach((relationTargetRecord) => {
triggerAttachRelationOptimisticEffect({
cache: apolloClient.cache,
sourceObjectNameSingular: sourceObjectMetadataItem.nameSingular,
sourceRecordId: sourceRecord.id,
fieldNameOnTargetRecord: fieldNameOnTargetRecord,
targetObjectNameSingular: targetObjectMetadataItem.nameSingular,
targetRecordId: relationTargetRecord.id,
});
triggerAttachRelationOptimisticEffect({
cache: apolloClient.cache,
sourceObjectNameSingular: targetObjectMetadataItem.nameSingular,
sourceRecordId: relationTargetRecord.id,
fieldNameOnTargetRecord: fieldNameOnSourceRecord,
targetObjectNameSingular: sourceObjectMetadataItem.nameSingular,
targetRecordId: sourceRecord.id,
});
});
};
return {
attachRelationInBothDirections,
};
};

View File

@ -0,0 +1,115 @@
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';
import { getActivityTargetsToCreateFromTargetableObjects } from '@/activities/utils/getActivityTargetsToCreateFromTargetableObjects';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useCreateManyRecordsInCache } from '@/object-record/hooks/useCreateManyRecordsInCache';
import { useCreateOneRecordInCache } from '@/object-record/hooks/useCreateOneRecordInCache';
import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
export const useCreateActivityInCache = () => {
const { createManyRecordsInCache: createManyActivityTargetsInCache } =
useCreateManyRecordsInCache<ActivityTarget>({
objectNameSingular: CoreObjectNameSingular.ActivityTarget,
});
const { createOneRecordInCache: createOneActivityInCache } =
useCreateOneRecordInCache<Activity>({
objectNameSingular: CoreObjectNameSingular.Activity,
});
const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState);
const { record: workspaceMemberRecord } = useFindOneRecord({
objectNameSingular: CoreObjectNameSingular.WorkspaceMember,
objectRecordId: currentWorkspaceMember?.id,
depth: 3,
});
const { injectIntoTimelineActivitiesQueryAfterDrawerMount } =
useInjectIntoTimelineActivitiesQueryAfterDrawerMount();
const { injectIntoActivityTargetInlineCellCache } =
useInjectIntoActivityTargetInlineCellCache();
const {
attachRelationInBothDirections:
attachRelationSourceRecordToItsRelationTargetRecordsAndViceVersaInCache,
} = useAttachRelationInBothDirections();
const createActivityInCache = ({
type,
targetableObjects,
timelineTargetableObject,
assigneeId,
}: {
type: ActivityType;
targetableObjects: ActivityTargetableObject[];
timelineTargetableObject: ActivityTargetableObject;
assigneeId?: string;
}) => {
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,
});
const activityTargetsToCreate =
getActivityTargetsToCreateFromTargetableObjects({
activityId,
targetableObjects,
});
const createdActivityTargetsInCache = createManyActivityTargetsInCache(
activityTargetsToCreate,
);
injectIntoTimelineActivitiesQueryAfterDrawerMount({
activityToInject: createdActivityInCache,
activityTargetsToInject: createdActivityTargetsInCache,
timelineTargetableObject,
});
injectIntoActivityTargetInlineCellCache({
activityId,
activityTargetsToInject: createdActivityTargetsInCache,
});
attachRelationSourceRecordToItsRelationTargetRecordsAndViceVersaInCache({
sourceRecord: createdActivityInCache,
fieldNameOnSourceRecord: 'activityTargets',
sourceObjectNameSingular: CoreObjectNameSingular.Activity,
fieldNameOnTargetRecord: 'activity',
targetObjectNameSingular: CoreObjectNameSingular.ActivityTarget,
targetRecords: createdActivityTargetsInCache,
});
return {
createdActivityInCache: {
...createdActivityInCache,
activityTargets: createdActivityTargetsInCache,
},
createdActivityTargetsInCache,
};
};
return {
createActivityInCache,
};
};

View File

@ -0,0 +1,59 @@
import { isNonEmptyArray } from '@sniptt/guards';
import { useModifyActivityTargetsOnActivityCache } from '@/activities/hooks/useModifyActivityTargetsOnActivityCache';
import { ActivityForEditor } from '@/activities/types/ActivityForEditor';
import { ActivityTarget } from '@/activities/types/ActivityTarget';
import { useActivityConnectionUtils } from '@/activities/utils/useActivityConnectionUtils';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useCreateManyRecords } from '@/object-record/hooks/useCreateManyRecords';
import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord';
export const useCreateActivityInDB = () => {
const { createOneRecord: createOneActivity } = useCreateOneRecord({
objectNameSingular: CoreObjectNameSingular.Activity,
});
const { createManyRecords: createManyActivityTargets } =
useCreateManyRecords<ActivityTarget>({
objectNameSingular: CoreObjectNameSingular.ActivityTarget,
});
const { makeActivityWithConnection } = useActivityConnectionUtils();
const { modifyActivityTargetsOnActivityCache } =
useModifyActivityTargetsOnActivityCache();
const createActivityInDB = async (activityToCreate: ActivityForEditor) => {
const { activityWithConnection } = makeActivityWithConnection(
activityToCreate as any, // TODO: fix type
);
await createOneActivity?.(
{
...activityWithConnection,
updatedAt: new Date().toISOString(),
},
{
skipOptimisticEffect: true,
},
);
const activityTargetsToCreate = activityToCreate.activityTargets ?? [];
if (isNonEmptyArray(activityTargetsToCreate)) {
await createManyActivityTargets(activityTargetsToCreate, {
skipOptimisticEffect: true,
});
}
// TODO: replace by trigger optimistic effect
modifyActivityTargetsOnActivityCache({
activityId: activityToCreate.id,
activityTargets: activityTargetsToCreate,
});
};
return {
createActivityInDB,
};
};

View File

@ -0,0 +1,39 @@
import { useApolloClient } from '@apollo/client';
import { ActivityForEditor } from '@/activities/types/ActivityForEditor';
import { useActivityConnectionUtils } from '@/activities/utils/useActivityConnectionUtils';
import { triggerDeleteRecordsOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerDeleteRecordsOptimisticEffect';
import { useObjectMetadataItemOnly } from '@/object-metadata/hooks/useObjectMetadataItemOnly';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
// TODO: this should be useDeleteRecordFromCache
export const useDeleteActivityFromCache = () => {
const { makeActivityWithConnection } = useActivityConnectionUtils();
const apolloClient = useApolloClient();
const { objectMetadataItem: objectMetadataItemActivity } =
useObjectMetadataItemOnly({
objectNameSingular: CoreObjectNameSingular.Activity,
});
const { objectMetadataItems } = useObjectMetadataItems();
const deleteActivityFromCache = (activityToDelete: ActivityForEditor) => {
const { activityWithConnection } = makeActivityWithConnection(
activityToDelete as any, // TODO: fix type
);
triggerDeleteRecordsOptimisticEffect({
cache: apolloClient.cache,
objectMetadataItem: objectMetadataItemActivity,
objectMetadataItems,
recordsToDelete: [activityWithConnection],
});
};
return {
deleteActivityFromCache,
};
};

View File

@ -1,163 +1,61 @@
import { useCallback } from 'react';
import { isNonEmptyString } from '@sniptt/guards';
import { useRecoilState, useRecoilValue } from 'recoil';
import { v4 } from 'uuid';
import { useRecoilState, useSetRecoilState } from 'recoil';
import { useActivityTargets } from '@/activities/hooks/useActivityTargets';
import { useModifyActivityOnActivityTargetsCache } from '@/activities/hooks/useModifyActivityOnActivityTargetCache';
import { useModifyActivityTargetsOnActivityCache } from '@/activities/hooks/useModifyActivityTargetsOnActivityCache';
import { useWriteActivityTargetsInCache } from '@/activities/hooks/useWriteActivityTargetsInCache';
import { useInjectIntoActivityTargetInlineCellCache } from '@/activities/inline-cell/hooks/useInjectIntoActivityTargetInlineCellCache';
import { useInjectIntoTimelineActivitiesQuery } from '@/activities/timeline/hooks/useInjectIntoTimelineActivitiesQuery';
import { Activity, ActivityType } from '@/activities/types/Activity';
import { ActivityTarget } from '@/activities/types/ActivityTarget';
import { getActivityTargetsToCreateFromTargetableObjects } from '@/activities/utils/getActivityTargetsToCreateFromTargetableObjects';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useCreateManyRecordsInCache } from '@/object-record/hooks/useCreateManyRecordsInCache';
import { useCreateOneRecordInCache } from '@/object-record/hooks/useCreateOneRecordInCache';
import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
import { mapToRecordId } from '@/object-record/utils/mapToObjectId';
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 { activityTargetableEntityArrayState } from '../states/activityTargetableEntityArrayState';
import { viewableActivityIdState } from '../states/viewableActivityIdState';
import { ActivityTargetableObject } from '../types/ActivityTargetableEntity';
export const useOpenCreateActivityDrawerV2 = ({
targetableObject,
}: {
targetableObject: ActivityTargetableObject;
}) => {
export const useOpenCreateActivityDrawerV2 = () => {
const { openRightDrawer } = useRightDrawer();
const { createManyRecordsInCache: createManyActivityTargetsInCache } =
useCreateManyRecordsInCache<ActivityTarget>({
objectNameSingular: CoreObjectNameSingular.ActivityTarget,
});
const { createOneRecordInCache: createOneActivityInCache } =
useCreateOneRecordInCache<Activity>({
objectNameSingular: CoreObjectNameSingular.Activity,
});
const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState);
const { record: workspaceMemberRecord } = useFindOneRecord({
objectNameSingular: CoreObjectNameSingular.WorkspaceMember,
objectRecordId: currentWorkspaceMember?.id,
});
const setHotkeyScope = useSetHotkeyScope();
const { createActivityInCache } = useCreateActivityInCache();
const [, setActivityTargetableEntityArray] = useRecoilState(
activityTargetableEntityArrayState,
);
const [, setViewableActivityId] = useRecoilState(viewableActivityIdState);
const { activityTargets } = useActivityTargets({
targetableObject,
});
const setIsCreatingActivity = useSetRecoilState(isCreatingActivityState);
const { injectIntoTimelineActivitiesNextQuery } =
useInjectIntoTimelineActivitiesQuery();
const setTemporaryActivityForEditor = useSetRecoilState(
temporaryActivityForEditorState,
);
const { injectIntoActivityTargetInlineCellCache } =
useInjectIntoActivityTargetInlineCellCache();
const { injectIntoUseActivityTargets } = useWriteActivityTargetsInCache();
const { modifyActivityTargetsOnActivityCache } =
useModifyActivityTargetsOnActivityCache();
const { modifyActivityOnActivityTargetsCache } =
useModifyActivityOnActivityTargetsCache();
return useCallback(
async ({
const openCreateActivityDrawer = async ({
type,
targetableObjects,
timelineTargetableObject,
assigneeId,
}: {
type: ActivityType;
targetableObjects: ActivityTargetableObject[];
timelineTargetableObject: ActivityTargetableObject;
assigneeId?: string;
}) => {
const { createdActivityInCache } = createActivityInCache({
type,
targetableObjects,
timelineTargetableObject,
assigneeId,
}: {
type: ActivityType;
targetableObjects: ActivityTargetableObject[];
assigneeId?: string;
}) => {
const activityId = v4();
});
const createdActivityInCache = await createOneActivityInCache({
id: activityId,
author: workspaceMemberRecord,
authorId: workspaceMemberRecord?.id,
assignee: !assigneeId ? workspaceMemberRecord : undefined,
assigneeId:
assigneeId ?? isNonEmptyString(workspaceMemberRecord?.id)
? workspaceMemberRecord?.id
: undefined,
type: type,
});
setTemporaryActivityForEditor(createdActivityInCache);
setIsCreatingActivity(true);
setHotkeyScope(RightDrawerHotkeyScope.RightDrawer, { goto: false });
setViewableActivityId(createdActivityInCache.id);
setActivityTargetableEntityArray(targetableObjects ?? []);
openRightDrawer(RightDrawerPages.CreateActivity);
};
if (!createdActivityInCache) {
return;
}
const activityTargetsToCreate =
getActivityTargetsToCreateFromTargetableObjects({
activityId,
targetableObjects,
});
const createdActivityTargetsInCache =
await createManyActivityTargetsInCache(activityTargetsToCreate);
injectIntoUseActivityTargets({
targetableObject,
activityTargetsToInject: createdActivityTargetsInCache,
});
injectIntoTimelineActivitiesNextQuery({
activityTargets,
activityToInject: createdActivityInCache,
});
injectIntoActivityTargetInlineCellCache({
activityId,
activityTargetsToInject: createdActivityTargetsInCache,
});
modifyActivityTargetsOnActivityCache({
activityId,
activityTargets: createdActivityTargetsInCache,
});
modifyActivityOnActivityTargetsCache({
activityTargetIds: createdActivityTargetsInCache.map(mapToRecordId),
activity: createdActivityInCache,
});
setHotkeyScope(RightDrawerHotkeyScope.RightDrawer, { goto: false });
setViewableActivityId(activityId);
setActivityTargetableEntityArray(targetableObjects ?? []);
openRightDrawer(RightDrawerPages.CreateActivity);
},
[
openRightDrawer,
setActivityTargetableEntityArray,
createManyActivityTargetsInCache,
setHotkeyScope,
setViewableActivityId,
createOneActivityInCache,
workspaceMemberRecord,
activityTargets,
targetableObject,
injectIntoTimelineActivitiesNextQuery,
injectIntoActivityTargetInlineCellCache,
injectIntoUseActivityTargets,
modifyActivityTargetsOnActivityCache,
modifyActivityOnActivityTargetsCache,
],
);
return openCreateActivityDrawer;
};

View File

@ -0,0 +1,45 @@
import { useRecoilState } from 'recoil';
import { useCreateActivityInDB } from '@/activities/hooks/useCreateActivityInDB';
import { isCreatingActivityState } from '@/activities/states/isCreatingActivityState';
import { Activity } from '@/activities/types/Activity';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
export const useUpsertActivity = () => {
const [isCreatingActivity, setIsCreatingActivity] = useRecoilState(
isCreatingActivityState,
);
const { updateOneRecord: updateOneActivity } = useUpdateOneRecord<Activity>({
objectNameSingular: CoreObjectNameSingular.Activity,
});
const { createActivityInDB } = useCreateActivityInDB();
const upsertActivity = ({
activity,
input,
}: {
activity: Activity;
input: Partial<Activity>;
}) => {
if (isCreatingActivity) {
createActivityInDB({
...activity,
...input,
});
setIsCreatingActivity(false);
} else {
updateOneActivity?.({
idToUpdate: activity.id,
updateOneRecordInput: input,
});
}
};
return {
upsertActivity,
};
};

View File

@ -1,84 +0,0 @@
import { useApolloClient } from '@apollo/client';
import { ActivityTarget } from '@/activities/types/ActivityTarget';
import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
import { getActivityTargetObjectFieldIdName } from '@/activities/utils/getTargetObjectFilterFieldName';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { getRecordConnectionFromRecords } from '@/object-record/cache/utils/getRecordConnectionFromRecords';
import { getRecordsFromRecordConnection } from '@/object-record/cache/utils/getRecordsFromRecordConnection';
import { ObjectRecordConnection } from '@/object-record/types/ObjectRecordConnection';
export const useWriteActivityTargetsInCache = () => {
const apolloClient = useApolloClient();
const {
objectMetadataItem: objectMetadataItemActivityTarget,
findManyRecordsQuery: findManyActivityTargetsQuery,
} = useObjectMetadataItem({
objectNameSingular: CoreObjectNameSingular.ActivityTarget,
});
const injectIntoUseActivityTargets = ({
targetableObject,
activityTargetsToInject,
}: {
targetableObject: Pick<
ActivityTargetableObject,
'id' | 'targetObjectNameSingular'
>;
activityTargetsToInject: ActivityTarget[];
}) => {
const targetObjectFieldName = getActivityTargetObjectFieldIdName({
nameSingular: targetableObject.targetObjectNameSingular,
});
const existingActivityTargetsForTargetableObjectQueryResult =
apolloClient.readQuery({
query: findManyActivityTargetsQuery,
variables: {
filter: {
[targetObjectFieldName]: {
eq: targetableObject.id,
},
},
},
});
const existingActivityTargetsForTargetableObject =
getRecordsFromRecordConnection({
recordConnection: existingActivityTargetsForTargetableObjectQueryResult[
objectMetadataItemActivityTarget.namePlural
] as ObjectRecordConnection<ActivityTarget>,
});
const newActivityTargetsForTargetableObject = [
...existingActivityTargetsForTargetableObject,
...activityTargetsToInject,
];
const newActivityTargetsConnection = getRecordConnectionFromRecords({
objectNameSingular: CoreObjectNameSingular.ActivityTarget,
records: newActivityTargetsForTargetableObject,
});
apolloClient.writeQuery({
query: findManyActivityTargetsQuery,
variables: {
filter: {
[targetObjectFieldName]: {
eq: targetableObject.id,
},
},
},
data: {
[objectMetadataItemActivityTarget.namePlural]:
newActivityTargetsConnection,
},
});
};
return {
injectIntoUseActivityTargets,
};
};

View File

@ -1,10 +1,18 @@
import styled from '@emotion/styled';
import { isNonEmptyArray } from '@sniptt/guards';
import { useRecoilState } from 'recoil';
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 { Activity } from '@/activities/types/Activity';
import { ActivityTarget } from '@/activities/types/ActivityTarget';
import { ActivityTargetObjectRecord } from '@/activities/types/ActivityTargetObject';
import { getActivityTargetObjectFieldIdName } from '@/activities/utils/getTargetObjectFilterFieldName';
import { useObjectMetadataItemOnly } from '@/object-metadata/hooks/useObjectMetadataItemOnly';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useGenerateObjectRecordOptimisticResponse } from '@/object-record/cache/hooks/useGenerateObjectRecordOptimisticResponse';
import { useCreateManyRecords } from '@/object-record/hooks/useCreateManyRecords';
import { useDeleteManyRecords } from '@/object-record/hooks/useDeleteManyRecords';
import { useInlineCell } from '@/object-record/record-inline-cell/hooks/useInlineCell';
@ -18,14 +26,16 @@ const StyledSelectContainer = styled.div`
`;
type ActivityTargetInlineCellEditModeProps = {
activityId: string;
activity: Activity;
activityTargetObjectRecords: ActivityTargetObjectRecord[];
};
export const ActivityTargetInlineCellEditMode = ({
activityId,
activity,
activityTargetObjectRecords,
}: ActivityTargetInlineCellEditModeProps) => {
const [isCreatingActivity] = useRecoilState(isCreatingActivityState);
const selectedObjectRecordIds = activityTargetObjectRecords.map(
(activityTarget) => ({
objectNameSingular: activityTarget.targetObjectNameSingular,
@ -46,6 +56,21 @@ export const ActivityTargetInlineCellEditMode = ({
const { closeInlineCell: closeEditableField } = useInlineCell();
const { upsertActivity } = useUpsertActivity();
const { objectMetadataItem: objectMetadataItemActivityTarget } =
useObjectMetadataItemOnly({
objectNameSingular: CoreObjectNameSingular.ActivityTarget,
});
const { injectIntoActivityTargetInlineCellCache } =
useInjectIntoActivityTargetInlineCellCache();
const { generateObjectRecordOptimisticResponse } =
useGenerateObjectRecordOptimisticResponse({
objectMetadataItem: objectMetadataItemActivityTarget,
});
const handleSubmit = async (selectedRecords: ObjectRecordForSelect[]) => {
closeEditableField();
@ -67,25 +92,74 @@ export const ActivityTargetInlineCellEditMode = ({
),
);
if (activityTargetRecordsToCreate.length > 0) {
await createManyActivityTargets(
activityTargetRecordsToCreate.map((selectedRecord) => ({
id: v4(),
activityId,
[getActivityTargetObjectFieldIdName({
nameSingular: selectedRecord.objectMetadataItem.nameSingular,
})]: selectedRecord.recordIdentifier.id,
})),
);
}
if (isCreatingActivity) {
let activityTargetsForCreation = activity.activityTargets;
if (activityTargetRecordsToDelete.length > 0) {
await deleteManyActivityTargets(
activityTargetRecordsToDelete.map(
(activityTargetObjectRecord) =>
activityTargetObjectRecord.activityTargetRecord.id,
),
);
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,
});
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) => ({
id: v4(),
activityId: activity.id,
[getActivityTargetObjectFieldIdName({
nameSingular: selectedRecord.objectMetadataItem.nameSingular,
})]: selectedRecord.recordIdentifier.id,
})),
);
}
if (activityTargetRecordsToDelete.length > 0) {
await deleteManyActivityTargets(
activityTargetRecordsToDelete.map(
(activityTargetObjectRecord) =>
activityTargetObjectRecord.activityTargetRecord.id,
),
);
}
}
};

View File

@ -1,8 +1,7 @@
import { ActivityTargetChips } from '@/activities/components/ActivityTargetChips';
import { useActivityTargetObjectRecords } from '@/activities/hooks/useActivityTargetObjectRecords';
import { ActivityTargetInlineCellEditMode } from '@/activities/inline-cell/components/ActivityTargetInlineCellEditMode';
import { ActivityTarget } from '@/activities/types/ActivityTarget';
import { GraphQLActivity } from '@/activities/types/GraphQLActivity';
import { Activity } from '@/activities/types/Activity';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useFieldContext } from '@/object-record/hooks/useFieldContext';
import { RecordFieldInputScope } from '@/object-record/record-field/scopes/RecordFieldInputScope';
@ -11,13 +10,7 @@ import { RelationPickerHotkeyScope } from '@/object-record/relation-picker/types
import { IconArrowUpRight, IconPencil } from '@/ui/display/icon';
type ActivityTargetsInlineCellProps = {
activity?: Pick<GraphQLActivity, 'id'> & {
activityTargets?: {
edges: Array<{
node: Pick<ActivityTarget, 'id'>;
}> | null;
};
};
activity: Activity;
};
export const ActivityTargetsInlineCell = ({
@ -47,8 +40,8 @@ export const ActivityTargetsInlineCell = ({
IconLabel={IconArrowUpRight}
editModeContent={
<ActivityTargetInlineCellEditMode
activityId={activity?.id ?? ''}
activityTargetObjectRecords={activityTargetObjectRecords as any}
activity={activity}
activityTargetObjectRecords={activityTargetObjectRecords}
/>
}
label="Relations"

View File

@ -1,19 +1,19 @@
import { useApolloClient } from '@apollo/client';
import { ActivityTarget } from '@/activities/types/ActivityTarget';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useObjectMetadataItemOnly } from '@/object-metadata/hooks/useObjectMetadataItemOnly';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { getRecordConnectionFromEdges } from '@/object-record/cache/utils/getRecordConnectionFromEdges';
import { getRecordEdgeFromRecord } from '@/object-record/cache/utils/getRecordEdgeFromRecord';
import { useUpsertFindManyRecordsQueryInCache } from '@/object-record/cache/hooks/useUpsertFindManyRecordsQueryInCache';
export const useInjectIntoActivityTargetInlineCellCache = () => {
const apolloClient = useApolloClient();
const { objectMetadataItem: objectMetadataItemActivityTarget } =
useObjectMetadataItemOnly({
objectNameSingular: CoreObjectNameSingular.ActivityTarget,
});
const {
upsertFindManyRecordsQueryInCache:
overwriteFindManyActivityTargetsQueryInCache,
} = useUpsertFindManyRecordsQueryInCache({
objectMetadataItem: objectMetadataItemActivityTarget,
findManyRecordsQuery: findManyActivityTargetsQuery,
} = useObjectMetadataItem({
objectNameSingular: CoreObjectNameSingular.ActivityTarget,
});
const injectIntoActivityTargetInlineCellCache = ({
@ -23,32 +23,17 @@ export const useInjectIntoActivityTargetInlineCellCache = () => {
activityId: string;
activityTargetsToInject: ActivityTarget[];
}) => {
const newActivityTargetEdgesForCache = activityTargetsToInject.map(
(activityTargetToInject) =>
getRecordEdgeFromRecord({
objectNameSingular: CoreObjectNameSingular.ActivityTarget,
record: activityTargetToInject,
}),
);
const newActivityTargetConnectionForCache = getRecordConnectionFromEdges({
objectNameSingular: CoreObjectNameSingular.ActivityTarget,
edges: newActivityTargetEdgesForCache,
});
apolloClient.writeQuery({
query: findManyActivityTargetsQuery,
variables: {
filter: {
activityId: {
eq: activityId,
},
const activityTargetInlineCellQueryVariables = {
filter: {
activityId: {
eq: activityId,
},
},
data: {
[objectMetadataItemActivityTarget.namePlural]:
newActivityTargetConnectionForCache,
},
};
overwriteFindManyActivityTargetsQueryInCache({
queryVariables: activityTargetInlineCellQueryVariables,
objectRecordsToOverwrite: activityTargetsToInject,
});
};

View File

@ -4,7 +4,6 @@ import styled from '@emotion/styled';
import { useOpenActivityRightDrawer } from '@/activities/hooks/useOpenActivityRightDrawer';
import { ActivityTargetsInlineCell } from '@/activities/inline-cell/components/ActivityTargetsInlineCell';
import { GraphQLActivity } from '@/activities/types/GraphQLActivity';
import { Note } from '@/activities/types/Note';
import { getActivityPreview } from '@/activities/utils/getActivityPreview';
import {
@ -101,9 +100,7 @@ export const NoteCard = ({
<StyledCardContent>{body}</StyledCardContent>
</StyledCardDetailsContainer>
<StyledFooter>
<ActivityTargetsInlineCell
activity={note as unknown as GraphQLActivity}
/>
<ActivityTargetsInlineCell activity={note} />
{note.comments && note.comments.length > 0 && (
<StyledCommentIcon>
<IconComment size={theme.icon.size.md} />

View File

@ -1,4 +1,4 @@
import { useActivityTargets } from '@/activities/hooks/useActivityTargets';
import { useActivityTargetsForTargetableObject } from '@/activities/hooks/useActivityTargetsForTargetableObject';
import { Note } from '@/activities/types/Note';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { OrderByField } from '@/object-metadata/types/OrderByField';
@ -7,7 +7,9 @@ import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { ActivityTargetableObject } from '../../types/ActivityTargetableEntity';
export const useNotes = (targetableObject: ActivityTargetableObject) => {
const { activityTargets } = useActivityTargets({ targetableObject });
const { activityTargets } = useActivityTargetsForTargetableObject({
targetableObject,
});
const filter = {
id: {

View File

@ -1,11 +1,16 @@
import { useApolloClient } from '@apollo/client';
import { useRecoilState, useRecoilValue } from 'recoil';
import { useDeleteActivityFromCache } from '@/activities/hooks/useDeleteActivityFromCache';
import { isCreatingActivityState } from '@/activities/states/isCreatingActivityState';
import { temporaryActivityForEditorState } from '@/activities/states/temporaryActivityForEditorState';
import { viewableActivityIdState } from '@/activities/states/viewableActivityIdState';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useDeleteOneRecord } from '@/object-record/hooks/useDeleteOneRecord';
import { IconTrash } from '@/ui/display/icon';
import { LightIconButton } from '@/ui/input/button/components/LightIconButton';
import { isRightDrawerOpenState } from '@/ui/layout/right-drawer/states/isRightDrawerOpenState';
import { isDefined } from '~/utils/isDefined';
export const ActivityActionBar = () => {
const viewableActivityId = useRecoilValue(viewableActivityIdState);
@ -15,9 +20,27 @@ export const ActivityActionBar = () => {
refetchFindManyQuery: true,
});
const [temporaryActivityForEditor, setTemporaryActivityForEditor] =
useRecoilState(temporaryActivityForEditorState);
const { deleteActivityFromCache } = useDeleteActivityFromCache();
const [isCreatingActivity] = useRecoilState(isCreatingActivityState);
const apolloClient = useApolloClient();
const deleteActivity = () => {
if (viewableActivityId) {
deleteOneActivity?.(viewableActivityId);
if (isCreatingActivity && 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'],
});
}
}
setIsRightDrawerOpen(false);

View File

@ -1,12 +1,7 @@
import React from 'react';
import styled from '@emotion/styled';
import { useSetRecoilState } from 'recoil';
import { ActivityEditor } from '@/activities/components/ActivityEditor';
import { Activity } from '@/activities/types/Activity';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { useActivityById } from '@/activities/hooks/useActivityById';
const StyledContainer = styled.div`
box-sizing: border-box;
@ -21,23 +16,16 @@ const StyledContainer = styled.div`
type RightDrawerActivityProps = {
activityId: string;
showComment?: boolean;
autoFillTitle?: boolean;
fillTitleFromBody?: boolean;
};
export const RightDrawerActivity = ({
activityId,
showComment = true,
autoFillTitle = false,
fillTitleFromBody = false,
}: RightDrawerActivityProps) => {
const setEntityFields = useSetRecoilState(recordStoreFamilyState(activityId));
const { record: activity } = useFindOneRecord({
objectNameSingular: CoreObjectNameSingular.Activity,
objectRecordId: activityId,
skip: !activityId,
onCompleted: (activity: Activity) => {
setEntityFields(activity ?? {});
},
const { activity } = useActivityById({
activityId,
});
if (!activity) {
@ -49,7 +37,7 @@ export const RightDrawerActivity = ({
<ActivityEditor
activity={activity}
showComment={showComment}
autoFillTitle={autoFillTitle}
fillTitleFromBody={fillTitleFromBody}
/>
</StyledContainer>
);

View File

@ -13,7 +13,7 @@ export const RightDrawerCreateActivity = () => {
<RightDrawerActivity
activityId={viewableActivityId}
showComment={false}
autoFillTitle={true}
fillTitleFromBody={true}
/>
)}
</>

View File

@ -0,0 +1,9 @@
import { atomFamily } from 'recoil';
export const activityTitleHasBeenSetFamilyState = atomFamily<
boolean,
{ activityId: string }
>({
key: 'activityTitleHasBeenSetFamilyState',
default: false,
});

View File

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

View File

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

View File

@ -1,71 +0,0 @@
import { isNonEmptyArray } from '@apollo/client/utilities';
import styled from '@emotion/styled';
import CommentCounter from '@/activities/comment/CommentCounter';
import { Activity } from '@/activities/types/Activity';
import { UserChip } from '@/users/components/UserChip';
import { WorkspaceMember } from '@/workspace-member/types/WorkspaceMember';
import { beautifyExactDate } from '~/utils/date-utils';
type TimelineActivityCardFooterProps = {
activity: Pick<Activity, 'id' | 'dueAt' | 'comments'> & {
assignee?: Pick<WorkspaceMember, 'id' | 'name' | 'avatarUrl'> | null;
};
};
const StyledContainer = styled.div`
align-items: center;
border-top: 1px solid ${({ theme }) => theme.border.color.medium};
color: ${({ theme }) => theme.font.color.primary};
display: flex;
flex-direction: row;
gap: ${({ theme }) => theme.spacing(2)};
padding: ${({ theme }) => theme.spacing(2)};
width: calc(100% - ${({ theme }) => theme.spacing(4)});
`;
const StyledVerticalSeparator = styled.div`
border-left: 1px solid ${({ theme }) => theme.border.color.medium};
height: 24px;
`;
const StyledComment = styled.div`
margin-left: auto;
`;
export const TimelineActivityCardFooter = ({
activity,
}: TimelineActivityCardFooterProps) => {
const hasComments = isNonEmptyArray(activity.comments || []);
return (
<>
{(activity.assignee || activity.dueAt || hasComments) && (
<StyledContainer>
{activity.assignee && (
<UserChip
id={activity.assignee.id}
name={
activity.assignee.name.firstName +
' ' +
activity.assignee.name.lastName ?? ''
}
avatarUrl={activity.assignee.avatarUrl ?? ''}
/>
)}
{activity.dueAt && (
<>
{activity.assignee && <StyledVerticalSeparator />}
{beautifyExactDate(activity.dueAt)}
</>
)}
<StyledComment>
{hasComments && (
<CommentCounter commentCount={activity.comments?.length || 0} />
)}
</StyledComment>
</StyledContainer>
)}
</>
);
};

View File

@ -1,7 +1,7 @@
import { useSetRecoilState } from 'recoil';
import { Button, ButtonGroup } from 'tsup.ui.index';
import { useOpenCreateActivityDrawer } from '@/activities/hooks/useOpenCreateActivityDrawer';
import { useOpenCreateActivityDrawerV2 } from '@/activities/hooks/useOpenCreateActivityDrawerV2';
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 = useOpenCreateActivityDrawer();
const openCreateActivity = useOpenCreateActivityDrawerV2();
return (
<ButtonGroup variant={'secondary'}>
@ -30,6 +30,7 @@ export const TimelineCreateButtonGroup = ({
openCreateActivity({
type: 'Note',
targetableObjects: [targetableObject],
timelineTargetableObject: targetableObject,
})
}
/>
@ -40,6 +41,7 @@ export const TimelineCreateButtonGroup = ({
openCreateActivity({
type: 'Task',
targetableObjects: [targetableObject],
timelineTargetableObject: targetableObject,
})
}
/>

View File

@ -1,89 +0,0 @@
import { useApolloClient } from '@apollo/client';
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 { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { getRecordConnectionFromRecords } from '@/object-record/cache/utils/getRecordConnectionFromRecords';
import { getRecordsFromRecordConnection } from '@/object-record/cache/utils/getRecordsFromRecordConnection';
import { ObjectRecordConnection } from '@/object-record/types/ObjectRecordConnection';
export const useInjectIntoTimelineActivitiesQuery = () => {
const apolloClient = useApolloClient();
const {
objectMetadataItem: objectMetadataItemActivity,
findManyRecordsQuery: findManyActivitiesQuery,
} = useObjectMetadataItem({
objectNameSingular: CoreObjectNameSingular.Activity,
});
const injectIntoTimelineActivitiesQuery = ({
activityTargets,
activityToInject,
}: {
activityTargets: ActivityTarget[];
activityToInject: Activity;
}) => {
const activityIds = activityTargets
?.map((activityTarget) => activityTarget.activityId)
.filter(isNonEmptyString);
const timelineActivitiesQueryVariables =
makeTimelineActivitiesQueryVariables({
activityIds,
});
const exitistingActivitiesQueryResult = apolloClient.readQuery({
query: findManyActivitiesQuery,
variables: timelineActivitiesQueryVariables,
});
const extistingActivities = exitistingActivitiesQueryResult
? getRecordsFromRecordConnection({
recordConnection: exitistingActivitiesQueryResult[
objectMetadataItemActivity.namePlural
] as ObjectRecordConnection<Activity>,
})
: [];
const newActivity = {
...activityToInject,
__typename: 'Activity',
};
const newActivitiesSortedAsActivitiesQuery = [
newActivity,
...extistingActivities,
];
const newActivityIdsSortedAsActivityTargetsQuery = [
...extistingActivities,
newActivity,
].map((activity) => activity.id);
const newTimelineActivitiesQueryVariables =
makeTimelineActivitiesQueryVariables({
activityIds: newActivityIdsSortedAsActivityTargetsQuery,
});
const newActivityConnectionForCache = getRecordConnectionFromRecords({
objectNameSingular: CoreObjectNameSingular.Activity,
records: newActivitiesSortedAsActivitiesQuery,
});
apolloClient.writeQuery({
query: findManyActivitiesQuery,
variables: newTimelineActivitiesQueryVariables,
data: {
[objectMetadataItemActivity.namePlural]: newActivityConnectionForCache,
},
});
};
return {
injectIntoTimelineActivitiesNextQuery: injectIntoTimelineActivitiesQuery,
};
};

View File

@ -0,0 +1,124 @@
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 { useObjectMetadataItemOnly } from '@/object-metadata/hooks/useObjectMetadataItemOnly';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useReadFindManyRecordsQueryInCache } from '@/object-record/cache/hooks/useReadFindManyRecordsQueryInCache';
import { useUpsertFindManyRecordsQueryInCache } from '@/object-record/cache/hooks/useUpsertFindManyRecordsQueryInCache';
export const useInjectIntoTimelineActivitiesQueryAfterDrawerMount = () => {
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 injectIntoTimelineActivitiesQueryAfterDrawerMount = ({
activityToInject,
activityTargetsToInject,
timelineTargetableObject,
}: {
activityToInject: Activity;
activityTargetsToInject: ActivityTarget[];
timelineTargetableObject: ActivityTargetableObject;
}) => {
const newActivity = {
...activityToInject,
__typename: 'Activity',
};
const targetObjectFieldName = getActivityTargetObjectFieldIdName({
nameSingular: timelineTargetableObject.targetObjectNameSingular,
});
const activitiyTargetsForTargetableObjectQueryVariables = {
filter: {
[targetObjectFieldName]: {
eq: timelineTargetableObject.id,
},
},
};
const existingActivityTargetsForTargetableObject =
readFindManyActivityTargetsQueryInCache({
queryVariables: activitiyTargetsForTargetableObjectQueryVariables,
});
const newActivityTargetsForTargetableObject = [
...existingActivityTargetsForTargetableObject,
...activityTargetsToInject,
];
const existingActivityIds = existingActivityTargetsForTargetableObject
?.map((activityTarget) => activityTarget.activityId)
.filter(isNonEmptyString);
const timelineActivitiesQueryVariablesBeforeDrawerMount =
makeTimelineActivitiesQueryVariables({
activityIds: existingActivityIds,
});
const existingActivities = readFindManyActivitiesQueryInCache({
queryVariables: timelineActivitiesQueryVariablesBeforeDrawerMount,
});
const activityIdsAfterDrawerMount = [
...existingActivityIds,
newActivity.id,
];
const timelineActivitiesQueryVariablesAfterDrawerMount =
makeTimelineActivitiesQueryVariables({
activityIds: activityIdsAfterDrawerMount,
});
overwriteFindManyActivityTargetsQueryInCache({
objectRecordsToOverwrite: newActivityTargetsForTargetableObject,
queryVariables: activitiyTargetsForTargetableObjectQueryVariables,
});
const newActivities = [newActivity, ...existingActivities];
overwriteFindManyActivitiesInCache({
objectRecordsToOverwrite: newActivities,
queryVariables: timelineActivitiesQueryVariablesAfterDrawerMount,
});
};
return {
injectIntoTimelineActivitiesQueryAfterDrawerMount,
};
};

View File

@ -1,7 +1,7 @@
import { useEffect, useState } from 'react';
import { isNonEmptyArray, isNonEmptyString } from '@sniptt/guards';
import { useActivityTargets } from '@/activities/hooks/useActivityTargets';
import { useActivityTargetsForTargetableObject } from '@/activities/hooks/useActivityTargetsForTargetableObject';
import { makeTimelineActivitiesQueryVariables } from '@/activities/timeline/utils/makeTimelineActivitiesQueryVariables';
import { Activity } from '@/activities/types/Activity';
import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
@ -17,14 +17,12 @@ export const useTimelineActivities = ({
activityTargets,
loadingActivityTargets,
initialized: initializedActivityTargets,
} = useActivityTargets({
} = useActivityTargetsForTargetableObject({
targetableObject,
});
const [initialized, setInitialized] = useState(false);
const [activities, setActivities] = useState<Activity[]>([]);
const activityIds = activityTargets
?.map((activityTarget) => activityTarget.activityId)
.filter(isNonEmptyString);
@ -35,7 +33,7 @@ export const useTimelineActivities = ({
},
);
const { records: activitiesFromRequest, loading: loadingActivities } =
const { records: activities, loading: loadingActivities } =
useFindManyRecords<Activity>({
skip: loadingActivityTargets || !isNonEmptyArray(activityTargets),
objectNameSingular: CoreObjectNameSingular.Activity,
@ -48,12 +46,6 @@ export const useTimelineActivities = ({
},
});
useEffect(() => {
if (!loadingActivities) {
setActivities(activitiesFromRequest);
}
}, [activitiesFromRequest, loadingActivities]);
const noActivityTargets =
initializedActivityTargets && !isNonEmptyArray(activityTargets);

View File

@ -12,7 +12,7 @@ export const makeTimelineActivitiesQueryVariables = ({
},
},
orderBy: {
createdAt: 'AscNullsFirst',
createdAt: 'DescNullsFirst',
},
};
};

View File

@ -0,0 +1,20 @@
import { Activity } from '@/activities/types/Activity';
import { ActivityTarget } from '@/activities/types/ActivityTarget';
import { Comment } from '@/activities/types/Comment';
import { WorkspaceMember } from '~/generated-metadata/graphql';
export type ActivityForEditor = Pick<
Activity,
'id' | 'title' | 'body' | 'type' | 'completedAt' | 'dueAt' | 'updatedAt'
> & {
comments?: Comment[];
} & {
assignee?: Pick<WorkspaceMember, 'id' | 'name' | 'avatarUrl'> | null;
} & {
activityTargets?: Array<
Pick<
ActivityTarget,
'id' | 'companyId' | 'personId' | 'createdAt' | 'updatedAt' | 'activity'
>
>;
};

View File

@ -6,8 +6,8 @@ export type ActivityTarget = {
id: string;
createdAt: string;
updatedAt: string;
companyId: string | null;
personId: string | null;
companyId?: string | null;
personId?: string | null;
activity: Pick<Activity, 'id' | 'createdAt' | 'updatedAt'>;
person?: Pick<Person, 'id' | 'name' | 'avatarUrl'> | null;
company?: Pick<Company, 'id' | 'name' | 'domainName'> | null;

View File

@ -24,13 +24,17 @@ export const getActivityTargetsToCreateFromTargetableObjects = ({
nameSingular: targetableObject.targetObjectNameSingular,
});
return {
const activityTarget = {
[targetableObject.targetObjectNameSingular]:
targetableObject.targetObjectRecord,
[targetableObjectFieldIdName]: targetableObject.id,
activityId,
id: v4(),
};
updatedAt: new Date().toISOString(),
createdAt: new Date().toISOString(),
} as Partial<ActivityTarget>;
return activityTarget;
},
);

View File

@ -0,0 +1,102 @@
import { isNonEmptyArray } from '@apollo/client/utilities';
import { Activity } from '@/activities/types/Activity';
import { ActivityTarget } from '@/activities/types/ActivityTarget';
import { Comment } from '@/activities/types/Comment';
import { isObjectRecordConnection } from '@/apollo/optimistic-effect/utils/isObjectRecordConnection';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { getEmptyPageInfo } from '@/object-record/cache/utils/getEmptyPageInfo';
import { useMapConnectionToRecords } from '@/object-record/hooks/useMapConnectionToRecords';
import { ObjectRecordConnection } from '@/object-record/types/ObjectRecordConnection';
import { isDefined } from '~/utils/isDefined';
export const useActivityConnectionUtils = () => {
const mapConnectionToRecords = useMapConnectionToRecords();
const makeActivityWithoutConnection = (activityWithConnections: any) => {
if (!isDefined(activityWithConnections)) {
return { activity: null };
}
const hasActivityTargetsConnection = isObjectRecordConnection(
CoreObjectNameSingular.ActivityTarget,
activityWithConnections?.activityTargets,
);
const activityTargets: ActivityTarget[] = [];
if (hasActivityTargetsConnection) {
const newActivityTargets = mapConnectionToRecords({
objectRecordConnection: activityWithConnections?.activityTargets,
objectNameSingular: CoreObjectNameSingular.ActivityTarget,
depth: 5,
}) as ActivityTarget[];
activityTargets.push(...newActivityTargets);
}
const hasCommentsConnection = isObjectRecordConnection(
CoreObjectNameSingular.Comment,
activityWithConnections?.comments,
);
const comments: Comment[] = [];
if (hasCommentsConnection) {
const newComments = mapConnectionToRecords({
objectRecordConnection: activityWithConnections?.comments,
objectNameSingular: CoreObjectNameSingular.Comment,
depth: 5,
}) as Comment[];
comments.push(...newComments);
}
const activity: Activity = {
...activityWithConnections,
activityTargets,
comments,
};
return { activity };
};
const makeActivityWithConnection = (activity: Activity) => {
const activityTargetEdges = isNonEmptyArray(activity?.activityTargets)
? activity.activityTargets.map((activityTarget) => ({
node: activityTarget,
cursor: '',
}))
: [];
const commentEdges = isNonEmptyArray(activity?.comments)
? activity.comments.map((comment) => ({
node: comment,
cursor: '',
}))
: [];
const activityTargets = {
edges: activityTargetEdges,
pageInfo: getEmptyPageInfo(),
} as ObjectRecordConnection<ActivityTarget>;
const comments = {
edges: commentEdges,
pageInfo: getEmptyPageInfo(),
} as ObjectRecordConnection<Comment>;
const activityWithConnection = {
...activity,
activityTargets,
comments,
};
return { activityWithConnection };
};
return {
makeActivityWithoutConnection,
makeActivityWithConnection,
};
};

View File

@ -22,7 +22,7 @@ export const useCachedRootQuery = ({
const buildRecordFieldsFragment = () => {
return objectMetadataItem.fields
.filter((field) => field.type !== 'RELATION')
.map((field) => mapFieldMetadataToGraphQLQuery(field))
.map((field) => mapFieldMetadataToGraphQLQuery({ field }))
.join(' \n');
};

View File

@ -0,0 +1,62 @@
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { RelationType } from '@/settings/data-model/types/RelationType';
import {
FieldMetadataType,
RelationMetadataType,
} from '~/generated-metadata/graphql';
export const getRelationDefinition = ({
objectMetadataItems,
fieldMetadataItemOnSourceRecord,
}: {
objectMetadataItems: ObjectMetadataItem[];
fieldMetadataItemOnSourceRecord: FieldMetadataItem;
}) => {
if (fieldMetadataItemOnSourceRecord.type !== FieldMetadataType.Relation) {
return null;
}
const relationMetadataItem =
fieldMetadataItemOnSourceRecord.fromRelationMetadata ||
fieldMetadataItemOnSourceRecord.toRelationMetadata;
if (!relationMetadataItem) return null;
const relationSourceFieldMetadataItemId =
'toFieldMetadataId' in relationMetadataItem
? relationMetadataItem.toFieldMetadataId
: relationMetadataItem.fromFieldMetadataId;
if (!relationSourceFieldMetadataItemId) return null;
// TODO: precise naming, is it relationTypeFromTargetPointOfView or relationTypeFromSourcePointOfView ?
const relationType =
relationMetadataItem.relationType === RelationMetadataType.OneToMany &&
fieldMetadataItemOnSourceRecord.toRelationMetadata
? ('MANY_TO_ONE' satisfies RelationType)
: (relationMetadataItem.relationType as RelationType);
const targetObjectMetadataNameSingular =
'toObjectMetadata' in relationMetadataItem
? relationMetadataItem.toObjectMetadata.nameSingular
: relationMetadataItem.fromObjectMetadata.nameSingular;
const targetObjectMetadataItem = objectMetadataItems.find(
(item) => item.nameSingular === targetObjectMetadataNameSingular,
);
if (!targetObjectMetadataItem) return null;
const fieldMetadataItemOnTargetRecord = targetObjectMetadataItem.fields.find(
(field) => field.id === relationSourceFieldMetadataItemId,
);
if (!fieldMetadataItemOnTargetRecord) return null;
return {
fieldMetadataItemOnTargetRecord,
targetObjectMetadataItem,
relationType,
};
};

View File

@ -2,57 +2,69 @@ import { ApolloCache, StoreObject } from '@apollo/client';
import { isCachedObjectRecordConnection } from '@/apollo/optimistic-effect/utils/isCachedObjectRecordConnection';
import { CachedObjectRecordEdge } from '@/apollo/types/CachedObjectRecordEdge';
import { isDefined } from '~/utils/isDefined';
import { capitalize } from '~/utils/string/capitalize';
export const triggerAttachRelationOptimisticEffect = ({
cache,
objectNameSingular,
recordId,
relationObjectMetadataNameSingular,
relationFieldName,
relationRecordId,
sourceObjectNameSingular,
sourceRecordId,
targetObjectNameSingular,
fieldNameOnTargetRecord,
targetRecordId,
}: {
cache: ApolloCache<unknown>;
objectNameSingular: string;
recordId: string;
relationObjectMetadataNameSingular: string;
relationFieldName: string;
relationRecordId: string;
sourceObjectNameSingular: string;
sourceRecordId: string;
targetObjectNameSingular: string;
fieldNameOnTargetRecord: string;
targetRecordId: string;
}) => {
const recordTypeName = capitalize(objectNameSingular);
const relationRecordTypeName = capitalize(relationObjectMetadataNameSingular);
const sourceRecordTypeName = capitalize(sourceObjectNameSingular);
const targetRecordTypeName = capitalize(targetObjectNameSingular);
const targetRecordCacheId = cache.identify({
id: targetRecordId,
__typename: targetRecordTypeName,
});
cache.modify<StoreObject>({
id: cache.identify({
id: relationRecordId,
__typename: relationRecordTypeName,
}),
id: targetRecordCacheId,
fields: {
[relationFieldName]: (cachedFieldValue, { toReference }) => {
const nodeReference = toReference({
id: recordId,
__typename: recordTypeName,
[fieldNameOnTargetRecord]: (targetRecordFieldValue, { toReference }) => {
const fieldValueIsCachedObjectRecordConnection =
isCachedObjectRecordConnection(
sourceObjectNameSingular,
targetRecordFieldValue,
);
const sourceRecordReference = toReference({
id: sourceRecordId,
__typename: sourceRecordTypeName,
});
if (!nodeReference) return cachedFieldValue;
if (!isDefined(sourceRecordReference)) {
return targetRecordFieldValue;
}
if (
isCachedObjectRecordConnection(objectNameSingular, cachedFieldValue)
) {
// To many objects => add record to next relation field list
if (fieldValueIsCachedObjectRecordConnection) {
const nextEdges: CachedObjectRecordEdge[] = [
...cachedFieldValue.edges,
...targetRecordFieldValue.edges,
{
__typename: `${recordTypeName}Edge`,
node: nodeReference,
__typename: `${sourceRecordTypeName}Edge`,
node: sourceRecordReference,
cursor: '',
},
];
return { ...cachedFieldValue, edges: nextEdges };
}
// To one object => attach next relation record
return nodeReference;
return {
...targetRecordFieldValue,
edges: nextEdges,
};
} else {
// To one object => attach next relation record
return sourceRecordReference;
}
},
},
});

View File

@ -4,9 +4,8 @@ import { isCachedObjectRecordConnection } from '@/apollo/optimistic-effect/utils
import { triggerUpdateRelationsOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerUpdateRelationsOptimisticEffect';
import { CachedObjectRecord } from '@/apollo/types/CachedObjectRecord';
import { CachedObjectRecordEdge } from '@/apollo/types/CachedObjectRecordEdge';
import { useGetRelationMetadata } from '@/object-metadata/hooks/useGetRelationMetadata';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { capitalize } from '~/utils/string/capitalize';
import { getEdgeTypename } from '@/object-record/cache/utils/getEdgeTypename';
/*
TODO: for now new records are added to all cached record lists, no matter what the variables (filters, orderBy, etc.) are.
@ -16,32 +15,32 @@ import { capitalize } from '~/utils/string/capitalize';
export const triggerCreateRecordsOptimisticEffect = ({
cache,
objectMetadataItem,
records,
getRelationMetadata,
recordsToCreate,
objectMetadataItems,
}: {
cache: ApolloCache<unknown>;
objectMetadataItem: ObjectMetadataItem;
records: CachedObjectRecord[];
getRelationMetadata: ReturnType<typeof useGetRelationMetadata>;
recordsToCreate: CachedObjectRecord[];
objectMetadataItems: ObjectMetadataItem[];
}) => {
const objectEdgeTypeName = `${capitalize(
objectMetadataItem.nameSingular,
)}Edge`;
const objectEdgeTypeName = getEdgeTypename({
objectNameSingular: objectMetadataItem.nameSingular,
});
records.forEach((record) =>
recordsToCreate.forEach((record) =>
triggerUpdateRelationsOptimisticEffect({
cache,
objectMetadataItem,
previousRecord: null,
nextRecord: record,
getRelationMetadata,
sourceObjectMetadataItem: objectMetadataItem,
currentSourceRecord: null,
updatedSourceRecord: record,
objectMetadataItems,
}),
);
cache.modify<StoreObject>({
fields: {
[objectMetadataItem.namePlural]: (
cachedConnection,
rootQueryCachedResponse,
{
DELETE: _DELETE,
readField,
@ -49,42 +48,49 @@ export const triggerCreateRecordsOptimisticEffect = ({
toReference,
},
) => {
if (
!isCachedObjectRecordConnection(
objectMetadataItem.nameSingular,
cachedConnection,
)
)
return cachedConnection;
/* const { variables } =
parseApolloStoreFieldName<CachedObjectRecordQueryVariables>(
storeFieldName,
); */
const cachedEdges = readField<CachedObjectRecordEdge[]>(
'edges',
cachedConnection,
const shouldSkip = !isCachedObjectRecordConnection(
objectMetadataItem.nameSingular,
rootQueryCachedResponse,
);
const nextCachedEdges = cachedEdges ? [...cachedEdges] : [];
const hasAddedRecords = records
.map((record) => {
/* const matchesFilter =
!variables?.filter ||
isRecordMatchingFilter({
record,
filter: variables.filter,
objectMetadataItem,
}); */
if (shouldSkip) {
return rootQueryCachedResponse;
}
if (/* matchesFilter && */ record.id) {
const nodeReference = toReference(record);
const rootQueryCachedObjectRecordConnection = rootQueryCachedResponse;
if (nodeReference) {
nextCachedEdges.unshift({
const rootQueryCachedRecordEdges = readField<CachedObjectRecordEdge[]>(
'edges',
rootQueryCachedObjectRecordConnection,
);
const nextRootQueryCachedRecordEdges = rootQueryCachedRecordEdges
? [...rootQueryCachedRecordEdges]
: [];
const hasAddedRecords = recordsToCreate
.map((recordToCreate) => {
if (recordToCreate.id) {
const recordToCreateReference = toReference(recordToCreate);
if (!recordToCreateReference) {
throw new Error(
`Failed to create reference for record with id: ${recordToCreate.id}`,
);
}
const recordAlreadyInCache = rootQueryCachedRecordEdges?.some(
(cachedEdge) => {
return (
cache.identify(recordToCreateReference) ===
cache.identify(cachedEdge.node)
);
},
);
if (recordToCreateReference && !recordAlreadyInCache) {
nextRootQueryCachedRecordEdges.unshift({
__typename: objectEdgeTypeName,
node: nodeReference,
node: recordToCreateReference,
cursor: '',
});
@ -96,30 +102,14 @@ export const triggerCreateRecordsOptimisticEffect = ({
})
.some((hasAddedRecord) => hasAddedRecord);
if (!hasAddedRecords) return cachedConnection;
/* if (variables?.orderBy) {
nextCachedEdges = sortCachedObjectEdges({
edges: nextCachedEdges,
orderBy: variables.orderBy,
readCacheField: readField,
});
if (!hasAddedRecords) {
return rootQueryCachedObjectRecordConnection;
}
if (isDefined(variables?.first)) {
if (
cachedEdges?.length === variables.first &&
nextCachedEdges.length < variables.first
) {
return DELETE;
}
if (nextCachedEdges.length > variables.first) {
nextCachedEdges.splice(variables.first);
}
} */
return { ...cachedConnection, edges: nextCachedEdges };
return {
...rootQueryCachedObjectRecordConnection,
edges: nextRootQueryCachedRecordEdges,
};
},
},
});

View File

@ -5,7 +5,6 @@ import { triggerUpdateRelationsOptimisticEffect } from '@/apollo/optimistic-effe
import { CachedObjectRecord } from '@/apollo/types/CachedObjectRecord';
import { CachedObjectRecordEdge } from '@/apollo/types/CachedObjectRecordEdge';
import { CachedObjectRecordQueryVariables } from '@/apollo/types/CachedObjectRecordQueryVariables';
import { useGetRelationMetadata } from '@/object-metadata/hooks/useGetRelationMetadata';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { isDefined } from '~/utils/isDefined';
import { parseApolloStoreFieldName } from '~/utils/parseApolloStoreFieldName';
@ -13,70 +12,79 @@ import { parseApolloStoreFieldName } from '~/utils/parseApolloStoreFieldName';
export const triggerDeleteRecordsOptimisticEffect = ({
cache,
objectMetadataItem,
records,
getRelationMetadata,
recordsToDelete,
objectMetadataItems,
}: {
cache: ApolloCache<unknown>;
objectMetadataItem: ObjectMetadataItem;
records: CachedObjectRecord[];
getRelationMetadata: ReturnType<typeof useGetRelationMetadata>;
recordsToDelete: CachedObjectRecord[];
objectMetadataItems: ObjectMetadataItem[];
}) => {
cache.modify<StoreObject>({
fields: {
[objectMetadataItem.namePlural]: (
cachedConnection,
rootQueryCachedResponse,
{ DELETE, readField, storeFieldName },
) => {
if (
const rootQueryCachedResponseIsNotACachedObjectRecordConnection =
!isCachedObjectRecordConnection(
objectMetadataItem.nameSingular,
cachedConnection,
)
) {
return cachedConnection;
rootQueryCachedResponse,
);
if (rootQueryCachedResponseIsNotACachedObjectRecordConnection) {
return rootQueryCachedResponse;
}
const { variables } =
const rootQueryCachedObjectRecordConnection = rootQueryCachedResponse;
const { fieldArguments: rootQueryVariables } =
parseApolloStoreFieldName<CachedObjectRecordQueryVariables>(
storeFieldName,
);
const recordIds = records.map(({ id }) => id);
const recordIdsToDelete = recordsToDelete.map(({ id }) => id);
const cachedEdges = readField<CachedObjectRecordEdge[]>(
'edges',
cachedConnection,
rootQueryCachedObjectRecordConnection,
);
const nextCachedEdges =
cachedEdges?.filter((cachedEdge) => {
const nodeId = readField<string>('id', cachedEdge.node);
return nodeId && !recordIds.includes(nodeId);
return nodeId && !recordIdsToDelete.includes(nodeId);
}) || [];
if (nextCachedEdges.length === cachedEdges?.length)
return cachedConnection;
return rootQueryCachedObjectRecordConnection;
// TODO: same as in update, should we trigger DELETE ?
if (
isDefined(variables?.first) &&
cachedEdges?.length === variables.first
isDefined(rootQueryVariables?.first) &&
cachedEdges?.length === rootQueryVariables.first
) {
return DELETE;
}
return { ...cachedConnection, edges: nextCachedEdges };
return {
...rootQueryCachedObjectRecordConnection,
edges: nextCachedEdges,
};
},
},
});
records.forEach((record) => {
recordsToDelete.forEach((recordToDelete) => {
triggerUpdateRelationsOptimisticEffect({
cache,
objectMetadataItem,
previousRecord: record,
nextRecord: null,
getRelationMetadata,
sourceObjectMetadataItem: objectMetadataItem,
currentSourceRecord: recordToDelete,
updatedSourceRecord: null,
objectMetadataItems,
});
cache.evict({ id: cache.identify(record) });
cache.evict({ id: cache.identify(recordToDelete) });
});
};

View File

@ -5,44 +5,60 @@ import { capitalize } from '~/utils/string/capitalize';
export const triggerDetachRelationOptimisticEffect = ({
cache,
objectNameSingular,
recordId,
relationObjectMetadataNameSingular,
relationFieldName,
relationRecordId,
sourceObjectNameSingular,
sourceRecordId,
targetObjectNameSingular,
fieldNameOnTargetRecord,
targetRecordId,
}: {
cache: ApolloCache<unknown>;
objectNameSingular: string;
recordId: string;
relationObjectMetadataNameSingular: string;
relationFieldName: string;
relationRecordId: string;
sourceObjectNameSingular: string;
sourceRecordId: string;
targetObjectNameSingular: string;
fieldNameOnTargetRecord: string;
targetRecordId: string;
}) => {
const relationRecordTypeName = capitalize(relationObjectMetadataNameSingular);
const targetRecordTypeName = capitalize(targetObjectNameSingular);
const targetRecordCacheId = cache.identify({
id: targetRecordId,
__typename: targetRecordTypeName,
});
cache.modify<StoreObject>({
id: cache.identify({
id: relationRecordId,
__typename: relationRecordTypeName,
}),
id: targetRecordCacheId,
fields: {
[relationFieldName]: (cachedFieldValue, { isReference, readField }) => {
// To many objects => remove record from previous relation field list
if (
isCachedObjectRecordConnection(objectNameSingular, cachedFieldValue)
) {
const nextEdges = cachedFieldValue.edges.filter(
({ node }) => readField('id', node) !== recordId,
[fieldNameOnTargetRecord]: (
targetRecordFieldValue,
{ isReference, readField },
) => {
const isRelationTargetFieldAnObjectRecordConnection =
isCachedObjectRecordConnection(
sourceObjectNameSingular,
targetRecordFieldValue,
);
return { ...cachedFieldValue, edges: nextEdges };
if (isRelationTargetFieldAnObjectRecordConnection) {
const relationTargetFieldEdgesWithoutRelationSourceRecordToDetach =
targetRecordFieldValue.edges.filter(
({ node }) => readField('id', node) !== sourceRecordId,
);
return {
...targetRecordFieldValue,
edges: relationTargetFieldEdgesWithoutRelationSourceRecordToDetach,
};
}
// To one object => detach previous relation record
if (isReference(cachedFieldValue)) {
const isRelationTargetFieldASingleObjectRecord = isReference(
targetRecordFieldValue,
);
if (isRelationTargetFieldASingleObjectRecord) {
return null;
}
return cachedFieldValue;
return targetRecordFieldValue;
},
},
});

View File

@ -6,124 +6,185 @@ import { triggerUpdateRelationsOptimisticEffect } from '@/apollo/optimistic-effe
import { CachedObjectRecord } from '@/apollo/types/CachedObjectRecord';
import { CachedObjectRecordEdge } from '@/apollo/types/CachedObjectRecordEdge';
import { CachedObjectRecordQueryVariables } from '@/apollo/types/CachedObjectRecordQueryVariables';
import { useGetRelationMetadata } from '@/object-metadata/hooks/useGetRelationMetadata';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { getEdgeTypename } from '@/object-record/cache/utils/getEdgeTypename';
import { isRecordMatchingFilter } from '@/object-record/record-filter/utils/isRecordMatchingFilter';
import { isDefined } from '~/utils/isDefined';
import { parseApolloStoreFieldName } from '~/utils/parseApolloStoreFieldName';
import { capitalize } from '~/utils/string/capitalize';
// TODO: add extensive unit tests for this function
// That will also serve as documentation
export const triggerUpdateRecordOptimisticEffect = ({
cache,
objectMetadataItem,
previousRecord,
nextRecord,
getRelationMetadata,
currentRecord,
updatedRecord,
objectMetadataItems,
}: {
cache: ApolloCache<unknown>;
objectMetadataItem: ObjectMetadataItem;
previousRecord: CachedObjectRecord;
nextRecord: CachedObjectRecord;
getRelationMetadata: ReturnType<typeof useGetRelationMetadata>;
currentRecord: CachedObjectRecord;
updatedRecord: CachedObjectRecord;
objectMetadataItems: ObjectMetadataItem[];
}) => {
const objectEdgeTypeName = `${capitalize(
objectMetadataItem.nameSingular,
)}Edge`;
const objectEdgeTypeName = getEdgeTypename({
objectNameSingular: objectMetadataItem.nameSingular,
});
triggerUpdateRelationsOptimisticEffect({
cache,
objectMetadataItem,
previousRecord,
nextRecord,
getRelationMetadata,
sourceObjectMetadataItem: objectMetadataItem,
currentSourceRecord: currentRecord,
updatedSourceRecord: updatedRecord,
objectMetadataItems,
});
// Optimistically update record lists
cache.modify<StoreObject>({
fields: {
[objectMetadataItem.namePlural]: (
cachedConnection,
rootQueryCachedResponse,
{ DELETE, readField, storeFieldName, toReference },
) => {
if (
const rootQueryCachedResponseIsNotACachedObjectRecordConnection =
!isCachedObjectRecordConnection(
objectMetadataItem.nameSingular,
cachedConnection,
)
)
return cachedConnection;
rootQueryCachedResponse,
);
const { variables } =
if (rootQueryCachedResponseIsNotACachedObjectRecordConnection) {
return rootQueryCachedResponse;
}
const rootQueryCachedObjectRecordConnection = rootQueryCachedResponse;
const { fieldArguments: rootQueryVariables } =
parseApolloStoreFieldName<CachedObjectRecordQueryVariables>(
storeFieldName,
);
const cachedEdges = readField<CachedObjectRecordEdge[]>(
'edges',
cachedConnection,
);
let nextCachedEdges = cachedEdges ? [...cachedEdges] : [];
const rootQueryCurrentCachedRecordEdges =
readField<CachedObjectRecordEdge[]>(
'edges',
rootQueryCachedObjectRecordConnection,
) ?? [];
// Test if the record matches this list's filters
if (variables?.filter) {
const matchesFilter = isRecordMatchingFilter({
record: nextRecord,
filter: variables.filter,
objectMetadataItem,
});
const recordIndex = nextCachedEdges.findIndex(
(cachedEdge) => readField('id', cachedEdge.node) === nextRecord.id,
);
let rootQueryNextCachedRecordEdges = [
...rootQueryCurrentCachedRecordEdges,
];
// If after update, the record matches this list's filters, then add it to the list
if (matchesFilter && recordIndex === -1) {
const nodeReference = toReference(nextRecord);
nodeReference &&
nextCachedEdges.push({
const rootQueryFilter = rootQueryVariables?.filter;
const rootQueryOrderBy = rootQueryVariables?.orderBy;
const rootQueryLimit = rootQueryVariables?.first;
const shouldTestThatUpdatedRecordMatchesThisRootQueryFilter =
isDefined(rootQueryFilter);
if (shouldTestThatUpdatedRecordMatchesThisRootQueryFilter) {
const updatedRecordMatchesThisRootQueryFilter =
isRecordMatchingFilter({
record: updatedRecord,
filter: rootQueryFilter,
objectMetadataItem,
});
const updatedRecordIndexInRootQueryEdges =
rootQueryCurrentCachedRecordEdges.findIndex(
(cachedEdge) =>
readField('id', cachedEdge.node) === updatedRecord.id,
);
const updatedRecordShouldBeAddedToRootQueryEdges =
updatedRecordMatchesThisRootQueryFilter &&
updatedRecordIndexInRootQueryEdges === -1;
const updatedRecordShouldBeRemovedFromRootQueryEdges =
updatedRecordMatchesThisRootQueryFilter &&
updatedRecordIndexInRootQueryEdges === -1;
if (updatedRecordShouldBeAddedToRootQueryEdges) {
const updatedRecordNodeReference = toReference(updatedRecord);
if (isDefined(updatedRecordNodeReference)) {
rootQueryNextCachedRecordEdges.push({
__typename: objectEdgeTypeName,
node: nodeReference,
node: updatedRecordNodeReference,
cursor: '',
});
}
}
// If after update, the record does not match this list's filters anymore, then remove it from the list
if (!matchesFilter && recordIndex > -1) {
nextCachedEdges.splice(recordIndex, 1);
if (updatedRecordShouldBeRemovedFromRootQueryEdges) {
rootQueryNextCachedRecordEdges.splice(
updatedRecordIndexInRootQueryEdges,
1,
);
}
}
// Sort updated list
if (variables?.orderBy) {
nextCachedEdges = sortCachedObjectEdges({
edges: nextCachedEdges,
orderBy: variables.orderBy,
const nextRootQueryEdgesShouldBeSorted = isDefined(rootQueryOrderBy);
if (nextRootQueryEdgesShouldBeSorted) {
rootQueryNextCachedRecordEdges = sortCachedObjectEdges({
edges: rootQueryNextCachedRecordEdges,
orderBy: rootQueryOrderBy,
readCacheField: readField,
});
}
// Limit the updated list to the required size
if (isDefined(variables?.first)) {
const shouldLimitNextRootQueryEdges = isDefined(rootQueryLimit);
// TODO: not sure that we should trigger a DELETE here, as it will trigger a network request
// Is it the responsibility of this optimistic effect function to delete a root query that will trigger a network request ?
// Shouldn't we let the response from the network overwrite the cache and keep this util purely about cache updates ?
//
// Shoud we even apply the limit at all since with pagination we cannot really do optimistic rendering and should
// wait for the network response to update the cache
//
// Maybe we could apply a merging function instead and exclude limit from the caching field arguments ?
// Also we have a problem that is not yet present with this but may arise if we start
// to use limit arguments, as for now we rely on the hard coded limit of 60 in pg_graphql.
// This is as if we had a { limit: 60 } argument in every query but we don't.
// so Apollo correctly merges the return of fetchMore for now, because of this,
// but wouldn't do it well like Thomas had the problem with mail threads
// because he applied a limit of 2 and Apollo created one root query in the cache for each.
// In Thomas' case we should implement this because he use a hack to overwrite the first request with the return of the other.
// See: https://www.apollographql.com/docs/react/pagination/cursor-based/#relay-style-cursor-pagination
// See: https://www.apollographql.com/docs/react/pagination/core-api/#merging-queries
if (shouldLimitNextRootQueryEdges) {
// If previous edges length was exactly at the required limit,
// but after update next edges length is under the limit,
// we cannot for sure know if re-fetching the query
// would return more edges, so we cannot optimistically deduce
// the query's result.
// In this case, invalidate the cache entry so it can be re-fetched.
if (
cachedEdges?.length === variables.first &&
nextCachedEdges.length < variables.first
) {
const rootQueryCurrentCachedRecordEdgesLengthIsAtLimit =
rootQueryCurrentCachedRecordEdges.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;
const shouldDeleteRootQuerySoItCanBeRefetched =
rootQueryCurrentCachedRecordEdgesLengthIsAtLimit &&
rootQueryNextCachedRecordEdgesLengthIsUnderLimit;
if (shouldDeleteRootQuerySoItCanBeRefetched) {
return DELETE;
}
// If next edges length exceeds the required limit,
// trim the next edges array to the correct length.
if (nextCachedEdges.length > variables.first) {
nextCachedEdges.splice(variables.first);
const rootQueryNextCachedRecordEdgesLengthIsAboveRootQueryLimit =
rootQueryNextCachedRecordEdges.length > rootQueryLimit;
if (rootQueryNextCachedRecordEdgesLengthIsAboveRootQueryLimit) {
rootQueryNextCachedRecordEdges.splice(rootQueryLimit);
}
}
return { ...cachedConnection, edges: nextCachedEdges };
return {
...rootQueryCachedObjectRecordConnection,
edges: rootQueryNextCachedRecordEdges,
};
},
},
});

View File

@ -1,111 +1,147 @@
import { ApolloCache } from '@apollo/client';
import { getRelationDefinition } from '@/apollo/optimistic-effect/utils/getRelationDefinition';
import { isObjectRecordConnection } from '@/apollo/optimistic-effect/utils/isObjectRecordConnection';
import { triggerAttachRelationOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerAttachRelationOptimisticEffect';
import { triggerDeleteRecordsOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerDeleteRecordsOptimisticEffect';
import { triggerDetachRelationOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerDetachRelationOptimisticEffect';
import { CachedObjectRecord } from '@/apollo/types/CachedObjectRecord';
import { coreObjectNamesToDeleteOnRelationDetach } from '@/apollo/types/coreObjectNamesToDeleteOnRelationDetach';
import { useGetRelationMetadata } from '@/object-metadata/hooks/useGetRelationMetadata';
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 { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { ObjectRecordConnection } from '@/object-record/types/ObjectRecordConnection';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
import { isDefined } from '~/utils/isDefined';
export const triggerUpdateRelationsOptimisticEffect = ({
cache,
objectMetadataItem,
previousRecord,
nextRecord,
getRelationMetadata,
sourceObjectMetadataItem,
currentSourceRecord,
updatedSourceRecord,
objectMetadataItems,
}: {
cache: ApolloCache<unknown>;
objectMetadataItem: ObjectMetadataItem;
previousRecord: CachedObjectRecord | null;
nextRecord: CachedObjectRecord | null;
getRelationMetadata: ReturnType<typeof useGetRelationMetadata>;
sourceObjectMetadataItem: ObjectMetadataItem;
currentSourceRecord: CachedObjectRecord | null;
updatedSourceRecord: CachedObjectRecord | null;
objectMetadataItems: ObjectMetadataItem[];
}) =>
// Optimistically update relation records
objectMetadataItem.fields.forEach((fieldMetadataItem) => {
if (nextRecord && !(fieldMetadataItem.name in nextRecord)) return;
sourceObjectMetadataItem.fields.forEach((fieldMetadataItemOnSourceRecord) => {
const notARelationField =
fieldMetadataItemOnSourceRecord.type !== FieldMetadataType.Relation;
const relationMetadata = getRelationMetadata({
fieldMetadataItem,
if (notARelationField) {
return;
}
const fieldDoesNotExist =
isDefined(updatedSourceRecord) &&
!(fieldMetadataItemOnSourceRecord.name in updatedSourceRecord);
if (fieldDoesNotExist) {
return;
}
const relationDefinition = getRelationDefinition({
fieldMetadataItemOnSourceRecord,
objectMetadataItems,
});
if (!relationMetadata) return;
if (!relationDefinition) {
return;
}
const {
// Object metadata for the related record
relationObjectMetadataItem,
// Field on the related record
relationFieldMetadataItem,
} = relationMetadata;
const { targetObjectMetadataItem, fieldMetadataItemOnTargetRecord } =
relationDefinition;
const previousFieldValue:
const currentFieldValueOnSourceRecord:
| ObjectRecordConnection
| CachedObjectRecord
| null = previousRecord?.[fieldMetadataItem.name];
const nextFieldValue: ObjectRecordConnection | CachedObjectRecord | null =
nextRecord?.[fieldMetadataItem.name];
| null = currentSourceRecord?.[fieldMetadataItemOnSourceRecord.name];
if (isDeeplyEqual(previousFieldValue, nextFieldValue)) return;
const updatedFieldValueOnSourceRecord:
| ObjectRecordConnection
| CachedObjectRecord
| null = updatedSourceRecord?.[fieldMetadataItemOnSourceRecord.name];
const isPreviousFieldValueRecordConnection = isObjectRecordConnection(
relationObjectMetadataItem.nameSingular,
previousFieldValue,
);
const relationRecordsToDetach = isPreviousFieldValueRecordConnection
? previousFieldValue.edges.map(({ node }) => node as CachedObjectRecord)
: [previousFieldValue].filter(isDefined);
if (
isDeeplyEqual(
currentFieldValueOnSourceRecord,
updatedFieldValueOnSourceRecord,
)
) {
return;
}
const isNextFieldValueRecordConnection = isObjectRecordConnection(
relationObjectMetadataItem.nameSingular,
nextFieldValue,
);
const relationRecordsToAttach = isNextFieldValueRecordConnection
? nextFieldValue.edges.map(({ node }) => node as CachedObjectRecord)
: [nextFieldValue].filter(isDefined);
const currentFieldValueOnSourceRecordIsARecordConnection =
isObjectRecordConnection(
targetObjectMetadataItem.nameSingular,
currentFieldValueOnSourceRecord,
);
if (previousRecord && relationRecordsToDetach.length) {
const shouldDeleteRelationRecord =
coreObjectNamesToDeleteOnRelationDetach.includes(
relationObjectMetadataItem.nameSingular as CoreObjectNameSingular,
const targetRecordsToDetachFrom =
currentFieldValueOnSourceRecordIsARecordConnection
? currentFieldValueOnSourceRecord.edges.map(
({ node }) => node as CachedObjectRecord,
)
: [currentFieldValueOnSourceRecord].filter(isDefined);
const updatedFieldValueOnSourceRecordIsARecordConnection =
isObjectRecordConnection(
targetObjectMetadataItem.nameSingular,
updatedFieldValueOnSourceRecord,
);
const targetRecordsToAttachTo =
updatedFieldValueOnSourceRecordIsARecordConnection
? updatedFieldValueOnSourceRecord.edges.map(
({ node }) => node as CachedObjectRecord,
)
: [updatedFieldValueOnSourceRecord].filter(isDefined);
const shouldDetachSourceFromAllTargets =
isDefined(currentSourceRecord) && targetRecordsToDetachFrom.length > 0;
if (shouldDetachSourceFromAllTargets) {
const shouldStartByDeletingRelationTargetRecordsFromCache =
CORE_OBJECT_NAMES_TO_DELETE_ON_OPTIMISTIC_RELATION_DETACH.includes(
targetObjectMetadataItem.nameSingular as CoreObjectNameSingular,
);
if (shouldDeleteRelationRecord) {
if (shouldStartByDeletingRelationTargetRecordsFromCache) {
triggerDeleteRecordsOptimisticEffect({
cache,
objectMetadataItem: relationObjectMetadataItem,
records: relationRecordsToDetach,
getRelationMetadata,
objectMetadataItem: targetObjectMetadataItem,
recordsToDelete: targetRecordsToDetachFrom,
objectMetadataItems,
});
} else {
relationRecordsToDetach.forEach((relationRecordToDetach) => {
targetRecordsToDetachFrom.forEach((targetRecordToDetachFrom) => {
triggerDetachRelationOptimisticEffect({
cache,
objectNameSingular: objectMetadataItem.nameSingular,
recordId: previousRecord.id,
relationFieldName: relationFieldMetadataItem.name,
relationObjectMetadataNameSingular:
relationObjectMetadataItem.nameSingular,
relationRecordId: relationRecordToDetach.id,
sourceObjectNameSingular: sourceObjectMetadataItem.nameSingular,
sourceRecordId: currentSourceRecord.id,
fieldNameOnTargetRecord: fieldMetadataItemOnTargetRecord.name,
targetObjectNameSingular: targetObjectMetadataItem.nameSingular,
targetRecordId: targetRecordToDetachFrom.id,
});
});
}
}
if (nextRecord && relationRecordsToAttach.length) {
relationRecordsToAttach.forEach((relationRecordToAttach) =>
const shouldAttachSourceToAllTargets =
updatedSourceRecord && targetRecordsToAttachTo.length;
if (shouldAttachSourceToAllTargets) {
targetRecordsToAttachTo.forEach((targetRecordToAttachTo) =>
triggerAttachRelationOptimisticEffect({
cache,
objectNameSingular: objectMetadataItem.nameSingular,
recordId: nextRecord.id,
relationFieldName: relationFieldMetadataItem.name,
relationObjectMetadataNameSingular:
relationObjectMetadataItem.nameSingular,
relationRecordId: relationRecordToAttach.id,
sourceObjectNameSingular: sourceObjectMetadataItem.nameSingular,
sourceRecordId: updatedSourceRecord.id,
fieldNameOnTargetRecord: fieldMetadataItemOnTargetRecord.name,
targetObjectNameSingular: targetObjectMetadataItem.nameSingular,
targetRecordId: targetRecordToAttachTo.id,
}),
);
}

View File

@ -1,5 +1,5 @@
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
export const coreObjectNamesToDeleteOnRelationDetach = [
export const CORE_OBJECT_NAMES_TO_DELETE_ON_TRIGGER_RELATION_DETACH = [
CoreObjectNameSingular.Favorite,
];

View File

@ -8,10 +8,15 @@ import { FieldMetadataItem } from '../types/FieldMetadataItem';
export const useMapFieldMetadataToGraphQLQuery = () => {
const objectMetadataItems = useRecoilValue(objectMetadataItemsState);
const mapFieldMetadataToGraphQLQuery = (
field: FieldMetadataItem,
maxDepthForRelations: number = 2,
): any => {
const mapFieldMetadataToGraphQLQuery = ({
field,
maxDepthForRelations = 2,
onlyTypenameAndIdOnDeepestRelationFields = false,
}: {
field: FieldMetadataItem;
maxDepthForRelations?: number;
onlyTypenameAndIdOnDeepestRelationFields?: boolean;
}): any => {
if (maxDepthForRelations <= 0) {
return '';
}
@ -45,14 +50,25 @@ export const useMapFieldMetadataToGraphQLQuery = () => {
(field.toRelationMetadata as any)?.fromObjectMetadata?.id,
);
let subfieldQuery = '';
if (maxDepthForRelations > 0) {
subfieldQuery = `${(relationMetadataItem?.fields ?? [])
.map((field) =>
mapFieldMetadataToGraphQLQuery({
field,
maxDepthForRelations: maxDepthForRelations - 1,
onlyTypenameAndIdOnDeepestRelationFields,
}),
)
.join('\n')}`;
}
return `${field.name}
{
__typename
id
${(relationMetadataItem?.fields ?? [])
.map((field) =>
mapFieldMetadataToGraphQLQuery(field, maxDepthForRelations - 1),
)
.join('\n')}
${subfieldQuery}
}`;
} else if (
fieldType === 'RELATION' &&
@ -64,14 +80,25 @@ export const useMapFieldMetadataToGraphQLQuery = () => {
(field.toRelationMetadata as any)?.fromObjectMetadata?.id,
);
let subfieldQuery = '';
if (maxDepthForRelations > 0) {
subfieldQuery = `${(relationMetadataItem?.fields ?? [])
.map((field) =>
mapFieldMetadataToGraphQLQuery({
field,
maxDepthForRelations: maxDepthForRelations - 1,
onlyTypenameAndIdOnDeepestRelationFields,
}),
)
.join('\n')}`;
}
return `${field.name}
{
__typename
id
${(relationMetadataItem?.fields ?? [])
.map((field) =>
mapFieldMetadataToGraphQLQuery(field, maxDepthForRelations - 1),
)
.join('\n')}
${subfieldQuery}
}`;
} else if (
fieldType === 'RELATION' &&
@ -83,16 +110,27 @@ export const useMapFieldMetadataToGraphQLQuery = () => {
(field.fromRelationMetadata as any)?.toObjectMetadata?.id,
);
let subfieldQuery = '';
if (maxDepthForRelations > 0) {
subfieldQuery = `${(relationMetadataItem?.fields ?? [])
.map((field) =>
mapFieldMetadataToGraphQLQuery({
field,
maxDepthForRelations: maxDepthForRelations - 1,
onlyTypenameAndIdOnDeepestRelationFields,
}),
)
.join('\n')}`;
}
return `${field.name}
{
edges {
node {
__typename
id
${(relationMetadataItem?.fields ?? [])
.map((field) =>
mapFieldMetadataToGraphQLQuery(field, maxDepthForRelations - 1),
)
.join('\n')}
${subfieldQuery}
}
}
}`;

View File

@ -0,0 +1,11 @@
import { useRecoilValue } from 'recoil';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
export const useObjectMetadataItems = () => {
const objectMetadataItems = useRecoilValue(objectMetadataItemsState);
return {
objectMetadataItems,
};
};

View File

@ -0,0 +1,22 @@
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
export const getObjectMetadataItemByNameSingular = ({
objectMetadataItems,
objectNameSingular,
}: {
objectMetadataItems: ObjectMetadataItem[];
objectNameSingular: string;
}) => {
const foundObjectMetadataItem = objectMetadataItems.find(
(objectMetadataItem) =>
objectMetadataItem.nameSingular === objectNameSingular,
);
if (!foundObjectMetadataItem) {
throw new Error(
`Could not find object metadata item with singular name ${objectNameSingular}`,
);
}
return foundObjectMetadataItem;
};

View File

@ -17,12 +17,19 @@ export const getObjectRecordIdentifier = ({
}): ObjectRecordIdentifier => {
switch (objectMetadataItem.nameSingular) {
case CoreObjectNameSingular.WorkspaceMember: {
const workspaceMember = record as WorkspaceMember;
const workspaceMember = record as Partial<WorkspaceMember> & {
id: string;
};
const name = workspaceMember.name
? `${workspaceMember.name?.firstName ?? ''} ${
workspaceMember.name?.lastName ?? ''
}`
: '';
return {
id: workspaceMember.id,
name:
workspaceMember.name.firstName + ' ' + workspaceMember.name.lastName,
name,
avatarUrl: workspaceMember.avatarUrl ?? undefined,
avatarType: 'rounded',
};

View File

@ -0,0 +1 @@
export const MAX_QUERY_DEPTH_FOR_CACHE_INJECTION = 1;

View File

@ -4,7 +4,8 @@ import { useRecoilCallback } from 'recoil';
import { useMapFieldMetadataToGraphQLQuery } from '@/object-metadata/hooks/useMapFieldMetadataToGraphQLQuery';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { useGenerateFindOneRecordQuery } from '@/object-record/hooks/useGenerateFindOneRecordQuery';
import { MAX_QUERY_DEPTH_FOR_CACHE_INJECTION } from '@/object-record/cache/constants/MaxQueryDepthForCacheInjection';
import { useInjectIntoFindOneRecordQueryCache } from '@/object-record/cache/hooks/useInjectIntoFindOneRecordQueryCache';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { capitalize } from '~/utils/string/capitalize';
@ -17,47 +18,44 @@ export const useAddRecordInCache = ({
const mapFieldMetadataToGraphQLQuery = useMapFieldMetadataToGraphQLQuery();
const apolloClient = useApolloClient();
const generateFindOneRecordQuery = useGenerateFindOneRecordQuery();
const findOneRecordQuery = generateFindOneRecordQuery({
objectMetadataItem,
});
const { injectIntoFindOneRecordQueryCache } =
useInjectIntoFindOneRecordQueryCache({
objectMetadataItem,
});
return useRecoilCallback(
({ set }) =>
(record: ObjectRecord) => {
const fragment = gql`
fragment Create${capitalize(
objectMetadataItem.nameSingular,
)}InCache on ${capitalize(objectMetadataItem.nameSingular)} {
__typename
id
${objectMetadataItem.fields
.map((field) =>
mapFieldMetadataToGraphQLQuery({
field,
maxDepthForRelations: MAX_QUERY_DEPTH_FOR_CACHE_INJECTION,
}),
)
.join('\n')}
}
`;
const cachedObjectRecord = {
__typename: `${capitalize(objectMetadataItem.nameSingular)}`,
...record,
};
apolloClient.writeFragment({
id: `${capitalize(objectMetadataItem.nameSingular)}:${record.id}`,
fragment: gql`
fragment Create${capitalize(
objectMetadataItem.nameSingular,
)}InCache on ${capitalize(objectMetadataItem.nameSingular)} {
__typename
id
${objectMetadataItem.fields
.map((field) => mapFieldMetadataToGraphQLQuery(field))
.join('\n')}
}
`,
data: {
__typename: `${capitalize(objectMetadataItem.nameSingular)}`,
...record,
},
fragment,
data: cachedObjectRecord,
});
// TODO: Turn into injectIntoFindOneRecordQueryCache
apolloClient.writeQuery({
query: findOneRecordQuery,
variables: {
objectRecordId: record.id,
},
data: {
[objectMetadataItem.nameSingular]: {
__typename: `${capitalize(objectMetadataItem.nameSingular)}`,
...record,
},
},
});
// TODO: should we keep this here ? Or should the caller of createOneRecordInCache/createManyRecordsInCache be responsible for this ?
injectIntoFindOneRecordQueryCache(cachedObjectRecord);
// TODO: remove this once we get rid of entityFieldsFamilyState
set(recordStoreFamilyState(record.id), record);
@ -66,7 +64,7 @@ export const useAddRecordInCache = ({
objectMetadataItem,
apolloClient,
mapFieldMetadataToGraphQLQuery,
findOneRecordQuery,
injectIntoFindOneRecordQueryCache,
],
);
};

View File

@ -0,0 +1,50 @@
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { useReadFindManyRecordsQueryInCache } from '@/object-record/cache/hooks/useReadFindManyRecordsQueryInCache';
import { useUpsertFindManyRecordsQueryInCache } from '@/object-record/cache/hooks/useUpsertFindManyRecordsQueryInCache';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { ObjectRecordQueryVariables } from '@/object-record/types/ObjectRecordQueryVariables';
export const useAppendToFindManyRecordsQueryInCache = ({
objectMetadataItem,
}: {
objectMetadataItem: ObjectMetadataItem;
}) => {
const { readFindManyRecordsQueryInCache } =
useReadFindManyRecordsQueryInCache({
objectMetadataItem,
});
const {
upsertFindManyRecordsQueryInCache: overwriteFindManyRecordsQueryInCache,
} = useUpsertFindManyRecordsQueryInCache({
objectMetadataItem,
});
const appendToFindManyRecordsQueryInCache = <
T extends ObjectRecord = ObjectRecord,
>({
queryVariables,
objectRecordsToAppend,
}: {
queryVariables: ObjectRecordQueryVariables;
objectRecordsToAppend: T[];
}) => {
const existingObjectRecords = readFindManyRecordsQueryInCache({
queryVariables,
});
const newObjectRecordList = [
...existingObjectRecords,
...objectRecordsToAppend,
];
overwriteFindManyRecordsQueryInCache({
objectRecordsToOverwrite: newObjectRecordList,
queryVariables,
});
};
return {
appendToFindManyRecordsQueryInCache,
};
};

View File

@ -27,7 +27,7 @@ export const useGetRecordFromCache = ({
fragment ${capitalizedObjectName}Fragment on ${capitalizedObjectName} {
id
${objectMetadataItem.fields
.map((field) => mapFieldMetadataToGraphQLQuery(field))
.map((field) => mapFieldMetadataToGraphQLQuery({ field }))
.join('\n')}
}
`;

View File

@ -0,0 +1,44 @@
import { useApolloClient } from '@apollo/client';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { useGenerateFindOneRecordQuery } from '@/object-record/hooks/useGenerateFindOneRecordQuery';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { capitalize } from '~/utils/string/capitalize';
export const useInjectIntoFindOneRecordQueryCache = ({
objectMetadataItem,
}: {
objectMetadataItem: ObjectMetadataItem;
}) => {
const apolloClient = useApolloClient();
const generateFindOneRecordQuery = useGenerateFindOneRecordQuery();
const injectIntoFindOneRecordQueryCache = <
T extends ObjectRecord = ObjectRecord,
>(
record: T,
) => {
const findOneRecordQueryForCacheInjection = generateFindOneRecordQuery({
objectMetadataItem,
depth: 1,
});
apolloClient.writeQuery({
query: findOneRecordQueryForCacheInjection,
variables: {
objectRecordId: record.id,
},
data: {
[objectMetadataItem.nameSingular]: {
__typename: `${capitalize(objectMetadataItem.nameSingular)}`,
...record,
},
},
});
};
return {
injectIntoFindOneRecordQueryCache,
};
};

View File

@ -0,0 +1,53 @@
import { useApolloClient } from '@apollo/client';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { getRecordsFromRecordConnection } from '@/object-record/cache/utils/getRecordsFromRecordConnection';
import { useGenerateFindManyRecordsQuery } from '@/object-record/hooks/useGenerateFindManyRecordsQuery';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { ObjectRecordQueryResult } from '@/object-record/types/ObjectRecordQueryResult';
import { ObjectRecordQueryVariables } from '@/object-record/types/ObjectRecordQueryVariables';
import { isDefined } from '~/utils/isDefined';
export const useReadFindManyRecordsQueryInCache = ({
objectMetadataItem,
}: {
objectMetadataItem: ObjectMetadataItem;
}) => {
const apolloClient = useApolloClient();
const generateFindManyRecordsQuery = useGenerateFindManyRecordsQuery();
const readFindManyRecordsQueryInCache = <
T extends ObjectRecord = ObjectRecord,
>({
queryVariables,
}: {
queryVariables: ObjectRecordQueryVariables;
}) => {
const findManyRecordsQueryForCacheRead = generateFindManyRecordsQuery({
objectMetadataItem,
});
const existingRecordsQueryResult = apolloClient.readQuery<
ObjectRecordQueryResult<T>
>({
query: findManyRecordsQueryForCacheRead,
variables: queryVariables,
});
const existingRecordConnection =
existingRecordsQueryResult?.[objectMetadataItem.namePlural];
const existingObjectRecords = isDefined(existingRecordConnection)
? getRecordsFromRecordConnection({
recordConnection: existingRecordConnection,
})
: [];
return existingObjectRecords;
};
return {
readFindManyRecordsQueryInCache,
};
};

View File

@ -0,0 +1,51 @@
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';
import { useGenerateFindManyRecordsQuery } from '@/object-record/hooks/useGenerateFindManyRecordsQuery';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { ObjectRecordQueryVariables } from '@/object-record/types/ObjectRecordQueryVariables';
export const useUpsertFindManyRecordsQueryInCache = ({
objectMetadataItem,
}: {
objectMetadataItem: ObjectMetadataItem;
}) => {
const apolloClient = useApolloClient();
const generateFindManyRecordsQuery = useGenerateFindManyRecordsQuery();
const upsertFindManyRecordsQueryInCache = <
T extends ObjectRecord = ObjectRecord,
>({
queryVariables,
objectRecordsToOverwrite,
}: {
queryVariables: ObjectRecordQueryVariables;
objectRecordsToOverwrite: T[];
}) => {
const findManyRecordsQueryForCacheOverwrite = generateFindManyRecordsQuery({
objectMetadataItem,
depth: MAX_QUERY_DEPTH_FOR_CACHE_INJECTION,
});
const newObjectRecordConnection = getRecordConnectionFromRecords({
objectNameSingular: CoreObjectNameSingular.ActivityTarget,
records: objectRecordsToOverwrite,
});
apolloClient.writeQuery({
query: findManyRecordsQueryForCacheOverwrite,
variables: queryVariables,
data: {
[objectMetadataItem.namePlural]: newObjectRecordConnection,
},
});
};
return {
upsertFindManyRecordsQueryInCache,
};
};

View File

@ -2,14 +2,18 @@ import { useApolloClient } from '@apollo/client';
import { v4 } from 'uuid';
import { triggerCreateRecordsOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerCreateRecordsOptimisticEffect';
import { useGetRelationMetadata } from '@/object-metadata/hooks/useGetRelationMetadata';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { ObjectMetadataItemIdentifier } from '@/object-metadata/types/ObjectMetadataItemIdentifier';
import { useGenerateObjectRecordOptimisticResponse } from '@/object-record/cache/hooks/useGenerateObjectRecordOptimisticResponse';
import { getCreateManyRecordsMutationResponseField } from '@/object-record/hooks/useGenerateCreateManyRecordMutation';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { sanitizeRecordInput } from '@/object-record/utils/sanitizeRecordInput';
type CreateManyRecordsOptions = {
skipOptimisticEffect?: boolean;
};
export const useCreateManyRecords = <
CreatedObjectRecord extends ObjectRecord = ObjectRecord,
>({
@ -27,15 +31,22 @@ export const useCreateManyRecords = <
objectMetadataItem,
});
const getRelationMetadata = useGetRelationMetadata();
const { objectMetadataItems } = useObjectMetadataItems();
const createManyRecords = async (data: Partial<CreatedObjectRecord>[]) => {
const sanitizedCreateManyRecordsInput = data.map((input) =>
sanitizeRecordInput({
const createManyRecords = async (
data: Partial<CreatedObjectRecord>[],
options?: CreateManyRecordsOptions,
) => {
const sanitizedCreateManyRecordsInput = data.map((input) => {
const idForCreation = input.id ?? v4();
const sanitizedRecordInput = sanitizeRecordInput({
objectMetadataItem,
recordInput: { ...input, id: v4() },
}),
);
recordInput: { ...input, id: idForCreation },
});
return sanitizedRecordInput;
});
const optimisticallyCreatedRecords = sanitizedCreateManyRecordsInput.map(
(record) =>
@ -51,21 +62,25 @@ export const useCreateManyRecords = <
variables: {
data: sanitizedCreateManyRecordsInput,
},
optimisticResponse: {
[mutationResponseField]: optimisticallyCreatedRecords,
},
update: (cache, { data }) => {
const records = data?.[mutationResponseField];
optimisticResponse: options?.skipOptimisticEffect
? undefined
: {
[mutationResponseField]: optimisticallyCreatedRecords,
},
update: options?.skipOptimisticEffect
? undefined
: (cache, { data }) => {
const records = data?.[mutationResponseField];
if (!records?.length) return;
if (!records?.length) return;
triggerCreateRecordsOptimisticEffect({
cache,
objectMetadataItem,
records,
getRelationMetadata,
});
},
triggerCreateRecordsOptimisticEffect({
cache,
objectMetadataItem,
recordsToCreate: records,
objectMetadataItems,
});
},
});
return createdObjects.data?.[mutationResponseField] ?? [];

View File

@ -22,7 +22,7 @@ export const useCreateManyRecordsInCache = <T extends ObjectRecord>({
objectMetadataItem,
});
const createManyRecordsInCache = async (data: Partial<T>[]) => {
const createManyRecordsInCache = (data: Partial<T>[]) => {
const recordsWithId = data.map((record) => ({
...record,
id: (record.id as string) ?? v4(),

View File

@ -2,8 +2,8 @@ import { useApolloClient } from '@apollo/client';
import { v4 } from 'uuid';
import { triggerCreateRecordsOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerCreateRecordsOptimisticEffect';
import { useGetRelationMetadata } from '@/object-metadata/hooks/useGetRelationMetadata';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { useGenerateObjectRecordOptimisticResponse } from '@/object-record/cache/hooks/useGenerateObjectRecordOptimisticResponse';
import { getCreateOneRecordMutationResponseField } from '@/object-record/hooks/useGenerateCreateOneRecordMutation';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
@ -13,6 +13,10 @@ type useCreateOneRecordProps = {
objectNameSingular: string;
};
type CreateOneRecordOptions = {
skipOptimisticEffect?: boolean;
};
export const useCreateOneRecord = <
CreatedObjectRecord extends ObjectRecord = ObjectRecord,
>({
@ -29,12 +33,17 @@ export const useCreateOneRecord = <
objectMetadataItem,
});
const getRelationMetadata = useGetRelationMetadata();
const { objectMetadataItems } = useObjectMetadataItems();
const createOneRecord = async (
input: Partial<CreatedObjectRecord>,
options?: CreateOneRecordOptions,
) => {
const idForCreation = input.id ?? v4();
const createOneRecord = async (input: Partial<CreatedObjectRecord>) => {
const sanitizedCreateOneRecordInput = sanitizeRecordInput({
objectMetadataItem,
recordInput: { ...input, id: v4() },
recordInput: { ...input, id: idForCreation },
});
const optimisticallyCreatedRecord =
@ -51,21 +60,25 @@ export const useCreateOneRecord = <
variables: {
input: sanitizedCreateOneRecordInput,
},
optimisticResponse: {
[mutationResponseField]: optimisticallyCreatedRecord,
},
update: (cache, { data }) => {
const record = data?.[mutationResponseField];
optimisticResponse: options?.skipOptimisticEffect
? undefined
: {
[mutationResponseField]: optimisticallyCreatedRecord,
},
update: options?.skipOptimisticEffect
? undefined
: (cache, { data }) => {
const record = data?.[mutationResponseField];
if (!record) return;
if (!record) return;
triggerCreateRecordsOptimisticEffect({
cache,
objectMetadataItem,
records: [record],
getRelationMetadata,
});
},
triggerCreateRecordsOptimisticEffect({
cache,
objectMetadataItem,
recordsToCreate: [record],
objectMetadataItems,
});
},
});
return createdObject.data?.[mutationResponseField] ?? null;

View File

@ -23,7 +23,7 @@ export const useCreateOneRecordInCache = <T>({
objectMetadataItem,
});
const createOneRecordInCache = async (input: ObjectRecord) => {
const createOneRecordInCache = (input: ObjectRecord) => {
const generatedCachedObjectRecord =
generateObjectRecordOptimisticResponse(input);

View File

@ -1,8 +1,8 @@
import { useApolloClient } from '@apollo/client';
import { triggerDeleteRecordsOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerDeleteRecordsOptimisticEffect';
import { useGetRelationMetadata } from '@/object-metadata/hooks/useGetRelationMetadata';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { getDeleteManyRecordsMutationResponseField } from '@/object-record/hooks/useGenerateDeleteManyRecordMutation';
import { isDefined } from '~/utils/isDefined';
import { capitalize } from '~/utils/string/capitalize';
@ -20,7 +20,7 @@ export const useDeleteManyRecords = ({
const { objectMetadataItem, deleteManyRecordsMutation, getRecordFromCache } =
useObjectMetadataItem({ objectNameSingular });
const getRelationMetadata = useGetRelationMetadata();
const { objectMetadataItems } = useObjectMetadataItems();
const mutationResponseField = getDeleteManyRecordsMutationResponseField(
objectMetadataItem.namePlural,
@ -50,8 +50,8 @@ export const useDeleteManyRecords = ({
triggerDeleteRecordsOptimisticEffect({
cache,
objectMetadataItem,
records: cachedRecords,
getRelationMetadata,
recordsToDelete: cachedRecords,
objectMetadataItems,
});
},
});

View File

@ -2,8 +2,8 @@ import { useCallback } from 'react';
import { useApolloClient } from '@apollo/client';
import { triggerDeleteRecordsOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerDeleteRecordsOptimisticEffect';
import { useGetRelationMetadata } from '@/object-metadata/hooks/useGetRelationMetadata';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { getDeleteOneRecordMutationResponseField } from '@/object-record/utils/generateDeleteOneRecordMutation';
import { capitalize } from '~/utils/string/capitalize';
@ -20,7 +20,7 @@ export const useDeleteOneRecord = ({
const { objectMetadataItem, deleteOneRecordMutation, getRecordFromCache } =
useObjectMetadataItem({ objectNameSingular });
const getRelationMetadata = useGetRelationMetadata();
const { objectMetadataItems } = useObjectMetadataItems();
const mutationResponseField =
getDeleteOneRecordMutationResponseField(objectNameSingular);
@ -48,8 +48,8 @@ export const useDeleteOneRecord = ({
triggerDeleteRecordsOptimisticEffect({
cache,
objectMetadataItem,
records: [cachedRecord],
getRelationMetadata,
recordsToDelete: [cachedRecord],
objectMetadataItems,
});
},
});
@ -60,10 +60,10 @@ export const useDeleteOneRecord = ({
apolloClient,
deleteOneRecordMutation,
getRecordFromCache,
getRelationMetadata,
mutationResponseField,
objectMetadataItem,
objectNameSingular,
objectMetadataItems,
],
);

View File

@ -17,6 +17,7 @@ export const useFieldContext = ({
isLabelIdentifier = false,
objectNameSingular,
objectRecordId,
customUseUpdateOneObjectHook,
}: {
clearable?: boolean;
fieldMetadataName: string;
@ -24,6 +25,7 @@ export const useFieldContext = ({
isLabelIdentifier?: boolean;
objectNameSingular: string;
objectRecordId: string;
customUseUpdateOneObjectHook?: RecordUpdateHook;
}) => {
const { basePathToShowPage, objectMetadataItem } = useObjectMetadataItem({
objectNameSingular,
@ -65,7 +67,8 @@ export const useFieldContext = ({
position: fieldPosition,
objectMetadataItem,
}),
useUpdateRecord: useUpdateOneObjectMutation,
useUpdateRecord:
customUseUpdateOneObjectHook ?? useUpdateOneObjectMutation,
hotkeyScope: InlineCellHotkeyScope.InlineCell,
clearable,
}}

View File

@ -2,10 +2,9 @@ import { useQuery } from '@apollo/client';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { ObjectMetadataItemIdentifier } from '@/object-metadata/types/ObjectMetadataItemIdentifier';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
export const useFindOneRecord = <
ObjectType extends { id: string } & Record<string, any>,
>({
export const useFindOneRecord = <T extends ObjectRecord = ObjectRecord>({
objectNameSingular,
objectRecordId = '',
onCompleted,
@ -13,7 +12,7 @@ export const useFindOneRecord = <
skip,
}: ObjectMetadataItemIdentifier & {
objectRecordId: string | undefined;
onCompleted?: (data: ObjectType) => void;
onCompleted?: (data: T) => void;
skip?: boolean;
depth?: number;
}) => {
@ -23,7 +22,7 @@ export const useFindOneRecord = <
);
const { data, loading, error } = useQuery<
{ [nameSingular: string]: ObjectType },
{ [nameSingular: string]: T },
{ objectRecordId: string }
>(findOneRecordQuery, {
skip: !objectMetadataItem || !objectRecordId || skip,

View File

@ -31,7 +31,11 @@ export const useGenerateCreateManyRecordMutation = ({
${mutationResponseField}(data: $data) {
id
${objectMetadataItem.fields
.map((field) => mapFieldMetadataToGraphQLQuery(field))
.map((field) =>
mapFieldMetadataToGraphQLQuery({
field,
}),
)
.join('\n')}
}
}`;

View File

@ -31,7 +31,11 @@ export const useGenerateCreateOneRecordMutation = ({
${mutationResponseField}(data: $input) {
id
${objectMetadataItem.fields
.map((field) => mapFieldMetadataToGraphQLQuery(field))
.map((field) =>
mapFieldMetadataToGraphQLQuery({
field,
}),
)
.join('\n')}
}
}

View File

@ -36,7 +36,11 @@ export const useGenerateExecuteQuickActionOnOneRecordMutation = ({
${graphQLFieldForExecuteQuickActionOnOneRecordMutation}(id: $idToExecuteQuickActionOn) {
id
${objectMetadataItem.fields
.map((field) => mapFieldMetadataToGraphQLQuery(field))
.map((field) =>
mapFieldMetadataToGraphQLQuery({
field,
}),
)
.join('\n')}
}
}

View File

@ -71,7 +71,12 @@ export const useGenerateFindManyRecordsForMultipleMetadataItemsQuery = ({
node {
id
${fields
.map((field) => mapFieldMetadataToGraphQLQuery(field, depth))
.map((field) =>
mapFieldMetadataToGraphQLQuery({
field,
maxDepthForRelations: depth,
}),
)
.join('\n')}
}
cursor

View File

@ -28,7 +28,12 @@ export const useGenerateFindManyRecordsQuery = () => {
node {
id
${objectMetadataItem.fields
.map((field) => mapFieldMetadataToGraphQLQuery(field, depth))
.map((field) =>
mapFieldMetadataToGraphQLQuery({
field,
maxDepthForRelations: depth,
}),
)
.join('\n')}
}
cursor

View File

@ -12,8 +12,8 @@ export const useGenerateFindOneRecordQuery = () => {
}: {
objectMetadataItem: Pick<ObjectMetadataItem, 'nameSingular' | 'fields'>;
depth?: number;
}) =>
gql`
}) => {
return gql`
query FindOne${objectMetadataItem.nameSingular}($objectRecordId: UUID!) {
${objectMetadataItem.nameSingular}(filter: {
id: {
@ -22,9 +22,15 @@ export const useGenerateFindOneRecordQuery = () => {
}){
id
${objectMetadataItem.fields
.map((field) => mapFieldMetadataToGraphQLQuery(field, depth))
.map((field) =>
mapFieldMetadataToGraphQLQuery({
field,
maxDepthForRelations: depth,
}),
)
.join('\n')}
}
}
`;
`;
};
};

View File

@ -31,7 +31,7 @@ export const useGenerateUpdateOneRecordMutation = ({
${mutationResponseField}(id: $idToUpdate, data: $input) {
id
${objectMetadataItem.fields
.map((field) => mapFieldMetadataToGraphQLQuery(field))
.map((field) => mapFieldMetadataToGraphQLQuery({ field }))
.join('\n')}
}
}

View File

@ -1,8 +1,8 @@
import { useApolloClient } from '@apollo/client';
import { triggerUpdateRecordOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerUpdateRecordOptimisticEffect';
import { useGetRelationMetadata } from '@/object-metadata/hooks/useGetRelationMetadata';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { useGenerateObjectRecordOptimisticResponse } from '@/object-record/cache/hooks/useGenerateObjectRecordOptimisticResponse';
import { getUpdateOneRecordMutationResponseField } from '@/object-record/hooks/useGenerateUpdateOneRecordMutation';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
@ -27,7 +27,7 @@ export const useUpdateOneRecord = <
objectMetadataItem,
});
const getRelationMetadata = useGetRelationMetadata();
const { objectMetadataItems } = useObjectMetadataItems();
const updateOneRecord = async ({
idToUpdate,
@ -69,9 +69,9 @@ export const useUpdateOneRecord = <
triggerUpdateRecordOptimisticEffect({
cache,
objectMetadataItem,
previousRecord: cachedRecord,
nextRecord: record,
getRelationMetadata,
currentRecord: cachedRecord,
updatedRecord: record,
objectMetadataItems,
});
},
});

View File

@ -1,5 +1,6 @@
import { ChangeEvent, useCallback, useContext, useState } from 'react';
import styled from '@emotion/styled';
import debounce from 'lodash.debounce';
import { IconTrash } from '@/ui/display/icon';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
@ -8,7 +9,6 @@ import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem';
import { MenuItemSelectColor } from '@/ui/navigation/menu-item/components/MenuItemSelectColor';
import { mainColorNames, ThemeColor } from '@/ui/theme/constants/colors';
import { textInputStyle } from '@/ui/theme/constants/effects';
import { debounce } from '~/utils/debounce';
import { BoardColumnContext } from '../contexts/BoardColumnContext';
import { useRecordBoardDeprecated } from '../hooks/useRecordBoardDeprecated';

View File

@ -36,7 +36,7 @@ export const generateEmptyFieldValue = (
return null;
}
case FieldMetadataType.Uuid: {
return '';
return null;
}
case FieldMetadataType.Boolean: {
return true;

View File

@ -0,0 +1,8 @@
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { ObjectRecordEdge } from '@/object-record/types/ObjectRecordEdge';
export const mapEdgeToObjectRecord = <T extends ObjectRecord>(
objectRecordEdge: ObjectRecordEdge<T>,
) => {
return objectRecordEdge.node as T;
};

View File

@ -58,7 +58,9 @@ export const RightDrawer = () => {
useListenClickOutside({
refs: [rightDrawerRef],
callback: () => closeRightDrawer(),
callback: () => {
closeRightDrawer();
},
mode: ClickOutsideMode.comparePixels,
});

View File

@ -1,6 +1,6 @@
import styled from '@emotion/styled';
import { useOpenCreateActivityDrawer } from '@/activities/hooks/useOpenCreateActivityDrawer';
import { useOpenCreateActivityDrawerV2 } from '@/activities/hooks/useOpenCreateActivityDrawerV2';
import { ActivityType } from '@/activities/types/Activity';
import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
import { PageHotkeyScope } from '@/types/PageHotkeyScope';
@ -19,17 +19,19 @@ const StyledContainer = styled.div`
`;
export const ShowPageAddButton = ({
entity,
activityTargetObject,
}: {
entity: ActivityTargetableObject;
activityTargetObject: ActivityTargetableObject;
}) => {
const { closeDropdown, toggleDropdown } = useDropdown(
SHOW_PAGE_ADD_BUTTON_DROPDOWN_ID,
);
const openCreateActivity = useOpenCreateActivityDrawer();
const { closeDropdown, toggleDropdown } = useDropdown('add-show-page');
const openCreateActivity = useOpenCreateActivityDrawerV2();
const handleSelect = (type: ActivityType) => {
openCreateActivity({ type, targetableObjects: [entity] });
openCreateActivity({
type,
targetableObjects: [activityTargetObject],
timelineTargetableObject: activityTargetObject,
});
closeDropdown();
};

View File

@ -1,3 +1,4 @@
import { clickOutsideListenerCallbacksStateScopeMap } from '@/ui/utilities/pointer-event/states/clickOutsideListenerCallbacksStateScopeMap';
import { clickOutsideListenerIsActivatedStateScopeMap } from '@/ui/utilities/pointer-event/states/clickOutsideListenerIsActivatedStateScopeMap';
import { clickOutsideListenerIsMouseDownInsideStateScopeMap } from '@/ui/utilities/pointer-event/states/clickOutsideListenerIsMouseDownInsideStateScopeMap';
import { lockedListenerIdState } from '@/ui/utilities/pointer-event/states/lockedListenerIdState';
@ -9,6 +10,10 @@ export const useClickOustideListenerStates = (componentId: string) => {
return {
scopeId,
getClickOutsideListenerCallbacksState: getState(
clickOutsideListenerCallbacksStateScopeMap,
scopeId,
),
getClickOutsideListenerIsMouseDownInsideState: getState(
clickOutsideListenerIsMouseDownInsideStateScopeMap,
scopeId,

View File

@ -1,3 +1,4 @@
import { useEffect } from 'react';
import { useRecoilCallback } from 'recoil';
import { useClickOustideListenerStates } from '@/ui/utilities/pointer-event/hooks/useClickOustideListenerStates';
@ -5,14 +6,18 @@ import {
ClickOutsideListenerProps,
useListenClickOutsideV2,
} from '@/ui/utilities/pointer-event/hooks/useListenClickOutsideV2';
import { ClickOutsideListenerCallback } from '@/ui/utilities/pointer-event/types/ClickOutsideListenerCallback';
import { getScopeIdFromComponentId } from '@/ui/utilities/recoil-scope/utils/getScopeIdFromComponentId';
import { isDefined } from '~/utils/isDefined';
export const useClickOutsideListener = (componentId: string) => {
// TODO: improve typing
const scopeId = getScopeIdFromComponentId(componentId) ?? '';
const { getClickOutsideListenerIsActivatedState } =
useClickOustideListenerStates(componentId);
const {
getClickOutsideListenerIsActivatedState,
getClickOutsideListenerCallbacksState,
} = useClickOustideListenerStates(componentId);
const useListenClickOutside = <T extends Element>({
callback,
@ -23,7 +28,21 @@ export const useClickOutsideListener = (componentId: string) => {
return useListenClickOutsideV2({
listenerId: componentId,
refs,
callback,
callback: useRecoilCallback(
({ snapshot }) =>
(event) => {
callback(event);
const additionalCallbacks = snapshot
.getLoadable(getClickOutsideListenerCallbacksState())
.getValue();
additionalCallbacks.forEach((additionalCallback) => {
additionalCallback.callbackFunction(event);
});
},
[callback],
),
enabled,
mode,
});
@ -37,9 +56,97 @@ export const useClickOutsideListener = (componentId: string) => {
[getClickOutsideListenerIsActivatedState],
);
const registerOnClickOutsideCallback = useRecoilCallback(
({ set, snapshot }) =>
({ callbackFunction, callbackId }: ClickOutsideListenerCallback) => {
const existingCallbacks = snapshot
.getLoadable(getClickOutsideListenerCallbacksState())
.getValue();
const existingCallbackWithSameId = existingCallbacks.find(
(callback) => callback.callbackId === callbackId,
);
if (!isDefined(existingCallbackWithSameId)) {
const existingCallbacksWithNewCallback = existingCallbacks.concat({
callbackId,
callbackFunction,
});
set(
getClickOutsideListenerCallbacksState(),
existingCallbacksWithNewCallback,
);
} else {
const existingCallbacksWithOverwrittenCallback = [
...existingCallbacks,
];
const indexOfExistingCallbackWithSameId =
existingCallbacksWithOverwrittenCallback.findIndex(
(callback) => callback.callbackId === callbackId,
);
existingCallbacksWithOverwrittenCallback[
indexOfExistingCallbackWithSameId
] = {
callbackId,
callbackFunction,
};
set(
getClickOutsideListenerCallbacksState(),
existingCallbacksWithOverwrittenCallback,
);
}
},
[getClickOutsideListenerCallbacksState],
);
const unregisterOnClickOutsideCallback = useRecoilCallback(
({ set, snapshot }) =>
({ callbackId }: { callbackId: string }) => {
const existingCallbacks = snapshot
.getLoadable(getClickOutsideListenerCallbacksState())
.getValue();
const indexOfCallbackToUnsubscribe = existingCallbacks.findIndex(
(callback) => callback.callbackId === callbackId,
);
const callbackToUnsubscribeIsFound = indexOfCallbackToUnsubscribe > -1;
if (callbackToUnsubscribeIsFound) {
const newCallbacksWithoutCallbackToUnsubscribe =
existingCallbacks.toSpliced(indexOfCallbackToUnsubscribe, 1);
set(
getClickOutsideListenerCallbacksState(),
newCallbacksWithoutCallbackToUnsubscribe,
);
}
},
[getClickOutsideListenerCallbacksState],
);
const useRegisterClickOutsideListenerCallback = (
callback: ClickOutsideListenerCallback,
) => {
useEffect(() => {
registerOnClickOutsideCallback(callback);
return () => {
unregisterOnClickOutsideCallback({
callbackId: callback.callbackId,
});
};
}, [callback]);
};
return {
scopeId,
useListenClickOutside,
toggleClickOutsideListener,
useRegisterClickOutsideListenerCallback,
};
};

View File

@ -0,0 +1,9 @@
import { ClickOutsideListenerCallback } from '@/ui/utilities/pointer-event/types/ClickOutsideListenerCallback';
import { createStateScopeMap } from '@/ui/utilities/recoil-scope/utils/createStateScopeMap';
export const clickOutsideListenerCallbacksStateScopeMap = createStateScopeMap<
ClickOutsideListenerCallback[]
>({
key: 'clickOutsideListenerCallbacksStateScopeMap',
defaultValue: [],
});

View File

@ -0,0 +1,6 @@
import { ClickOutsideListenerCallbackFunction } from '@/ui/utilities/pointer-event/types/ClickOutsideListenerCallbackFunction';
export type ClickOutsideListenerCallback = {
callbackId: string;
callbackFunction: ClickOutsideListenerCallbackFunction;
};

View File

@ -0,0 +1,3 @@
export type ClickOutsideListenerCallbackFunction = (
event: MouseEvent | TouchEvent,
) => void;

View File

@ -94,7 +94,7 @@ export const RecordShowPage = () => {
/>
<ShowPageAddButton
key="add"
entity={{
activityTargetObject={{
id: record.id,
targetObjectNameSingular: objectMetadataItem?.nameSingular,
targetObjectRecord: record,

View File

@ -0,0 +1 @@
export type StringKeyOf<T> = Extract<keyof T, string>;

View File

@ -1,12 +0,0 @@
export const debounce = <FuncArgs extends any[]>(
func: (...args: FuncArgs) => void,
delay: number,
) => {
let timeoutId: ReturnType<typeof setTimeout>;
return (...args: FuncArgs) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
func(...args);
}, delay);
};
};

View File

@ -16,11 +16,11 @@ export const parseApolloStoreFieldName = <
const fieldName = matches[1] as string;
try {
const variables = stringifiedVariables
const fieldArguments = stringifiedVariables
? (JSON.parse(stringifiedVariables) as Variables)
: undefined;
return { fieldName, variables };
return { fieldName, fieldArguments };
} catch {
return { fieldName };
}

View File

@ -22307,6 +22307,13 @@ __metadata:
languageName: node
linkType: hard
"debounce@npm:^2.0.0":
version: 2.0.0
resolution: "debounce@npm:2.0.0"
checksum: 35a492f7f4f346f0539d486d7e68a68bd2eaa0c7fb159df8e59f89a20afd9b0ed024d0dc5a6f283b6963f5f032dacd66d805a325e9d8338511e77b505b76f26e
languageName: node
linkType: hard
"debug@npm:2.6.9, debug@npm:^2.6.0, debug@npm:^2.6.8, debug@npm:^2.6.9":
version: 2.6.9
resolution: "debug@npm:2.6.9"
@ -43447,6 +43454,7 @@ __metadata:
danger-plugin-todos: "npm:^1.3.1"
dataloader: "npm:^2.2.2"
date-fns: "npm:^2.30.0"
debounce: "npm:^2.0.0"
deep-equal: "npm:^2.2.2"
docusaurus-node-polyfills: "npm:^1.0.0"
dotenv-cli: "npm:^7.2.1"
@ -43560,6 +43568,7 @@ __metadata:
type-fest: "npm:^4.1.0"
typeorm: "npm:^0.3.17"
typescript: "npm:^5.3.3"
use-debounce: "npm:^10.0.0"
uuid: "npm:^9.0.0"
vite: "npm:^5.0.0"
vite-plugin-checker: "npm:^0.6.2"
@ -44676,6 +44685,15 @@ __metadata:
languageName: node
linkType: hard
"use-debounce@npm:^10.0.0":
version: 10.0.0
resolution: "use-debounce@npm:10.0.0"
peerDependencies:
react: ">=16.8.0"
checksum: c1166cba52dedeab17e3e29275af89c57a3e8981b75f6e38ae2896ac36ecd4ed7d8fff5f882ba4b2f91eac7510d5ae0dd89fa4f7d081622ed436c3c89eda5cd1
languageName: node
linkType: hard
"use-isomorphic-layout-effect@npm:^1.1.1":
version: 1.1.2
resolution: "use-isomorphic-layout-effect@npm:1.1.2"