feat: dialog to change template metadata in editor

This commit is contained in:
2024-12-03 18:34:23 +00:00
parent 4e34570792
commit e5bbd15837
10 changed files with 369 additions and 72 deletions

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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,

View 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 };

View File

@@ -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 },
})),
}));
}

View File

@@ -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,

View 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 };