feat: dialog to change template metadata in editor
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
|
@@ -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 {
|
||||
|
@@ -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 {
|
||||
|
@@ -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 };
|
||||
|
@@ -15,6 +15,10 @@ interface OkStatus {
|
||||
type: "ok";
|
||||
}
|
||||
|
||||
type QueryStatus = IdleStatus | LoadingStatus | ErrorStatus | OkStatus;
|
||||
type QueryStatus<TErr = unknown> =
|
||||
| IdleStatus
|
||||
| LoadingStatus
|
||||
| ErrorStatus<TErr>
|
||||
| OkStatus;
|
||||
|
||||
export type { QueryStatus, IdleStatus, LoadingStatus, ErrorStatus, OkStatus };
|
||||
|
@@ -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<QueryStatus<ApiError>>({ type: "idle" });
|
||||
const { mutate } = useSWRConfig();
|
||||
|
||||
const updateTemplateMetadata = useCallback(
|
||||
async ({
|
||||
currentName,
|
||||
newName,
|
||||
description,
|
||||
}: {
|
||||
currentName: string;
|
||||
newName?: string;
|
||||
description?: string;
|
||||
}): Promise<TemplateMeta | null> => {
|
||||
setStatus({ type: "loading" });
|
||||
try {
|
||||
const body: Record<string, string> = {};
|
||||
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<TemplateMeta, ApiError>(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<string, ApiError>(
|
||||
filePath ? ["/templates", templateName, filePath] : null,
|
||||
@@ -188,6 +247,7 @@ export {
|
||||
useTemplate,
|
||||
useTemplateFile,
|
||||
useCreateTemplate,
|
||||
useUpdateTemplateMetadata,
|
||||
useUpdateTemplateFile,
|
||||
buildTemplate,
|
||||
useDeleteTemplate,
|
||||
|
87
web/src/templates/template-editor-sidebar.tsx
Normal file
87
web/src/templates/template-editor-sidebar.tsx
Normal file
@@ -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 (
|
||||
<Sidebar>
|
||||
<SidebarHeader>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton asChild className="opacity-80">
|
||||
<Link to="/templates" className="text-xs">
|
||||
<ArrowLeft /> All templates
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex flex-col px-2">
|
||||
<p className="font-semibold">{templateName}</p>
|
||||
<p className="text-xs opacity-80">{templateDescription}</p>
|
||||
</div>
|
||||
<TemplateNameDescriptionEditButton />
|
||||
</div>
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Files</SidebarGroupLabel>
|
||||
<EditorSidebarFileTree />
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
|
||||
function EditorSidebarFileTree() {
|
||||
const template = useTemplateEditorStore((state) => state.template);
|
||||
const currentFilePath = useTemplateEditorStore(
|
||||
(state) => state.currentFilePath,
|
||||
);
|
||||
|
||||
return (
|
||||
<SidebarMenu>
|
||||
{Object.values(template.files).map((file) => (
|
||||
<SidebarMenuItem key={file.path}>
|
||||
<SidebarMenuButton isActive={currentFilePath === file.path} asChild>
|
||||
<Link to={`/templates/${template.name}/${file.path}`}>
|
||||
{file.path}
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function TemplateNameDescriptionEditButton() {
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<Pencil />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<TemplateMetadataDialog />
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export { TemplateEditorSidebar };
|
@@ -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<typeof createTemplateEditorStore>;
|
||||
@@ -86,6 +88,12 @@ function createTemplateEditorStore({
|
||||
|
||||
setIsVimModeEnabled: (enabled: boolean) =>
|
||||
set({ isVimModeEnabled: enabled }),
|
||||
|
||||
updateTemplateMetadata: (templateMetadata) =>
|
||||
set((state) => ({
|
||||
...state,
|
||||
template: { ...state.template, ...templateMetadata },
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
|
@@ -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 (
|
||||
<TemplateEditorStoreContext.Provider value={store.current}>
|
||||
<SidebarProvider>
|
||||
<EditorSidebar />
|
||||
<TemplateEditorSidebar />
|
||||
<div className="flex flex-col w-full min-w-0">
|
||||
<TemplateEditorTopBar />
|
||||
<main className="w-full h-full flex flex-col">
|
||||
@@ -216,53 +207,6 @@ function Editor() {
|
||||
);
|
||||
}
|
||||
|
||||
function EditorSidebar() {
|
||||
const templateName = useTemplateEditorStore((state) => state.template.name);
|
||||
return (
|
||||
<Sidebar>
|
||||
<SidebarHeader>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton asChild className="opacity-80">
|
||||
<Link to="/templates" className="text-xs">
|
||||
<ArrowLeft /> All templates
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
<p className="px-2 font-semibold">{templateName}</p>
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Files</SidebarGroupLabel>
|
||||
<EditorSidebarFileTree />
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
|
||||
function EditorSidebarFileTree() {
|
||||
const template = useTemplateEditorStore((state) => state.template);
|
||||
const currentFilePath = useTemplateEditorStore(
|
||||
(state) => state.currentFilePath,
|
||||
);
|
||||
|
||||
return (
|
||||
<SidebarMenu>
|
||||
{Object.values(template.files).map((file) => (
|
||||
<SidebarMenuItem key={file.path}>
|
||||
<SidebarMenuButton isActive={currentFilePath === file.path} asChild>
|
||||
<Link to={`/templates/${template.name}/${file.path}`}>
|
||||
{file.path}
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function TemplateBuildOutputPanel() {
|
||||
const isBuildOutputVisible = useTemplateEditorStore(
|
||||
(state) => state.isBuildOutputVisible,
|
||||
|
161
web/src/templates/template-metadata-dialog.tsx
Normal file
161
web/src/templates/template-metadata-dialog.tsx
Normal file
@@ -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 (
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit metadata</DialogTitle>
|
||||
</DialogHeader>
|
||||
<TemplateMetadataForm />
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
|
||||
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<typeof TemplateMetadataFormSchema>) {
|
||||
const updated = await updateTemplateMetadata({
|
||||
currentName: templateName,
|
||||
newName: values.name,
|
||||
description: values.description,
|
||||
});
|
||||
if (updated) {
|
||||
console.log(updated);
|
||||
updateTemplateMetadataInStore(updated);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Template name</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Must only contain alphanumeric characters and "-".
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Must only contain alphanumeric characters and "-".
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<Button disabled={isUpdating} type="submit">
|
||||
{isUpdating ? <LoadingSpinner /> : null}
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
export { TemplateMetadataDialog };
|
Reference in New Issue
Block a user