-
Notifications
You must be signed in to change notification settings - Fork 97
feat: Add YAML import when creating a Canvas #3076
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 }[]; | ||
|
|
@@ -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 || ""); | ||
|
|
@@ -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(""); | ||
|
|
||
|
|
@@ -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"; | ||
|
|
@@ -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> | ||
|
|
@@ -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(""); | ||
| }} | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Stale parsed data submitted after YAML text editsMedium Severity The YAML textarea's Additional Locations (1) |
||
| 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 | ||
|
|
@@ -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> | ||
| ); | ||
| } | ||


There was a problem hiding this comment.
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, theTabsonValueChangeis justsetActiveTab, unlikeCreateCanvasPagewhich also clearsimportedSpec,yamlText, andyamlErrorwhen switching back to "manual". If a user imports YAML (settingimportedSpec), then switches to the manual tab and selects a template,handleSubmitstill passes the staleimportedSpec?.nodesandimportedSpec?.edges. Since those are truthy arrays, the template lookup inuseCreateCanvasModalStateis skipped (!nodes && !edgesis false), silently ignoring the user's template selection.Additional Locations (1)
web_src/src/pages/canvas/CreateCanvasPage.tsx#L156-L164