Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/sdk-palette-mobile-drag-and-drop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@workflowbuilder/sdk': patch
---

Dragging a node from the palette onto the canvas now works on touch devices; the palette hands the dragged item over through pointer events instead of the HTML5 drag `dataTransfer`, which mobile browsers never deliver. Ids the editor generates for nodes, AI agent tools and variables also fall back to a `crypto.getRandomValues()`-based UUID when `crypto.randomUUID()` is unavailable, e.g. when the editor is opened over plain HTTP from a LAN address. The `draggedItem` and `setDraggedItem` fields are gone from the editor store returned by `useStore`; the in-flight palette item is internal to the palette now.
2 changes: 1 addition & 1 deletion packages/sdk/src/features/diagram/diagram.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ vi.mock('./hooks/use-on-connect', () => ({
useConnect: () => ({ onConnect: vi.fn(), onConnectStart: vi.fn(), onConnectEnd: vi.fn() }),
}));
vi.mock('./edges/temporary-edge/temporary-edge', () => ({ TemporaryEdge: () => null }));
vi.mock('../../hooks/use-palette-drop', () => ({ usePaletteDrop: () => ({ onDropFromPalette: vi.fn() }) }));
vi.mock('../palette/hooks/use-palette-drop', () => ({ usePaletteDrop: () => ({ onDropFromPalette: vi.fn() }) }));
vi.mock('../modals/delete-confirmation/use-delete-confirmation', () => ({
useDeleteConfirmation: () => ({ openDeleteConfirmationModal: vi.fn() }),
}));
Expand Down
2 changes: 1 addition & 1 deletion packages/sdk/src/features/diagram/diagram.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,14 @@ import type { DragEvent } from 'react';
import styles from './diagram.module.css';

import { getReactFlowProps } from '../../data/react-flow-config';
import { usePaletteDrop } from '../../hooks/use-palette-drop';
import type { WorkflowBuilderOnSelectionChangeParams } from '../../node/common';
import type { WorkflowBuilderEdge, WorkflowBuilderNode } from '../../node/node-data';
import { getStoreNodes } from '../../store/slices/diagram-slice/actions';
import { useStore } from '../../store/store';
import type { WorkflowBuilderReactFlowProps } from '../../workflow-builder-root/workflow-builder-root.types';
import { trackFutureChange } from '../changes-tracker/stores/use-changes-tracker-store';
import { useDeleteConfirmation } from '../modals/delete-confirmation/use-delete-confirmation';
import { usePaletteDrop } from '../palette/hooks/use-palette-drop';
import { withOptionalComponentPlugins } from '../plugins-core/adapters/adapter-components';
import { deleteKeyCode } from './const';
import { SNAP_GRID, SNAP_IS_ACTIVE } from './diagram.const';
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { generateId } from '../../../../utils/generate-id';
import { getHandleId } from '../../../diagram/handles/get-handle-id';
import type { AiAgentTool } from '../../types/controls';

Expand All @@ -6,7 +7,7 @@ export function hasAnyValue(data: AiAgentTool): boolean {
}

