Skip to content
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,7 @@ import { WindowsMenu } from "./components/WindowsMenu";
export const EditorMenuBar = observer(function EditorMenuBar() {
const { navigation, windows } = useSharedStores();
const { pipelineFile } = useEditorSession();
const spec = navigation.activeSpec;
const pipelineName = spec?.name ?? "Untitled pipeline";
const pipelineName = navigation.rootSpec?.name ?? "Untitled pipeline";

const displayMenu = Boolean(pipelineFile.activePipelineFile);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { useState } from "react";
import { BlockStack } from "@/components/ui/layout";
import { cn } from "@/lib/utils";
import type { ComponentSpec } from "@/models/componentSpec";
import { RenameSubgraphMenuItem } from "@/routes/v2/pages/Editor/components/SubgraphActions/RenameSubgraphMenuItem";
import { useAutoLayout } from "@/routes/v2/pages/Editor/hooks/useAutoLayout";
import { SubgraphBreadcrumbs } from "@/routes/v2/shared/components/SubgraphBreadcrumbs";
import { FLOW_CANVAS_DEFAULT_PROPS } from "@/routes/v2/shared/flowCanvasDefaults";
Expand Down Expand Up @@ -89,7 +90,7 @@ export const FlowCanvas = observer(function FlowCanvas({
className,
)}
>
<SubgraphBreadcrumbs />
<SubgraphBreadcrumbs extraMenuItems={<RenameSubgraphMenuItem />} />
<ReactFlow
{...FLOW_CANVAS_DEFAULT_PROPS}
nodeTypes={nodeTypes}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { observer } from "mobx-react-lite";
import { type ChangeEvent, type SubmitEvent, useState } from "react";

import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import { Icon } from "@/components/ui/icon";
import { Input } from "@/components/ui/input";
import { BlockStack } from "@/components/ui/layout";
import useToastNotification from "@/hooks/useToastNotification";
import { usePipelineActions } from "@/routes/v2/pages/Editor/store/actions/usePipelineActions";
import { useSharedStores } from "@/routes/v2/shared/store/SharedStoreContext";

export const RenameSubgraphMenuItem = observer(
function RenameSubgraphMenuItem() {
const { navigation } = useSharedStores();
const { renameSubgraph } = usePipelineActions();
const notify = useToastNotification();

const [open, setOpen] = useState(false);
const [name, setName] = useState("");
const [error, setError] = useState<string | null>(null);

const depth = navigation.navigationDepth;
const currentName =
depth > 0 ? navigation.navigationPath[depth].displayName : "";

if (depth === 0) return null;

const parentSpec = navigation.parentSpec;
const siblingNames = new Set(
parentSpec?.tasks.map((t) => t.name).filter((n) => n !== currentName) ??
[],
);

const handleOpenChange = (next: boolean) => {
if (next) {
setName(currentName);
setError(null);
}
setOpen(next);
};

const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
const next = e.target.value;
setName(next);

const trimmed = next.trim();
if (!trimmed) {
setError("Name cannot be empty");
} else if (siblingNames.has(trimmed)) {
setError("A sibling task already uses this name");
} else {
setError(null);
}
};

const handleSubmit = (event: SubmitEvent) => {
event.preventDefault();
const trimmed = name.trim();
if (!trimmed || error) return;

if (siblingNames.has(trimmed)) {
setError("A sibling task already uses this name");
return;
}

const ok = renameSubgraph(trimmed);
if (!ok) {
notify("Could not rename subgraph", "error");
return;
}
setOpen(false);
};

const isDisabled = !!error || !name.trim() || name.trim() === currentName;

return (
<>
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault();
handleOpenChange(true);
}}
>
<Icon name="Pencil" size="sm" />
Rename Subgraph
</DropdownMenuItem>

<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Rename Subgraph</DialogTitle>
<DialogDescription>
Renames this subgraph within its parent. References from sibling
tasks are preserved.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit}>
<BlockStack gap="2">
<Input
value={name}
onChange={handleChange}
autoFocus
data-testid="rename-subgraph-input"
/>
{error && (
<Alert variant="destructive">
<Icon name="CircleAlert" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
</BlockStack>
<DialogFooter className="sm:justify-end mt-4">
<DialogClose asChild>
<Button type="button" variant="secondary">
Cancel
</Button>
</DialogClose>
<Button
type="submit"
size="sm"
className="px-3"
disabled={isDisabled}
>
Rename
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</>
);
},
);
11 changes: 11 additions & 0 deletions src/routes/v2/pages/Editor/store/actions/pipeline.actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
} from "@/models/componentSpec";
import { generateUniqueTaskName } from "@/routes/v2/pages/Editor/store/nameUtils";
import type { UndoGroupable } from "@/routes/v2/shared/nodes/types";
import type { NavigationStore } from "@/routes/v2/shared/store/navigationStore";
import { PIPELINE_NOTES_ANNOTATION } from "@/utils/annotations";

import { idGen } from "./utils";
Expand All @@ -22,6 +23,16 @@ export function renamePipeline(
});
}

export function renameSubgraph(
undo: UndoGroupable,
navigation: NavigationStore,
newName: string,
): boolean {
return undo.withGroup("Rename subgraph", () =>
navigation.renameCurrentSubgraph(newName),
);
}

