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
58 changes: 58 additions & 0 deletions src/components/shared/LocalPipelineMigration.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,64 @@ it.each([false, true])(
},
);

it("finishes an already-consented move for a hidden tab that opted in", async () => {
Comment thread
Volv-G marked this conversation as resolved.
Resolved
render(
<QueryClientProvider client={client}>
<LocalPipelineMigration
pipelineRef={pipelineRef}
isActive={false}
allowBackgroundMigration
onMigrated={onMigrated}
>
<p>Local editor</p>
</LocalPipelineMigration>
</QueryClientProvider>,
);
await waitFor(() => expect(onMigrated).toHaveBeenCalledWith(remote));
expect(screen.queryByRole("alertdialog")).toBeNull();
});

it("never prompts, checks write access or uploads for a hidden opted-in tab without consent", async () => {
localStorage.removeItem(consentKey);
const view = (active: boolean) => (
<QueryClientProvider client={client}>
<LocalPipelineMigration
pipelineRef={pipelineRef}
isActive={active}
allowBackgroundMigration
onMigrated={onMigrated}
>
<p>Local editor</p>
</LocalPipelineMigration>
</QueryClientProvider>
);
const { rerender } = render(view(false));
await waitFor(() => expect(storage.resolvePipeline).toHaveBeenCalled());
await act(async () => {});
expect(screen.queryByRole("alertdialog")).toBeNull();
expect(getRemotePipelineWriter).not.toHaveBeenCalled();
expect(storage.moveLocalPipelineToRemote).not.toHaveBeenCalled();

rerender(view(true));
expect(await screen.findByRole("alertdialog")).toBeInTheDocument();
});

it("keeps a cancelled pipeline local when consent is granted elsewhere", async () => {
localStorage.removeItem(consentKey);
open();
fireEvent.click(await screen.findByRole("button", { name: "Cancel" }));
await screen.findByText("Local editor");

act(() => {
localStorage.setItem(consentKey, "true");
window.dispatchEvent(new StorageEvent("storage"));
Comment thread
Volv-G marked this conversation as resolved.
Resolved
});

await act(async () => {});
expect(storage.moveLocalPipelineToRemote).not.toHaveBeenCalled();
expect(screen.getByText("Local editor")).toBeInTheDocument();
});

