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
294 changes: 244 additions & 50 deletions web_src/src/components/CreateCanvasModal/index.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,28 @@
import { useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { Upload } from "lucide-react";
import { showErrorToast } from "../../utils/toast";
import { parseCanvasYaml, readFileAsText, type ParsedCanvas } from "../../utils/parseCanvasYaml";
import type { ComponentsNode, ComponentsEdge } from "@/api-client";
import { Dialog, DialogActions, DialogBody, DialogDescription, DialogTitle } from "../Dialog/dialog";
import { Field, Label } from "../Fieldset/fieldset";
import { Icon } from "../Icon";
import { Input } from "../Input/input";
import { Textarea } from "../ui/textarea";
import { Button } from "../ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";

export interface CreateCanvasSubmitData {
name: string;
description?: string;
templateId?: string;
nodes?: ComponentsNode[];
edges?: ComponentsEdge[];
}

interface CreateCanvasModalProps {
isOpen: boolean;
onClose: () => void;
onSubmit: (data: { name: string; description?: string; templateId?: string }) => Promise<void>;
onSubmit: (data: CreateCanvasSubmitData) => Promise<void>;
isLoading?: boolean;
initialData?: { name: string; description?: string };
templates?: { id: string; name: string; description?: string }[];
Expand All @@ -37,11 +49,21 @@ export function CreateCanvasModal({
const [nameError, setNameError] = useState("");
const [templateId, setTemplateId] = useState("");

const [activeTab, setActiveTab] = useState<string>("manual");
const [yamlText, setYamlText] = useState("");
const [yamlError, setYamlError] = useState("");
const [importedSpec, setImportedSpec] = useState<{ nodes: ComponentsNode[]; edges: ComponentsEdge[] } | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);

useEffect(() => {
if (isOpen) {
setName(initialData?.name ?? "");
setDescription(initialData?.description ?? "");
setNameError("");
setActiveTab("manual");
setYamlText("");
setYamlError("");
setImportedSpec(null);
}
if (isOpen && mode === "create") {
setTemplateId(defaultTemplateId || "");
Expand All @@ -56,9 +78,62 @@ export function CreateCanvasModal({
setDescription("");
setNameError("");
setTemplateId("");
setYamlText("");
setYamlError("");
setImportedSpec(null);
onClose();
};

const applyParsedYaml = useCallback((parsed: ParsedCanvas) => {
setName(parsed.name.slice(0, MAX_CANVAS_NAME_LENGTH));
setDescription((parsed.description ?? "").slice(0, MAX_CANVAS_DESCRIPTION_LENGTH));
setImportedSpec({ nodes: parsed.nodes, edges: parsed.edges });
setYamlError("");
setNameError("");
}, []);

const handleYamlParse = useCallback(() => {
if (!yamlText.trim()) {
setYamlError("Paste or upload a YAML file first.");
setImportedSpec(null);
return;
}

try {
const parsed = parseCanvasYaml(yamlText);
applyParsedYaml(parsed);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
setYamlError(message);
setImportedSpec(null);
}
}, [yamlText, applyParsedYaml]);

const handleFileUpload = useCallback(
async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;

try {
const text = await readFileAsText(file);
setYamlText(text);

const parsed = parseCanvasYaml(text);
applyParsedYaml(parsed);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
setYamlError(message);
setImportedSpec(null);
}

// Reset file input so re-uploading the same file triggers onChange
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
},
[applyParsedYaml],
);

const handleSubmit = async () => {
setNameError("");

Expand All @@ -77,13 +152,18 @@ export function CreateCanvasModal({
name: name.trim(),
description: description.trim() || undefined,
templateId: templateId || undefined,
nodes: importedSpec?.nodes,
edges: importedSpec?.edges,
});

// Reset form and close modal
setName("");
setDescription("");
setNameError("");
setTemplateId("");
setYamlText("");
setYamlError("");
setImportedSpec(null);
onClose();
} catch (error) {
const errorMessage = (error as Error)?.message || error?.toString() || "Failed to create canvas";
Expand All @@ -96,6 +176,8 @@ export function CreateCanvasModal({
}
};

const showYamlTab = mode === "create" && !fromTemplate;

return (
<Dialog open={isOpen} onClose={handleClose} size="lg" className="text-left relative">
<DialogTitle>
Expand All @@ -106,66 +188,113 @@ export function CreateCanvasModal({
? "Create a canvas from this template. Give it a name and optional description to get started."
: mode === "edit"
? "Update the canvas details to keep things clear for your teammates."
: "Create a new canvas to orchestrate your DevOps work. You can tweak the details any time."}
: "Create a new canvas or import one from a YAML file."}
</DialogDescription>
<button onClick={handleClose} className="absolute top-4 right-4">
<Icon name="close" size="sm" />
</button>

<DialogBody>
<div className="space-y-6">
<Field>
<Label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Canvas name *</Label>
<Input
data-testid="canvas-name-input"
type="text"
autoComplete="off"
value={name}
onChange={(e) => {
if (e.target.value.length <= MAX_CANVAS_DESCRIPTION_LENGTH) {
setName(e.target.value);
}
if (nameError) {
setNameError("");
}
}}
placeholder=""
className={`w-full ${nameError ? "border-red-500" : ""}`}
autoFocus
maxLength={MAX_CANVAS_NAME_LENGTH}
/>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{name.length}/{MAX_CANVAS_NAME_LENGTH} characters
</div>
{nameError && <div className="text-xs text-red-600 mt-1">{nameError}</div>}
</Field>

<Field>
<Label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Description</Label>
<Textarea
value={description}
onChange={(e) => {
if (e.target.value.length <= MAX_CANVAS_DESCRIPTION_LENGTH) {
setDescription(e.target.value);
}
}}
placeholder="Describe what is does (optional)"
rows={3}
className="w-full"
maxLength={MAX_CANVAS_DESCRIPTION_LENGTH}
/>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{description.length}/{MAX_CANVAS_DESCRIPTION_LENGTH} characters
</div>
</Field>
</div>
{showYamlTab ? (
<Tabs value={activeTab} onValueChange={setActiveTab}>
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Modal doesn't clear imported YAML state on tab switch

Medium Severity

In CreateCanvasModal, the Tabs onValueChange is just setActiveTab, unlike CreateCanvasPage which also clears importedSpec, yamlText, and yamlError when switching back to "manual". If a user imports YAML (setting importedSpec), then switches to the manual tab and selects a template, handleSubmit still passes the stale importedSpec?.nodes and importedSpec?.edges. Since those are truthy arrays, the template lookup in useCreateCanvasModalState is skipped (!nodes && !edges is false), silently ignoring the user's template selection.

Additional Locations (1)

Fix in Cursor Fix in Web

<TabsList className="mb-4">
<TabsTrigger value="manual">Create manually</TabsTrigger>
<TabsTrigger value="yaml">Import from YAML</TabsTrigger>
</TabsList>

<TabsContent value="manual">
<CanvasFormFields
name={name}
description={description}
nameError={nameError}
onNameChange={setName}
onDescriptionChange={setDescription}
onNameErrorChange={setNameError}
/>
</TabsContent>

<TabsContent value="yaml">
<div className="space-y-4">
<div>
<Label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
YAML content
</Label>
<Textarea
data-testid="yaml-import-textarea"
value={yamlText}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => {
setYamlText(e.target.value);
if (yamlError) setYamlError("");
}}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale parsed data submitted after YAML text edits

Medium Severity

The YAML textarea's onChange handler clears yamlError but not importedSpec. After a successful parse, if the user edits the YAML text, importedSpec still holds the old parsed nodes/edges, the "Parsed successfully" message remains visible, and the submit button stays enabled. Submitting sends the stale parsed data rather than reflecting the user's modifications.

Additional Locations (1)

Fix in Cursor Fix in Web

placeholder={`metadata:\n name: My Canvas\n description: Optional description\nspec:\n nodes: []\n edges: []`}
rows={10}
className="w-full font-mono text-sm"
/>
{yamlError && <div className="text-xs text-red-600 mt-1">{yamlError}</div>}
{importedSpec && (
<div className="text-xs text-green-700 mt-1">
Parsed successfully: {importedSpec.nodes.length} node(s), {importedSpec.edges.length} edge(s).
</div>
)}
</div>

<div className="flex items-center gap-3">
<Button type="button" variant="outline" size="sm" onClick={handleYamlParse}>
Parse YAML
</Button>
<input
ref={fileInputRef}
type="file"
accept=".yaml,.yml"
onChange={handleFileUpload}
className="hidden"
data-testid="yaml-file-input"
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => fileInputRef.current?.click()}
className="flex items-center gap-1.5"
>
<Upload className="h-3.5 w-3.5" />
Upload file
</Button>
</div>

{importedSpec && (
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
<CanvasFormFields
name={name}
description={description}
nameError={nameError}
onNameChange={setName}
onDescriptionChange={setDescription}
onNameErrorChange={setNameError}
/>
</div>
)}
</div>
</TabsContent>
</Tabs>
) : (
<CanvasFormFields
name={name}
description={description}
nameError={nameError}
onNameChange={setName}
onDescriptionChange={setDescription}
onNameErrorChange={setNameError}
/>
)}
</DialogBody>

<DialogActions>
<Button
onClick={handleSubmit}
disabled={!name.trim() || isLoading || !!nameError}
disabled={!name.trim() || isLoading || !!nameError || (activeTab === "yaml" && !importedSpec)}
className="flex items-center gap-2"
data-testid="create-canvas-submit"
>
{mode === "edit"
? isLoading
Expand All @@ -183,3 +312,68 @@ export function CreateCanvasModal({
</Dialog>
);
}

function CanvasFormFields({
name,
description,
nameError,
onNameChange,
onDescriptionChange,
onNameErrorChange,
}: {
name: string;
description: string;
nameError: string;
onNameChange: (value: string) => void;
onDescriptionChange: (value: string) => void;
onNameErrorChange: (value: string) => void;
}) {
return (
<div className="space-y-6">
<Field>
<Label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Canvas name *</Label>
<Input
data-testid="canvas-name-input"
type="text"
autoComplete="off"
value={name}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.value.length <= MAX_CANVAS_NAME_LENGTH) {
onNameChange(e.target.value);
}
if (nameError) {
onNameErrorChange("");
}
}}
placeholder=""
className={`w-full ${nameError ? "border-red-500" : ""}`}
autoFocus
maxLength={MAX_CANVAS_NAME_LENGTH}
/>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{name.length}/{MAX_CANVAS_NAME_LENGTH} characters
</div>
{nameError && <div className="text-xs text-red-600 mt-1">{nameError}</div>}
</Field>

<Field>
<Label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Description</Label>
<Textarea
value={description}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => {
if (e.target.value.length <= MAX_CANVAS_DESCRIPTION_LENGTH) {
onDescriptionChange(e.target.value);
}
}}
placeholder="Describe what it does (optional)"
rows={3}
className="w-full"
maxLength={MAX_CANVAS_DESCRIPTION_LENGTH}
/>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{description.length}/{MAX_CANVAS_DESCRIPTION_LENGTH} characters
</div>
</Field>
</div>
);
}
Loading