diff --git a/packages/twenty-front/src/modules/blocknote-editor/utils/__tests__/countBlocksDeep.test.ts b/packages/twenty-front/src/modules/blocknote-editor/utils/__tests__/countBlocksDeep.test.ts new file mode 100644 index 00000000000..d646f55ee62 --- /dev/null +++ b/packages/twenty-front/src/modules/blocknote-editor/utils/__tests__/countBlocksDeep.test.ts @@ -0,0 +1,18 @@ +import { countBlocksDeep } from '@/blocknote-editor/utils/countBlocksDeep'; + +describe('countBlocksDeep', () => { + it('should count nothing when there is nothing', () => { + expect(countBlocksDeep(undefined)).toBe(0); + expect(countBlocksDeep([])).toBe(0); + }); + + it('should count top level blocks', () => { + expect(countBlocksDeep([{}, {}])).toBe(2); + }); + + it('should count nested children', () => { + expect(countBlocksDeep([{ children: [{}, { children: [{}] }] }, {}])).toBe( + 5, + ); + }); +}); diff --git a/packages/twenty-front/src/modules/blocknote-editor/utils/__tests__/filterBlocksSupportedBySchema.test.ts b/packages/twenty-front/src/modules/blocknote-editor/utils/__tests__/filterBlocksSupportedBySchema.test.ts new file mode 100644 index 00000000000..dd8ee9d8d85 --- /dev/null +++ b/packages/twenty-front/src/modules/blocknote-editor/utils/__tests__/filterBlocksSupportedBySchema.test.ts @@ -0,0 +1,70 @@ +import { + type FilterableBlock, + filterBlocksSupportedBySchema, +} from '@/blocknote-editor/utils/filterBlocksSupportedBySchema'; + +const blockSchema = { + paragraph: {}, + bulletListItem: {}, +}; + +describe('filterBlocksSupportedBySchema', () => { + it('should return undefined when there is nothing to filter', () => { + expect( + filterBlocksSupportedBySchema(undefined, blockSchema), + ).toBeUndefined(); + }); + + it('should keep blocks the schema knows', () => { + const blocks = [{ type: 'paragraph', content: [] }]; + + expect(filterBlocksSupportedBySchema(blocks, blockSchema)).toEqual([ + { type: 'paragraph', content: [], children: undefined }, + ]); + }); + + it('should drop blocks the schema does not know', () => { + const blocks = [ + { type: 'image', props: { url: 'https://example.com/a.png' } }, + { type: 'paragraph', content: [] }, + ]; + + const filtered = filterBlocksSupportedBySchema(blocks, blockSchema); + + expect(filtered).toHaveLength(1); + expect(filtered?.[0].type).toBe('paragraph'); + }); + + it('should drop a block with no type', () => { + const blocks: FilterableBlock[] = [{}]; + + expect(filterBlocksSupportedBySchema(blocks, blockSchema)).toEqual([]); + }); + + it('should drop unsupported children while keeping their parent', () => { + const blocks = [ + { + type: 'bulletListItem', + children: [{ type: 'image' }, { type: 'paragraph' }], + }, + ]; + + const filtered = filterBlocksSupportedBySchema(blocks, blockSchema); + + expect(filtered?.[0].children).toEqual([ + { type: 'paragraph', children: undefined }, + ]); + }); + + it('should drop a whole subtree when its root is unsupported', () => { + const blocks = [{ type: 'image', children: [{ type: 'paragraph' }] }]; + + expect(filterBlocksSupportedBySchema(blocks, blockSchema)).toEqual([]); + }); + + it('should drop every block of a stored TipTap document', () => { + const blocks = [{ type: 'bulletList', content: [{ type: 'listItem' }] }]; + + expect(filterBlocksSupportedBySchema(blocks, blockSchema)).toEqual([]); + }); +}); diff --git a/packages/twenty-front/src/modules/blocknote-editor/utils/countBlocksDeep.ts b/packages/twenty-front/src/modules/blocknote-editor/utils/countBlocksDeep.ts new file mode 100644 index 00000000000..d718389c7a9 --- /dev/null +++ b/packages/twenty-front/src/modules/blocknote-editor/utils/countBlocksDeep.ts @@ -0,0 +1,9 @@ +type CountableBlock = { + children?: CountableBlock[]; +}; + +export const countBlocksDeep = (blocks: CountableBlock[] | undefined): number => + (blocks ?? []).reduce( + (total, block) => total + 1 + countBlocksDeep(block.children), + 0, + ); diff --git a/packages/twenty-front/src/modules/blocknote-editor/utils/filterBlocksSupportedBySchema.ts b/packages/twenty-front/src/modules/blocknote-editor/utils/filterBlocksSupportedBySchema.ts new file mode 100644 index 00000000000..bb0c68df146 --- /dev/null +++ b/packages/twenty-front/src/modules/blocknote-editor/utils/filterBlocksSupportedBySchema.ts @@ -0,0 +1,39 @@ +import { isDefined } from 'twenty-shared/utils'; + +export type FilterableBlock = { + type?: string; + children?: FilterableBlock[]; +}; + +const filterBlockRecursively = ( + block: TBlock, + supportedBlockTypes: string[], +): TBlock | undefined => { + if (!isDefined(block.type) || !supportedBlockTypes.includes(block.type)) { + return undefined; + } + + return { + ...block, + children: block.children + ?.map((childBlock) => + filterBlockRecursively(childBlock, supportedBlockTypes), + ) + .filter(isDefined), + }; +}; + +export const filterBlocksSupportedBySchema = ( + blocks: TBlock[] | undefined, + blockSchema: Record, +): TBlock[] | undefined => { + if (!isDefined(blocks)) { + return undefined; + } + + const supportedBlockTypes = Object.keys(blockSchema); + + return blocks + .map((block) => filterBlockRecursively(block, supportedBlockTypes)) + .filter(isDefined); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/components/FormFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/ui/components/FormFieldInput.tsx index 8c1e73b62be..f05e8da280f 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/ui/components/FormFieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/components/FormFieldInput.tsx @@ -1,3 +1,4 @@ +import { isDefined } from 'twenty-shared/utils'; import { FormAddressFieldInput } from '@/object-record/record-field/ui/form-types/components/FormAddressFieldInput'; import { FormArrayFieldInput } from '@/object-record/record-field/ui/form-types/components/FormArrayFieldInput'; import { FormBooleanFieldInput } from '@/object-record/record-field/ui/form-types/components/FormBooleanFieldInput'; @@ -17,6 +18,7 @@ import { FormNumberFieldInput } from '@/object-record/record-field/ui/form-types import { FormPhoneFieldInput } from '@/object-record/record-field/ui/form-types/components/FormPhoneFieldInput'; import { FormRawJsonFieldInput } from '@/object-record/record-field/ui/form-types/components/FormRawJsonFieldInput'; import { FormRelationToOneFieldInput } from '@/object-record/record-field/ui/form-types/components/FormRelationToOneFieldInput'; +import { FormRecordRichTextFieldInput } from '@/object-record/record-field/ui/form-types/components/FormRecordRichTextFieldInput'; import { FormRichTextFieldInput } from '@/object-record/record-field/ui/form-types/components/FormRichTextFieldInput'; import { FormSelectFieldInput } from '@/object-record/record-field/ui/form-types/components/FormSelectFieldInput'; import { FormTextFieldInput } from '@/object-record/record-field/ui/form-types/components/FormTextFieldInput'; @@ -227,14 +229,24 @@ export const FormFieldInput = ({ readonly={readonly} /> ) : isFieldRichText(field) ? ( - + isDefined(VariablePicker) ? ( + + ) : ( + + ) ) : isFieldRelationManyToOne(field) ? ( void; + readonly?: boolean; + placeholder?: string; +}; + +export const FormRecordRichTextFieldInput = ({ + label, + defaultValue, + placeholder, + onChange, + readonly, +}: FormRecordRichTextFieldInputProps) => { + const { t } = useLingui(); + + const focusId = useId(); + + const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack(); + const { removeFocusItemFromFocusStackById } = + useRemoveFocusItemFromFocusStackById(); + + const { enqueueErrorSnackBar } = useSnackBar(); + + const [{ initialBlocks, hasUnreadableStoredValue }] = useState(() => { + const parsedBlocks = parseInitialBlocknote( + defaultValue?.blocknote ?? defaultValue?.markdown, + ); + + const supportedBlocks = filterBlocksSupportedBySchema( + parsedBlocks, + BLOCK_SCHEMA.blockSchema, + ); + + return { + initialBlocks: isNonEmptyArray(supportedBlocks) + ? supportedBlocks + : undefined, + hasUnreadableStoredValue: + countBlocksDeep(supportedBlocks) < countBlocksDeep(parsedBlocks), + }; + }); + + const handleUploadFile = async (): Promise => { + enqueueErrorSnackBar({ + message: t`Save the record before attaching a file`, + }); + + return ''; + }; + + const editor = useCreateBlockNote({ + uploadFile: handleUploadFile, + initialContent: initialBlocks, + domAttributes: { editor: { class: 'editor' } }, + schema: BLOCK_SCHEMA, + placeholders: { + default: placeholder ?? t`Type '/' for commands`, + }, + }); + + const handleChange = () => { + onChange({ + blocknote: JSON.stringify(editor.document), + markdown: null, + }); + }; + + const handleFocus = () => { + pushFocusItemToFocusStack({ + component: { + instanceId: focusId, + type: FocusComponentType.ACTIVITY_RICH_TEXT_EDITOR, + }, + focusId, + globalHotkeysConfig: BLOCK_EDITOR_GLOBAL_HOTKEYS_CONFIG, + }); + }; + + const handleBlur = () => { + removeFocusItemFromFocusStackById({ focusId }); + }; + + useEffect(() => { + return () => { + removeFocusItemFromFocusStackById({ focusId }); + }; + }, [focusId, removeFocusItemFromFocusStackById]); + + useEffect(() => { + if (hasUnreadableStoredValue) { + enqueueErrorSnackBar({ + message: t`This content was saved in an older format and cannot be edited here`, + }); + } + }, [hasUnreadableStoredValue, enqueueErrorSnackBar, t]); + + return ( + + {label ? {label} : null} + + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/__stories__/FormRecordRichTextFieldInput.stories.tsx b/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/__stories__/FormRecordRichTextFieldInput.stories.tsx new file mode 100644 index 00000000000..f5e1b417068 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/__stories__/FormRecordRichTextFieldInput.stories.tsx @@ -0,0 +1,142 @@ +import { FormRecordRichTextFieldInput } from '@/object-record/record-field/ui/form-types/components/FormRecordRichTextFieldInput'; +import { type Meta, type StoryObj } from '@storybook/react-vite'; +import { expect, fn, userEvent, waitFor, within } from 'storybook/test'; +import { ComponentDecorator } from 'twenty-ui/testing'; +import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadataItemsDecorator'; +import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator'; +import { graphqlMocks } from '~/testing/graphqlMocks'; + +const BLOCKNOTE_PARAGRAPH = JSON.stringify([ + { + id: 'block-1', + type: 'paragraph', + props: {}, + content: [{ type: 'text', text: 'Rich Text', styles: {} }], + }, +]); + +const BLOCKNOTE_BULLET_LIST = JSON.stringify([ + { + id: 'block-1', + type: 'bulletListItem', + props: {}, + content: [{ type: 'text', text: 'First item', styles: {} }], + }, + { + id: 'block-2', + type: 'bulletListItem', + props: {}, + content: [{ type: 'text', text: 'Second item', styles: {} }], + }, +]); + +const meta: Meta = { + title: 'UI/Data/Field/Form/Input/FormRecordRichTextFieldInput', + component: FormRecordRichTextFieldInput, + decorators: [ + ObjectMetadataItemsDecorator, + SnackBarDecorator, + ComponentDecorator, + ], + parameters: { + msw: graphqlMocks, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + placeholder: 'Rich Text field...', + }, +}; + +export const WithLabel: Story = { + args: { + label: 'Rich Text', + placeholder: 'Rich Text field...', + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await canvas.findByText('Rich Text'); + }, +}; + +export const WithBulletList: Story = { + args: { + defaultValue: { blocknote: BLOCKNOTE_BULLET_LIST, markdown: null }, + onChange: fn(), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await canvas.findByText('First item'); + await canvas.findByText('Second item'); + }, +}; + +export const WritesBlockNoteBlocks: Story = { + args: { + onChange: fn(), + }, + play: async ({ canvasElement, args }) => { + const editor = await waitFor(() => { + const editorElement = canvasElement.querySelector('.ProseMirror'); + + expect(editorElement).toBeVisible(); + + return editorElement; + }); + + if (!editor) { + throw new Error('Editor element not found'); + } + + await userEvent.click(editor); + await userEvent.keyboard('Hello'); + + await waitFor(() => { + expect(args.onChange).toHaveBeenCalled(); + }); + + expect(args.onChange).toHaveBeenLastCalledWith( + expect.objectContaining({ + blocknote: expect.stringContaining('"styles"'), + markdown: null, + }), + ); + }, +}; + +export const Disabled: Story = { + args: { + defaultValue: { blocknote: BLOCKNOTE_PARAGRAPH, markdown: null }, + readonly: true, + onChange: fn(), + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + + const editor = await waitFor(() => { + const editorElement = canvasElement.querySelector('.ProseMirror'); + + expect(editorElement).toBeVisible(); + + return editorElement; + }); + + if (!editor) { + throw new Error('Editor element not found'); + } + + const defaultValue = await canvas.findByText('Rich Text'); + + await userEvent.type(editor, 'Hello'); + + expect(args.onChange).not.toHaveBeenCalled(); + expect(defaultValue).toBeVisible(); + }, +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/standalone-rich-text/utils/filterSupportedBlocks.ts b/packages/twenty-front/src/modules/page-layout/widgets/standalone-rich-text/utils/filterSupportedBlocks.ts index e6372a7044e..8aef210caff 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/standalone-rich-text/utils/filterSupportedBlocks.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/standalone-rich-text/utils/filterSupportedBlocks.ts @@ -1,39 +1,13 @@ import { type PartialBlock } from '@blocknote/core'; +import { filterBlocksSupportedBySchema } from '@/blocknote-editor/utils/filterBlocksSupportedBySchema'; import { DASHBOARD_BLOCK_SCHEMA } from '@/page-layout/widgets/standalone-rich-text/constants/DashboardBlockSchema'; -import { isDefined } from 'twenty-shared/utils'; type DashboardPartialBlock = (typeof DASHBOARD_BLOCK_SCHEMA)['PartialBlock']; -type SupportedBlockType = keyof (typeof DASHBOARD_BLOCK_SCHEMA)['blockSchema']; - -const SUPPORTED_BLOCK_TYPES = Object.keys( - DASHBOARD_BLOCK_SCHEMA.blockSchema, -) as SupportedBlockType[]; - -const isSupportedBlockType = (type: string): type is SupportedBlockType => - SUPPORTED_BLOCK_TYPES.includes(type as SupportedBlockType); - -const filterBlockRecursively = ( - block: PartialBlock, -): DashboardPartialBlock | undefined => { - if (!isDefined(block.type) || !isSupportedBlockType(block.type)) { - return undefined; - } - - return { - ...block, - children: block.children - ?.map(filterBlockRecursively) - .filter(isDefined) as DashboardPartialBlock[], - } as DashboardPartialBlock; -}; export const filterSupportedBlocks = ( blocks: PartialBlock[] | undefined, -): DashboardPartialBlock[] | undefined => { - if (!isDefined(blocks)) { - return undefined; - } - - return blocks.map(filterBlockRecursively).filter(isDefined); -}; +): DashboardPartialBlock[] | undefined => + filterBlocksSupportedBySchema(blocks, DASHBOARD_BLOCK_SCHEMA.blockSchema) as + | DashboardPartialBlock[] + | undefined;