diff --git a/web/src/templates/api.ts b/web/src/templates/api.ts index 7a170cd..268c8db 100644 --- a/web/src/templates/api.ts +++ b/web/src/templates/api.ts @@ -129,15 +129,17 @@ function useUpdateTemplateFile(name: string) { async function buildTemplate({ imageTag, templateName, + buildArgs, onBuildOutput, }: { imageTag: string; templateName: string; + buildArgs: Record; onBuildOutput: (chunk: string) => void; }) { const res = await fetchApi(`/templates/${templateName}`, { method: "POST", - body: JSON.stringify({ imageTag }), + body: JSON.stringify({ imageTag, buildArgs }), headers: { Accept: "text/event-stream", }, diff --git a/web/src/templates/build-template-dialog.tsx b/web/src/templates/build-template-dialog.tsx new file mode 100644 index 0000000..430c567 --- /dev/null +++ b/web/src/templates/build-template-dialog.tsx @@ -0,0 +1,262 @@ +import { useCallback, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { + DialogHeader, + DialogFooter, + DialogContent, + DialogTitle, + DialogDescription, + DialogClose, +} from "@/components/ui/dialog"; +import { + Form, + FormField, + FormItem, + FormLabel, + FormControl, + FormDescription, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { superstructResolver } from "@hookform/resolvers/superstruct"; +import { useFieldArray, useForm } from "react-hook-form"; +import { array, object, pattern, string, type Infer } from "superstruct"; +import { useTemplateEditorStore } from "./template-editor-store"; +import { Check, Pencil, Plus, Trash2, X } from "lucide-react"; + +interface BuildArg { + argName: string; + arg: string; +} + +const BuildOptionForm = object({ + imageName: pattern(string(), /^[\w-]+$/), + buildArgs: array( + object({ + argName: string(), + arg: string(), + }), + ), +}); + +function BuildTemplateDialog() { + return ( + + + Build options + + Build options for this Docker image + + + + + ); +} + +function BuildTemplateForm() { + const templateName = useTemplateEditorStore((state) => state.template.name); + const startBuild = useTemplateEditorStore((state) => state.startBuild); + const form = useForm({ + resolver: superstructResolver(BuildOptionForm), + defaultValues: { + imageName: templateName, + buildArgs: [], + }, + }); + + function onSubmit(values: Infer) { + startBuild({ + imageTag: values.imageName, + buildArgs: values.buildArgs.reduce>( + (allArgs, { argName, arg }) => { + allArgs[argName] = arg; + return allArgs; + }, + {}, + ), + }); + } + + return ( +
+ + ( + + Image name + + + + + Must only contain alphanumeric characters and "-". + + + + )} + /> + + + + + + + + + + + ); +} + +function BuildArgControl() { + const [isAdding, setIsAdding] = useState(false); + const { + fields: buildArgs, + append, + update, + remove, + } = useFieldArray>({ name: "buildArgs" }); + + function addRow(arg: BuildArg) { + append(arg); + setIsAdding(false); + } + + return ( + + Build arguments + +
+
+ {buildArgs.map((arg, i) => ( + update(i, arg)} + onDelete={() => remove(i)} + /> + ))} + {isAdding ? ( + setIsAdding(false)} + /> + ) : null} +
+ {isAdding ? null : ( + + )} +
+
+
+ ); +} + +function BuildArgRow({ + initialArg = { argName: "", arg: "" }, + isNew = false, + onFinish, + onCancel, + onDelete, +}: { + initialArg?: BuildArg; + isNew?: boolean; + onFinish: (arg: BuildArg) => void; + onCancel?: () => void; + onDelete?: () => void; +}) { + const [argName, setArgName] = useState(initialArg.argName); + const [arg, setArg] = useState(initialArg.arg); + const [isEditing, setIsEditing] = useState(isNew); + + const cancelEditing = useCallback(() => { + if (isNew) { + onCancel?.(); + } else { + setIsEditing(false); + } + }, [isNew, onCancel]); + + const enableEditing = useCallback(() => { + setIsEditing(true); + }, []); + + const finishEditing = useCallback(() => { + onFinish({ argName, arg }); + }, [argName, arg, onFinish]); + + return ( + <> + { + setArgName(event.currentTarget.value); + }} + /> + { + setArg(event.currentTarget.value); + }} + /> +
+ {isEditing ? ( + + ) : ( + + )} + {isEditing ? ( + + ) : ( + + )} +
+ + ); +} + +export { BuildTemplateDialog }; diff --git a/web/src/templates/template-editor-store.tsx b/web/src/templates/template-editor-store.tsx index 9088315..28b5feb 100644 --- a/web/src/templates/template-editor-store.tsx +++ b/web/src/templates/template-editor-store.tsx @@ -10,7 +10,10 @@ interface TemplateEditorState { isBuildOutputVisible: boolean; buildOutput: string; - startBuild: ({ imageTag }: { imageTag: string }) => Promise; + startBuild: ({ + imageTag, + buildArgs, + }: { imageTag: string; buildArgs: Record }) => Promise; setCurrentFilePath: (path: string) => void; @@ -32,7 +35,7 @@ function createTemplateEditorStore({ isBuildOutputVisible: false, buildOutput: "", - startBuild: async ({ imageTag }) => { + startBuild: async ({ imageTag, buildArgs }) => { const state = get(); set({ @@ -44,6 +47,7 @@ function createTemplateEditorStore({ try { await buildTemplate({ imageTag, + buildArgs, templateName: state.template.name, onBuildOutput: state.addBuildOutputChunk, }); diff --git a/web/src/templates/template-editor.tsx b/web/src/templates/template-editor.tsx index db61ff5..7f57d80 100644 --- a/web/src/templates/template-editor.tsx +++ b/web/src/templates/template-editor.tsx @@ -3,23 +3,8 @@ import { CodeMirrorEditor } from "@/components/codemirror-editor"; import { Button } from "@/components/ui/button.tsx"; import { Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; -import { - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form"; -import { Input } from "@/components/ui/input"; import { Sidebar, SidebarContent, @@ -32,7 +17,6 @@ import { SidebarProvider, } from "@/components/ui/sidebar.tsx"; import { cn } from "@/lib/utils"; -import { superstructResolver } from "@hookform/resolvers/superstruct"; import { Link } from "@tanstack/react-router"; import { ArrowLeft, @@ -42,10 +26,9 @@ import { Loader2, } from "lucide-react"; import { useEffect, useRef } from "react"; -import { useForm } from "react-hook-form"; -import { object, pattern, string, type Infer } from "superstruct"; import { useStore } from "zustand"; import { useTemplate, useTemplateFile, useUpdateTemplateFile } from "./api"; +import { BuildTemplateDialog } from "./build-template-dialog"; import { templateEditorRoute } from "./routes"; import { type TemplateEditorStore, @@ -54,11 +37,6 @@ import { useTemplateEditorStore, } from "./template-editor-store"; import type { Template } from "./types"; -import { DialogClose } from "@radix-ui/react-dialog"; - -const BuildOptionForm = object({ - imageName: pattern(string(), /^[\w-]+$/), -}); function TemplateEditor() { const { templateName, _splat } = templateEditorRoute.useParams(); @@ -235,7 +213,7 @@ function EditorTopBar() { - + ); } @@ -331,58 +309,4 @@ function BuildOutput() { ); } -function BuildOptionDialog() { - const templateName = useTemplateEditorStore((state) => state.template.name); - const startBuild = useTemplateEditorStore((state) => state.startBuild); - const form = useForm({ - resolver: superstructResolver(BuildOptionForm), - defaultValues: { - imageName: templateName, - }, - }); - - function onSubmit(values: Infer) { - startBuild({ - imageTag: values.imageName, - }); - } - - return ( - - - Build options - - Build options for this Docker image - - -
- - ( - - Image name - - - - - Must only contain alphanumeric characters and "-". - - - - )} - /> - - - - - - - - -
- ); -} - export { TemplateEditor };