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
87 changed files with 2195 additions and 1058 deletions

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