export function createAiTool(toolData: AiAgentTool): AiAgentTool {
const id = crypto.randomUUID();
const id = generateId();
const sourceHandle = getHandleId({ innerId: id, handleType: 'source' });

return { ...toolData, id, sourceHandle };
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import clsx from 'clsx';
import type { DragEvent } from 'react';

import styles from './palette-item.module.css';

Expand All @@ -8,21 +7,19 @@ import { NodePreviewContainer } from '../../node-preview-container';

type PaletteItemProps = {
item: PaletteItemType;
onDragStart: (event: DragEvent) => void;
onMouseDown: (type: string) => void;
onPointerDown: (event: React.PointerEvent<HTMLDivElement>, item: PaletteItemType) => void;
isDisabled?: boolean;
};

export function PaletteItem({ item, onDragStart, onMouseDown, isDisabled = false }: PaletteItemProps) {
export function PaletteItem({ item, onPointerDown, isDisabled = false }: PaletteItemProps) {
return (
<div
key={item.type}
draggable={!isDisabled}
className={clsx(styles['item'], {
[styles['disabled']]: isDisabled,
})}
onMouseDown={() => onMouseDown(item.type)}
onDragStart={onDragStart}
onPointerDown={(event) => onPointerDown(event, item)}
>
<NodePreviewContainer type={item.type} />
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { Accordion } from '@workflowbuilder/ui';
import type { DragEvent } from 'react';

import styles from './palette-items.module.css';

Expand All @@ -8,13 +7,12 @@ import type { PaletteGroup, PaletteItem as PaletteItemType } from '../../../../n
import { PaletteItem } from './palette-item';

type PaletteItemsProps = {
onDragStart: (event: DragEvent) => void;
onMouseDown: (type: string) => void;
onPointerDown: (event: React.PointerEvent<HTMLDivElement>, item: PaletteItemType) => void;
items: (PaletteItemType | PaletteGroup)[];
isDisabled?: boolean;
};

export function PaletteItems({ items, onDragStart, onMouseDown, isDisabled = false }: PaletteItemsProps) {
export function PaletteItems({ items, onPointerDown, isDisabled = false }: PaletteItemsProps) {
const translateIfPossible = useTranslateIfPossible();

return (
Expand All @@ -34,13 +32,7 @@ export function PaletteItems({ items, onDragStart, onMouseDown, isDisabled = fal
>
<div className={styles['accordion-content']}>
{group.groupItems.map((item) => (
<PaletteItem
key={item.type}
item={item}
isDisabled={isDisabled}
onMouseDown={onMouseDown}
onDragStart={onDragStart}
/>
<PaletteItem key={item.type} item={item} isDisabled={isDisabled} onPointerDown={onPointerDown} />
))}
</div>
</Accordion>
Expand All @@ -49,15 +41,7 @@ export function PaletteItems({ items, onDragStart, onMouseDown, isDisabled = fal

const item = itemOrGroup as PaletteItemType;

return (
<PaletteItem
key={item.type}
item={item}
isDisabled={isDisabled}
onMouseDown={onMouseDown}
onDragStart={onDragStart}
/>
);
return <PaletteItem key={item.type} item={item} isDisabled={isDisabled} onPointerDown={onPointerDown} />;
})}
</div>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,34 +1,24 @@
import { type DragEvent, useRef } from 'react';
import { useRef } from 'react';

import type { PaletteItem } from '../../../node/common';
import { useStore } from '../../../store/store';
import { dataFormat } from '../../../utils/consts';
import { setDraggedItem } from '../stores/use-palette-store';

export function usePaletteDragAndDrop(canDrag: boolean) {
const setDraggedItem = useStore((state) => state.setDraggedItem);
const draggedItem = useStore((state) => state.draggedItem);
const zoom = useStore((state) => state.reactFlowInstance?.getZoom() || 1);

const ref = useRef<HTMLDivElement>(null);

function onMouseDown(type: string) {
if (canDrag) {
setDraggedItem({ type });
}
}

function onDragStart(event: DragEvent) {
function onPointerDown(event: React.PointerEvent<HTMLDivElement>, item: PaletteItem) {
if (!canDrag) {
return event.preventDefault();
}
event.dataTransfer.setDragImage(ref.current as Element, 0, 0);
event.dataTransfer.setData(dataFormat, JSON.stringify(draggedItem));
(event.target as HTMLElement).setPointerCapture(event.pointerId);
setDraggedItem(item);
}

return {
draggedItem,
zoom,
ref,
onMouseDown,
onDragStart,
onPointerDown,
};
}
142 changes: 142 additions & 0 deletions packages/sdk/src/features/palette/hooks/use-palette-drop.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
// Pins what a palette drop does with the item the user is dragging. The drop
// no longer reads `dataTransfer` (which never fires on touch devices): the
// palette item is parked in the palette store on pointer-down and picked up
// here on drop. The `isStartNode` flag is what execution integrations read to
// find a workflow's entry point, so it has to survive the palette-item ->
// node-data copy — and stay absent on every node that did not declare it.
//
// `@xyflow/react` is mocked for `useStoreApi`: the hook reads
// `resetSelectedElements` off the ReactFlow store, which only exists inside a
// `<ReactFlowProvider>`. `use-translate-if-possible` is mocked to keep the
// i18next singleton out of the test.
import { renderHook } from '@testing-library/react';
import type { NodeAddChange, XYPosition } from '@xyflow/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';

import type { PaletteItem } from '../../../node/common';
import type { WorkflowBuilderNode } from '../../../node/node-data';
import { NodeType } from '../../../node/node-types';
import { resetWorkflowStore, useStore } from '../../../store/store';
import { setDraggedItem, usePaletteStore } from '../stores/use-palette-store';
import { usePaletteDrop } from './use-palette-drop';

vi.mock('@xyflow/react', () => ({
useStoreApi: () => ({ getState: () => ({ resetSelectedElements: vi.fn() }) }),
}));

const { noTranslation } = vi.hoisted(() => ({ noTranslation: () => '' }));
vi.mock('../../../hooks/use-translate-if-possible', () => ({
useTranslateIfPossible: () => noTranslation,
}));

vi.mock('../../changes-tracker/stores/use-changes-tracker-store', () => ({
trackFutureChange: vi.fn(),
}));

const TRIGGER_TYPE = 'my-product/trigger';

function paletteItem(overrides: Partial<PaletteItem> = {}): PaletteItem {
return {
label: 'Trigger',
description: 'Start the workflow',
type: TRIGGER_TYPE,
icon: 'Lightning',
defaultPropertiesData: {},
schema: { type: 'object', properties: {} },
...overrides,
} as PaletteItem;
}

type DropOptions = {
draggedItem?: PaletteItem | null;
clientPosition?: XYPosition;
screenToFlowPosition?: (position: XYPosition) => XYPosition;
};

function drop(definition: PaletteItem, options: DropOptions = {}) {
const {
draggedItem = definition,
clientPosition = { x: 0, y: 0 },
screenToFlowPosition = () => ({ x: 0, y: 0 }),
} = options;

const onNodesChange = vi.fn();
useStore.setState({
getNodeDefinition: () => definition,
onNodesChange,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
reactFlowInstance: { screenToFlowPosition } as any,
});
setDraggedItem(draggedItem);

const { result } = renderHook(() => usePaletteDrop());
result.current.onDropFromPalette({
preventDefault: vi.fn(),
clientX: clientPosition.x,
clientY: clientPosition.y,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);

return onNodesChange;
}

function dropFromPalette(definition: PaletteItem, options: DropOptions = {}) {
const onNodesChange = drop(definition, options);
const [changes] = onNodesChange.mock.calls[0] as [NodeAddChange<WorkflowBuilderNode>[]];
return changes[0]!.item;
}

beforeEach(() => {
resetWorkflowStore();
setDraggedItem(null);
});

describe('usePaletteDrop — dragged item handoff', () => {
it('adds the node the palette store says is being dragged', () => {
const node = dropFromPalette(paletteItem());

expect(node.data.type).toBe(TRIGGER_TYPE);
});

it('does nothing when no palette item is being dragged', () => {
const onNodesChange = drop(paletteItem(), { draggedItem: null });

expect(onNodesChange).not.toHaveBeenCalled();
});

it('clears the dragged item once the drop is handled', () => {
dropFromPalette(paletteItem());

expect(usePaletteStore.getState().draggedItem).toBeNull();
});

it('places the node at the flow position of the pointer', () => {
const node = dropFromPalette(paletteItem(), {
clientPosition: { x: 10, y: 20 },
screenToFlowPosition: ({ x, y }) => ({ x: x + 100, y: y + 200 }),
});

expect(node.position).toEqual({ x: 110, y: 220 });
});
});

describe('usePaletteDrop — start-node flag', () => {
it('copies isStartNode onto the dropped node', () => {
const node = dropFromPalette(paletteItem({ isStartNode: true, templateType: NodeType.StartNode }));

expect(node.data.isStartNode).toBe(true);
});

it('leaves the flag off a node whose palette item does not declare it', () => {
const node = dropFromPalette(paletteItem());

expect(node.data).not.toHaveProperty('isStartNode');
});

it('keeps the flag independent of the visual template', () => {
const node = dropFromPalette(paletteItem({ isStartNode: true, templateType: NodeType.AiNode }));

expect(node.type).toBe(NodeType.AiNode);
expect(node.data.isStartNode).toBe(true);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,16 @@ import { type XYPosition, useStoreApi } from '@xyflow/react';
import { type DragEvent, useCallback } from 'react';
import { useShallow } from 'zustand/shallow';

import { getCustomNodeTemplates } from '../data/node-templates';
import { trackFutureChange } from '../features/changes-tracker/stores/use-changes-tracker-store';
import type { DraggingItem } from '../node/common';
import type { BaseNodeProperties } from '../node/node-schema';
import { NodeType } from '../node/node-types';
import { useStore } from '../store/store';
import { dataFormat } from '../utils/consts';
import { getNodeAddChange } from '../utils/get-node-add-change';
import { resolveReactFlowNodeType } from '../utils/resolve-react-flow-node-type';
import { useTranslateIfPossible } from './use-translate-if-possible';
import { getCustomNodeTemplates } from '../../../data/node-templates';
import { useTranslateIfPossible } from '../../../hooks/use-translate-if-possible';
import type { BaseNodeProperties } from '../../../node/node-schema';
import { NodeType } from '../../../node/node-types';
import { useStore } from '../../../store/store';
import { generateId } from '../../../utils/generate-id';
import { getNodeAddChange } from '../../../utils/get-node-add-change';
import { resolveReactFlowNodeType } from '../../../utils/resolve-react-flow-node-type';
import { trackFutureChange } from '../../changes-tracker/stores/use-changes-tracker-store';
import { getDraggedItemAction, setDraggedItem } from '../stores/use-palette-store';

export function usePaletteDrop() {
const resetSelectedElements = useStoreApi().getState().resetSelectedElements;
Expand Down Expand Up @@ -48,7 +48,7 @@ export function usePaletteDrop() {

const reactFlowNodeType = resolveReactFlowNodeType(type, templateType, getCustomNodeTemplates());

const newNodeId = crypto.randomUUID();
const newNodeId = generateId();
trackFutureChange('addNode', { nodeType: type });
resetSelectedElements();
onNodesChange(getNodeAddChange(reactFlowNodeType, position, data, newNodeId));
Expand All @@ -65,12 +65,15 @@ export function usePaletteDrop() {
y: event.clientY,
});

const json = event.dataTransfer?.getData(dataFormat);
if (!json) return;
const draggedItem = getDraggedItemAction();

const draggingItem = JSON.parse(json) as DraggingItem;
const { type } = draggingItem;
if (!draggedItem) {
return;
}

const { type } = draggedItem;

setDraggedItem(null);
dropNode(position, type);
},
[reactFlowInstance, dropNode],
Expand Down
Loading
Loading