diff --git a/src/routes/remotePipelineEditor.test.tsx b/src/routes/remotePipelineEditor.test.tsx index 4fb8110c7..8576f80a6 100644 --- a/src/routes/remotePipelineEditor.test.tsx +++ b/src/routes/remotePipelineEditor.test.tsx @@ -548,7 +548,7 @@ describe("remote pipeline editor entry", () => { }); renderEditor(); expect(screen.getByRole("alert")).toHaveTextContent(message); - expect(screen.queryByText("View only")).not.toBeInTheDocument(); + expect(screen.queryByText("Read only")).not.toBeInTheDocument(); expect(Boolean(screen.queryByRole("button", { name: "Retry" }))).toBe( retryable, ); @@ -654,7 +654,7 @@ describe("remote pipeline editor entry", () => { }); renderEditor(); expect(screen.getByRole("alert")).toHaveTextContent(message); - expect(screen.queryByText("View only")).not.toBeInTheDocument(); + expect(screen.queryByText("Read only")).not.toBeInTheDocument(); expect(screen.getByRole("link", { name: "Home" })).toBeInTheDocument(); expect( screen.getByRole("button", { name: "Sign in" }), diff --git a/src/routes/v2/pages/Editor/components/RemotePipelineView/EmbeddedPipelineViewControls.tsx b/src/routes/v2/pages/Editor/components/RemotePipelineView/EmbeddedPipelineViewControls.tsx new file mode 100644 index 000000000..c41aaf62d --- /dev/null +++ b/src/routes/v2/pages/Editor/components/RemotePipelineView/EmbeddedPipelineViewControls.tsx @@ -0,0 +1,33 @@ +import { Panel } from "@xyflow/react"; +import type { PropsWithChildren } from "react"; + +import { InlineStack } from "@/components/ui/layout"; +import { cn } from "@/lib/utils"; +import { CANVAS_MENU_SURFACE_CLASS } from "@/routes/v2/shared/components/CanvasMenuBar"; + +import { ReadOnlyBadge } from "./ReadOnlyBadge"; + +export function EmbeddedPipelineViewControls({ children }: PropsWithChildren) { + return ( + + + + + {children} + + + + ); +} diff --git a/src/routes/v2/pages/Editor/components/RemotePipelineView/ReadOnlyBadge.tsx b/src/routes/v2/pages/Editor/components/RemotePipelineView/ReadOnlyBadge.tsx new file mode 100644 index 000000000..754af211a --- /dev/null +++ b/src/routes/v2/pages/Editor/components/RemotePipelineView/ReadOnlyBadge.tsx @@ -0,0 +1,9 @@ +import { Badge } from "@/components/ui/badge"; + +export function ReadOnlyBadge() { + return ( + + Read only + + ); +} diff --git a/src/routes/v2/pages/Editor/components/RemotePipelineView/RemotePipelineShell.tsx b/src/routes/v2/pages/Editor/components/RemotePipelineView/RemotePipelineShell.tsx index c24b4a845..96329c603 100644 --- a/src/routes/v2/pages/Editor/components/RemotePipelineView/RemotePipelineShell.tsx +++ b/src/routes/v2/pages/Editor/components/RemotePipelineView/RemotePipelineShell.tsx @@ -1,6 +1,5 @@ import type { PropsWithChildren, ReactNode } from "react"; -import { Badge } from "@/components/ui/badge"; import { BlockStack, InlineStack } from "@/components/ui/layout"; import { Text } from "@/components/ui/typography"; import { AppMenuActions } from "@/routes/v2/shared/components/AppMenuActions"; @@ -9,6 +8,21 @@ import { CanvasMenuBar, } from "@/routes/v2/shared/components/CanvasMenuBar"; +import { ReadOnlyBadge } from "./ReadOnlyBadge"; + +export function RemotePipelineFrame({ children }: PropsWithChildren) { + return ( + + {children} + + ); +} + export function RemotePipelineShell({ name = "Pipeline", readOnly = false, @@ -20,12 +34,7 @@ export function RemotePipelineShell({ menus?: ReactNode; }>) { return ( - + {name} - {readOnly && ( - - View only - - )} + {readOnly && } {menus && ( @@ -66,6 +71,6 @@ export function RemotePipelineShell({ {children} - + ); } diff --git a/src/routes/v2/pages/Editor/components/RemotePipelineView/RemotePipelineView.integration.test.tsx b/src/routes/v2/pages/Editor/components/RemotePipelineView/RemotePipelineView.integration.test.tsx index bb72954af..be9e1142d 100644 --- a/src/routes/v2/pages/Editor/components/RemotePipelineView/RemotePipelineView.integration.test.tsx +++ b/src/routes/v2/pages/Editor/components/RemotePipelineView/RemotePipelineView.integration.test.tsx @@ -389,8 +389,11 @@ it.each([false, true])("inspects pipelines (embed=%s)", async (embedded) => { screen.getByRole("menuitemcheckbox", { name: "Sidekick" }), ).toBeInTheDocument(); await user.keyboard("{Escape}"); + const viewControls = embedded + ? screen.getByRole("group", { name: "Pipeline view controls" }) + : screen.getByRole("banner"); expect( - within(screen.getByRole("banner")).queryByRole("button", { + within(viewControls).queryByRole("button", { name: /^(Details|Clone pipeline|Submit Run)$/, }), ).not.toBeInTheDocument(); diff --git a/src/routes/v2/pages/Editor/components/RemotePipelineView/RemotePipelineView.test.tsx b/src/routes/v2/pages/Editor/components/RemotePipelineView/RemotePipelineView.test.tsx index cedc694e5..785a9aede 100644 --- a/src/routes/v2/pages/Editor/components/RemotePipelineView/RemotePipelineView.test.tsx +++ b/src/routes/v2/pages/Editor/components/RemotePipelineView/RemotePipelineView.test.tsx @@ -14,6 +14,7 @@ import { render, screen, waitFor, + within, } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import type { ReactFlowProps } from "@xyflow/react"; @@ -179,7 +180,7 @@ it("shows a read-only graph and navigates subgraphs without changing the spec", { wrapper: RouterWrapper }, ); await screen.findByText("First task"); - expect(screen.getByText("View only")).toBeInTheDocument(); + expect(screen.getByText("Read only")).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Fit view" })).toBeInTheDocument(); expect(canvas).toMatchObject({ nodesConnectable: false, @@ -212,7 +213,7 @@ it("starts embedded views compact with Sidekick disabled and keeps the minimap a const { container } = render(, { wrapper: RouterWrapper, }); - await screen.findByText("View only"); + await screen.findByText("Read only"); expect(container.querySelector('[data-dock-area="left"]')).toBeNull(); expect(container.querySelector('[data-dock-area="right"]')).toHaveStyle({ width: "36px", @@ -226,6 +227,27 @@ it("starts embedded views compact with Sidekick disabled and keeps the minimap a expect(await screen.findByRole("button", { name: "Fit view" })).toBeVisible(); }); +it("floats the view controls on an embedded canvas instead of a menu bar", async () => { + const spec = new YamlDeserializer(new IncrementingIdGenerator()).deserialize({ + name: "Shared pipeline", + implementation: { graph: { tasks: {} } }, + }); + render(, { + wrapper: RouterWrapper, + }); + const controls = await screen.findByRole("group", { + name: "Pipeline view controls", + }); + expect(within(controls).getByText("Read only")).toBeInTheDocument(); + expect(within(controls).getAllByRole("button")).toHaveLength(2); + for (const name of ["View", "Windows"]) { + expect(within(controls).getByRole("button", { name })).toBeInTheDocument(); + } + expect(screen.queryByRole("banner")).toBeNull(); + expect(screen.queryByRole("link", { name: "Home" })).toBeNull(); + expect(screen.queryByText("Shared pipeline")).toBeNull(); +}); + it("copies the selected task only while the view is active", async () => { const writeText = vi.fn().mockResolvedValue(undefined); const originalClipboard = Object.getOwnPropertyDescriptor( @@ -406,7 +428,7 @@ it.each(["task", "input", "output"])( await waitFor(() => expect(editor.selectedNodeId).toBe(node.$id)); expect(editor.selectedNodeType).toBe(nodeType); expect(canvas.nodes?.some((item) => item.id === node.$id)).toBe(true); - expect(screen.getByText("View only")).toBeInTheDocument(); + expect(screen.getByText("Read only")).toBeInTheDocument(); await waitFor(() => expect(router.state.location.search.nodeId).toBe("shared"), ); diff --git a/src/routes/v2/pages/Editor/components/RemotePipelineView/RemotePipelineView.tsx b/src/routes/v2/pages/Editor/components/RemotePipelineView/RemotePipelineView.tsx index 224f5acdc..4b64fd8ba 100644 --- a/src/routes/v2/pages/Editor/components/RemotePipelineView/RemotePipelineView.tsx +++ b/src/routes/v2/pages/Editor/components/RemotePipelineView/RemotePipelineView.tsx @@ -45,8 +45,12 @@ import { useWindowPersistence } from "@/routes/v2/shared/windows/windowPersisten import type { PipelineFile } from "@/services/pipelineStorage/PipelineFile"; import { getEditorPipelineIdentity } from "@/services/pipelineStorage/types"; +import { EmbeddedPipelineViewControls } from "./EmbeddedPipelineViewControls"; import { RemotePipelineActions } from "./RemotePipelineActions"; -import { RemotePipelineShell } from "./RemotePipelineShell"; +import { + RemotePipelineFrame, + RemotePipelineShell, +} from "./RemotePipelineShell"; import { remotePipelineViewRegistry } from "./remotePipelineViewRegistry"; const VIEW_PRESETS: ViewPreset[] = [ @@ -136,20 +140,18 @@ const RemotePipelineContent = observer(function RemotePipelineContent({ const activeSpec = navigation.activeSpec; - return ( - - - - - } - > + const menus = ( + <> + + + + ); + + const content = ( + <> {file?.editingError && (
@@ -189,13 +191,33 @@ const RemotePipelineContent = observer(function RemotePipelineContent({ spec={activeSpec} nodesDraggable={false} className="h-full" - /> + > + {embedded && ( + + {menus} + + )} +
)} + + ); + + if (embedded) { + return {content}; + } + + return ( + + {content} ); }); diff --git a/src/routes/v2/shared/components/CanvasMenuBar.tsx b/src/routes/v2/shared/components/CanvasMenuBar.tsx index 3ae3502d4..85d97f620 100644 --- a/src/routes/v2/shared/components/CanvasMenuBar.tsx +++ b/src/routes/v2/shared/components/CanvasMenuBar.tsx @@ -3,14 +3,20 @@ import type { PropsWithChildren } from "react"; import logo from "/Tangle_Icon_White.png"; import { InlineStack } from "@/components/ui/layout"; +import { cn } from "@/lib/utils"; import { APP_ROUTES } from "@/routes/appRoutes"; import { TOP_NAV_HEIGHT } from "@/utils/constants"; import { tracking } from "@/utils/tracking"; +export const CANVAS_MENU_SURFACE_CLASS = "bg-stone-900"; + export function CanvasMenuBar({ children }: PropsWithChildren) { return (