0f2af6a6cb
## Recoil to Jotai Migration — PR 13: Workflow, Page Layout, AI, Activities, Settings, SSE & Remaining Modules ### Summary This is the 13th PR in the [14-PR migration series](packages/twenty-front/src/modules/ui/utilities/state/jotai/MIGRATION_PLAN.md) to replace Recoil with Jotai as the state management library in `twenty-front`. This PR migrates the remaining consumer code across all modules — updating ~1,076 files with ~10k insertions/deletions. ### What changed **New Jotai infrastructure (13 new files):** - `createComponentSelectorV2` and `createComponentFamilySelectorV2` — instance-scoped derived atoms, replacing Recoil's component selectors - 6 new hooks: `useRecoilComponentSelectorValueV2`, `useRecoilComponentFamilySelectorValueV2`, `useRecoilComponentSelectorCallbackStateV2`, `useRecoilComponentFamilySelectorCallbackStateV2`, `useRecoilComponentStateCallbackStateV2`, `useRecoilComponentFamilyStateCallbackStateV2` - New types: `ComponentSelectorV2`, `ComponentFamilySelectorV2`, `SelectorCallbacksV2` - Extended `buildGetHelper` to support the new selector patterns **State definition migrations:** - `createState()` → `createStateV2()` (Jotai `atom()`) - `createFamilyState()` → `createFamilyStateV2()` (Jotai `atom()` with family cache) - Recoil `selector`/`selectorFamily` → Jotai derived `atom()` via V2 utilities **Hook migrations (~2,400 removed, ~1,545 added V2 equivalents):** - `useRecoilValue` → `useRecoilValueV2` - `useRecoilState` → `useRecoilStateV2` - `useSetRecoilState` → `useSetRecoilStateV2` - `useRecoilCallback` → `useCallback` + `jotaiStore.get/set` - `useRecoilComponentValue` → `useRecoilComponentValueV2` - `useSetRecoilComponentState` → `useSetRecoilComponentStateV2` - `useRecoilComponentFamilyValue` → `useRecoilComponentFamilyValueV2` / `useFamilySelectorValueV2` - ~397 direct `import from 'recoil'` removed **Deleted files (9 files):** - `dateLocaleState.ts` — consolidated - `currentAIChatThreadTitleStateV2.ts` — renamed to replace the original - `isCommandMenuOpenedState.ts` — removed - `filesFieldUploadState.ts` — replaced by V2 - `recordStoreFamilySelector.ts`, `recordStoreFieldValueSelector.ts`, `recordStoreRecordsSelector.ts` — replaced by V2 equivalents - `settingsAllRolesSelector.ts` — replaced by `useSettingsAllRoles` hook - `settingsRoleIdsStateV2.ts` — consolidated **Modules migrated:** - Workflow (diagram, steps, variables, filters) - Page Layout (widgets, fields, graph, tabs) - AI (chat, agents, browsing context) - Activities (rich text editor, targets, timeline) - Action Menu (single/multiple record actions) - Command Menu (navigation, history, pages, workflow) - Context Store - Record Board, Record Table, Record Calendar, Record Index - Record Field, Record Filter, Record Sort, Record Group - Record Drag, Record Picker, Record Store - Views (view bar, view picker, filters, sorts) - Settings (roles, permissions, SSO, security, data model, developers, domains) - SSE (event streams, subscriptions) - Spreadsheet Import - Auth, Favorites, Front Components, Information Banner
358 lines
12 KiB
TypeScript
358 lines
12 KiB
TypeScript
import { useCallback, useMemo } from 'react';
|
|
import { useAtom, useStore } from 'jotai';
|
|
import { v4 } from 'uuid';
|
|
|
|
import { useUploadAttachmentFile } from '@/activities/files/hooks/useUploadAttachmentFile';
|
|
import { useUpsertActivity } from '@/activities/hooks/useUpsertActivity';
|
|
import { canCreateActivityState } from '@/activities/states/canCreateActivityState';
|
|
import { useRecoilStateV2 } from '@/ui/utilities/state/jotai/hooks/useRecoilStateV2';
|
|
import { getActivityTargetObjectFieldIdName } from '@/activities/utils/getActivityTargetObjectFieldIdName';
|
|
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
|
|
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
|
|
import { modifyRecordFromCache } from '@/object-record/cache/utils/modifyRecordFromCache';
|
|
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
|
|
import { isNonTextWritingKey } from '@/ui/utilities/hotkey/utils/isNonTextWritingKey';
|
|
import { Key } from 'ts-key-enum';
|
|
import { useDebouncedCallback } from 'use-debounce';
|
|
|
|
import { BLOCK_SCHEMA } from '@/blocknote-editor/blocks/Schema';
|
|
import { ActivityRichTextEditorChangeOnActivityIdEffect } from '@/activities/components/ActivityRichTextEditorChangeOnActivityIdEffect';
|
|
import { type Attachment } from '@/activities/files/types/Attachment';
|
|
import { type Note } from '@/activities/types/Note';
|
|
import { type Task } from '@/activities/types/Task';
|
|
import { SIDE_PANEL_FOCUS_ID } from '@/command-menu/constants/SidePanelFocusId';
|
|
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
|
|
import { useLabelIdentifierFieldMetadataItem } from '@/object-metadata/hooks/useLabelIdentifierFieldMetadataItem';
|
|
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
|
|
import { useIsRecordFieldReadOnly } from '@/object-record/read-only/hooks/useIsRecordFieldReadOnly';
|
|
import { isTitleCellInEditModeComponentState } from '@/object-record/record-title-cell/states/isTitleCellInEditModeComponentState';
|
|
import { RecordTitleCellContainerType } from '@/object-record/record-title-cell/types/RecordTitleCellContainerType';
|
|
import { getRecordFieldInputInstanceId } from '@/object-record/utils/getRecordFieldInputId';
|
|
import { BlockEditor } from '@/blocknote-editor/components/BlockEditor';
|
|
import { BLOCK_EDITOR_GLOBAL_HOTKEYS_CONFIG } from '@/blocknote-editor/constants/BlockEditorGlobalHotkeysConfig';
|
|
import { useAttachmentSync } from '@/blocknote-editor/hooks/useAttachmentSync';
|
|
import { parseInitialBlocknote } from '@/blocknote-editor/utils/parseInitialBlocknote';
|
|
import { prepareBodyWithSignedUrls } from '@/blocknote-editor/utils/prepareBodyWithSignedUrls';
|
|
import { usePushFocusItemToFocusStack } from '@/ui/utilities/focus/hooks/usePushFocusItemToFocusStack';
|
|
import { useRemoveFocusItemFromFocusStackById } from '@/ui/utilities/focus/hooks/useRemoveFocusItemFromFocusStackById';
|
|
import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
|
|
import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement';
|
|
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
|
|
import { t } from '@lingui/core/macro';
|
|
import '@blocknote/core/fonts/inter.css';
|
|
import '@blocknote/mantine/style.css';
|
|
import { useCreateBlockNote } from '@blocknote/react';
|
|
import '@blocknote/react/style.css';
|
|
import { isDefined } from 'twenty-shared/utils';
|
|
import { FeatureFlagKey } from '~/generated-metadata/graphql';
|
|
|
|
type ActivityRichTextEditorProps = {
|
|
activityId: string;
|
|
activityObjectNameSingular:
|
|
| CoreObjectNameSingular.Task
|
|
| CoreObjectNameSingular.Note;
|
|
};
|
|
|
|
export const ActivityRichTextEditor = ({
|
|
activityId,
|
|
activityObjectNameSingular,
|
|
}: ActivityRichTextEditorProps) => {
|
|
const store = useStore();
|
|
const [activityInStore] = useAtom(
|
|
recordStoreFamilyState.atomFamily(activityId),
|
|
);
|
|
|
|
const cache = useApolloCoreClient().cache;
|
|
const activity = activityInStore as Task | Note | null;
|
|
|
|
const { objectMetadataItem: objectMetadataItemActivity } =
|
|
useObjectMetadataItem({
|
|
objectNameSingular: activityObjectNameSingular,
|
|
});
|
|
|
|
const bodyV2FieldMetadataItem = objectMetadataItemActivity.fields.find(
|
|
(field) => field.name === 'bodyV2',
|
|
);
|
|
|
|
const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack();
|
|
const { removeFocusItemFromFocusStackById } =
|
|
useRemoveFocusItemFromFocusStackById();
|
|
|
|
const isAttachmentMigrated = useIsFeatureEnabled(
|
|
FeatureFlagKey.IS_ATTACHMENT_MIGRATED,
|
|
);
|
|
|
|
const attachmentTargetFieldIdName = getActivityTargetObjectFieldIdName({
|
|
nameSingular: activityObjectNameSingular,
|
|
isMorphRelation: isAttachmentMigrated,
|
|
});
|
|
|
|
const { records: attachments } = useFindManyRecords<Attachment>({
|
|
objectNameSingular: CoreObjectNameSingular.Attachment,
|
|
filter: {
|
|
[attachmentTargetFieldIdName]: {
|
|
eq: activityId,
|
|
},
|
|
},
|
|
});
|
|
|
|
const { syncAttachments } = useAttachmentSync(attachments);
|
|
|
|
const { upsertActivity } = useUpsertActivity({
|
|
activityObjectNameSingular: activityObjectNameSingular,
|
|
});
|
|
|
|
const isRecordFieldReadOnly = useIsRecordFieldReadOnly({
|
|
recordId: activityId,
|
|
objectMetadataId: objectMetadataItemActivity.id,
|
|
fieldMetadataId: bodyV2FieldMetadataItem?.id ?? '',
|
|
});
|
|
|
|
const persistBodyDebounced = useDebouncedCallback((blocknote: string) => {
|
|
if (isRecordFieldReadOnly === true) return;
|
|
|
|
const input = {
|
|
bodyV2: {
|
|
blocknote,
|
|
markdown: null,
|
|
},
|
|
};
|
|
|
|
if (isDefined(activity)) {
|
|
upsertActivity({
|
|
activity,
|
|
input,
|
|
});
|
|
}
|
|
}, 300);
|
|
|
|
const [canCreateActivity, setCanCreateActivity] = useRecoilStateV2(
|
|
canCreateActivityState,
|
|
);
|
|
|
|
const { uploadAttachmentFile } = useUploadAttachmentFile();
|
|
|
|
const handleUploadAttachment = async (file: File) => {
|
|
return await uploadAttachmentFile(file, {
|
|
id: activityId,
|
|
targetObjectNameSingular: activityObjectNameSingular,
|
|
});
|
|
};
|
|
|
|
const handlePersistBody = useCallback(
|
|
(activityBody: string) => {
|
|
if (!canCreateActivity) {
|
|
setCanCreateActivity(true);
|
|
}
|
|
persistBodyDebounced(prepareBodyWithSignedUrls(activityBody));
|
|
},
|
|
[canCreateActivity, persistBodyDebounced, setCanCreateActivity],
|
|
);
|
|
|
|
const handleBodyChange = useCallback(
|
|
async (newStringifiedBody: string) => {
|
|
const oldActivity = store.get(
|
|
recordStoreFamilyState.atomFamily(activityId),
|
|
);
|
|
|
|
store.set(
|
|
recordStoreFamilyState.atomFamily(activityId),
|
|
(prev: typeof oldActivity) => ({
|
|
...prev,
|
|
id: activityId,
|
|
bodyV2: {
|
|
blocknote: newStringifiedBody,
|
|
markdown: null,
|
|
},
|
|
__typename: 'Activity',
|
|
}),
|
|
);
|
|
|
|
modifyRecordFromCache({
|
|
recordId: activityId,
|
|
fieldModifiers: {
|
|
bodyV2: () => {
|
|
return {
|
|
blocknote: newStringifiedBody,
|
|
markdown: null,
|
|
};
|
|
},
|
|
},
|
|
cache,
|
|
objectMetadataItem: objectMetadataItemActivity,
|
|
});
|
|
|
|
handlePersistBody(newStringifiedBody);
|
|
|
|
await syncAttachments(newStringifiedBody, oldActivity?.bodyV2.blocknote);
|
|
},
|
|
[
|
|
store,
|
|
activityId,
|
|
cache,
|
|
objectMetadataItemActivity,
|
|
handlePersistBody,
|
|
syncAttachments,
|
|
],
|
|
);
|
|
|
|
const handleBodyChangeDebounced = useDebouncedCallback(handleBodyChange, 500);
|
|
|
|
const handleEditorChange = () => {
|
|
const newStringifiedBody = JSON.stringify(editor.document) ?? '';
|
|
|
|
handleBodyChangeDebounced(newStringifiedBody);
|
|
};
|
|
|
|
const initialBody = useMemo(() => {
|
|
if (!isDefined(activity)) {
|
|
return undefined;
|
|
}
|
|
|
|
return parseInitialBlocknote(
|
|
activity?.bodyV2?.blocknote,
|
|
`Failed to parse body for activity ${activityId}, for rich text version 'v2'`,
|
|
);
|
|
}, [activity, activityId]);
|
|
|
|
const handleEditorBuiltInUploadFile = async (file: File) => {
|
|
const { attachmentAbsoluteURL } = await handleUploadAttachment(file);
|
|
|
|
return attachmentAbsoluteURL;
|
|
};
|
|
|
|
const editor = useCreateBlockNote({
|
|
initialContent: initialBody,
|
|
domAttributes: { editor: { class: 'editor' } },
|
|
schema: BLOCK_SCHEMA,
|
|
uploadFile: handleEditorBuiltInUploadFile,
|
|
placeholders: {
|
|
default: t`Type '/' for commands, '@' for mentions`,
|
|
},
|
|
});
|
|
|
|
useHotkeysOnFocusedElement({
|
|
keys: Key.Escape,
|
|
callback: () => {
|
|
editor.domElement?.blur();
|
|
},
|
|
focusId: activityId,
|
|
dependencies: [editor],
|
|
});
|
|
|
|
const focusRichTextEditorWhenFocusOnSidePanel = (
|
|
keyboardEvent: KeyboardEvent,
|
|
) => {
|
|
if (keyboardEvent.key === Key.Escape) {
|
|
return;
|
|
}
|
|
|
|
const isWritingText =
|
|
!isNonTextWritingKey(keyboardEvent.key) &&
|
|
!keyboardEvent.ctrlKey &&
|
|
!keyboardEvent.metaKey;
|
|
|
|
if (!isWritingText) {
|
|
return;
|
|
}
|
|
|
|
keyboardEvent.preventDefault();
|
|
keyboardEvent.stopPropagation();
|
|
keyboardEvent.stopImmediatePropagation();
|
|
|
|
const newBlockId = v4();
|
|
const newBlock = {
|
|
id: newBlockId,
|
|
type: 'paragraph' as const,
|
|
content: keyboardEvent.key,
|
|
};
|
|
|
|
const lastBlock = editor.document[editor.document.length - 1];
|
|
editor.insertBlocks([newBlock], lastBlock);
|
|
|
|
editor.setTextCursorPosition(newBlockId, 'end');
|
|
editor.focus();
|
|
};
|
|
|
|
useHotkeysOnFocusedElement({
|
|
keys: '*',
|
|
callback: focusRichTextEditorWhenFocusOnSidePanel,
|
|
focusId: SIDE_PANEL_FOCUS_ID,
|
|
dependencies: [focusRichTextEditorWhenFocusOnSidePanel],
|
|
});
|
|
|
|
const { labelIdentifierFieldMetadataItem } =
|
|
useLabelIdentifierFieldMetadataItem({
|
|
objectNameSingular: activityObjectNameSingular,
|
|
});
|
|
|
|
const recordTitleCellId = getRecordFieldInputInstanceId({
|
|
recordId: activityId,
|
|
fieldName: labelIdentifierFieldMetadataItem?.name,
|
|
// TODO: see comments below, this is a very temporary fix,
|
|
// it won't work for the breadcrumb title input, but that's ok for now.
|
|
prefix: RecordTitleCellContainerType.ShowPage,
|
|
});
|
|
|
|
// TODO: Here instead of closing the input, as it was intially planned, we should block if there is anything open,
|
|
// This information should be derived from the focus stack
|
|
// The problem with this library is that it takes the focus before anything else and does not prevent the event from bubbling
|
|
// Because of this, other events listen at the same time, and when we're in luck, the click outside gets triggered,
|
|
// but this leaves the door open for unpredicted behavior with click handlers conflicts,
|
|
// we recently had a bug which was deleting what the user typed and closed the right drawer if he used backspace key.
|
|
// We could maybe use the types of components in the focus stack.
|
|
const handleBlockEditorFocus = useCallback(() => {
|
|
const isRecordTitleCellOpen = store.get(
|
|
isTitleCellInEditModeComponentState.atomFamily({
|
|
instanceId: recordTitleCellId,
|
|
}),
|
|
);
|
|
|
|
if (isRecordTitleCellOpen) {
|
|
editor.domElement?.blur();
|
|
return;
|
|
}
|
|
|
|
pushFocusItemToFocusStack({
|
|
component: {
|
|
instanceId: activityId,
|
|
type: FocusComponentType.ACTIVITY_RICH_TEXT_EDITOR,
|
|
},
|
|
focusId: activityId,
|
|
globalHotkeysConfig: BLOCK_EDITOR_GLOBAL_HOTKEYS_CONFIG,
|
|
});
|
|
}, [recordTitleCellId, activityId, editor, pushFocusItemToFocusStack, store]);
|
|
|
|
const handlerBlockEditorBlur = useCallback(() => {
|
|
const isRecordTitleCellOpen = store.get(
|
|
isTitleCellInEditModeComponentState.atomFamily({
|
|
instanceId: recordTitleCellId,
|
|
}),
|
|
);
|
|
|
|
if (isRecordTitleCellOpen) {
|
|
return;
|
|
}
|
|
|
|
removeFocusItemFromFocusStackById({
|
|
focusId: activityId,
|
|
});
|
|
}, [activityId, recordTitleCellId, removeFocusItemFromFocusStackById, store]);
|
|
|
|
return (
|
|
<>
|
|
<ActivityRichTextEditorChangeOnActivityIdEffect
|
|
editor={editor}
|
|
activityId={activityId}
|
|
/>
|
|
<BlockEditor
|
|
onFocus={handleBlockEditorFocus}
|
|
onBlur={handlerBlockEditorBlur}
|
|
onChange={handleEditorChange}
|
|
editor={editor}
|
|
readonly={isRecordFieldReadOnly}
|
|
/>
|
|
</>
|
|
);
|
|
};
|