Allow copy to clipboard and pointer/mousemove events in front components (#20858)

Follow-up to #20525, picks up the clipboard + mouse/pointer events asks
from the "Allow to copy to clipboard in front-component" Slack thread.
`navigator.geolocation` and `getBoundingClientRect` are intentionally
out of scope until we have a permission model.

### `copyToClipboard` host API

New SDK function `copyToClipboard` (in `twenty-sdk/front-component`)
that goes through the host bridge to `useCopyToClipboard` in
`twenty-front`:

```ts
import { copyToClipboard } from 'twenty-sdk/front-component';

await copyToClipboard('hello');
```

Host-side hardening (front-component code is untrusted):
- Drops anything that isn't a non-empty string
- Caps payload at 64KB
- Throttles to 1 call/sec per front-component instance
- Snackbar shows a truncated preview so the user can spot a mismatch
between the affordance they clicked and what actually got copied

### `mousemove` and pointer events

Added to `COMMON_HTML_EVENTS` (and the React mapping) so they fire on
every HTML tag the renderer ships: `mousemove`, `pointerdown/up/move`,
`pointerover/out/enter/leave/cancel`. Generator rerun for
`remote-elements.ts` and `remote-components.ts`.

`SerializedEventData` now also forwards pointer geometry: `pointerId`,
`pointerType`, `pressure`, `tangentialPressure`, `tiltX/Y`, `twist`,
`width/height`, `isPrimary`. Existing positional fields are unchanged.

### Coverage

- New Storybook stories: `HostApi/CopyToClipboard` and
`HtmlTag/Grouping/Div/Events::PointerMove`
- `useFrontComponentExecutionContext` unit tests cover the API call,
preview truncation, type guard, length cap, and rate limit
- Renderer Storybook suite 227 → 229, prebuild bundle count 219 → 221
This commit is contained in:
Charles Bochet
2026-05-23 10:28:11 +02:00
committed by GitHub
parent 452433a9ae
commit 563acc3f57
20 changed files with 1599 additions and 87 deletions
@@ -0,0 +1,60 @@
import { type Meta, type StoryObj } from '@storybook/react-vite';
import { expect, userEvent, waitFor, within } from 'storybook/test';
import { isDefined } from 'twenty-shared/utils';
import { FrontComponentRenderer } from '@/host/components/FrontComponentRenderer';
import {
FRONT_COMPONENT_STORY_DEFAULT_ARGS,
resetFrontComponentStoryMocks,
} from '@/__stories__/shared/test-utils/createFrontComponentStoryMeta';
import { expectFrontComponentMounted } from '@/__stories__/shared/test-utils/matchers/expectFrontComponentMounted';
import { runFrontComponentStory } from '@/__stories__/shared/test-utils/runFrontComponentStory';
import {
HOST_API_TIMEOUT,
INTERACTION_TIMEOUT,
} from '@/__stories__/shared/test-utils/timeouts';
const meta: Meta<typeof FrontComponentRenderer> = {
title: 'FrontComponent/HostApi/CopyToClipboard',
component: FrontComponentRenderer,
parameters: { layout: 'centered' },
args: FRONT_COMPONENT_STORY_DEFAULT_ARGS,
beforeEach: resetFrontComponentStoryMocks,
};
export default meta;
type Story = StoryObj<typeof FrontComponentRenderer>;
export const CopyToClipboard: Story = runFrontComponentStory({
frontComponentBundleName: 'host-api-copy-to-clipboard',
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const api = args.frontComponentHostCommunicationApi;
if (!isDefined(api)) {
throw new Error('frontComponentHostCommunicationApi is required');
}
await expectFrontComponentMounted(canvas);
const subject = await canvas.findByTestId('subject');
await userEvent.click(subject);
await waitFor(
() => {
expect(api.copyToClipboard).toHaveBeenCalledWith('Hello clipboard');
},
{ timeout: HOST_API_TIMEOUT },
);
expect(
await canvas.findByText(
'clipboard:success',
{},
{ timeout: INTERACTION_TIMEOUT },
),
).toBeVisible();
},
});
@@ -0,0 +1,50 @@
import { useState } from 'react';
import { defineFrontComponent } from 'twenty-sdk/define';
import { copyToClipboard } from 'twenty-sdk/front-component';
import { FrontComponentCard } from '@/__stories__/shared/front-components/front-component-card';
import { BUTTON_STYLE } from '@/__stories__/shared/front-components/styles';
const STATUS_STYLE = {
fontSize: 13,
color: '#1f2937',
fontFamily: 'monospace',
};
const HostApiCopyToClipboardFrontComponent = () => {
const [status, setStatus] = useState('idle');
const handleClick = async () => {
try {
await copyToClipboard('Hello clipboard');
setStatus('clipboard:success');
} catch (error) {
setStatus(
`clipboard:error:${error instanceof Error ? error.message : String(error)}`,
);
}
};
return (
<FrontComponentCard title="host-api:copy-to-clipboard">
<button
data-testid="subject"
type="button"
onClick={handleClick}
style={BUTTON_STYLE}
>
Copy
</button>
<span data-testid="api-status" style={STATUS_STYLE}>
{status}
</span>
</FrontComponentCard>
);
};
export default defineFrontComponent({
universalIdentifier: 'fc-host-clipboard-00000000-0000-0000-0000-000000000020',
name: 'host-api-copy-to-clipboard-front-component',
description: 'Front component covering copyToClipboard host API',
component: HostApiCopyToClipboardFrontComponent,
});
@@ -72,3 +72,20 @@ export const MouseEnterLeave: Story = runFrontComponentStory({
await expectEventLogged({ canvas, matcher: { type: 'mouseleave' } });
},
});
export const PointerMove: Story = runFrontComponentStory({
frontComponentBundleName: 'div-pointermove',
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expectFrontComponentMounted(canvas);
const subject = await canvas.findByTestId('subject');
await userEvent.pointer({ target: subject, coords: { x: 10, y: 10 } });
await userEvent.pointer({ target: subject, coords: { x: 50, y: 30 } });
await expectEventLogged({ canvas, matcher: { type: 'pointermove' } });
await expectEventLogged({ canvas, matcher: { type: 'mousemove' } });
},
});
@@ -0,0 +1,45 @@
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';
const SURFACE_STYLE = {
width: 200,
height: 80,
border: '1px solid #d1d5db',
borderRadius: 8,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'crosshair',
userSelect: 'none' as const,
backgroundColor: '#f3f4f6',
};
const DivPointerMoveFrontComponent = () => {
const { entries, pushEvent } = useEventLog();
return (
<FrontComponentCard title="div:mousemove-pointermove">
<div
data-testid="subject"
onMouseMove={(event) => pushEvent(event)}
onPointerMove={(event) => pushEvent(event)}
style={SURFACE_STYLE}
>
Move pointer
</div>
<EventLog entries={entries} />
</FrontComponentCard>
);
};
export default defineFrontComponent({
universalIdentifier: 'fc-div-pmm-00000000-0000-0000-0000-000000000020',
name: 'div-pointermove-front-component',
description: 'Front component covering mousemove/pointermove on <div>',
component: DivPointerMoveFrontComponent,
});
@@ -14,6 +14,7 @@ export const hostApiMocks = {
updateProgress: fn().mockResolvedValue(undefined),
requestAccessTokenRefresh: fn().mockResolvedValue('refreshed-token'),
openCommandConfirmationModal: fn().mockResolvedValue(undefined),
copyToClipboard: fn().mockResolvedValue(undefined),
};
export const FRONT_COMPONENT_STORY_DEFAULT_ARGS: NonNullable<
@@ -41,4 +42,5 @@ export const resetFrontComponentStoryMocks = () => {
hostApiMocks.updateProgress.mockClear();
hostApiMocks.requestAccessTokenRefresh.mockClear();
hostApiMocks.openCommandConfirmationModal.mockClear();
hostApiMocks.copyToClipboard.mockClear();
};
@@ -3,10 +3,19 @@ export const COMMON_HTML_EVENTS = [
'dblclick',
'mousedown',
'mouseup',
'mousemove',
'mouseover',
'mouseout',
'mouseenter',
'mouseleave',
'pointerdown',
'pointerup',
'pointermove',
'pointerover',
'pointerout',
'pointerenter',
'pointerleave',
'pointercancel',
'keydown',
'keyup',
'keypress',
@@ -3,10 +3,19 @@ export const EVENT_TO_REACT: Record<string, string> = {
dblclick: 'onDoubleClick',
mousedown: 'onMouseDown',
mouseup: 'onMouseUp',
mousemove: 'onMouseMove',
mouseover: 'onMouseOver',
mouseout: 'onMouseOut',
mouseenter: 'onMouseEnter',
mouseleave: 'onMouseLeave',
pointerdown: 'onPointerDown',
pointerup: 'onPointerUp',
pointermove: 'onPointerMove',
pointerover: 'onPointerOver',
pointerout: 'onPointerOut',
pointerenter: 'onPointerEnter',
pointerleave: 'onPointerLeave',
pointercancel: 'onPointerCancel',
keydown: 'onKeyDown',
keyup: 'onKeyUp',
keypress: 'onKeyPress',
@@ -23,6 +23,16 @@ export type SerializedEventData = {
movementY?: number;
button?: number;
buttons?: number;
pointerId?: number;
pointerType?: string;
pressure?: number;
tangentialPressure?: number;
tiltX?: number;
tiltY?: number;
twist?: number;
width?: number;
height?: number;
isPrimary?: boolean;
key?: string;
code?: string;
repeat?: boolean;
@@ -1,5 +1,6 @@
import {
type CloseSidePanelFunction,
type CopyToClipboardFunction,
type EnqueueSnackbarFunction,
type NavigateFunction,
type OpenCommandConfirmationModalFunction,
@@ -20,6 +21,7 @@ type FrontComponentHostCommunicationApiStore = {
enqueueSnackbar?: EnqueueSnackbarFunction;
closeSidePanel?: CloseSidePanelFunction;
updateProgress?: UpdateProgressFunction;
copyToClipboard?: CopyToClipboardFunction;
};
(globalThis as Record<string, unknown>)[
@@ -171,6 +171,37 @@ const serializeEvent = (event: unknown): SerializedEventData => {
serialized.buttons = domEvent.buttons;
}
if (isNumber(domEvent.pointerId)) {
serialized.pointerId = domEvent.pointerId;
}
if (isString(domEvent.pointerType)) {
serialized.pointerType = domEvent.pointerType;
}
if (isNumber(domEvent.pressure)) {
serialized.pressure = domEvent.pressure;
}
if (isNumber(domEvent.tangentialPressure)) {
serialized.tangentialPressure = domEvent.tangentialPressure;
}
if (isNumber(domEvent.tiltX)) {
serialized.tiltX = domEvent.tiltX;
}
if (isNumber(domEvent.tiltY)) {
serialized.tiltY = domEvent.tiltY;
}
if (isNumber(domEvent.twist)) {
serialized.twist = domEvent.twist;
}
if (isNumber(domEvent.width)) {
serialized.width = domEvent.width;
}
if (isNumber(domEvent.height)) {
serialized.height = domEvent.height;
}
if (isBoolean(domEvent.isPrimary)) {
serialized.isPrimary = domEvent.isPrimary;
}
if (isString(domEvent.key)) {
serialized.key = domEvent.key;
}
@@ -29,6 +29,7 @@ const HOST_COMMUNICATION_API_NOOP_INITIALIZATION: FrontComponentHostCommunicatio
enqueueSnackbar: noopAsync,
closeSidePanel: noopAsync,
updateProgress: noopAsync,
copyToClipboard: noopAsync,
};
type FrontComponentWorkerEffectProps = {
@@ -27,10 +27,19 @@ export type HtmlCommonEvents = {
dblclick(event: RemoteEvent<SerializedEventData>): void;
mousedown(event: RemoteEvent<SerializedEventData>): void;
mouseup(event: RemoteEvent<SerializedEventData>): void;
mousemove(event: RemoteEvent<SerializedEventData>): void;
mouseover(event: RemoteEvent<SerializedEventData>): void;
mouseout(event: RemoteEvent<SerializedEventData>): void;
mouseenter(event: RemoteEvent<SerializedEventData>): void;
mouseleave(event: RemoteEvent<SerializedEventData>): void;
pointerdown(event: RemoteEvent<SerializedEventData>): void;
pointerup(event: RemoteEvent<SerializedEventData>): void;
pointermove(event: RemoteEvent<SerializedEventData>): void;
pointerover(event: RemoteEvent<SerializedEventData>): void;
pointerout(event: RemoteEvent<SerializedEventData>): void;
pointerenter(event: RemoteEvent<SerializedEventData>): void;
pointerleave(event: RemoteEvent<SerializedEventData>): void;
pointercancel(event: RemoteEvent<SerializedEventData>): void;
keydown(event: RemoteEvent<SerializedEventData>): void;
keyup(event: RemoteEvent<SerializedEventData>): void;
keypress(event: RemoteEvent<SerializedEventData>): void;
@@ -50,10 +59,19 @@ const HTML_COMMON_EVENTS_ARRAY = [
'dblclick',
'mousedown',
'mouseup',
'mousemove',
'mouseover',
'mouseout',
'mouseenter',
'mouseleave',
'pointerdown',
'pointerup',
'pointermove',
'pointerover',
'pointerout',
'pointerenter',
'pointerleave',
'pointercancel',
'keydown',
'keyup',
'keypress',
@@ -163,6 +163,8 @@ const initializeHostCommunicationApi: WorkerExports['initializeHostCommunication
hostApi.enqueueSnackbar;
frontComponentHostCommunicationApi.closeSidePanel = hostApi.closeSidePanel;
frontComponentHostCommunicationApi.updateProgress = hostApi.updateProgress;
frontComponentHostCommunicationApi.copyToClipboard =
hostApi.copyToClipboard;
};
const onConfirmationModalResult: WorkerExports['onConfirmationModalResult'] =
@@ -1,5 +1,6 @@
import {
type CloseSidePanelFunction,
type CopyToClipboardFunction,
type EnqueueSnackbarFunction,
type NavigateFunction,
type OpenCommandConfirmationModalHostFunction,
@@ -18,4 +19,5 @@ export type FrontComponentHostCommunicationApi = {
enqueueSnackbar: EnqueueSnackbarFunction;
closeSidePanel: CloseSidePanelFunction;
updateProgress: UpdateProgressFunction;
copyToClipboard: CopyToClipboardFunction;
};
@@ -1,3 +1,5 @@
import { i18n } from '@lingui/core';
import { I18nProvider } from '@lingui/react';
import { act, renderHook } from '@testing-library/react';
import { useFrontComponentExecutionContext } from '@/front-components/hooks/useFrontComponentExecutionContext';
@@ -15,6 +17,7 @@ const mockEnqueueInfoSnackBar = jest.fn();
const mockEnqueueWarningSnackBar = jest.fn();
const mockCloseSidePanelMenu = jest.fn();
const mockSetCommandMenuItemProgress = jest.fn();
const mockCopyToClipboard = jest.fn();
let mockCurrentUser: { id: string } | null = { id: 'user-123' };
@@ -83,6 +86,19 @@ jest.mock('@/ui/utilities/state/jotai/hooks/useSetAtomFamilyState', () => ({
useSetAtomFamilyState: () => mockSetCommandMenuItemProgress,
}));
jest.mock('~/hooks/useCopyToClipboard', () => ({
useCopyToClipboard: () => ({
copyToClipboard: mockCopyToClipboard,
}),
}));
const renderUseFrontComponentExecutionContext = (
params: Parameters<typeof useFrontComponentExecutionContext>[0],
) =>
renderHook(() => useFrontComponentExecutionContext(params), {
wrapper: ({ children }) => I18nProvider({ i18n, children }),
});
const FRONT_COMPONENT_ID = 'fc-test-id';
const COMMAND_MENU_ITEM_ID = 'cmd-item-1';
@@ -94,12 +110,10 @@ describe('useFrontComponentExecutionContext', () => {
describe('executionContext', () => {
it('should return frontComponentId, userId, recordId, and selectedRecordIds with single record', () => {
const { result } = renderHook(() =>
useFrontComponentExecutionContext({
frontComponentId: FRONT_COMPONENT_ID,
selectedRecordIds: ['record-456'],
}),
);
const { result } = renderUseFrontComponentExecutionContext({
frontComponentId: FRONT_COMPONENT_ID,
selectedRecordIds: ['record-456'],
});
expect(result.current.executionContext).toEqual({
frontComponentId: FRONT_COMPONENT_ID,
@@ -110,12 +124,10 @@ describe('useFrontComponentExecutionContext', () => {
});
it('should return null recordId when multiple selectedRecordIds provided', () => {
const { result } = renderHook(() =>
useFrontComponentExecutionContext({
frontComponentId: FRONT_COMPONENT_ID,
selectedRecordIds: ['record-1', 'record-2', 'record-3'],
}),
);
const { result } = renderUseFrontComponentExecutionContext({
frontComponentId: FRONT_COMPONENT_ID,
selectedRecordIds: ['record-1', 'record-2', 'record-3'],
});
expect(result.current.executionContext).toEqual({
frontComponentId: FRONT_COMPONENT_ID,
@@ -128,33 +140,27 @@ describe('useFrontComponentExecutionContext', () => {
it('should return null userId when no current user', () => {
mockCurrentUser = null;
const { result } = renderHook(() =>
useFrontComponentExecutionContext({
frontComponentId: FRONT_COMPONENT_ID,
}),
);
const { result } = renderUseFrontComponentExecutionContext({
frontComponentId: FRONT_COMPONENT_ID,
});
expect(result.current.executionContext.userId).toBeNull();
});
it('should return null recordId and empty selectedRecordIds when no selectedRecordIds provided', () => {
const { result } = renderHook(() =>
useFrontComponentExecutionContext({
frontComponentId: FRONT_COMPONENT_ID,
}),
);
const { result } = renderUseFrontComponentExecutionContext({
frontComponentId: FRONT_COMPONENT_ID,
});
expect(result.current.executionContext.recordId).toBeNull();
expect(result.current.executionContext.selectedRecordIds).toEqual([]);
});
it('should return null recordId and empty selectedRecordIds when empty array provided', () => {
const { result } = renderHook(() =>
useFrontComponentExecutionContext({
frontComponentId: FRONT_COMPONENT_ID,
selectedRecordIds: [],
}),
);
const { result } = renderUseFrontComponentExecutionContext({
frontComponentId: FRONT_COMPONENT_ID,
selectedRecordIds: [],
});
expect(result.current.executionContext.recordId).toBeNull();
expect(result.current.executionContext.selectedRecordIds).toEqual([]);
@@ -163,11 +169,9 @@ describe('useFrontComponentExecutionContext', () => {
describe('navigate', () => {
it('should call navigateApp with the provided arguments', async () => {
const { result } = renderHook(() =>
useFrontComponentExecutionContext({
frontComponentId: FRONT_COMPONENT_ID,
}),
);
const { result } = renderUseFrontComponentExecutionContext({
frontComponentId: FRONT_COMPONENT_ID,
});
await act(async () => {
await result.current.frontComponentHostCommunicationApi.navigate(
@@ -189,11 +193,9 @@ describe('useFrontComponentExecutionContext', () => {
describe('openSidePanelPage', () => {
it('should call navigateSidePanel with resolved icon', async () => {
const { result } = renderHook(() =>
useFrontComponentExecutionContext({
frontComponentId: FRONT_COMPONENT_ID,
}),
);
const { result } = renderUseFrontComponentExecutionContext({
frontComponentId: FRONT_COMPONENT_ID,
});
await act(async () => {
await result.current.frontComponentHostCommunicationApi.openSidePanelPage(
@@ -216,11 +218,9 @@ describe('useFrontComponentExecutionContext', () => {
});
it('should reset side panel search state when shouldResetSearchState is true', async () => {
const { result } = renderHook(() =>
useFrontComponentExecutionContext({
frontComponentId: FRONT_COMPONENT_ID,
}),
);
const { result } = renderUseFrontComponentExecutionContext({
frontComponentId: FRONT_COMPONENT_ID,
});
await act(async () => {
await result.current.frontComponentHostCommunicationApi.openSidePanelPage(
@@ -239,11 +239,9 @@ describe('useFrontComponentExecutionContext', () => {
describe('openCommandConfirmationModal', () => {
it('should call openConfirmationModal with frontComponent caller', async () => {
const { result } = renderHook(() =>
useFrontComponentExecutionContext({
frontComponentId: FRONT_COMPONENT_ID,
}),
);
const { result } = renderUseFrontComponentExecutionContext({
frontComponentId: FRONT_COMPONENT_ID,
});
await act(async () => {
await result.current.frontComponentHostCommunicationApi.openCommandConfirmationModal(
@@ -278,11 +276,9 @@ describe('useFrontComponentExecutionContext', () => {
])(
'should route $variant snackbar to the correct handler',
async ({ variant, mock }) => {
const { result } = renderHook(() =>
useFrontComponentExecutionContext({
frontComponentId: FRONT_COMPONENT_ID,
}),
);
const { result } = renderUseFrontComponentExecutionContext({
frontComponentId: FRONT_COMPONENT_ID,
});
await act(async () => {
await result.current.frontComponentHostCommunicationApi.enqueueSnackbar(
@@ -310,12 +306,10 @@ describe('useFrontComponentExecutionContext', () => {
describe('unmountFrontComponent', () => {
it('should call unmountEngineCommand when commandMenuItemId is provided', async () => {
const { result } = renderHook(() =>
useFrontComponentExecutionContext({
frontComponentId: FRONT_COMPONENT_ID,
commandMenuItemId: COMMAND_MENU_ITEM_ID,
}),
);
const { result } = renderUseFrontComponentExecutionContext({
frontComponentId: FRONT_COMPONENT_ID,
commandMenuItemId: COMMAND_MENU_ITEM_ID,
});
await act(async () => {
await result.current.frontComponentHostCommunicationApi.unmountFrontComponent();
@@ -327,11 +321,9 @@ describe('useFrontComponentExecutionContext', () => {
});
it('should not call unmountEngineCommand when commandMenuItemId is undefined', async () => {
const { result } = renderHook(() =>
useFrontComponentExecutionContext({
frontComponentId: FRONT_COMPONENT_ID,
}),
);
const { result } = renderUseFrontComponentExecutionContext({
frontComponentId: FRONT_COMPONENT_ID,
});
await act(async () => {
await result.current.frontComponentHostCommunicationApi.unmountFrontComponent();
@@ -343,11 +335,9 @@ describe('useFrontComponentExecutionContext', () => {
describe('closeSidePanel', () => {
it('should call closeSidePanelMenu', async () => {
const { result } = renderHook(() =>
useFrontComponentExecutionContext({
frontComponentId: FRONT_COMPONENT_ID,
}),
);
const { result } = renderUseFrontComponentExecutionContext({
frontComponentId: FRONT_COMPONENT_ID,
});
await act(async () => {
await result.current.frontComponentHostCommunicationApi.closeSidePanel();
@@ -359,12 +349,10 @@ describe('useFrontComponentExecutionContext', () => {
describe('updateProgress', () => {
it('should set clamped progress when commandMenuItemId is provided', async () => {
const { result } = renderHook(() =>
useFrontComponentExecutionContext({
frontComponentId: FRONT_COMPONENT_ID,
commandMenuItemId: COMMAND_MENU_ITEM_ID,
}),
);
const { result } = renderUseFrontComponentExecutionContext({
frontComponentId: FRONT_COMPONENT_ID,
commandMenuItemId: COMMAND_MENU_ITEM_ID,
});
await act(async () => {
await result.current.frontComponentHostCommunicationApi.updateProgress(
@@ -376,12 +364,10 @@ describe('useFrontComponentExecutionContext', () => {
});
it('should clamp progress to 0 when negative value is provided', async () => {
const { result } = renderHook(() =>
useFrontComponentExecutionContext({
frontComponentId: FRONT_COMPONENT_ID,
commandMenuItemId: COMMAND_MENU_ITEM_ID,
}),
);
const { result } = renderUseFrontComponentExecutionContext({
frontComponentId: FRONT_COMPONENT_ID,
commandMenuItemId: COMMAND_MENU_ITEM_ID,
});
await act(async () => {
await result.current.frontComponentHostCommunicationApi.updateProgress(
@@ -393,12 +379,10 @@ describe('useFrontComponentExecutionContext', () => {
});
it('should clamp progress to 100 when value exceeds 100', async () => {
const { result } = renderHook(() =>
useFrontComponentExecutionContext({
frontComponentId: FRONT_COMPONENT_ID,
commandMenuItemId: COMMAND_MENU_ITEM_ID,
}),
);
const { result } = renderUseFrontComponentExecutionContext({
frontComponentId: FRONT_COMPONENT_ID,
commandMenuItemId: COMMAND_MENU_ITEM_ID,
});
await act(async () => {
await result.current.frontComponentHostCommunicationApi.updateProgress(
@@ -409,4 +393,138 @@ describe('useFrontComponentExecutionContext', () => {
expect(mockSetCommandMenuItemProgress).toHaveBeenCalledWith(100);
});
});
describe('copyToClipboard', () => {
it('should call useCopyToClipboard with the provided text and a preview message', async () => {
const { result } = renderUseFrontComponentExecutionContext({
frontComponentId: FRONT_COMPONENT_ID,
});
await act(async () => {
await result.current.frontComponentHostCommunicationApi.copyToClipboard(
'hello clipboard',
);
});
expect(mockCopyToClipboard).toHaveBeenCalledWith(
'hello clipboard',
'Application copied "hello clipboard" to your clipboard',
);
});
it('should truncate the preview when the text is longer than the preview length', async () => {
const { result } = renderUseFrontComponentExecutionContext({
frontComponentId: FRONT_COMPONENT_ID,
});
const longText = 'a'.repeat(50);
await act(async () => {
await result.current.frontComponentHostCommunicationApi.copyToClipboard(
longText,
);
});
expect(mockCopyToClipboard).toHaveBeenCalledWith(
longText,
`Application copied "${'a'.repeat(30)}…" to your clipboard`,
);
});
it.each([
['empty string', ''],
['number', 123],
['object', { malicious: true }],
['undefined', undefined],
['null', null],
])('should silently drop non-string payloads (%s)', async (_, value) => {
const { result } = renderUseFrontComponentExecutionContext({
frontComponentId: FRONT_COMPONENT_ID,
});
await act(async () => {
await result.current.frontComponentHostCommunicationApi.copyToClipboard(
value as never,
);
});
expect(mockCopyToClipboard).not.toHaveBeenCalled();
});
it('should silently drop payloads exceeding the maximum length', async () => {
const { result } = renderUseFrontComponentExecutionContext({
frontComponentId: FRONT_COMPONENT_ID,
});
const oversizedPayload = 'a'.repeat(64 * 1024 + 1);
await act(async () => {
await result.current.frontComponentHostCommunicationApi.copyToClipboard(
oversizedPayload,
);
});
expect(mockCopyToClipboard).not.toHaveBeenCalled();
});
it('should rate-limit consecutive calls within one second', async () => {
const { result } = renderUseFrontComponentExecutionContext({
frontComponentId: FRONT_COMPONENT_ID,
});
await act(async () => {
await result.current.frontComponentHostCommunicationApi.copyToClipboard(
'first',
);
await result.current.frontComponentHostCommunicationApi.copyToClipboard(
'second',
);
});
expect(mockCopyToClipboard).toHaveBeenCalledTimes(1);
expect(mockCopyToClipboard).toHaveBeenCalledWith(
'first',
expect.stringContaining('first'),
);
});
it('should allow a follow-up call once the rate-limit window has passed', async () => {
const { result } = renderUseFrontComponentExecutionContext({
frontComponentId: FRONT_COMPONENT_ID,
});
let currentTimeMs = 0;
const dateNowSpy = jest
.spyOn(Date, 'now')
.mockImplementation(() => currentTimeMs);
await act(async () => {
await result.current.frontComponentHostCommunicationApi.copyToClipboard(
'first',
);
});
currentTimeMs = 1500;
await act(async () => {
await result.current.frontComponentHostCommunicationApi.copyToClipboard(
'second',
);
});
expect(mockCopyToClipboard).toHaveBeenCalledTimes(2);
expect(mockCopyToClipboard).toHaveBeenNthCalledWith(
1,
'first',
expect.stringContaining('first'),
);
expect(mockCopyToClipboard).toHaveBeenNthCalledWith(
2,
'second',
expect.stringContaining('second'),
);
dateNowSpy.mockRestore();
});
});
});
@@ -1,4 +1,7 @@
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { isNonEmptyString } from '@sniptt/guards';
import { useLingui } from '@lingui/react/macro';
import { useRef } from 'react';
import {
type FrontComponentExecutionContext,
type FrontComponentHostCommunicationApi,
@@ -18,8 +21,13 @@ import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomState
import { useSetAtomFamilyState } from '@/ui/utilities/state/jotai/hooks/useSetAtomFamilyState';
import { assertUnreachable, isDefined } from 'twenty-shared/utils';
import { useIcons } from 'twenty-ui/display';
import { useCopyToClipboard } from '~/hooks/useCopyToClipboard';
import { useNavigateApp } from '~/hooks/useNavigateApp';
const FRONT_COMPONENT_CLIPBOARD_MAX_LENGTH = 64 * 1024;
const FRONT_COMPONENT_CLIPBOARD_RATE_LIMIT_MS = 1000;
const FRONT_COMPONENT_CLIPBOARD_PREVIEW_LENGTH = 30;
export const useFrontComponentExecutionContext = ({
frontComponentId,
commandMenuItemId,
@@ -49,6 +57,10 @@ export const useFrontComponentExecutionContext = ({
enqueueWarningSnackBar,
} = useSnackBar();
const { closeSidePanelMenu } = useSidePanelMenu();
const { copyToClipboard: copyToClipboardWithSnackbar } = useCopyToClipboard();
const { t } = useLingui();
// oxlint-disable-next-line twenty/no-state-useref
const lastCopyToClipboardCallAtRef = useRef<number>(Number.NEGATIVE_INFINITY);
const setCommandMenuItemProgress = useSetAtomFamilyState(
commandMenuItemProgressFamilyState,
commandMenuItemId ?? '',
@@ -152,6 +164,36 @@ export const useFrontComponentExecutionContext = ({
setCommandMenuItemProgress(Math.max(0, Math.min(100, progress)));
};
const copyToClipboard: FrontComponentHostCommunicationApi['copyToClipboard'] =
async (text) => {
if (!isNonEmptyString(text)) {
return;
}
if (text.length > FRONT_COMPONENT_CLIPBOARD_MAX_LENGTH) {
return;
}
const now = Date.now();
if (
now - lastCopyToClipboardCallAtRef.current <
FRONT_COMPONENT_CLIPBOARD_RATE_LIMIT_MS
) {
return;
}
lastCopyToClipboardCallAtRef.current = now;
const preview =
text.length > FRONT_COMPONENT_CLIPBOARD_PREVIEW_LENGTH
? `${text.slice(0, FRONT_COMPONENT_CLIPBOARD_PREVIEW_LENGTH)}`
: text;
await copyToClipboardWithSnackbar(
text,
t`Application copied "${preview}" to your clipboard`,
);
};
const frontComponentHostCommunicationApi: FrontComponentHostCommunicationApi =
{
navigate,
@@ -162,6 +204,7 @@ export const useFrontComponentExecutionContext = ({
unmountFrontComponent,
closeSidePanel,
updateProgress,
copyToClipboard,
};
return {
@@ -0,0 +1,17 @@
import { isDefined } from 'twenty-shared/utils';
import {
type CopyToClipboardFunction,
frontComponentHostCommunicationApi,
} from '../globals/frontComponentHostCommunicationApi';
export const copyToClipboard: CopyToClipboardFunction = (text: string) => {
const copyToClipboardFunction =
frontComponentHostCommunicationApi.copyToClipboard;
if (!isDefined(copyToClipboardFunction)) {
throw new Error('copyToClipboardFunction is not set');
}
return copyToClipboardFunction(text);
};
@@ -43,6 +43,8 @@ export type UpdateProgressFunction = (progress: number) => Promise<void>;
export type RequestAccessTokenRefreshFunction = () => Promise<string>;
export type CopyToClipboardFunction = (text: string) => Promise<void>;
export type OpenCommandConfirmationModalHostFunction = (
params: Parameters<OpenCommandConfirmationModalFunction>[0],
) => Promise<void>;
@@ -56,6 +58,7 @@ export type FrontComponentHostCommunicationApiStore = {
enqueueSnackbar?: EnqueueSnackbarFunction;
closeSidePanel?: CloseSidePanelFunction;
updateProgress?: UpdateProgressFunction;
copyToClipboard?: CopyToClipboardFunction;
};
import { FRONT_COMPONENT_HOST_COMMUNICATION_API_KEY } from '../constants/front-component-host-communication-api-key';
@@ -29,6 +29,7 @@ export {
objectMetadataItem,
} from './conditional-availability/conditional-availability-variables';
export { closeSidePanel } from './functions/closeSidePanel';
export { copyToClipboard } from './functions/copyToClipboard';
export { getApplicationVariable } from './functions/getApplicationVariable';
export { enqueueSnackbar } from './functions/enqueueSnackbar';
export { navigate } from './functions/navigate';
@@ -47,6 +48,7 @@ export type {
CloseSidePanelFunction,
CommandConfirmationModalAccent,
CommandConfirmationModalResult,
CopyToClipboardFunction,
EnqueueSnackbarFunction,
NavigateFunction,
OpenCommandConfirmationModalFunction,