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;