Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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}>
<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("");
}}
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