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:
+60
@@ -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();
|
||||
},
|
||||
});
|
||||
+50
@@ -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,
|
||||
});
|
||||
+17
@@ -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' } });
|
||||
},
|
||||
});
|
||||
|
||||
+45
@@ -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,
|
||||
});
|
||||
+2
@@ -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;
|
||||
|
||||
+2
@@ -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;
|
||||
}
|
||||
|
||||
+1
@@ -29,6 +29,7 @@ const HOST_COMMUNICATION_API_NOOP_INITIALIZATION: FrontComponentHostCommunicatio
|
||||
enqueueSnackbar: noopAsync,
|
||||
closeSidePanel: noopAsync,
|
||||
updateProgress: noopAsync,
|
||||
copyToClipboard: noopAsync,
|
||||
};
|
||||
|
||||
type FrontComponentWorkerEffectProps = {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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'] =
|
||||
|
||||
+2
@@ -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;
|
||||
};
|
||||
|
||||
+205
-87
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+43
@@ -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);
|
||||
};
|
||||
+3
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user