Feat/activity optimistic activities (#4009)

* Fix naming

* Fixed cache.evict bug for relation target deletion

* Fixed cascade delete activity targets

* Working version

* Fix

* fix

* WIP

* Fixed optimistic effect target inline cell

* Removed openCreateActivityDrawer v1

* Ok for timeline

* Removed console.log

* Fix update record optimistic effect

* Refactored activity queries into useActivities for everything

* Fixed bugs

* Cleaned

* Fix lint

---------

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

View File

@ -1,20 +1,22 @@
import { useCallback, useEffect, useState } from 'react';
import { useCallback } from 'react';
import { BlockNoteEditor } from '@blocknote/core';
import { useBlockNote } from '@blocknote/react';
import styled from '@emotion/styled';
import { isArray, isNonEmptyString } from '@sniptt/guards';
import { useRecoilState } from 'recoil';
import { useRecoilCallback, useRecoilState } from 'recoil';
import { Key } from 'ts-key-enum';
import { useDebouncedCallback } from 'use-debounce';
import { v4 } from 'uuid';
import { useUpsertActivity } from '@/activities/hooks/useUpsertActivity';
import { activityTitleHasBeenSetFamilyState } from '@/activities/states/activityTitleHasBeenSetFamilyState';
import { canCreateActivityState } from '@/activities/states/canCreateActivityState';
import { Activity } from '@/activities/types/Activity';
import { ActivityEditorHotkeyScope } from '@/activities/types/ActivityEditorHotkeyScope';
import { useObjectMetadataItemOnly } from '@/object-metadata/hooks/useObjectMetadataItemOnly';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useModifyRecordFromCache } from '@/object-record/cache/hooks/useModifyRecordFromCache';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { BlockEditor } from '@/ui/input/editor/components/BlockEditor';
import { RightDrawerHotkeyScope } from '@/ui/layout/right-drawer/types/RightDrawerHotkeyScope';
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
@ -42,10 +44,6 @@ export const ActivityBodyEditor = ({
activity,
fillTitleFromBody,
}: ActivityBodyEditorProps) => {
const [stringifiedBodyFromEditor, setStringifiedBodyFromEditor] = useState<
string | null
>(activity.body);
const [activityTitleHasBeenSet, setActivityTitleHasBeenSet] = useRecoilState(
activityTitleHasBeenSetFamilyState({
activityId: activity.id,
@ -96,19 +94,21 @@ export const ActivityBodyEditor = ({
const blockBody = JSON.parse(newStringifiedBody);
const newTitleFromBody = blockBody[0]?.content?.[0]?.text;
modifyActivityFromCache(activity.id, {
title: () => {
return newTitleFromBody;
},
});
persistTitleAndBodyDebounced(newTitleFromBody, newStringifiedBody);
},
[activity.id, modifyActivityFromCache, persistTitleAndBodyDebounced],
[persistTitleAndBodyDebounced],
);
const [canCreateActivity, setCanCreateActivity] = useRecoilState(
canCreateActivityState,
);
const handleBodyChange = useCallback(
(activityBody: string) => {
if (!canCreateActivity) {
setCanCreateActivity(true);
}
if (!activityTitleHasBeenSet && fillTitleFromBody) {
updateTitleAndBody(activityBody);
} else {
@ -120,18 +120,11 @@ export const ActivityBodyEditor = ({
persistBodyDebounced,
activityTitleHasBeenSet,
updateTitleAndBody,
setCanCreateActivity,
canCreateActivity,
],
);
useEffect(() => {
if (
isNonEmptyString(stringifiedBodyFromEditor) &&
activity.body !== stringifiedBodyFromEditor
) {
handleBodyChange(stringifiedBodyFromEditor);
}
}, [stringifiedBodyFromEditor, handleBodyChange, activity]);
const slashMenuItems = getSlashMenu();
const [uploadFile] = useUploadFileMutation();
@ -160,9 +153,57 @@ export const ActivityBodyEditor = ({
? JSON.parse(activity.body)
: undefined,
domAttributes: { editor: { class: 'editor' } },
onEditorContentChange: (editor: BlockNoteEditor) => {
setStringifiedBodyFromEditor(JSON.stringify(editor.topLevelBlocks) ?? '');
},
onEditorContentChange: useRecoilCallback(
({ snapshot, set }) =>
(editor: BlockNoteEditor) => {
const newStringifiedBody =
JSON.stringify(editor.topLevelBlocks) ?? '';
set(recordStoreFamilyState(activity.id), (oldActivity) => {
return {
...oldActivity,
id: activity.id,
body: newStringifiedBody,
};
});
modifyActivityFromCache(activity.id, {
body: () => {
return newStringifiedBody;
},
});
const activityTitleHasBeenSet = snapshot
.getLoadable(
activityTitleHasBeenSetFamilyState({
activityId: activity.id,
}),
)
.getValue();
const blockBody = JSON.parse(newStringifiedBody);
const newTitleFromBody = blockBody[0]?.content?.[0]?.text as string;
if (!activityTitleHasBeenSet && fillTitleFromBody) {
set(recordStoreFamilyState(activity.id), (oldActivity) => {
return {
...oldActivity,
id: activity.id,
title: newTitleFromBody,
};
});
modifyActivityFromCache(activity.id, {
title: () => {
return newTitleFromBody;
},
});
}
handleBodyChange(newStringifiedBody);
},
[activity, fillTitleFromBody, modifyActivityFromCache, handleBodyChange],
),
slashMenuItems,
blockSpecs: blockSpecs,
uploadFile: handleUploadAttachment,

View File

@ -8,7 +8,9 @@ import { ActivityTypeDropdown } from '@/activities/components/ActivityTypeDropdo
import { useDeleteActivityFromCache } from '@/activities/hooks/useDeleteActivityFromCache';
import { useUpsertActivity } from '@/activities/hooks/useUpsertActivity';
import { ActivityTargetsInlineCell } from '@/activities/inline-cell/components/ActivityTargetsInlineCell';
import { isCreatingActivityState } from '@/activities/states/isCreatingActivityState';
import { canCreateActivityState } from '@/activities/states/canCreateActivityState';
import { isActivityInCreateModeState } from '@/activities/states/isActivityInCreateModeState';
import { isUpsertingActivityInDBState } from '@/activities/states/isCreatingActivityInDBState';
import { Activity } from '@/activities/types/Activity';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useFieldContext } from '@/object-record/hooks/useFieldContext';
@ -18,6 +20,7 @@ import {
} from '@/object-record/record-field/contexts/FieldContext';
import { RecordInlineCell } from '@/object-record/record-inline-cell/components/RecordInlineCell';
import { PropertyBox } from '@/object-record/record-inline-cell/property-box/components/PropertyBox';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { RIGHT_DRAWER_CLICK_OUTSIDE_LISTENER_ID } from '@/ui/layout/right-drawer/constants/RightDrawerClickOutsideListener';
import { useClickOutsideListener } from '@/ui/utilities/pointer-event/hooks/useClickOutsideListener';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
@ -78,8 +81,10 @@ export const ActivityEditor = ({
const { deleteActivityFromCache } = useDeleteActivityFromCache();
const useUpsertOneActivityMutation: RecordUpdateHook = () => {
const upsertActivityMutation = ({ variables }: RecordUpdateHookParams) => {
upsertActivity({ activity, input: variables.updateOneRecordInput });
const upsertActivityMutation = async ({
variables,
}: RecordUpdateHookParams) => {
await upsertActivity({ activity, input: variables.updateOneRecordInput });
};
return [upsertActivityMutation, { loading: false }];
@ -104,6 +109,20 @@ export const ActivityEditor = ({
customUseUpdateOneObjectHook: useUpsertOneActivityMutation,
});
const [isActivityInCreateMode, setIsActivityInCreateMode] = useRecoilState(
isActivityInCreateModeState,
);
const [isUpsertingActivityInDB] = useRecoilState(
isUpsertingActivityInDBState,
);
const [canCreateActivity] = useRecoilState(canCreateActivityState);
const [activityFromStore] = useRecoilState(
recordStoreFamilyState(activity.id),
);
const { FieldContextProvider: ActivityTargetsContextProvider } =
useFieldContext({
objectNameSingular: CoreObjectNameSingular.Activity,
@ -112,16 +131,40 @@ export const ActivityEditor = ({
fieldPosition: 2,
});
const [isCreatingActivity, setIsCreatingActivity] = useRecoilState(
isCreatingActivityState,
);
useRegisterClickOutsideListenerCallback({
callbackId: 'activity-editor',
callbackFunction: () => {
if (isCreatingActivity) {
setIsCreatingActivity(false);
deleteActivityFromCache(activity);
if (isUpsertingActivityInDB || !activityFromStore) {
return;
}
if (isActivityInCreateMode) {
if (canCreateActivity) {
upsertActivity({
activity,
input: {
title: activityFromStore.title,
body: activityFromStore.body,
},
});
} else {
deleteActivityFromCache(activity);
}
setIsActivityInCreateMode(false);
} else {
if (
activityFromStore.title !== activity.title ||
activityFromStore.body !== activity.body
) {
upsertActivity({
activity,
input: {
title: activityFromStore.title,
body: activityFromStore.body,
},
});
}
}
},
});

View File

@ -1,6 +1,6 @@
import styled from '@emotion/styled';
import { ActivityTargetObjectRecord } from '@/activities/types/ActivityTargetObject';
import { ActivityTargetWithTargetRecord } from '@/activities/types/ActivityTargetObject';
import { RecordChip } from '@/object-record/components/RecordChip';
const StyledContainer = styled.div`
@ -12,14 +12,14 @@ const StyledContainer = styled.div`
export const ActivityTargetChips = ({
activityTargetObjectRecords,
}: {
activityTargetObjectRecords: ActivityTargetObjectRecord[];
activityTargetObjectRecords: ActivityTargetWithTargetRecord[];
}) => {
return (
<StyledContainer>
{activityTargetObjectRecords?.map((activityTargetObjectRecord) => (
<RecordChip
key={activityTargetObjectRecord.targetObjectRecord.id}
record={activityTargetObjectRecord.targetObjectRecord}
key={activityTargetObjectRecord.targetObject.id}
record={activityTargetObjectRecord.targetObject}
objectNameSingular={
activityTargetObjectRecord.targetObjectMetadataItem.nameSingular
}

View File

@ -1,17 +1,19 @@
import { useRef } from 'react';
import { useState } from 'react';
import styled from '@emotion/styled';
import { isNonEmptyString } from '@sniptt/guards';
import { useRecoilState } from 'recoil';
import { Key } from 'ts-key-enum';
import { useDebouncedCallback } from 'use-debounce';
import { useUpsertActivity } from '@/activities/hooks/useUpsertActivity';
import { activityTitleHasBeenSetFamilyState } from '@/activities/states/activityTitleHasBeenSetFamilyState';
import { canCreateActivityState } from '@/activities/states/canCreateActivityState';
import { Activity } from '@/activities/types/Activity';
import { ActivityEditorHotkeyScope } from '@/activities/types/ActivityEditorHotkeyScope';
import { useObjectMetadataItemOnly } from '@/object-metadata/hooks/useObjectMetadataItemOnly';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useModifyRecordFromCache } from '@/object-record/cache/hooks/useModifyRecordFromCache';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import {
Checkbox,
CheckboxShape,
@ -57,7 +59,13 @@ type ActivityTitleProps = {
};
export const ActivityTitle = ({ activity }: ActivityTitleProps) => {
const [internalTitle, setInternalTitle] = useState(activity.title);
const [activityInStore, setActivityInStore] = useRecoilState(
recordStoreFamilyState(activity.id),
);
const [canCreateActivity, setCanCreateActivity] = useRecoilState(
canCreateActivityState,
);
const { upsertActivity } = useUpsertActivity();
@ -115,7 +123,17 @@ export const ActivityTitle = ({ activity }: ActivityTitleProps) => {
}, 500);
const handleTitleChange = (newTitle: string) => {
setInternalTitle(newTitle);
setActivityInStore((currentActivity) => {
return {
...currentActivity,
id: activity.id,
title: newTitle,
};
});
if (isNonEmptyString(newTitle) && !canCreateActivity) {
setCanCreateActivity(true);
}
modifyActivityFromCache(activity.id, {
title: () => {
@ -153,7 +171,7 @@ export const ActivityTitle = ({ activity }: ActivityTitleProps) => {
ref={titleInputRef}
placeholder={`${activity.type} title`}
onChange={(event) => handleTitleChange(event.target.value)}
value={internalTitle}
value={activityInStore?.title ?? ''}
completed={completed}
onBlur={handleBlur}
onFocus={handleFocus}