it("opens the local editor without offering a move when writing is unavailable", async () => {
vi.mocked(getRemotePipelineWriter).mockRejectedValue(
new Error("Write permission denied"),
Expand Down
18 changes: 15 additions & 3 deletions src/components/shared/LocalPipelineMigration.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ interface LocalPipelineMigrationProps {
pipelineRef: PipelineRef;
projectId?: string;
isActive?: boolean;
allowBackgroundMigration?: boolean;
onMigrated: (file: PipelineFile, clearProjectContext?: boolean) => void;
children: ReactNode;
}
Expand Down Expand Up @@ -119,6 +120,7 @@ function MigrateLocalPipeline({
pipelineRef,
projectId,
isActive = true,
allowBackgroundMigration = false,
onMigrated,
children,
file,
Expand Down Expand Up @@ -191,16 +193,24 @@ function MigrateLocalPipeline({
const writer = useQuery({
queryKey: RemotePipelineQueryKeys.Writer(backendUrl),
queryFn: () => getRemotePipelineWriter(backendUrl),
enabled: ready && isActive && shouldMigrate && !isFetching,
enabled:
ready &&
!keepLocal &&
(isActive || (allowBackgroundMigration && hasConsent)) &&
shouldMigrate &&
!isFetching,
retry: false,
});
useEffect(() => {
if (
!isActive ||
(!isActive && !allowBackgroundMigration) ||
!file ||
isFetching ||
started.current ||
(!file.remote && (!shouldMigrate || !hasConsent || !writer.isSuccess))
// Declining the dialog keeps this pipeline local until the user opens it
// again; later consent from another tab must not override that choice.
(!file.remote &&
(keepLocal || !shouldMigrate || !hasConsent || !writer.isSuccess))
)
return;
started.current = true;
Expand All @@ -210,6 +220,8 @@ function MigrateLocalPipeline({
hasConsent,
isFetching,
isActive,
keepLocal,
allowBackgroundMigration,
migration,
shouldMigrate,
writer.isSuccess,
Expand Down
6 changes: 6 additions & 0 deletions src/components/ui/resize-handle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ interface ResizeHandleProps {
minWidth?: number;
maxWidth?: number;
side?: "left" | "right";
// Names the handle as a separator for assistive tech and tests.
label?: string;
onDoubleClick?: () => void;
/** Called while dragging with the width requested before min/max constraints. */
onResize?: (attemptedWidth: number) => void;
Expand All @@ -18,6 +20,7 @@ export const VerticalResizeHandle = ({
minWidth = 200,
maxWidth = 600,
side = "left",
label,
onDoubleClick,
onResize,
onResizeEnd,
Expand Down Expand Up @@ -92,6 +95,9 @@ export const VerticalResizeHandle = ({
"w-2 -mx-0.5",
)}
ref={captureParentElement}
role={label ? "separator" : undefined}
aria-orientation={label ? "vertical" : undefined}
aria-label={label}
onMouseDown={handleMouseDown}
onDoubleClick={onDoubleClick}
>
Expand Down
2 changes: 2 additions & 0 deletions src/routes/v2/pages/Editor/EmbeddedPipelineEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ import { TangentEditorAgentProvider } from "./TangentEditorAgentProvider";
interface EmbeddedPipelineEditorProps {
pipelineRef: EditorPipelineRef;
isActive: boolean;
allowBackgroundMigration?: boolean;
projectId: string;
onMigrated?: (file: PipelineFile) => void;
onTitleChange?: (title: string) => void;
Expand Down Expand Up @@ -281,6 +282,7 @@ export function EmbeddedPipelineEditor(props: EmbeddedPipelineEditorProps) {
pipelineRef={props.pipelineRef}
projectId={props.projectId}
isActive={props.isActive}
allowBackgroundMigration={props.allowBackgroundMigration}
onMigrated={(file) => props.onMigrated?.(file)}
>
<EditableEmbeddedPipelineEditor {...props} />
Expand Down
145 changes: 145 additions & 0 deletions src/routes/v2/pages/Editor/components/FlowCanvas/FlowCanvas.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { act, fireEvent, render, waitFor } from "@testing-library/react";
import { ReactFlowProvider, useReactFlow } from "@xyflow/react";
import { useEffect } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";

import { Binding, ComponentSpec, Task } from "@/models/componentSpec";
import { KeyboardStore } from "@/routes/v2/shared/store/keyboardStore";

import { FlowCanvas } from "./FlowCanvas";

let keyboard: KeyboardStore;
let spec: ComponentSpec;
const undoGroup = vi.fn((_label: string, apply: () => void) => apply());
const clearSelection = vi.fn();
const clearMultiSelection = vi.fn();

vi.mock("@/routes/v2/shared/store/SharedStoreContext", () => ({
useSharedStores: () => ({
keyboard,
editor: { clearSelection, clearMultiSelection },
navigation: { parentContext: undefined },
}),
}));
vi.mock("@/routes/v2/pages/Editor/store/EditorSessionContext", () => ({
useEditorSession: () => ({ undo: { withGroup: undoGroup } }),
}));
vi.mock("@/routes/v2/shared/nodes/NodeRegistryContext", () => ({
useNodeRegistry: () => ({
getNodeTypes: () => ({}),
getEdgeTypes: () => ({}),
getByNodeId: () => ({
entityType: "task",
getPosition: () => ({ x: 0, y: 0 }),
}),
}),
}));
vi.mock("@/routes/v2/shared/hooks/useFlowCanvasState", () => ({
useFlowCanvasState: () => ({
displayNodes: [
{
id: "task_1",
position: { x: 0, y: 0 },
data: { label: "Selected task" },
selected: true,
},
],
displayEdges: [
{
id: "edge_binding_1",
source: "task_1",
target: "task_1",
selected: true,
},
],
selectionBehavior: {},
}),
}));
vi.mock("@/routes/v2/shared/components/SubgraphBreadcrumbs", () => ({
SubgraphBreadcrumbs: () => null,
}));
vi.mock("./components/CanvasUndoRedo", () => ({ CanvasUndoRedo: () => null }));
vi.mock("./components/FloatingSelectionToolbar", () => ({
FloatingSelectionToolbar: () => null,
}));
vi.mock("./hooks/useDropBehavior", () => ({ useDropBehavior: () => ({}) }));

let deleteProgrammatically: () => Promise<unknown>;
function FlowProbe() {
const flow = useReactFlow();
useEffect(() => {
deleteProgrammatically = () =>
flow.deleteElements({ nodes: [{ id: "task_1" }] });
}, [flow]);
return null;
}
function Canvas() {
return (
<ReactFlowProvider>
<FlowCanvas spec={spec} />
<FlowProbe />
</ReactFlowProvider>
);
}

async function press(key: string) {
await act(async () => {
fireEvent.keyDown(document.body, { key, code: key });
});
await act(async () => {
fireEvent.keyUp(document.body, { key, code: key });
});
}

beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal(
"ResizeObserver",
class {
observe() {}
unobserve() {}
disconnect() {}
},
);
keyboard = new KeyboardStore();
spec = new ComponentSpec({ name: "Pipeline" });
spec.addTask(
new Task({ $id: "task_1", name: "Selected task", componentRef: {} }),
);
spec.addBinding(
new Binding({
$id: "binding_1",
sourceEntityId: "task_1",
sourcePortName: "output",
targetEntityId: "task_1",
targetPortName: "input",
}),
);
});

describe("real ReactFlow keyboard activity", () => {
it.each(["Delete", "Backspace"])(
"ignores %s while the editor is not listening and deletes once it is",
async (key) => {
render(<Canvas />);

await press(key);
expect(spec.tasks).toHaveLength(1);
expect(spec.bindings).toHaveLength(1);

act(() => keyboard.setListening(true));
await press(key);
await waitFor(() => expect(spec.tasks).toHaveLength(0));
expect(spec.bindings).toHaveLength(0);
},
);

it("keeps deliberate programmatic/agent deletion available while not listening", async () => {
render(<Canvas />);
await act(async () => {
await deleteProgrammatically();
});
expect(spec.tasks).toHaveLength(0);
expect(spec.bindings).toHaveLength(0);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,11 @@ export const FlowCanvas = observer(function FlowCanvas({
onViewportChange={handleViewportChange}
onBeforeDelete={onBeforeDelete}
connectionLineComponent={ConnectionLine}
deleteKeyCode={["Delete", "Backspace"]}
deleteKeyCode={keyboard.listening ? ["Delete", "Backspace"] : null}
selectionKeyCode={keyboard.listening ? undefined : null}
multiSelectionKeyCode={keyboard.listening ? undefined : null}
panActivationKeyCode={keyboard.listening ? undefined : null}
zoomActivationKeyCode={keyboard.listening ? undefined : null}
className={cn(
shiftKeyPressed && !isConnecting && "cursor-crosshair",
!isDetailedView && "connections-disabled",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export const AssetsWindowContent = observer(function AssetsWindowContent() {
function handleOpenAsset(asset: EmbedAsset) {
store.selectAsset(asset);
if (asset.kind !== "trigger") {
store.showWorkarea();
store.openArtifactTab(asset.url, asset.title);
}
}
Expand Down
Loading
Loading