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.
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22630?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
+6
@@ -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');
|
||||
|
||||
+41
@@ -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 (
|
||||
<FrontComponentCard title="input:text:clipboard">
|
||||
<div style={SUBJECT_WRAPPER_STYLE}>
|
||||
<label style={LABEL_STYLE}>Clipboard input</label>
|
||||
<input
|
||||
data-testid="subject"
|
||||
type="text"
|
||||
onPaste={pushEvent}
|
||||
onCopy={pushEvent}
|
||||
onCut={pushEvent}
|
||||
style={INPUT_STYLE}
|
||||
/>
|
||||
</div>
|
||||
<EventLog entries={entries} />
|
||||
</FrontComponentCard>
|
||||
);
|
||||
};
|
||||
|
||||
export default defineFrontComponent({
|
||||
universalIdentifier:
|
||||
'fc-input-clipboard-00000000-0000-0000-0000-000000000020',
|
||||
name: 'input-clipboard-front-component',
|
||||
description: 'Front component covering clipboard events on <input>',
|
||||
component: InputClipboardFrontComponent,
|
||||
});
|
||||
+69
@@ -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<typeof FrontComponentRenderer> = {
|
||||
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<typeof FrontComponentRenderer>;
|
||||
|
||||
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 },
|
||||
});
|
||||
},
|
||||
});
|
||||
+42
@@ -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 (
|
||||
<FrontComponentCard title="input:text:editing">
|
||||
<div style={SUBJECT_WRAPPER_STYLE}>
|
||||
<label style={LABEL_STYLE}>Editing input</label>
|
||||
<input
|
||||
data-testid="subject"
|
||||
type="text"
|
||||
onBeforeInput={pushEvent}
|
||||
onCompositionStart={pushEvent}
|
||||
onCompositionUpdate={pushEvent}
|
||||
onCompositionEnd={pushEvent}
|
||||
style={INPUT_STYLE}
|
||||
/>
|
||||
</div>
|
||||
<EventLog entries={entries} />
|
||||
</FrontComponentCard>
|
||||
);
|
||||
};
|
||||
|
||||
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 <input>',
|
||||
component: InputEditingFrontComponent,
|
||||
});
|
||||
+90
@@ -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<typeof FrontComponentRenderer> = {
|
||||
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<typeof FrontComponentRenderer>;
|
||||
|
||||
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: 'か' },
|
||||
});
|
||||
},
|
||||
});
|
||||
+42
@@ -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<string, unknown> =>
|
||||
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<string, unknown> =>
|
||||
isUnknownRecord(value) ? value : {};
|
||||
|
||||
const readClipboardText = (
|
||||
eventRecord: Record<string, unknown>,
|
||||
): 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<LoggedEventEntry[]>([]);
|
||||
|
||||
@@ -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;
|
||||
|
||||
+3
@@ -13,6 +13,9 @@ type LoggedEventMatcher = {
|
||||
checked?: boolean;
|
||||
key?: string;
|
||||
code?: string;
|
||||
inputType?: string;
|
||||
data?: string;
|
||||
clipboardText?: string;
|
||||
shiftKey?: boolean;
|
||||
ctrlKey?: boolean;
|
||||
metaKey?: boolean;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -45,6 +45,13 @@ export const DOM_EVENT_TYPE_TO_REACT_PROP: Record<string, string> = {
|
||||
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',
|
||||
|
||||
+52
@@ -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<string, unknown> = {};
|
||||
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();
|
||||
});
|
||||
});
|
||||
+22
@@ -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<string, unknown>,
|
||||
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,
|
||||
};
|
||||
};
|
||||
+5
@@ -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);
|
||||
};
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const MAX_SERIALIZED_EVENT_TEXT_LENGTH = 100_000;
|
||||
+1
@@ -4,6 +4,7 @@ export const REACT_UNSUPPORTED_EVENT_PROP_TO_EVENT_TYPE: Record<
|
||||
string,
|
||||
ReactUnsupportedEventType
|
||||
> = {
|
||||
onBeforeInput: 'beforeinput',
|
||||
onFocusIn: 'focusin',
|
||||
onFocusOut: 'focusout',
|
||||
};
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
export type ReactUnsupportedEventType = 'focusin' | 'focusout';
|
||||
export type ReactUnsupportedEventType = 'beforeinput' | 'focusin' | 'focusout';
|
||||
|
||||
+27
-4
@@ -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',
|
||||
);
|
||||
|
||||
+34
@@ -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');
|
||||
|
||||
+12
@@ -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)' });
|
||||
|
||||
+97
@@ -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',
|
||||
|
||||
@@ -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<string, unknown>,
|
||||
): void => {
|
||||
if (serializedEvent.type !== 'paste') {
|
||||
return;
|
||||
}
|
||||
|
||||
const clipboardData = domEvent.clipboardData;
|
||||
|
||||
if (!isObject(clipboardData)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const getData = (clipboardData as Record<string, unknown>).getData;
|
||||
|
||||
if (!isFunction(getData)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const clipboardText = getData.call(clipboardData, 'text');
|
||||
|
||||
if (isString(clipboardText)) {
|
||||
serializedEvent.clipboardText = clipboardText.slice(
|
||||
0,
|
||||
MAX_SERIALIZED_EVENT_TEXT_LENGTH,
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -540,7 +540,15 @@ export const HtmlInputElement = createRemoteElement<
|
||||
HtmlInputProperties,
|
||||
Record<string, never>,
|
||||
Record<string, never>,
|
||||
HtmlCommonEvents
|
||||
HtmlCommonEvents & {
|
||||
beforeinput(event: RemoteEvent<SerializedEventData>): void;
|
||||
compositionstart(event: RemoteEvent<SerializedEventData>): void;
|
||||
compositionupdate(event: RemoteEvent<SerializedEventData>): void;
|
||||
compositionend(event: RemoteEvent<SerializedEventData>): void;
|
||||
copy(event: RemoteEvent<SerializedEventData>): void;
|
||||
paste(event: RemoteEvent<SerializedEventData>): void;
|
||||
cut(event: RemoteEvent<SerializedEventData>): 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<string, never>,
|
||||
Record<string, never>,
|
||||
HtmlCommonEvents
|
||||
HtmlCommonEvents & {
|
||||
beforeinput(event: RemoteEvent<SerializedEventData>): void;
|
||||
compositionstart(event: RemoteEvent<SerializedEventData>): void;
|
||||
compositionupdate(event: RemoteEvent<SerializedEventData>): void;
|
||||
compositionend(event: RemoteEvent<SerializedEventData>): void;
|
||||
copy(event: RemoteEvent<SerializedEventData>): void;
|
||||
paste(event: RemoteEvent<SerializedEventData>): void;
|
||||
cut(event: RemoteEvent<SerializedEventData>): 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'),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user