diff --git a/internal/template/http_handlers.go b/internal/template/http_handlers.go index 9bdc3bf..1c08cca 100644 --- a/internal/template/http_handlers.go +++ b/internal/template/http_handlers.go @@ -19,6 +19,7 @@ type createTemplateRequestBody struct { } type postTemplateRequestBody struct { + Name *string `json:"name"` Description *string `json:"description"` Files []templateFile `json:"files"` @@ -114,13 +115,22 @@ func updateTemplate(c echo.Context, body postTemplateRequestBody) error { mgr := templateManagerFrom(c) ctx := c.Request().Context() - updatedTemplate, err := mgr.updateTemplate(ctx, name, updateTemplateOptions{ - description: *body.Description, - }) + var opts updateTemplateOptions + if body.Name != nil { + opts.name = *body.Name + } + if body.Description != nil { + opts.description = *body.Description + } + + updatedTemplate, err := mgr.updateTemplate(ctx, name, opts) if err != nil { if errors.Is(err, errTemplateNotFound) { return echo.NewHTTPError(http.StatusNotFound) } + if errors.Is(err, errTemplateExists) { + return echo.NewHTTPError(http.StatusConflict) + } return err } diff --git a/internal/template/template.go b/internal/template/template.go index 6dd774b..573b7e8 100644 --- a/internal/template/template.go +++ b/internal/template/template.go @@ -20,7 +20,7 @@ type template struct { IsBuilt bool `json:"isBuilt"` Files []*templateFile `bun:"rel:has-many,join:id=template_id" json:"-"` - FileMap map[string]*templateFile `bun:"-" json:"files"` + FileMap map[string]*templateFile `bun:"-" json:"files,omitempty"` } type templateFile struct { diff --git a/internal/template/template_manager.go b/internal/template/template_manager.go index ebec825..f9eabf7 100644 --- a/internal/template/template_manager.go +++ b/internal/template/template_manager.go @@ -30,7 +30,12 @@ type createTemplateOptions struct { } type updateTemplateOptions struct { - tx *bun.Tx + tx *bun.Tx + + // name is the new name for the template + name string + + // description is the new description for the template description string } @@ -183,10 +188,29 @@ func (mgr *templateManager) updateTemplate(ctx context.Context, name string, opt tx = &_tx } + if opts.name != "" { + exists, err := tx.NewSelect(). + Table("templates"). + Where("name = ?", opts.name). + Exists(ctx) + if err != nil { + return nil, err + } + if exists { + return nil, errTemplateExists + } + } + var template template - err := tx.NewUpdate().Model(&template). - Where("Name = ?", name). - Set("description = ?", opts.description). + q := tx.NewUpdate().Model(&template).Where("name = ?", name) + if opts.name != "" { + q = q.Set("name = ?", opts.name) + } + if opts.description != "" { + q = q.Set("description = ?", opts.description) + } + + err := q. Returning("*"). Scan(ctx) if err != nil { diff --git a/web/src/api.ts b/web/src/api.ts index c8d9ecb..4199c60 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -11,7 +11,6 @@ const API_ERROR_WORKSPACE_EXISTS = "WORKSPACE_EXISTS"; type ApiError = | { type: "NOT_FOUND" } | { type: "NETWORK" } - | { type: "BAD_REQUEST" } | { type: "CONFLICT" } | { type: "INTERNAL" } | { type: "BAD_REQUEST"; details: ApiErrorDetails }; diff --git a/web/src/lib/query.ts b/web/src/lib/query.ts index 5c88dcd..b1a1f51 100644 --- a/web/src/lib/query.ts +++ b/web/src/lib/query.ts @@ -15,6 +15,10 @@ interface OkStatus { type: "ok"; } -type QueryStatus = IdleStatus | LoadingStatus | ErrorStatus | OkStatus; +type QueryStatus = + | IdleStatus + | LoadingStatus + | ErrorStatus + | OkStatus; export type { QueryStatus, IdleStatus, LoadingStatus, ErrorStatus, OkStatus }; diff --git a/web/src/templates/api.ts b/web/src/templates/api.ts index 4114bbd..61f36d3 100644 --- a/web/src/templates/api.ts +++ b/web/src/templates/api.ts @@ -8,6 +8,7 @@ import type { TemplateImage, TemplateMeta, } from "./types"; +import { QueryStatus } from "@/lib/query"; function useTemplates() { return useSWR( @@ -93,6 +94,64 @@ function useCreateTemplate() { return { createTemplate, isCreatingTemplate: isCreating, error }; } +function useUpdateTemplateMetadata() { + const [status, setStatus] = useState>({ type: "idle" }); + const { mutate } = useSWRConfig(); + + const updateTemplateMetadata = useCallback( + async ({ + currentName, + newName, + description, + }: { + currentName: string; + newName?: string; + description?: string; + }): Promise => { + setStatus({ type: "loading" }); + try { + const body: Record = {}; + if (newName && newName !== currentName) { + body.name = newName; + } + if (description !== undefined) { + body.description = description; + } + + const result = await mutate( + `/templates/${currentName}`, + fetchApi(`/templates/${currentName}`, { + method: "POST", + body: JSON.stringify(body), + }).then((res) => + promiseOrThrow(res.json(), () => ({ + type: "INTERNAL", + })), + ), + { + populateCache: (newTemplate, currentTemplate) => ({ + ...currentTemplate, + ...newTemplate, + }), + revalidate: false, + throwOnError: true, + }, + ); + + setStatus({ type: "ok" }); + + return result ?? null; + } catch (err: unknown) { + setStatus({ type: "error", error: err as ApiError }); + return null; + } + }, + [mutate], + ); + + return { updateTemplateMetadata, status }; +} + function useTemplateFile(templateName: string, filePath: string) { return useSWR( filePath ? ["/templates", templateName, filePath] : null, @@ -188,6 +247,7 @@ export { useTemplate, useTemplateFile, useCreateTemplate, + useUpdateTemplateMetadata, useUpdateTemplateFile, buildTemplate, useDeleteTemplate, diff --git a/web/src/templates/template-editor-sidebar.tsx b/web/src/templates/template-editor-sidebar.tsx new file mode 100644 index 0000000..82472d4 --- /dev/null +++ b/web/src/templates/template-editor-sidebar.tsx @@ -0,0 +1,87 @@ +import { Button } from "@/components/ui/button"; +import { Dialog, DialogTrigger } from "@/components/ui/dialog"; +import { + Sidebar, + SidebarContent, + SidebarGroup, + SidebarGroupLabel, + SidebarHeader, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, +} from "@/components/ui/sidebar"; +import { Link } from "@tanstack/react-router"; +import { ArrowLeft, Pencil } from "lucide-react"; +import { useTemplateEditorStore } from "./template-editor-store"; +import { TemplateMetadataDialog } from "./template-metadata-dialog"; + +function TemplateEditorSidebar() { + const templateName = useTemplateEditorStore((state) => state.template.name); + const templateDescription = useTemplateEditorStore( + (state) => state.template.description || "No description", + ); + return ( + + + + + + + All templates + + + + +
+
+

{templateName}

+

{templateDescription}

+
+ +
+
+ + + Files + + + +
+ ); +} + +function EditorSidebarFileTree() { + const template = useTemplateEditorStore((state) => state.template); + const currentFilePath = useTemplateEditorStore( + (state) => state.currentFilePath, + ); + + return ( + + {Object.values(template.files).map((file) => ( + + + + {file.path} + + + + ))} + + ); +} + +function TemplateNameDescriptionEditButton() { + return ( + + + + + + + ); +} + +export { TemplateEditorSidebar }; diff --git a/web/src/templates/template-editor-store.tsx b/web/src/templates/template-editor-store.tsx index 3222a1f..833af55 100644 --- a/web/src/templates/template-editor-store.tsx +++ b/web/src/templates/template-editor-store.tsx @@ -2,7 +2,7 @@ import { type ApiErrorDetails, isApiErrorResponse } from "@/api"; import { createContext, useContext } from "react"; import { createStore, useStore } from "zustand"; import { buildTemplate } from "./api"; -import type { Template } from "./types"; +import type { Template, TemplateMeta } from "./types"; interface TemplateEditorState { template: Template; @@ -25,6 +25,8 @@ interface TemplateEditorState { toggleBuildOutput: () => void; setIsVimModeEnabled: (enabled: boolean) => void; + + updateTemplateMetadata: (templateMetadata: TemplateMeta) => void; } type TemplateEditorStore = ReturnType; @@ -86,6 +88,12 @@ function createTemplateEditorStore({ setIsVimModeEnabled: (enabled: boolean) => set({ isVimModeEnabled: enabled }), + + updateTemplateMetadata: (templateMetadata) => + set((state) => ({ + ...state, + template: { ...state.template, ...templateMetadata }, + })), })); } diff --git a/web/src/templates/template-editor.tsx b/web/src/templates/template-editor.tsx index 2f0fa1f..5b0bc29 100644 --- a/web/src/templates/template-editor.tsx +++ b/web/src/templates/template-editor.tsx @@ -1,26 +1,17 @@ import { API_ERROR_BAD_TEMPLATE } from "@/api"; import { CodeMirrorEditor } from "@/components/codemirror-editor"; import { Button } from "@/components/ui/button.tsx"; -import { - Sidebar, - SidebarContent, - SidebarGroup, - SidebarGroupLabel, - SidebarHeader, - SidebarMenu, - SidebarMenuButton, - SidebarMenuItem, - SidebarProvider, -} from "@/components/ui/sidebar.tsx"; +import { SidebarProvider } from "@/components/ui/sidebar.tsx"; import { Toaster } from "@/components/ui/toaster"; import { useToast } from "@/hooks/use-toast"; import { cn } from "@/lib/utils"; -import { Link, useRouter } from "@tanstack/react-router"; -import { ArrowLeft, ChevronDown, ChevronUp, Loader2 } from "lucide-react"; +import { useRouter } from "@tanstack/react-router"; +import { ChevronDown, ChevronUp, Loader2 } from "lucide-react"; import { useEffect, useRef } from "react"; import { useStore } from "zustand"; import { useTemplate, useTemplateFile, useUpdateTemplateFile } from "./api"; import { templateEditorRoute } from "./routes"; +import { TemplateEditorSidebar } from "./template-editor-sidebar"; import { type TemplateEditorStore, TemplateEditorStoreContext, @@ -111,7 +102,7 @@ function _TemplateEditor({ return ( - +
@@ -216,53 +207,6 @@ function Editor() { ); } -function EditorSidebar() { - const templateName = useTemplateEditorStore((state) => state.template.name); - return ( - - - - - - - All templates - - - - -

{templateName}

-
- - - Files - - - -
- ); -} - -function EditorSidebarFileTree() { - const template = useTemplateEditorStore((state) => state.template); - const currentFilePath = useTemplateEditorStore( - (state) => state.currentFilePath, - ); - - return ( - - {Object.values(template.files).map((file) => ( - - - - {file.path} - - - - ))} - - ); -} - function TemplateBuildOutputPanel() { const isBuildOutputVisible = useTemplateEditorStore( (state) => state.isBuildOutputVisible, diff --git a/web/src/templates/template-metadata-dialog.tsx b/web/src/templates/template-metadata-dialog.tsx new file mode 100644 index 0000000..05b08de --- /dev/null +++ b/web/src/templates/template-metadata-dialog.tsx @@ -0,0 +1,161 @@ +import { Button } from "@/components/ui/button"; +import { + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { LoadingSpinner } from "@/components/ui/loading-spinner"; +import { useToast } from "@/hooks/use-toast"; +import { superstructResolver } from "@hookform/resolvers/superstruct"; +import { useEffect } from "react"; +import { useForm } from "react-hook-form"; +import { type Infer, object, optional, pattern, string } from "superstruct"; +import { useUpdateTemplateMetadata } from "./api"; +import { useTemplateEditorStore } from "./template-editor-store"; + +const TemplateMetadataFormSchema = object({ + name: pattern(string(), /^[\w-]+$/), + description: optional(string()), +}); + +function TemplateMetadataDialog() { + return ( + + + Edit metadata + + + + ); +} + +function TemplateMetadataForm() { + const templateName = useTemplateEditorStore((state) => state.template.name); + const templateDescription = useTemplateEditorStore( + (state) => state.template.description, + ); + const updateTemplateMetadataInStore = useTemplateEditorStore( + (state) => state.updateTemplateMetadata, + ); + const { updateTemplateMetadata, status } = useUpdateTemplateMetadata(); + const { toast } = useToast(); + const isUpdating = status.type === "loading"; + + const form = useForm({ + resolver: superstructResolver(TemplateMetadataFormSchema), + disabled: isUpdating, + defaultValues: { + name: templateName, + description: templateDescription, + }, + }); + + useEffect(() => { + switch (status.type) { + case "error": + switch (status.error.type) { + case "CONFLICT": + toast({ + variant: "destructive", + title: "This name is already in use.", + description: "Please choose another name.", + }); + break; + + case "NETWORK": + toast({ + variant: "destructive", + title: "Failed to update template", + description: "Network error", + }); + break; + + default: + toast({ + variant: "destructive", + title: "Failed to update template", + description: "Unknown error", + }); + break; + } + break; + + case "ok": + toast({ + title: "Template updated!", + }); + } + }, [status, toast]); + + async function onSubmit(values: Infer) { + const updated = await updateTemplateMetadata({ + currentName: templateName, + newName: values.name, + description: values.description, + }); + if (updated) { + console.log(updated); + updateTemplateMetadataInStore(updated); + } + } + + return ( +
+ + ( + + Template name + + + + + Must only contain alphanumeric characters and "-". + + + + )} + /> + + ( + + Description + + + + + Must only contain alphanumeric characters and "-". + + + + )} + /> + + + + + + + ); +} + +export { TemplateMetadataDialog };