From 07be5e089276b0d6aa825e4d85fa5341b04c9cf3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Bosi?= <71827178+bosiraphael@users.noreply.github.com> Date: Tue, 21 Jul 2026 16:21:53 +0200 Subject: [PATCH] Forward editing and clipboard events to front components (#22630) Adds the text-editing events input-heavy front components need: `beforeinput`, `compositionstart/update/end` and `copy/paste/cut`, allowed on `input` and `textarea` only. These events carry payload: `beforeinput` forwards `inputType`/`data` through a native host listener (React synthesizes `onBeforeInput` without them), composition events forward `data`, and paste forwards `clipboardData.getData('text')` capped at 100k chars. Clipboard text is read only on an explicit paste into the component's own input, never on copy/cut, and the worker synthesizes a minimal `clipboardData` so `onPaste` handlers work. `beforeinput` is observe-only: `preventDefault` cannot cross the async worker boundary. Allow-listing these events makes the host bind them, so the `buildHostReactPropsFromRemoteProps` test that pinned them as rejected now pins events that are still unmapped. Review in cubic --- .../generators/remote-components.generator.ts | 6 ++ .../input/input-clipboard.front-component.tsx | 41 ++++++++ .../form/input/input-clipboard.stories.tsx | 69 +++++++++++++ .../input/input-editing.front-component.tsx | 42 ++++++++ .../form/input/input-editing.stories.tsx | 90 +++++++++++++++++ .../shared/front-components/event-log.tsx | 42 ++++++++ .../test-utils/matchers/expectEventLogged.ts | 3 + .../src/constants/AllowedHtmlElements.ts | 18 ++++ .../src/constants/DomEventTypeToReactProp.ts | 7 ++ .../applySerializedEventClipboardData.test.ts | 52 ++++++++++ .../applySerializedEventClipboardData.ts | 22 +++++ .../applySerializedEventProperties.ts | 5 + .../constants/MaxSerializedEventTextLength.ts | 1 + .../ReactUnsupportedEventPropToEventType.ts | 1 + .../host/types/ReactUnsupportedEventType.ts | 2 +- ...buildHostReactPropsFromRemoteProps.test.ts | 31 +++++- .../__tests__/createHtmlHostWrapper.test.ts | 34 +++++++ ...tractReactUnsupportedEventHandlers.test.ts | 12 +++ .../utils/__tests__/serializeEvent.test.ts | 97 +++++++++++++++++++ .../src/host/utils/applyPasteClipboardText.ts | 34 +++++++ .../src/host/utils/serializeEvent.ts | 11 +++ .../src/remote/generated/remote-components.ts | 14 +++ .../src/remote/generated/remote-elements.ts | 34 ++++++- .../src/types/SerializedEventData.ts | 3 + 24 files changed, 664 insertions(+), 7 deletions(-) create mode 100644 packages/twenty-front-component-renderer/src/__stories__/html-tag/form/input/input-clipboard.front-component.tsx create mode 100644 packages/twenty-front-component-renderer/src/__stories__/html-tag/form/input/input-clipboard.stories.tsx create mode 100644 packages/twenty-front-component-renderer/src/__stories__/html-tag/form/input/input-editing.front-component.tsx create mode 100644 packages/twenty-front-component-renderer/src/__stories__/html-tag/form/input/input-editing.stories.tsx create mode 100644 packages/twenty-front-component-renderer/src/constants/__tests__/applySerializedEventClipboardData.test.ts create mode 100644 packages/twenty-front-component-renderer/src/constants/applySerializedEventClipboardData.ts create mode 100644 packages/twenty-front-component-renderer/src/host/constants/MaxSerializedEventTextLength.ts create mode 100644 packages/twenty-front-component-renderer/src/host/utils/applyPasteClipboardText.ts diff --git a/packages/twenty-front-component-renderer/scripts/remote-dom/generators/remote-components.generator.ts b/packages/twenty-front-component-renderer/scripts/remote-dom/generators/remote-components.generator.ts index 84bad657e3..05b3077fa3 100644 --- a/packages/twenty-front-component-renderer/scripts/remote-dom/generators/remote-components.generator.ts +++ b/packages/twenty-front-component-renderer/scripts/remote-dom/generators/remote-components.generator.ts @@ -1,4 +1,5 @@ import type { Project, SourceFile } from 'ts-morph'; +import { isDefined } from 'twenty-shared/utils'; import { DOM_EVENT_TYPE_TO_REACT_PROP } from '../../../src/constants/DomEventTypeToReactProp'; import { type ComponentSchema } from './schemas'; @@ -16,6 +17,11 @@ const generateComponentDefinition = ( const eventProps = component.events .map((event) => { const propName = DOM_EVENT_TYPE_TO_REACT_PROP[event]; + if (!isDefined(propName)) { + throw new Error( + `Missing DOM_EVENT_TYPE_TO_REACT_PROP mapping for '${event}'`, + ); + } return ` ${propName}: { event: '${event}' },`; }) .join('\n'); diff --git a/packages/twenty-front-component-renderer/src/__stories__/html-tag/form/input/input-clipboard.front-component.tsx b/packages/twenty-front-component-renderer/src/__stories__/html-tag/form/input/input-clipboard.front-component.tsx new file mode 100644 index 0000000000..d7a32f3f7f --- /dev/null +++ b/packages/twenty-front-component-renderer/src/__stories__/html-tag/form/input/input-clipboard.front-component.tsx @@ -0,0 +1,41 @@ +import { defineFrontComponent } from 'twenty-sdk/define'; + +import { + EventLog, + useEventLog, +} from '@/__stories__/shared/front-components/event-log'; +import { FrontComponentCard } from '@/__stories__/shared/front-components/front-component-card'; +import { + INPUT_STYLE, + LABEL_STYLE, + SUBJECT_WRAPPER_STYLE, +} from '@/__stories__/shared/front-components/styles'; + +const InputClipboardFrontComponent = () => { + const { entries, pushEvent } = useEventLog(); + + return ( + +
+ + +
+ +
+ ); +}; + +export default defineFrontComponent({ + universalIdentifier: + 'fc-input-clipboard-00000000-0000-0000-0000-000000000020', + name: 'input-clipboard-front-component', + description: 'Front component covering clipboard events on ', + component: InputClipboardFrontComponent, +}); diff --git a/packages/twenty-front-component-renderer/src/__stories__/html-tag/form/input/input-clipboard.stories.tsx b/packages/twenty-front-component-renderer/src/__stories__/html-tag/form/input/input-clipboard.stories.tsx new file mode 100644 index 0000000000..3856f64419 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/__stories__/html-tag/form/input/input-clipboard.stories.tsx @@ -0,0 +1,69 @@ +import { type Meta, type StoryObj } from '@storybook/react-vite'; +import { userEvent, within } from 'storybook/test'; + +import { FrontComponentRenderer } from '@/host/components/FrontComponentRenderer'; +import { + FRONT_COMPONENT_STORY_DEFAULT_ARGS, + resetFrontComponentStoryMocks, +} from '@/__stories__/shared/test-utils/createFrontComponentStoryMeta'; +import { expectEventLogged } from '@/__stories__/shared/test-utils/matchers/expectEventLogged'; +import { expectFrontComponentMounted } from '@/__stories__/shared/test-utils/matchers/expectFrontComponentMounted'; +import { runFrontComponentStory } from '@/__stories__/shared/test-utils/runFrontComponentStory'; + +const meta: Meta = { + title: 'FrontComponent/HtmlTag/Form/Input/Clipboard', + component: FrontComponentRenderer, + parameters: { layout: 'centered' }, + args: FRONT_COMPONENT_STORY_DEFAULT_ARGS, + beforeEach: resetFrontComponentStoryMocks, +}; + +export default meta; + +type Story = StoryObj; + +export const Paste: Story = runFrontComponentStory({ + frontComponentBundleName: 'input-clipboard', + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await expectFrontComponentMounted(canvas); + + const subject = await canvas.findByTestId('subject'); + + await userEvent.click(subject); + await userEvent.paste('hello-clipboard'); + + await expectEventLogged({ + canvas, + matcher: { type: 'paste', clipboardText: 'hello-clipboard' }, + }); + }, +}); + +export const CopyAndCutStayPrivate: Story = runFrontComponentStory({ + frontComponentBundleName: 'input-clipboard', + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await expectFrontComponentMounted(canvas); + + const subject = await canvas.findByTestId('subject'); + + await userEvent.click(subject); + await userEvent.type(subject, 'secret'); + (subject as HTMLInputElement).select(); + + await userEvent.copy(); + await expectEventLogged({ + canvas, + matcher: { type: 'copy', clipboardText: undefined }, + }); + + await userEvent.cut(); + await expectEventLogged({ + canvas, + matcher: { type: 'cut', clipboardText: undefined }, + }); + }, +}); diff --git a/packages/twenty-front-component-renderer/src/__stories__/html-tag/form/input/input-editing.front-component.tsx b/packages/twenty-front-component-renderer/src/__stories__/html-tag/form/input/input-editing.front-component.tsx new file mode 100644 index 0000000000..fdfcb148d0 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/__stories__/html-tag/form/input/input-editing.front-component.tsx @@ -0,0 +1,42 @@ +import { defineFrontComponent } from 'twenty-sdk/define'; + +import { + EventLog, + useEventLog, +} from '@/__stories__/shared/front-components/event-log'; +import { FrontComponentCard } from '@/__stories__/shared/front-components/front-component-card'; +import { + INPUT_STYLE, + LABEL_STYLE, + SUBJECT_WRAPPER_STYLE, +} from '@/__stories__/shared/front-components/styles'; + +const InputEditingFrontComponent = () => { + const { entries, pushEvent } = useEventLog(); + + return ( + +
+ + +
+ +
+ ); +}; + +export default defineFrontComponent({ + universalIdentifier: 'fc-input-editing-00000000-0000-0000-0000-000000000020', + name: 'input-editing-front-component', + description: + 'Front component covering beforeinput and composition events on ', + component: InputEditingFrontComponent, +}); diff --git a/packages/twenty-front-component-renderer/src/__stories__/html-tag/form/input/input-editing.stories.tsx b/packages/twenty-front-component-renderer/src/__stories__/html-tag/form/input/input-editing.stories.tsx new file mode 100644 index 0000000000..098f4eb004 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/__stories__/html-tag/form/input/input-editing.stories.tsx @@ -0,0 +1,90 @@ +import { type Meta, type StoryObj } from '@storybook/react-vite'; +import { userEvent, within } from 'storybook/test'; + +import { FrontComponentRenderer } from '@/host/components/FrontComponentRenderer'; +import { + FRONT_COMPONENT_STORY_DEFAULT_ARGS, + resetFrontComponentStoryMocks, +} from '@/__stories__/shared/test-utils/createFrontComponentStoryMeta'; +import { expectEventLogged } from '@/__stories__/shared/test-utils/matchers/expectEventLogged'; +import { expectFrontComponentMounted } from '@/__stories__/shared/test-utils/matchers/expectFrontComponentMounted'; +import { runFrontComponentStory } from '@/__stories__/shared/test-utils/runFrontComponentStory'; + +const meta: Meta = { + title: 'FrontComponent/HtmlTag/Form/Input/Editing', + component: FrontComponentRenderer, + parameters: { layout: 'centered' }, + args: FRONT_COMPONENT_STORY_DEFAULT_ARGS, + beforeEach: resetFrontComponentStoryMocks, +}; + +export default meta; + +type Story = StoryObj; + +export const BeforeInputInsert: Story = runFrontComponentStory({ + frontComponentBundleName: 'input-editing', + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await expectFrontComponentMounted(canvas); + + const subject = await canvas.findByTestId('subject'); + + await userEvent.type(subject, 'a'); + + await expectEventLogged({ + canvas, + matcher: { type: 'beforeinput', inputType: 'insertText', data: 'a' }, + }); + }, +}); + +export const BeforeInputDelete: Story = runFrontComponentStory({ + frontComponentBundleName: 'input-editing', + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await expectFrontComponentMounted(canvas); + + const subject = await canvas.findByTestId('subject'); + + await userEvent.type(subject, 'ab{backspace}'); + + await expectEventLogged({ + canvas, + matcher: { type: 'beforeinput', inputType: 'deleteContentBackward' }, + }); + }, +}); + +export const Composition: Story = runFrontComponentStory({ + frontComponentBundleName: 'input-editing', + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await expectFrontComponentMounted(canvas); + + const subject = await canvas.findByTestId('subject'); + + subject.dispatchEvent( + new CompositionEvent('compositionstart', { bubbles: true }), + ); + subject.dispatchEvent( + new CompositionEvent('compositionupdate', { data: 'か', bubbles: true }), + ); + subject.dispatchEvent( + new CompositionEvent('compositionend', { data: 'か', bubbles: true }), + ); + + await expectEventLogged({ canvas, matcher: { type: 'compositionstart' } }); + await expectEventLogged({ + canvas, + matcher: { type: 'compositionupdate', data: 'か' }, + }); + await expectEventLogged({ + canvas, + matcher: { type: 'compositionend', data: 'か' }, + }); + }, +}); diff --git a/packages/twenty-front-component-renderer/src/__stories__/shared/front-components/event-log.tsx b/packages/twenty-front-component-renderer/src/__stories__/shared/front-components/event-log.tsx index cc1ad0ed4e..c0feae7c38 100644 --- a/packages/twenty-front-component-renderer/src/__stories__/shared/front-components/event-log.tsx +++ b/packages/twenty-front-component-renderer/src/__stories__/shared/front-components/event-log.tsx @@ -16,6 +16,9 @@ export type LoggedEventEntry = { files?: LoggedEventFile[]; key?: string; code?: string; + inputType?: string; + data?: string; + clipboardText?: string; shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean; @@ -61,6 +64,10 @@ const isBooleanValue = (value: unknown): value is boolean => const isUnknownRecord = (value: unknown): value is Record => isDefined(value) && typeof value === 'object'; +const isFunctionValue = ( + value: unknown, +): value is (...args: unknown[]) => unknown => typeof value === 'function'; + const isElement = (value: unknown): value is Element => isUnknownRecord(value) && typeof value.getAttribute === 'function'; @@ -133,6 +140,26 @@ const readTestId = ( const toUnknownRecord = (value: unknown): Record => isUnknownRecord(value) ? value : {}; +const readClipboardText = ( + eventRecord: Record, +): string | undefined => { + const clipboardData = eventRecord.clipboardData; + if (!isUnknownRecord(clipboardData)) { + return undefined; + } + + const getData = clipboardData.getData; + if (!isFunctionValue(getData)) { + return undefined; + } + + const clipboardText = getData.call(clipboardData, 'text'); + + return isStringValue(clipboardText) && clipboardText !== '' + ? clipboardText + : undefined; +}; + export const useEventLog = () => { const [entries, setEntries] = useState([]); @@ -182,6 +209,21 @@ export const useEventLog = () => { entry.code = code; } + const inputType = pickFromRecords(records, 'inputType', isStringValue); + if (isDefined(inputType)) { + entry.inputType = inputType; + } + + const data = pickFromRecords(records, 'data', isStringValue); + if (isDefined(data)) { + entry.data = data; + } + + const clipboardText = readClipboardText(eventRecord); + if (isDefined(clipboardText)) { + entry.clipboardText = clipboardText; + } + const shiftKey = pickFromRecords(records, 'shiftKey', isBooleanValue); if (isDefined(shiftKey)) { entry.shiftKey = shiftKey; diff --git a/packages/twenty-front-component-renderer/src/__stories__/shared/test-utils/matchers/expectEventLogged.ts b/packages/twenty-front-component-renderer/src/__stories__/shared/test-utils/matchers/expectEventLogged.ts index 1f5bc4d453..870f830a6c 100644 --- a/packages/twenty-front-component-renderer/src/__stories__/shared/test-utils/matchers/expectEventLogged.ts +++ b/packages/twenty-front-component-renderer/src/__stories__/shared/test-utils/matchers/expectEventLogged.ts @@ -13,6 +13,9 @@ type LoggedEventMatcher = { checked?: boolean; key?: string; code?: string; + inputType?: string; + data?: string; + clipboardText?: string; shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean; diff --git a/packages/twenty-front-component-renderer/src/constants/AllowedHtmlElements.ts b/packages/twenty-front-component-renderer/src/constants/AllowedHtmlElements.ts index fd6e299c5e..68db899144 100644 --- a/packages/twenty-front-component-renderer/src/constants/AllowedHtmlElements.ts +++ b/packages/twenty-front-component-renderer/src/constants/AllowedHtmlElements.ts @@ -85,6 +85,15 @@ export const ALLOWED_HTML_ELEMENTS: AllowedHtmlElement[] = [ multiple: { type: 'boolean', optional: true }, capture: { type: 'string', optional: true }, }, + events: [ + 'beforeinput', + 'compositionstart', + 'compositionupdate', + 'compositionend', + 'copy', + 'paste', + 'cut', + ], }, { tag: 'html-textarea', @@ -98,6 +107,15 @@ export const ALLOWED_HTML_ELEMENTS: AllowedHtmlElement[] = [ rows: { type: 'number', optional: true }, cols: { type: 'number', optional: true }, }, + events: [ + 'beforeinput', + 'compositionstart', + 'compositionupdate', + 'compositionend', + 'copy', + 'paste', + 'cut', + ], }, { tag: 'html-select', diff --git a/packages/twenty-front-component-renderer/src/constants/DomEventTypeToReactProp.ts b/packages/twenty-front-component-renderer/src/constants/DomEventTypeToReactProp.ts index 47668df22c..52ce712345 100644 --- a/packages/twenty-front-component-renderer/src/constants/DomEventTypeToReactProp.ts +++ b/packages/twenty-front-component-renderer/src/constants/DomEventTypeToReactProp.ts @@ -45,6 +45,13 @@ export const DOM_EVENT_TYPE_TO_REACT_PROP: Record = { scrollend: 'onScrollEnd', toggle: 'onToggle', load: 'onLoad', + beforeinput: 'onBeforeInput', + compositionstart: 'onCompositionStart', + compositionupdate: 'onCompositionUpdate', + compositionend: 'onCompositionEnd', + copy: 'onCopy', + paste: 'onPaste', + cut: 'onCut', timeupdate: 'onTimeUpdate', play: 'onPlay', pause: 'onPause', diff --git a/packages/twenty-front-component-renderer/src/constants/__tests__/applySerializedEventClipboardData.test.ts b/packages/twenty-front-component-renderer/src/constants/__tests__/applySerializedEventClipboardData.test.ts new file mode 100644 index 0000000000..d9046e8753 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/constants/__tests__/applySerializedEventClipboardData.test.ts @@ -0,0 +1,52 @@ +import { applySerializedEventClipboardData } from '@/constants/applySerializedEventClipboardData'; + +type SynthesizedClipboardData = { + types: string[]; + getData: (format: string) => string; + setData: (format: string, data: string) => void; +}; + +const applyToEvent = (eventData: { + type: string; + clipboardText?: string; +}): SynthesizedClipboardData | undefined => { + const event: Record = {}; + applySerializedEventClipboardData(event, eventData); + return event.clipboardData as SynthesizedClipboardData | undefined; +}; + +describe('applySerializedEventClipboardData', () => { + it('should expose the pasted text through getData on a paste event', () => { + const clipboardData = applyToEvent({ + type: 'paste', + clipboardText: 'pasted text', + }); + + expect(clipboardData?.getData('text')).toBe('pasted text'); + expect(clipboardData?.getData('text/plain')).toBe('pasted text'); + expect(clipboardData?.types).toEqual(['text/plain']); + }); + + it('should return an empty string for other formats', () => { + const clipboardData = applyToEvent({ + type: 'paste', + clipboardText: 'pasted text', + }); + + expect(clipboardData?.getData('text/html')).toBe(''); + }); + + it('should synthesize an empty clipboard for copy and cut events', () => { + for (const type of ['copy', 'cut']) { + const clipboardData = applyToEvent({ type }); + + expect(clipboardData?.getData('text')).toBe(''); + expect(clipboardData?.types).toEqual([]); + expect(clipboardData?.setData('text/plain', 'x')).toBeUndefined(); + } + }); + + it('should not synthesize clipboard data for other event types', () => { + expect(applyToEvent({ type: 'input' })).toBeUndefined(); + }); +}); diff --git a/packages/twenty-front-component-renderer/src/constants/applySerializedEventClipboardData.ts b/packages/twenty-front-component-renderer/src/constants/applySerializedEventClipboardData.ts new file mode 100644 index 0000000000..91ace4e6ca --- /dev/null +++ b/packages/twenty-front-component-renderer/src/constants/applySerializedEventClipboardData.ts @@ -0,0 +1,22 @@ +import { type SerializedEventData } from '@/types/SerializedEventData'; + +const CLIPBOARD_EVENT_TYPES = new Set(['copy', 'cut', 'paste']); +const CLIPBOARD_TEXT_FORMATS = new Set(['text', 'text/plain']); + +export const applySerializedEventClipboardData = ( + event: Record, + eventData: SerializedEventData, +): void => { + if (!CLIPBOARD_EVENT_TYPES.has(eventData.type)) { + return; + } + + const clipboardText = eventData.clipboardText ?? ''; + + event.clipboardData = { + types: clipboardText === '' ? [] : ['text/plain'], + getData: (format: string) => + CLIPBOARD_TEXT_FORMATS.has(format) ? clipboardText : '', + setData: () => undefined, + }; +}; diff --git a/packages/twenty-front-component-renderer/src/constants/applySerializedEventProperties.ts b/packages/twenty-front-component-renderer/src/constants/applySerializedEventProperties.ts index 385d18bafa..efc3bdf4b1 100644 --- a/packages/twenty-front-component-renderer/src/constants/applySerializedEventProperties.ts +++ b/packages/twenty-front-component-renderer/src/constants/applySerializedEventProperties.ts @@ -1,3 +1,4 @@ +import { applySerializedEventClipboardData } from '@/constants/applySerializedEventClipboardData'; import { type SerializedEventData } from '@/types/SerializedEventData'; const SERIALIZED_EVENT_PROPERTY_KEYS = [ @@ -32,6 +33,8 @@ const SERIALIZED_EVENT_PROPERTY_KEYS = [ 'key', 'code', 'repeat', + 'inputType', + 'data', 'deltaX', 'deltaY', 'deltaZ', @@ -47,4 +50,6 @@ export const applySerializedEventProperties = ( event[key] = eventData[key]; } } + + applySerializedEventClipboardData(event, eventData); }; diff --git a/packages/twenty-front-component-renderer/src/host/constants/MaxSerializedEventTextLength.ts b/packages/twenty-front-component-renderer/src/host/constants/MaxSerializedEventTextLength.ts new file mode 100644 index 0000000000..925bb2bb1a --- /dev/null +++ b/packages/twenty-front-component-renderer/src/host/constants/MaxSerializedEventTextLength.ts @@ -0,0 +1 @@ +export const MAX_SERIALIZED_EVENT_TEXT_LENGTH = 100_000; diff --git a/packages/twenty-front-component-renderer/src/host/constants/ReactUnsupportedEventPropToEventType.ts b/packages/twenty-front-component-renderer/src/host/constants/ReactUnsupportedEventPropToEventType.ts index fd7220bd77..8f300f02d4 100644 --- a/packages/twenty-front-component-renderer/src/host/constants/ReactUnsupportedEventPropToEventType.ts +++ b/packages/twenty-front-component-renderer/src/host/constants/ReactUnsupportedEventPropToEventType.ts @@ -4,6 +4,7 @@ export const REACT_UNSUPPORTED_EVENT_PROP_TO_EVENT_TYPE: Record< string, ReactUnsupportedEventType > = { + onBeforeInput: 'beforeinput', onFocusIn: 'focusin', onFocusOut: 'focusout', }; diff --git a/packages/twenty-front-component-renderer/src/host/types/ReactUnsupportedEventType.ts b/packages/twenty-front-component-renderer/src/host/types/ReactUnsupportedEventType.ts index f273798ef8..cc2b551e0e 100644 --- a/packages/twenty-front-component-renderer/src/host/types/ReactUnsupportedEventType.ts +++ b/packages/twenty-front-component-renderer/src/host/types/ReactUnsupportedEventType.ts @@ -1 +1 @@ -export type ReactUnsupportedEventType = 'focusin' | 'focusout'; +export type ReactUnsupportedEventType = 'beforeinput' | 'focusin' | 'focusout'; diff --git a/packages/twenty-front-component-renderer/src/host/utils/__tests__/buildHostReactPropsFromRemoteProps.test.ts b/packages/twenty-front-component-renderer/src/host/utils/__tests__/buildHostReactPropsFromRemoteProps.test.ts index 3f8ec663c4..c6028af0a9 100644 --- a/packages/twenty-front-component-renderer/src/host/utils/__tests__/buildHostReactPropsFromRemoteProps.test.ts +++ b/packages/twenty-front-component-renderer/src/host/utils/__tests__/buildHostReactPropsFromRemoteProps.test.ts @@ -87,15 +87,38 @@ describe('buildHostReactPropsFromRemoteProps', () => { expect('onmouseover' in result).toBe(false); }); + it('should normalize and wrap editing and clipboard event handlers', () => { + const handler = jest.fn(); + const result = buildHostReactPropsFromRemoteProps( + { + onBeforeinput: handler, + onCompositionstart: handler, + onCompositionupdate: handler, + onCompositionend: handler, + onCopy: handler, + onPaste: handler, + onCut: handler, + }, + 'div', + ); + + expect(typeof result.onBeforeInput).toBe('function'); + expect(typeof result.onCompositionStart).toBe('function'); + expect(typeof result.onCompositionUpdate).toBe('function'); + expect(typeof result.onCompositionEnd).toBe('function'); + expect(typeof result.onCopy).toBe('function'); + expect(typeof result.onPaste).toBe('function'); + expect(typeof result.onCut).toBe('function'); + }); + it('should drop handler props whose event type is not allow-listed', () => { const handler = jest.fn(); const result = buildHostReactPropsFromRemoteProps( { - onCopy: handler, - onCut: handler, - onPaste: handler, onSelect: handler, - onBeforeInput: handler, + onInvalid: handler, + onReset: handler, + onAbort: handler, }, 'div', ); diff --git a/packages/twenty-front-component-renderer/src/host/utils/__tests__/createHtmlHostWrapper.test.ts b/packages/twenty-front-component-renderer/src/host/utils/__tests__/createHtmlHostWrapper.test.ts index 20f8d42e09..7104e5b85c 100644 --- a/packages/twenty-front-component-renderer/src/host/utils/__tests__/createHtmlHostWrapper.test.ts +++ b/packages/twenty-front-component-renderer/src/host/utils/__tests__/createHtmlHostWrapper.test.ts @@ -155,6 +155,40 @@ describe('createHtmlHostWrapper client events', () => { expect(handleFocusIn).not.toHaveBeenCalled(); }); + it('should forward beforeinput on a text input through a native listener', () => { + const handleBeforeInput = jest.fn(); + const Wrapper = createHtmlHostWrapper('input'); + + act(() => { + root.render( + createElement(Wrapper, { + onBeforeinput: handleBeforeInput, + type: 'text', + }), + ); + }); + + const node = container.firstElementChild as HTMLInputElement; + act(() => { + node.dispatchEvent( + new InputEvent('beforeinput', { + bubbles: true, + inputType: 'insertText', + data: 'a', + }), + ); + }); + + expect(handleBeforeInput).toHaveBeenCalledTimes(1); + expect(handleBeforeInput).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'beforeinput', + inputType: 'insertText', + data: 'a', + }), + ); + }); + it('should prevent default on dragover when a remote drop handler exists', () => { const handleDrop = jest.fn(); const Wrapper = createHtmlHostWrapper('div'); diff --git a/packages/twenty-front-component-renderer/src/host/utils/__tests__/extractReactUnsupportedEventHandlers.test.ts b/packages/twenty-front-component-renderer/src/host/utils/__tests__/extractReactUnsupportedEventHandlers.test.ts index 327d6290da..a4a26910fd 100644 --- a/packages/twenty-front-component-renderer/src/host/utils/__tests__/extractReactUnsupportedEventHandlers.test.ts +++ b/packages/twenty-front-component-renderer/src/host/utils/__tests__/extractReactUnsupportedEventHandlers.test.ts @@ -15,6 +15,18 @@ describe('extractReactUnsupportedEventHandlers', () => { expect(reactBindableProps).toEqual({ id: 'x' }); }); + it('should extract a beforeinput handler from react props', () => { + const onBeforeInput = jest.fn(); + + const { reactUnsupportedEventHandlers, reactBindableProps } = + extractReactUnsupportedEventHandlers({ onBeforeInput, id: 'x' }); + + expect(reactUnsupportedEventHandlers).toEqual({ + beforeinput: onBeforeInput, + }); + expect(reactBindableProps).toEqual({ id: 'x' }); + }); + it('should drop a react-unsupported handler whose value is not a function', () => { const { reactUnsupportedEventHandlers, reactBindableProps } = extractReactUnsupportedEventHandlers({ onFocusIn: 'alert(1)' }); diff --git a/packages/twenty-front-component-renderer/src/host/utils/__tests__/serializeEvent.test.ts b/packages/twenty-front-component-renderer/src/host/utils/__tests__/serializeEvent.test.ts index 8c5dc4a589..84d64b4a3c 100644 --- a/packages/twenty-front-component-renderer/src/host/utils/__tests__/serializeEvent.test.ts +++ b/packages/twenty-front-component-renderer/src/host/utils/__tests__/serializeEvent.test.ts @@ -1,3 +1,5 @@ +import { MAX_SERIALIZED_EVENT_TEXT_LENGTH } from '@/host/constants/MaxSerializedEventTextLength'; + import { serializeEvent } from '../serializeEvent'; describe('serializeEvent', () => { @@ -80,6 +82,101 @@ describe('serializeEvent', () => { expect(result).toEqual({ type: 'touchend' }); }); + it('should forward the input type and data of a beforeinput event', () => { + const result = serializeEvent({ + type: 'beforeinput', + inputType: 'insertText', + data: 'a', + }); + + expect(result).toEqual({ + type: 'beforeinput', + inputType: 'insertText', + data: 'a', + }); + }); + + it('should forward the input type without data on a deletion beforeinput', () => { + const result = serializeEvent({ + type: 'beforeinput', + inputType: 'deleteContentBackward', + data: null, + }); + + expect(result).toEqual({ + type: 'beforeinput', + inputType: 'deleteContentBackward', + }); + }); + + it('should cap the forwarded data length', () => { + const result = serializeEvent({ + type: 'beforeinput', + inputType: 'insertFromPaste', + data: 'a'.repeat(MAX_SERIALIZED_EVENT_TEXT_LENGTH + 1), + }); + + expect(result.data).toHaveLength(MAX_SERIALIZED_EVENT_TEXT_LENGTH); + }); + + it('should forward the composition data', () => { + const result = serializeEvent({ + type: 'compositionupdate', + data: 'か', + }); + + expect(result).toEqual({ type: 'compositionupdate', data: 'か' }); + }); + + it('should forward the clipboard text of a paste event', () => { + const result = serializeEvent({ + type: 'paste', + clipboardData: { getData: () => 'pasted text' }, + }); + + expect(result).toEqual({ type: 'paste', clipboardText: 'pasted text' }); + }); + + it('should cap the forwarded clipboard text length', () => { + const result = serializeEvent({ + type: 'paste', + clipboardData: { + getData: () => 'a'.repeat(MAX_SERIALIZED_EVENT_TEXT_LENGTH + 1), + }, + }); + + expect(result.clipboardText).toHaveLength(MAX_SERIALIZED_EVENT_TEXT_LENGTH); + }); + + it('should never read clipboard data on copy and cut events', () => { + const getData = jest.fn(() => 'selected text'); + + const copyResult = serializeEvent({ + type: 'copy', + clipboardData: { getData }, + }); + const cutResult = serializeEvent({ + type: 'cut', + clipboardData: { getData }, + }); + + expect(getData).not.toHaveBeenCalled(); + expect(copyResult).toEqual({ type: 'copy' }); + expect(cutResult).toEqual({ type: 'cut' }); + }); + + it('should ignore malformed clipboard data on paste', () => { + expect( + serializeEvent({ type: 'paste', clipboardData: 'not-an-object' }), + ).toEqual({ type: 'paste' }); + expect( + serializeEvent({ type: 'paste', clipboardData: { getData: 'x' } }), + ).toEqual({ type: 'paste' }); + expect( + serializeEvent({ type: 'paste', clipboardData: { getData: () => 1 } }), + ).toEqual({ type: 'paste' }); + }); + it('should extract safe target properties', () => { const result = serializeEvent({ type: 'change', diff --git a/packages/twenty-front-component-renderer/src/host/utils/applyPasteClipboardText.ts b/packages/twenty-front-component-renderer/src/host/utils/applyPasteClipboardText.ts new file mode 100644 index 0000000000..10c034b69a --- /dev/null +++ b/packages/twenty-front-component-renderer/src/host/utils/applyPasteClipboardText.ts @@ -0,0 +1,34 @@ +import { isFunction, isObject, isString } from '@sniptt/guards'; + +import { MAX_SERIALIZED_EVENT_TEXT_LENGTH } from '@/host/constants/MaxSerializedEventTextLength'; +import { type SerializedEventData } from '@/types/SerializedEventData'; + +export const applyPasteClipboardText = ( + serializedEvent: SerializedEventData, + domEvent: Record, +): void => { + if (serializedEvent.type !== 'paste') { + return; + } + + const clipboardData = domEvent.clipboardData; + + if (!isObject(clipboardData)) { + return; + } + + const getData = (clipboardData as Record).getData; + + if (!isFunction(getData)) { + return; + } + + const clipboardText = getData.call(clipboardData, 'text'); + + if (isString(clipboardText)) { + serializedEvent.clipboardText = clipboardText.slice( + 0, + MAX_SERIALIZED_EVENT_TEXT_LENGTH, + ); + } +}; diff --git a/packages/twenty-front-component-renderer/src/host/utils/serializeEvent.ts b/packages/twenty-front-component-renderer/src/host/utils/serializeEvent.ts index 1da81bddd5..a3bcd6b216 100644 --- a/packages/twenty-front-component-renderer/src/host/utils/serializeEvent.ts +++ b/packages/twenty-front-component-renderer/src/host/utils/serializeEvent.ts @@ -1,7 +1,9 @@ import { isBoolean, isNumber, isObject, isString } from '@sniptt/guards'; import { isDefined } from 'twenty-shared/utils'; +import { MAX_SERIALIZED_EVENT_TEXT_LENGTH } from '@/host/constants/MaxSerializedEventTextLength'; import { applyFirstChangedTouchCoordinates } from '@/host/utils/applyFirstChangedTouchCoordinates'; +import { applyPasteClipboardText } from '@/host/utils/applyPasteClipboardText'; import { serializeFileList } from '@/host/utils/serializeFileList'; import { type SerializedEventData } from '@/types/SerializedEventData'; @@ -114,6 +116,15 @@ export const serializeEvent = (event: unknown): SerializedEventData => { serialized.repeat = domEvent.repeat; } + if (isString(domEvent.inputType)) { + serialized.inputType = domEvent.inputType; + } + if (isString(domEvent.data)) { + serialized.data = domEvent.data.slice(0, MAX_SERIALIZED_EVENT_TEXT_LENGTH); + } + + applyPasteClipboardText(serialized, domEvent); + if (isNumber(domEvent.deltaX)) { serialized.deltaX = domEvent.deltaX; } diff --git a/packages/twenty-front-component-renderer/src/remote/generated/remote-components.ts b/packages/twenty-front-component-renderer/src/remote/generated/remote-components.ts index 659b7a7de0..848e50ba78 100644 --- a/packages/twenty-front-component-renderer/src/remote/generated/remote-components.ts +++ b/packages/twenty-front-component-renderer/src/remote/generated/remote-components.ts @@ -1586,6 +1586,13 @@ export const HtmlInput = createRemoteComponent('html-input', HtmlInputElement, { onAnimationEnd: { event: 'animationend' }, onTransitionEnd: { event: 'transitionend' }, onScrollEnd: { event: 'scrollend' }, + onBeforeInput: { event: 'beforeinput' }, + onCompositionStart: { event: 'compositionstart' }, + onCompositionUpdate: { event: 'compositionupdate' }, + onCompositionEnd: { event: 'compositionend' }, + onCopy: { event: 'copy' }, + onPaste: { event: 'paste' }, + onCut: { event: 'cut' }, }, }); export const HtmlTextarea = createRemoteComponent( @@ -1637,6 +1644,13 @@ export const HtmlTextarea = createRemoteComponent( onAnimationEnd: { event: 'animationend' }, onTransitionEnd: { event: 'transitionend' }, onScrollEnd: { event: 'scrollend' }, + onBeforeInput: { event: 'beforeinput' }, + onCompositionStart: { event: 'compositionstart' }, + onCompositionUpdate: { event: 'compositionupdate' }, + onCompositionEnd: { event: 'compositionend' }, + onCopy: { event: 'copy' }, + onPaste: { event: 'paste' }, + onCut: { event: 'cut' }, }, }, ); diff --git a/packages/twenty-front-component-renderer/src/remote/generated/remote-elements.ts b/packages/twenty-front-component-renderer/src/remote/generated/remote-elements.ts index 2128e1bf6a..7d02962bfb 100644 --- a/packages/twenty-front-component-renderer/src/remote/generated/remote-elements.ts +++ b/packages/twenty-front-component-renderer/src/remote/generated/remote-elements.ts @@ -540,7 +540,15 @@ export const HtmlInputElement = createRemoteElement< HtmlInputProperties, Record, Record, - HtmlCommonEvents + HtmlCommonEvents & { + beforeinput(event: RemoteEvent): void; + compositionstart(event: RemoteEvent): void; + compositionupdate(event: RemoteEvent): void; + compositionend(event: RemoteEvent): void; + copy(event: RemoteEvent): void; + paste(event: RemoteEvent): void; + cut(event: RemoteEvent): void; + } >({ properties: { ...HTML_COMMON_PROPERTIES_CONFIG, @@ -557,6 +565,13 @@ export const HtmlInputElement = createRemoteElement< }, events: { ...HTML_COMMON_EVENTS_CONFIG, + beforeinput: createSerializedEventConfig('beforeinput'), + compositionstart: createSerializedEventConfig('compositionstart'), + compositionupdate: createSerializedEventConfig('compositionupdate'), + compositionend: createSerializedEventConfig('compositionend'), + copy: createSerializedEventConfig('copy'), + paste: createSerializedEventConfig('paste'), + cut: createSerializedEventConfig('cut'), }, }); @@ -574,7 +589,15 @@ export const HtmlTextareaElement = createRemoteElement< HtmlTextareaProperties, Record, Record, - HtmlCommonEvents + HtmlCommonEvents & { + beforeinput(event: RemoteEvent): void; + compositionstart(event: RemoteEvent): void; + compositionupdate(event: RemoteEvent): void; + compositionend(event: RemoteEvent): void; + copy(event: RemoteEvent): void; + paste(event: RemoteEvent): void; + cut(event: RemoteEvent): void; + } >({ properties: { ...HTML_COMMON_PROPERTIES_CONFIG, @@ -588,6 +611,13 @@ export const HtmlTextareaElement = createRemoteElement< }, events: { ...HTML_COMMON_EVENTS_CONFIG, + beforeinput: createSerializedEventConfig('beforeinput'), + compositionstart: createSerializedEventConfig('compositionstart'), + compositionupdate: createSerializedEventConfig('compositionupdate'), + compositionend: createSerializedEventConfig('compositionend'), + copy: createSerializedEventConfig('copy'), + paste: createSerializedEventConfig('paste'), + cut: createSerializedEventConfig('cut'), }, }); diff --git a/packages/twenty-front-component-renderer/src/types/SerializedEventData.ts b/packages/twenty-front-component-renderer/src/types/SerializedEventData.ts index 80863bfa62..6a20ce1955 100644 --- a/packages/twenty-front-component-renderer/src/types/SerializedEventData.ts +++ b/packages/twenty-front-component-renderer/src/types/SerializedEventData.ts @@ -33,6 +33,9 @@ export type SerializedEventData = { key?: string; code?: string; repeat?: boolean; + inputType?: string; + data?: string; + clipboardText?: string; value?: string; checked?: boolean; scrollTop?: number;