feat: handle template conflict error
This commit is contained in:
@@ -56,36 +56,21 @@ func fetchTemplate(c echo.Context) error {
|
|||||||
return c.JSON(http.StatusOK, template)
|
return c.JSON(http.StatusOK, template)
|
||||||
}
|
}
|
||||||
|
|
||||||
func createOrUpdateTemplate(c echo.Context) error {
|
|
||||||
mgr := templateManagerFrom(c)
|
|
||||||
exists, err := mgr.hasTemplate(c.Request().Context(), c.Param("templateName"))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !exists {
|
|
||||||
return createTemplate(c)
|
|
||||||
}
|
|
||||||
|
|
||||||
var body postTemplateRequestBody
|
|
||||||
err = json.NewDecoder(c.Request().Body).Decode(&body)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if body.ImageTag != nil || body.BuildArgs != nil {
|
|
||||||
return buildTemplate(c, body)
|
|
||||||
}
|
|
||||||
|
|
||||||
return updateTemplate(c, body)
|
|
||||||
}
|
|
||||||
|
|
||||||
func createTemplate(c echo.Context) error {
|
func createTemplate(c echo.Context) error {
|
||||||
mgr := templateManagerFrom(c)
|
|
||||||
name := c.Param("templateName")
|
name := c.Param("templateName")
|
||||||
|
mgr := templateManagerFrom(c)
|
||||||
|
|
||||||
|
exists, err := mgr.hasTemplate(c.Request().Context(), name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if exists {
|
||||||
|
return echo.NewHTTPError(http.StatusConflict)
|
||||||
|
}
|
||||||
|
|
||||||
var body createTemplateRequestBody
|
var body createTemplateRequestBody
|
||||||
err := json.NewDecoder(c.Request().Body).Decode(&body)
|
if err = json.NewDecoder(c.Request().Body).Decode(&body); err != nil {
|
||||||
if err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,6 +86,29 @@ func createTemplate(c echo.Context) error {
|
|||||||
return c.JSON(http.StatusOK, createdTemplate)
|
return c.JSON(http.StatusOK, createdTemplate)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func updateOrBuildTemplate(c echo.Context) error {
|
||||||
|
mgr := templateManagerFrom(c)
|
||||||
|
exists, err := mgr.hasTemplate(c.Request().Context(), c.Param("templateName"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
return echo.NewHTTPError(http.StatusNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body postTemplateRequestBody
|
||||||
|
err = json.NewDecoder(c.Request().Body).Decode(&body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if body.ImageTag != nil || body.BuildArgs != nil {
|
||||||
|
return buildTemplate(c, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
return updateTemplate(c, body)
|
||||||
|
}
|
||||||
|
|
||||||
func updateTemplate(c echo.Context, body postTemplateRequestBody) error {
|
func updateTemplate(c echo.Context, body postTemplateRequestBody) error {
|
||||||
name := c.Param("templateName")
|
name := c.Param("templateName")
|
||||||
mgr := templateManagerFrom(c)
|
mgr := templateManagerFrom(c)
|
||||||
|
@@ -9,7 +9,8 @@ func DefineRoutes(g *echo.Group, services service.Services) {
|
|||||||
g.Use(newTemplateManagerMiddleware(services))
|
g.Use(newTemplateManagerMiddleware(services))
|
||||||
g.GET("/templates", fetchAllTemplates)
|
g.GET("/templates", fetchAllTemplates)
|
||||||
g.GET("/templates/:templateName", fetchTemplate, validateTemplateName)
|
g.GET("/templates/:templateName", fetchTemplate, validateTemplateName)
|
||||||
g.POST("/templates/:templateName", createOrUpdateTemplate, validateTemplateName)
|
g.PUT("/templates/:templateName", createTemplate, validateTemplateName)
|
||||||
|
g.POST("/templates/:templateName", updateOrBuildTemplate, validateTemplateName)
|
||||||
g.DELETE("/templates/:templateName", deleteTemplate, validateTemplateName)
|
g.DELETE("/templates/:templateName", deleteTemplate, validateTemplateName)
|
||||||
g.GET("/templates/:templateName/:filePath", fetchTemplateFile, validateTemplateName, validateTemplateFilePath)
|
g.GET("/templates/:templateName/:filePath", fetchTemplateFile, validateTemplateName, validateTemplateFilePath)
|
||||||
g.POST("/templates/:templateName/:filePath", updateTemplateFile, validateTemplateName, validateTemplateFilePath)
|
g.POST("/templates/:templateName/:filePath", updateTemplateFile, validateTemplateName, validateTemplateFilePath)
|
||||||
|
@@ -41,6 +41,7 @@ type buildTemplateOptions struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var errTemplateNotFound = errors.New("template not found")
|
var errTemplateNotFound = errors.New("template not found")
|
||||||
|
var errTemplateExists = errors.New("template already exists")
|
||||||
var errBaseTemplateNotFound = errors.New("base template not found")
|
var errBaseTemplateNotFound = errors.New("base template not found")
|
||||||
var errTemplateFileNotFound = errors.New("template file not found")
|
var errTemplateFileNotFound = errors.New("template file not found")
|
||||||
|
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
import { promiseOrThrow } from "./lib/errors";
|
import { promiseOrThrow } from "./lib/errors";
|
||||||
|
|
||||||
interface ApiErrorResponse {
|
interface ApiErrorDetails {
|
||||||
code: string;
|
code: string;
|
||||||
error: string;
|
error: string;
|
||||||
}
|
}
|
||||||
@@ -8,12 +8,13 @@ interface ApiErrorResponse {
|
|||||||
const API_ERROR_BAD_TEMPLATE = "BAD_TEMPLATE";
|
const API_ERROR_BAD_TEMPLATE = "BAD_TEMPLATE";
|
||||||
const API_ERROR_WORKSPACE_EXISTS = "WORKSPACE_EXISTS";
|
const API_ERROR_WORKSPACE_EXISTS = "WORKSPACE_EXISTS";
|
||||||
|
|
||||||
enum ApiError {
|
type ApiError =
|
||||||
NotFound = "NOT_FOUND",
|
| { type: "NOT_FOUND" }
|
||||||
BadRequest = "BAD_REQUEST",
|
| { type: "NETWORK" }
|
||||||
Internal = "INTERNAL",
|
| { type: "BAD_REQUEST" }
|
||||||
Network = "NETWORK",
|
| { type: "CONFLICT" }
|
||||||
}
|
| { type: "INTERNAL" }
|
||||||
|
| { type: "BAD_REQUEST"; details: ApiErrorDetails };
|
||||||
|
|
||||||
async function fetchApi(
|
async function fetchApi(
|
||||||
url: URL | RequestInfo,
|
url: URL | RequestInfo,
|
||||||
@@ -21,22 +22,27 @@ async function fetchApi(
|
|||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
const res = await promiseOrThrow(
|
const res = await promiseOrThrow(
|
||||||
fetch(`${import.meta.env.VITE_API_URL}/api${url}`, init),
|
fetch(`${import.meta.env.VITE_API_URL}/api${url}`, init),
|
||||||
() => ApiError.Network,
|
() => ({ type: "NETWORK" }),
|
||||||
);
|
);
|
||||||
if (res.status !== 200) {
|
if (res.status !== 200) {
|
||||||
switch (res.status) {
|
switch (res.status) {
|
||||||
case 400:
|
case 400:
|
||||||
throw await res.json();
|
throw {
|
||||||
|
type: "BAD_REQUEST",
|
||||||
|
details: await res.json(),
|
||||||
|
};
|
||||||
case 404:
|
case 404:
|
||||||
throw ApiError.NotFound;
|
throw { type: "NOT_FOUND" };
|
||||||
|
case 409:
|
||||||
|
throw { type: "CONFLICT" };
|
||||||
default:
|
default:
|
||||||
throw ApiError.Internal;
|
throw { type: "INTERNAL" };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isApiErrorResponse(error: unknown): error is ApiErrorResponse {
|
function isApiErrorResponse(error: unknown): error is ApiErrorDetails {
|
||||||
return (
|
return (
|
||||||
error !== null &&
|
error !== null &&
|
||||||
error !== undefined &&
|
error !== undefined &&
|
||||||
@@ -49,8 +55,7 @@ function isApiErrorResponse(error: unknown): error is ApiErrorResponse {
|
|||||||
export {
|
export {
|
||||||
API_ERROR_BAD_TEMPLATE,
|
API_ERROR_BAD_TEMPLATE,
|
||||||
API_ERROR_WORKSPACE_EXISTS,
|
API_ERROR_WORKSPACE_EXISTS,
|
||||||
ApiError,
|
|
||||||
fetchApi,
|
fetchApi,
|
||||||
isApiErrorResponse,
|
isApiErrorResponse,
|
||||||
};
|
};
|
||||||
export type { ApiErrorResponse };
|
export type { ApiError, ApiErrorDetails };
|
||||||
|
@@ -6,7 +6,8 @@ import type {
|
|||||||
TemplateImage,
|
TemplateImage,
|
||||||
BaseTemplate,
|
BaseTemplate,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import { fetchApi } from "@/api";
|
import { ApiError, fetchApi } from "@/api";
|
||||||
|
import { promiseOrThrow } from "@/lib/errors";
|
||||||
|
|
||||||
function useTemplates() {
|
function useTemplates() {
|
||||||
return useSWR(
|
return useSWR(
|
||||||
@@ -17,7 +18,7 @@ function useTemplates() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function useTemplate(name: string) {
|
function useTemplate(name: string) {
|
||||||
return useSWR(
|
return useSWR<Template, ApiError>(
|
||||||
["/templates", name],
|
["/templates", name],
|
||||||
async (): Promise<Template> =>
|
async (): Promise<Template> =>
|
||||||
fetchApi(`/templates/${name}`).then((res) => res.json()),
|
fetchApi(`/templates/${name}`).then((res) => res.json()),
|
||||||
@@ -45,7 +46,7 @@ function useDeleteTemplate() {
|
|||||||
|
|
||||||
function useCreateTemplate() {
|
function useCreateTemplate() {
|
||||||
const [isCreating, setIsCreating] = useState(false);
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
const [error, setError] = useState<unknown | null>(null);
|
const [error, setError] = useState<ApiError | null>(null);
|
||||||
const { mutate } = useSWRConfig();
|
const { mutate } = useSWRConfig();
|
||||||
|
|
||||||
const createTemplate = useCallback(
|
const createTemplate = useCallback(
|
||||||
@@ -60,14 +61,17 @@ function useCreateTemplate() {
|
|||||||
}): Promise<Template | null> => {
|
}): Promise<Template | null> => {
|
||||||
try {
|
try {
|
||||||
const res = await fetchApi(`/templates/${name}`, {
|
const res = await fetchApi(`/templates/${name}`, {
|
||||||
method: "POST",
|
method: "PUT",
|
||||||
body: JSON.stringify({ description, baseTemplate }),
|
body: JSON.stringify({ description, baseTemplate }),
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const template: Template = await res.json();
|
const template = await promiseOrThrow<Template, ApiError>(
|
||||||
|
res.json(),
|
||||||
|
() => ({ type: "INTERNAL" }),
|
||||||
|
);
|
||||||
mutate(["/templates", name], template, {
|
mutate(["/templates", name], template, {
|
||||||
populateCache: (newTemplate) => newTemplate,
|
populateCache: (newTemplate) => newTemplate,
|
||||||
revalidate: false,
|
revalidate: false,
|
||||||
@@ -75,7 +79,7 @@ function useCreateTemplate() {
|
|||||||
|
|
||||||
return template;
|
return template;
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
setError(err);
|
setError(err as ApiError);
|
||||||
return null;
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
setIsCreating(false);
|
setIsCreating(false);
|
||||||
@@ -88,10 +92,12 @@ function useCreateTemplate() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function useTemplateFile(templateName: string, filePath: string) {
|
function useTemplateFile(templateName: string, filePath: string) {
|
||||||
return useSWR(filePath ? ["/templates", templateName, filePath] : null, () =>
|
return useSWR<string, ApiError>(
|
||||||
fetchApi(`/templates/${templateName}/${filePath}`).then((res) =>
|
filePath ? ["/templates", templateName, filePath] : null,
|
||||||
res.text(),
|
() =>
|
||||||
),
|
fetchApi(`/templates/${templateName}/${filePath}`).then((res) =>
|
||||||
|
res.text(),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -30,6 +30,8 @@ import {
|
|||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||||
import type { BaseTemplate } from "./types";
|
import type { BaseTemplate } from "./types";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
const NewTemplateForm = object({
|
const NewTemplateForm = object({
|
||||||
baseTemplate: nonempty(string()),
|
baseTemplate: nonempty(string()),
|
||||||
@@ -75,7 +77,8 @@ function TemplateFormContainer() {
|
|||||||
|
|
||||||
function TemplateForm({ baseTemplates }: { baseTemplates: BaseTemplate[] }) {
|
function TemplateForm({ baseTemplates }: { baseTemplates: BaseTemplate[] }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { createTemplate, isCreatingTemplate } = useCreateTemplate();
|
const { createTemplate, isCreatingTemplate, error } = useCreateTemplate();
|
||||||
|
const { toast } = useToast();
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
resolver: superstructResolver(NewTemplateForm),
|
resolver: superstructResolver(NewTemplateForm),
|
||||||
disabled: isCreatingTemplate,
|
disabled: isCreatingTemplate,
|
||||||
@@ -86,6 +89,36 @@ function TemplateForm({ baseTemplates }: { baseTemplates: BaseTemplate[] }) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!error) return;
|
||||||
|
|
||||||
|
switch (error.type) {
|
||||||
|
case "CONFLICT":
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Template already exists",
|
||||||
|
description: "Please use another name for the template",
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "NETWORK":
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Failed to create the template",
|
||||||
|
description: "Network error",
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Failed to create the template",
|
||||||
|
description: "Unknown error",
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}, [error, toast]);
|
||||||
|
|
||||||
async function onSubmit(values: Infer<typeof NewTemplateForm>) {
|
async function onSubmit(values: Infer<typeof NewTemplateForm>) {
|
||||||
const createdTemplate = await createTemplate({
|
const createdTemplate = await createTemplate({
|
||||||
name: values.templateName,
|
name: values.templateName,
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
import { API_ERROR_BAD_TEMPLATE, ApiError } from "@/api";
|
import { API_ERROR_BAD_TEMPLATE } from "@/api";
|
||||||
import { CodeMirrorEditor } from "@/components/codemirror-editor";
|
import { CodeMirrorEditor } from "@/components/codemirror-editor";
|
||||||
import { Button } from "@/components/ui/button.tsx";
|
import { Button } from "@/components/ui/button.tsx";
|
||||||
import {
|
import {
|
||||||
@@ -12,6 +12,8 @@ import {
|
|||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
SidebarProvider,
|
SidebarProvider,
|
||||||
} from "@/components/ui/sidebar.tsx";
|
} from "@/components/ui/sidebar.tsx";
|
||||||
|
import { Toaster } from "@/components/ui/toaster";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Link, useRouter } from "@tanstack/react-router";
|
import { Link, useRouter } from "@tanstack/react-router";
|
||||||
import { ArrowLeft, ChevronDown, ChevronUp, Loader2 } from "lucide-react";
|
import { ArrowLeft, ChevronDown, ChevronUp, Loader2 } from "lucide-react";
|
||||||
@@ -25,10 +27,8 @@ import {
|
|||||||
createTemplateEditorStore,
|
createTemplateEditorStore,
|
||||||
useTemplateEditorStore,
|
useTemplateEditorStore,
|
||||||
} from "./template-editor-store";
|
} from "./template-editor-store";
|
||||||
import type { Template } from "./types";
|
|
||||||
import { useToast } from "@/hooks/use-toast";
|
|
||||||
import { Toaster } from "@/components/ui/toaster";
|
|
||||||
import { TemplateEditorTopBar } from "./template-editor-top-bar";
|
import { TemplateEditorTopBar } from "./template-editor-top-bar";
|
||||||
|
import type { Template } from "./types";
|
||||||
|
|
||||||
function TemplateEditor() {
|
function TemplateEditor() {
|
||||||
const { templateName, _splat } = templateEditorRoute.useParams();
|
const { templateName, _splat } = templateEditorRoute.useParams();
|
||||||
@@ -43,13 +43,13 @@ function TemplateEditor() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (error || !template) {
|
if (error || !template) {
|
||||||
if (error === ApiError.NotFound) {
|
if (error?.type === "NOT_FOUND") {
|
||||||
return <TemplateNotFound />;
|
return <TemplateNotFound />;
|
||||||
}
|
}
|
||||||
|
|
||||||
let message = "";
|
let message = "";
|
||||||
switch (error) {
|
switch (error?.type) {
|
||||||
case ApiError.Network:
|
case "NETWORK":
|
||||||
message = "Having trouble contacting the server.";
|
message = "Having trouble contacting the server.";
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
@@ -178,11 +178,11 @@ function Editor() {
|
|||||||
|
|
||||||
if (error || fileContent === undefined) {
|
if (error || fileContent === undefined) {
|
||||||
let message = "";
|
let message = "";
|
||||||
switch (error) {
|
switch (error?.type) {
|
||||||
case ApiError.NotFound:
|
case "NOT_FOUND":
|
||||||
message = "This file does not exist in the template.";
|
message = "This file does not exist in the template.";
|
||||||
break;
|
break;
|
||||||
case ApiError.Network:
|
case "NETWORK":
|
||||||
message = "Having trouble contacting the server.";
|
message = "Having trouble contacting the server.";
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
|
Reference in New Issue
Block a user