export function updatePipelineDescription(
undo: UndoGroupable,
spec: ComponentSpec,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,17 +1,21 @@
import { useEditorSession } from "@/routes/v2/pages/Editor/store/EditorSessionContext";
import { useSharedStores } from "@/routes/v2/shared/store/SharedStoreContext";

import {
createSubgraph,
renamePipeline,
renameSubgraph,
updatePipelineDescription,
updatePipelineNotes,
} from "./pipeline.actions";

export function usePipelineActions() {
const { undo } = useEditorSession();
const { navigation } = useSharedStores();

return {
renamePipeline: renamePipeline.bind(null, undo),
renameSubgraph: renameSubgraph.bind(null, undo, navigation),
updatePipelineDescription: updatePipelineDescription.bind(null, undo),
updatePipelineNotes: updatePipelineNotes.bind(null, undo),
createSubgraph: createSubgraph.bind(null, undo),
Expand Down
18 changes: 16 additions & 2 deletions src/routes/v2/shared/components/SubgraphActionsMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { observer } from "mobx-react-lite";
import { useState } from "react";
import { type ReactNode, useState } from "react";

import { CodeViewer } from "@/components/shared/CodeViewer";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Icon } from "@/components/ui/icon";
Expand All @@ -20,7 +21,13 @@ import { serializeComponentSpecToYaml } from "@/models/componentSpec";
import { useSharedStores } from "@/routes/v2/shared/store/SharedStoreContext";
import { downloadYamlFromComponentText } from "@/utils/URL";

export const SubgraphActionsMenu = observer(function SubgraphActionsMenu() {
interface SubgraphActionsMenuProps {
extraItems?: ReactNode;
}

export const SubgraphActionsMenu = observer(function SubgraphActionsMenu({
extraItems,
}: SubgraphActionsMenuProps) {
const { navigation } = useSharedStores();
const notify = useToastNotification();
const [showCodeViewer, setShowCodeViewer] = useState(false);
Expand Down Expand Up @@ -69,6 +76,13 @@ export const SubgraphActionsMenu = observer(function SubgraphActionsMenu() {
</Tooltip>

<DropdownMenuContent align="end">
{extraItems && (
<>
{extraItems}
<DropdownMenuSeparator />
</>
)}

<DropdownMenuItem onClick={handleViewYaml}>
<Icon name="FileCode" size="sm" />
View YAML
Expand Down
11 changes: 9 additions & 2 deletions src/routes/v2/shared/components/SubgraphBreadcrumbs.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
import { observer } from "mobx-react-lite";
import type { ReactNode } from "react";

import { SubgraphBreadcrumbsView } from "@/components/shared/SubgraphBreadcrumbsView";
import { useSharedStores } from "@/routes/v2/shared/store/SharedStoreContext";

import { SubgraphActionsMenu } from "./SubgraphActionsMenu";

export const SubgraphBreadcrumbs = observer(function SubgraphBreadcrumbs() {
interface SubgraphBreadcrumbsProps {
extraMenuItems?: ReactNode;
}

export const SubgraphBreadcrumbs = observer(function SubgraphBreadcrumbs({
extraMenuItems,
}: SubgraphBreadcrumbsProps) {
const { navigation } = useSharedStores();

const path = navigation.navigationPath.map((entry, i) =>
Expand All @@ -20,7 +27,7 @@ export const SubgraphBreadcrumbs = observer(function SubgraphBreadcrumbs() {
<SubgraphBreadcrumbsView
path={path}
onNavigate={handleNavigate}
actions={<SubgraphActionsMenu />}
actions={<SubgraphActionsMenu extraItems={extraMenuItems} />}
/>
);
});
68 changes: 68 additions & 0 deletions src/routes/v2/shared/store/navigationStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,74 @@ export class NavigationStore {
return this.navigationPath.length > 1;
}

/** Spec one level up from the current navigation depth, or null at root. */
@computed get parentSpec(): ComponentSpec | null {
const depth = this.navigationDepth;
if (depth === 0) return null;
return this.getSpecAtDepth(depth - 1) ?? null;
}

/**
* Rename the subgraph at the current depth (depth >= 1). Renames the parent
* task in the parent spec, then re-keys our navigation state so the path
* key for the renamed subgraph (and any deeper cached subgraphs that pass
* through it) keeps matching.
*
* Returns false if there is no nested subgraph to rename, the new name
* collides with a sibling, or the rename otherwise fails.
*/
@action renameCurrentSubgraph(newName: string): boolean {
const trimmed = newName.trim();
if (!trimmed) return false;

const depth = this.navigationDepth;
if (depth === 0) return false;

const oldName = this.navigationPath[depth].displayName;
if (oldName === trimmed) return false;

const parentSpec = this.getSpecAtDepth(depth - 1);
if (!parentSpec) return false;

const task = parentSpec.tasks.find((t) => t.name === oldName);
if (!task) return false;

const renamed = parentSpec.renameTask(task.$id, trimmed);
if (!renamed) return false;

const nestedSpec = this.getSpecAtDepth(depth);
nestedSpec?.setName(trimmed);

const newPath = [...this.navigationPath];
newPath[depth] = { ...newPath[depth], displayName: trimmed };
this.navigationPath = newPath;

const prefixSegments = this.navigationPath
.slice(1, depth)
.map((e) => e.displayName);
const targetIndex = depth - 1;

const updatedSpecs = new Map<string, ComponentSpec>();
for (const [key, spec] of this.nestedSpecs) {
const segments = key.split("/");
const prefixMatches = prefixSegments.every((s, i) => segments[i] === s);
if (
prefixMatches &&
segments.length > targetIndex &&
segments[targetIndex] === oldName
) {
const newSegments = [...segments];
newSegments[targetIndex] = trimmed;
updatedSpecs.set(newSegments.join("/"), spec);
} else {
updatedSpecs.set(key, spec);
}
}
this.nestedSpecs = updatedSpecs;

return true;
}

/**
* Serialize each active nested spec and write it back into the parent
* task's componentRef.spec so the root serialization includes subgraph edits.
Expand Down
Loading