# Introduction In this PR we've migrated `twenty-shared` from a `vite` app [libary-mode](https://vite.dev/guide/build#library-mode) to a [preconstruct](https://preconstruct.tools/) "atomic" application ( in the future would like to introduce preconstruct to handle of all our atomic dependencies such as `twenty-emails` `twenty-ui` etc it will be integrated at the monorepo's root directly, would be to invasive in the first, starting incremental via `twenty-shared`) For more information regarding the motivations please refer to nor: - https://github.com/twentyhq/core-team-issues/issues/587 - https://github.com/twentyhq/core-team-issues/issues/281#issuecomment-2630949682 close https://github.com/twentyhq/core-team-issues/issues/589 close https://github.com/twentyhq/core-team-issues/issues/590 ## How to test In order to ease the review this PR will ship all the codegen at the very end, the actual meaning full diff is `+2,411 −114` In order to migrate existing dependent packages to `twenty-shared` multi barrel new arch you need to run in local: ```sh yarn tsx packages/twenty-shared/scripts/migrateFromSingleToMultiBarrelImport.ts && \ npx nx run-many -t lint --fix -p twenty-front twenty-ui twenty-server twenty-emails twenty-shared twenty-zapier ``` Note that `migrateFromSingleToMultiBarrelImport` is idempotent, it's atm included in the PR but should not be merged. ( such as codegen will be added before merging this script will be removed ) ## Misc - related opened issue preconstruct https://github.com/preconstruct/preconstruct/issues/617 ## Closed related PR - https://github.com/twentyhq/twenty/pull/11028 - https://github.com/twentyhq/twenty/pull/10993 - https://github.com/twentyhq/twenty/pull/10960 ## Upcoming enhancement: ( in others dedicated PRs ) - 1/ refactor generate barrel to export atomic module instead of `*` - 2/ generate barrel own package with several files and tests - 3/ Migration twenty-ui the same way - 4/ Use `preconstruct` at monorepo global level ## Conclusion As always any suggestions are welcomed !
334 lines
9.8 KiB
TypeScript
334 lines
9.8 KiB
TypeScript
import { useApolloClient } from '@apollo/client';
|
|
import { useCallback, useMemo } from 'react';
|
|
import { useRecoilCallback, useRecoilState } from 'recoil';
|
|
import { v4 } from 'uuid';
|
|
|
|
import { BLOCK_SCHEMA } from '@/activities/blocks/constants/Schema';
|
|
import { useUploadAttachmentFile } from '@/activities/files/hooks/useUploadAttachmentFile';
|
|
import { useUpsertActivity } from '@/activities/hooks/useUpsertActivity';
|
|
import { canCreateActivityState } from '@/activities/states/canCreateActivityState';
|
|
import { ActivityEditorHotkeyScope } from '@/activities/types/ActivityEditorHotkeyScope';
|
|
import { contextStoreCurrentViewTypeComponentState } from '@/context-store/states/contextStoreCurrentViewTypeComponentState';
|
|
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
|
|
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
|
|
import { modifyRecordFromCache } from '@/object-record/cache/utils/modifyRecordFromCache';
|
|
import { isFieldValueReadOnly } from '@/object-record/record-field/utils/isFieldValueReadOnly';
|
|
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
|
|
import { useHasObjectReadOnlyPermission } from '@/settings/roles/hooks/useHasObjectReadOnlyPermission';
|
|
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
|
|
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
|
|
import { AppHotkeyScope } from '@/ui/utilities/hotkey/types/AppHotkeyScope';
|
|
import { isNonTextWritingKey } from '@/ui/utilities/hotkey/utils/isNonTextWritingKey';
|
|
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
|
|
import { Key } from 'ts-key-enum';
|
|
import { useDebouncedCallback } from 'use-debounce';
|
|
|
|
import { ActivityRichTextEditorChangeOnActivityIdEffect } from '@/activities/components/ActivityRichTextEditorChangeOnActivityIdEffect';
|
|
import { Note } from '@/activities/types/Note';
|
|
import { Task } from '@/activities/types/Task';
|
|
import { BlockEditor } from '@/ui/input/editor/components/BlockEditor';
|
|
import { PartialBlock } from '@blocknote/core';
|
|
import '@blocknote/core/fonts/inter.css';
|
|
import '@blocknote/mantine/style.css';
|
|
import { useCreateBlockNote } from '@blocknote/react';
|
|
import '@blocknote/react/style.css';
|
|
import { isArray, isNonEmptyString } from '@sniptt/guards';
|
|
import { isDefined } from 'twenty-shared/utils';
|
|
|
|
type ActivityRichTextEditorProps = {
|
|
activityId: string;
|
|
activityObjectNameSingular:
|
|
| CoreObjectNameSingular.Task
|
|
| CoreObjectNameSingular.Note;
|
|
};
|
|
|
|
export const ActivityRichTextEditor = ({
|
|
activityId,
|
|
activityObjectNameSingular,
|
|
}: ActivityRichTextEditorProps) => {
|
|
const [activityInStore] = useRecoilState(recordStoreFamilyState(activityId));
|
|
|
|
const cache = useApolloClient().cache;
|
|
const activity = activityInStore as Task | Note | null;
|
|
|
|
const { objectMetadataItem: objectMetadataItemActivity } =
|
|
useObjectMetadataItem({
|
|
objectNameSingular: activityObjectNameSingular,
|
|
});
|
|
|
|
const contextStoreCurrentViewType = useRecoilComponentValueV2(
|
|
contextStoreCurrentViewTypeComponentState,
|
|
);
|
|
|
|
const hasObjectReadOnlyPermission = useHasObjectReadOnlyPermission();
|
|
|
|
const isReadOnly = isFieldValueReadOnly({
|
|
objectNameSingular: activityObjectNameSingular,
|
|
hasObjectReadOnlyPermission,
|
|
contextStoreCurrentViewType,
|
|
isRecordDeleted: activityInStore?.deletedAt !== null,
|
|
});
|
|
|
|
const {
|
|
goBackToPreviousHotkeyScope,
|
|
setHotkeyScopeAndMemorizePreviousScope,
|
|
} = usePreviousHotkeyScope();
|
|
|
|
const { upsertActivity } = useUpsertActivity({
|
|
activityObjectNameSingular: activityObjectNameSingular,
|
|
});
|
|
|
|
const persistBodyDebounced = useDebouncedCallback((blocknote: string) => {
|
|
if (isReadOnly) return;
|
|
|
|
const input = {
|
|
bodyV2: {
|
|
blocknote,
|
|
markdown: null,
|
|
},
|
|
};
|
|
|
|
if (isDefined(activity)) {
|
|
upsertActivity({
|
|
activity,
|
|
input,
|
|
});
|
|
}
|
|
}, 300);
|
|
|
|
const [canCreateActivity, setCanCreateActivity] = useRecoilState(
|
|
canCreateActivityState,
|
|
);
|
|
|
|
const { uploadAttachmentFile } = useUploadAttachmentFile();
|
|
|
|
const handleUploadAttachment = async (file: File) => {
|
|
return await uploadAttachmentFile(file, {
|
|
id: activityId,
|
|
targetObjectNameSingular: activityObjectNameSingular,
|
|
});
|
|
};
|
|
|
|
const prepareBody = (newStringifiedBody: string) => {
|
|
if (!newStringifiedBody) return newStringifiedBody;
|
|
|
|
const body = JSON.parse(newStringifiedBody);
|
|
|
|
const bodyWithSignedPayload = body.map((block: any) => {
|
|
if (block.type !== 'image' || !block.props.url) {
|
|
return block;
|
|
}
|
|
|
|
const imageProps = block.props;
|
|
const imageUrl = new URL(imageProps.url);
|
|
|
|
return {
|
|
...block,
|
|
props: {
|
|
...imageProps,
|
|
url: `${imageUrl.toString()}`,
|
|
},
|
|
};
|
|
});
|
|
return JSON.stringify(bodyWithSignedPayload);
|
|
};
|
|
|
|
const handlePersistBody = useCallback(
|
|
(activityBody: string) => {
|
|
if (!canCreateActivity) {
|
|
setCanCreateActivity(true);
|
|
}
|
|
|
|
persistBodyDebounced(prepareBody(activityBody));
|
|
},
|
|
[persistBodyDebounced, setCanCreateActivity, canCreateActivity],
|
|
);
|
|
|
|
const handleBodyChange = useRecoilCallback(
|
|
({ set }) =>
|
|
(newStringifiedBody: string) => {
|
|
set(recordStoreFamilyState(activityId), (oldActivity) => {
|
|
return {
|
|
...oldActivity,
|
|
id: activityId,
|
|
bodyV2: {
|
|
blocknote: newStringifiedBody,
|
|
markdown: null,
|
|
},
|
|
__typename: 'Activity',
|
|
};
|
|
});
|
|
|
|
modifyRecordFromCache({
|
|
recordId: activityId,
|
|
fieldModifiers: {
|
|
bodyV2: () => {
|
|
return {
|
|
blocknote: newStringifiedBody,
|
|
markdown: null,
|
|
};
|
|
},
|
|
},
|
|
cache,
|
|
objectMetadataItem: objectMetadataItemActivity,
|
|
});
|
|
|
|
handlePersistBody(newStringifiedBody);
|
|
},
|
|
[activityId, cache, objectMetadataItemActivity, handlePersistBody],
|
|
);
|
|
|
|
const handleBodyChangeDebounced = useDebouncedCallback(handleBodyChange, 500);
|
|
|
|
const handleEditorChange = () => {
|
|
const newStringifiedBody = JSON.stringify(editor.document) ?? '';
|
|
|
|
handleBodyChangeDebounced(newStringifiedBody);
|
|
};
|
|
|
|
const initialBody = useMemo(() => {
|
|
const blocknote = activity?.bodyV2?.blocknote;
|
|
|
|
if (
|
|
isDefined(activity) &&
|
|
isNonEmptyString(blocknote) &&
|
|
blocknote !== '{}'
|
|
) {
|
|
let parsedBody: PartialBlock[] | undefined = undefined;
|
|
|
|
// TODO: Remove this once we have removed the old rich text
|
|
try {
|
|
parsedBody = JSON.parse(blocknote);
|
|
} catch (error) {
|
|
// eslint-disable-next-line no-console
|
|
console.warn(
|
|
`Failed to parse body for activity ${activityId}, for rich text version 'v2'`,
|
|
);
|
|
// eslint-disable-next-line no-console
|
|
console.warn(blocknote);
|
|
}
|
|
|
|
if (isArray(parsedBody) && parsedBody.length === 0) {
|
|
return undefined;
|
|
}
|
|
|
|
return parsedBody;
|
|
}
|
|
|
|
return undefined;
|
|
}, [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,
|
|
});
|
|
|
|
useScopedHotkeys(
|
|
Key.Escape,
|
|
() => {
|
|
editor.domElement?.blur();
|
|
},
|
|
ActivityEditorHotkeyScope.ActivityBody,
|
|
);
|
|
|
|
useScopedHotkeys(
|
|
'*',
|
|
(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 blockIdentifier = editor.getTextCursorPosition().block;
|
|
const currentBlockContent = blockIdentifier?.content;
|
|
|
|
if (
|
|
isDefined(currentBlockContent) &&
|
|
isArray(currentBlockContent) &&
|
|
currentBlockContent.length === 0
|
|
) {
|
|
// Empty block case
|
|
editor.updateBlock(blockIdentifier, {
|
|
content: keyboardEvent.key,
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (
|
|
isDefined(currentBlockContent) &&
|
|
isArray(currentBlockContent) &&
|
|
isDefined(currentBlockContent[0]) &&
|
|
currentBlockContent[0].type === 'text'
|
|
) {
|
|
// Text block case
|
|
editor.updateBlock(blockIdentifier, {
|
|
content: currentBlockContent[0].text + keyboardEvent.key,
|
|
});
|
|
return;
|
|
}
|
|
|
|
const newBlockId = v4();
|
|
const newBlock = {
|
|
id: newBlockId,
|
|
type: 'paragraph' as const,
|
|
content: keyboardEvent.key,
|
|
};
|
|
editor.insertBlocks([newBlock], blockIdentifier, 'after');
|
|
|
|
editor.setTextCursorPosition(newBlockId, 'end');
|
|
editor.focus();
|
|
},
|
|
AppHotkeyScope.CommandMenuOpen,
|
|
[],
|
|
{
|
|
preventDefault: false,
|
|
},
|
|
);
|
|
|
|
const handleBlockEditorFocus = () => {
|
|
setHotkeyScopeAndMemorizePreviousScope(
|
|
ActivityEditorHotkeyScope.ActivityBody,
|
|
);
|
|
};
|
|
|
|
const handlerBlockEditorBlur = () => {
|
|
goBackToPreviousHotkeyScope();
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<ActivityRichTextEditorChangeOnActivityIdEffect
|
|
editor={editor}
|
|
activityId={activityId}
|
|
/>
|
|
<BlockEditor
|
|
onFocus={handleBlockEditorFocus}
|
|
onBlur={handlerBlockEditorBlur}
|
|
onChange={handleEditorChange}
|
|
editor={editor}
|
|
readonly={isReadOnly}
|
|
/>
|
|
</>
|
|
);
|
|
};
|