diff --git a/package-lock.json b/package-lock.json index 472dc5d..591c3e4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1237,7 +1237,6 @@ "version": "0.129.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.129.0.tgz", "integrity": "sha512-3oz8m3FGdr2nDXVqmFUw7jolKliC4MoyXYIG2c7gpjBnzUWQpUGIYcXYKxTdTi+N2jusvt610ckTMkxdwHkYEg==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/Boshen" @@ -2409,7 +2408,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2426,7 +2424,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2443,7 +2440,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2460,7 +2456,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2477,7 +2472,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2494,7 +2488,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2511,7 +2504,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2528,7 +2520,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2545,7 +2536,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2562,7 +2552,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2579,7 +2568,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2596,7 +2584,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2613,7 +2600,6 @@ "cpu": [ "wasm32" ], - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -2632,7 +2618,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2649,7 +2634,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3361,7 +3345,7 @@ "version": "25.7.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.7.0.tgz", "integrity": "sha512-z+pdZyxE+RTQE9AcboAZCb4otwcrvgHD+GlBpPgn0emDVt0ohrTMhAwlr2Wd9nZ+nihhYFxO2pThz3C5qSu2Eg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~7.21.0" @@ -3371,7 +3355,7 @@ "version": "19.2.7", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -3381,7 +3365,7 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "dev": true, + "devOptional": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.2.0" @@ -4362,7 +4346,6 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, "license": "MIT" }, "node_modules/data-urls": { @@ -5845,7 +5828,6 @@ "version": "3.3.12", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", - "dev": true, "funding": [ { "type": "github", @@ -6004,7 +5986,6 @@ "version": "8.5.14", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", - "dev": true, "funding": [ { "type": "opencollective", @@ -6241,7 +6222,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0.tgz", "integrity": "sha512-yD986aXDESFGS95spT1LAv0jssywP4npMEjmMHyN2/5+eE8qQJUype2AaKkRiLgBgyD0LFlubwAht7VmY8rGoA==", - "dev": true, "license": "MIT", "dependencies": { "@oxc-project/types": "=0.129.0", @@ -6275,7 +6255,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0.tgz", "integrity": "sha512-aKs/3GSWyV0mrhNmt/96/Z3yczC3yvrzYATCiCXQebBsGyYzjNdUphRVLeJQ67ySKVXRfMxt2lm12pmXvbPFQQ==", - "dev": true, "license": "MIT" }, "node_modules/saxes": { @@ -6696,7 +6675,7 @@ "version": "7.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.21.0.tgz", "integrity": "sha512-w9IMgQrz4O0YN1LtB7K5P63vhlIOvC7opSmouCJ+ZywlPAlO9gIkJ+otk6LvGpAs2wg4econaCz3TvQ9xPoyuQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/unplugin": { @@ -6821,7 +6800,6 @@ "version": "8.0.12", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.12.tgz", "integrity": "sha512-w2dDofOWv2QB09ZITZBsvKTVAlYvPR4IAmrY/v0ir9KvLs0xybR7i48wxhM1/oyBWO34wPns+bPGw5ZrZqDpZg==", - "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", @@ -6899,7 +6877,6 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" diff --git a/src/__tests__/setup.ts b/src/__tests__/setup.ts index 9a98628..68b5c41 100644 --- a/src/__tests__/setup.ts +++ b/src/__tests__/setup.ts @@ -1,5 +1,39 @@ import "@testing-library/jest-dom/vitest"; +// Some runners expose a partial localStorage object when the native backing +// file is unavailable. Tests need the full Storage surface. +if (typeof window.localStorage?.clear !== "function") { + const store = new Map(); + const storage: Storage = { + get length() { + return store.size; + }, + clear() { + store.clear(); + }, + getItem(key: string) { + return store.has(key) ? store.get(key)! : null; + }, + key(index: number) { + return Array.from(store.keys())[index] ?? null; + }, + removeItem(key: string) { + store.delete(key); + }, + setItem(key: string, value: string) { + store.set(key, String(value)); + }, + }; + Object.defineProperty(window, "localStorage", { + configurable: true, + value: storage, + }); + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: storage, + }); +} + // matchMedia polyfill for jsdom if (!window.matchMedia) { window.matchMedia = (query: string) => ({ diff --git a/src/components/ConversionDialog.tsx b/src/components/ConversionDialog.tsx index 0ed9d70..1ad6830 100644 --- a/src/components/ConversionDialog.tsx +++ b/src/components/ConversionDialog.tsx @@ -8,7 +8,7 @@ import { } from "./ui/dialog"; import { Checkbox } from "./ui/checkbox"; import { Label } from "./ui/label"; -import { Copy, Download } from "lucide-react"; +import { Copy, Download, AlertCircle } from "lucide-react"; import { copyToClipboard, downloadFile } from "../utils/clipboard"; import type { ConversionType } from "../hooks/useConversionDialog"; @@ -17,6 +17,7 @@ export interface ConversionDialogProps { onOpenChange: (open: boolean) => void; conversionType: ConversionType | string; conversionOutput: string; + conversionValid?: boolean; clearEnvAfterDownload?: boolean; onClearEnvChange?: (checked: boolean) => void; onToast?: (message: { title: string; description?: string; variant?: "default" | "success" | "error" }) => void; @@ -51,6 +52,7 @@ export function ConversionDialog({ onOpenChange, conversionType, conversionOutput, + conversionValid = true, clearEnvAfterDownload = false, onClearEnvChange, onToast, @@ -60,6 +62,7 @@ export function ConversionDialog({ const mimeType = CONVERSION_MIME_TYPES[conversionType] || "text/plain"; const handleCopy = async () => { + if (!conversionValid) return; try { await copyToClipboard(conversionOutput); onToast?.({ @@ -67,7 +70,7 @@ export function ConversionDialog({ description: `${title} has been copied to your clipboard.`, variant: "success", }); - } catch (error) { + } catch { onToast?.({ title: "Failed to copy", description: "Could not copy to clipboard. Please try again.", @@ -77,6 +80,7 @@ export function ConversionDialog({ }; const handleDownload = () => { + if (!conversionValid) return; try { downloadFile(conversionOutput, filename, mimeType); onToast?.({ @@ -84,7 +88,7 @@ export function ConversionDialog({ description: `Downloading ${filename}`, variant: "success", }); - } catch (error) { + } catch { onToast?.({ title: "Download failed", description: "Could not download file. Please try again.", @@ -100,6 +104,13 @@ export function ConversionDialog({ {title} + {!conversionValid && ( +
+ + Output is blocked until validation errors are fixed. +
+ )} +
             {conversionOutput}
@@ -120,11 +131,11 @@ export function ConversionDialog({
         )}
 
         
-          
-          
diff --git a/src/components/Header.tsx b/src/components/Header.tsx
index 72d92a4..c96c618 100644
--- a/src/components/Header.tsx
+++ b/src/components/Header.tsx
@@ -86,7 +86,7 @@ export function Header({ onOpenMarket }: HeaderProps) {
           
            {
-  it("hides the trash icon on an unnamed service", () => {
+  it("hides the trash icon on the last remaining service", () => {
     render(
        {
     ).not.toBeInTheDocument();
   });
 
-  it("shows the trash icon once a service is named", () => {
-    const svc = { ...defaultService(), name: "web", image: "nginx" };
+  it("shows trash icons for manually added blank services when more than one exists", () => {
     render(
        {}}
         onAddService={() => {}}
@@ -36,8 +35,8 @@ describe("ServicesTab", () => {
       />,
     );
     expect(
-      screen.getByRole("button", { name: /remove service/i }),
-    ).toBeInTheDocument();
+      screen.getAllByRole("button", { name: /remove service/i }),
+    ).toHaveLength(2);
   });
 
   it("fires onRemoveService when the trash is clicked", async () => {
@@ -45,7 +44,7 @@ describe("ServicesTab", () => {
     const svc = { ...defaultService(), name: "web", image: "nginx" };
     render(
        {}}
@@ -55,7 +54,7 @@ describe("ServicesTab", () => {
       />,
     );
     await userEvent.click(
-      screen.getByRole("button", { name: /remove service/i }),
+      screen.getByRole("button", { name: /remove service web/i }),
     );
     expect(onRemove).toHaveBeenCalledWith(0);
   });
diff --git a/src/components/compose-builder/CodePanel.tsx b/src/components/compose-builder/CodePanel.tsx
index 8272ff5..cd3704e 100644
--- a/src/components/compose-builder/CodePanel.tsx
+++ b/src/components/compose-builder/CodePanel.tsx
@@ -6,15 +6,14 @@ import type {
   VolumeConfig,
 } from "../../types/compose";
 import type { VPNConfig } from "../../types/vpn-configs";
-import {
-  convertToDockerRun,
-  convertToSystemd,
-  generateKomodoToml,
-  generateEnvFile,
-} from "../../utils/converters";
 import { copyToClipboard } from "../../utils/clipboard";
+import {
+  createValidatedComposeOutput,
+  type ComposeOutputFormat,
+} from "../../utils/validation/outputs";
+import { formatValidationIssues } from "../../utils/validation/issues";
 
-type FormatId = "compose" | "docker-run" | "systemd" | "env" | "komodo";
+type FormatId = Exclude;
 
 interface FormatTab {
   id: FormatId;
@@ -44,6 +43,8 @@ export interface CodePanelProps {
 export function CodePanel({
   yaml,
   services,
+  networks,
+  volumes,
   vpn,
   validationError,
   validationSuccess: _validationSuccess,
@@ -52,22 +53,21 @@ export function CodePanel({
   const [format, setFormat] = useState("compose");
   const [copied, setCopied] = useState(false);
 
-  const output = useMemo(() => {
-    switch (format) {
-      case "compose":
-        return yaml;
-      case "docker-run":
-        return services.map((s) => convertToDockerRun(s)).join("\n\n");
-      case "systemd":
-        return services.map((s) => convertToSystemd(s)).join("\n\n");
-      case "env":
-        return generateEnvFile(services, vpn);
-      case "komodo":
-        return generateKomodoToml(yaml);
-      default:
-        return "";
-    }
-  }, [format, yaml, services, vpn]);
+  const validatedOutput = useMemo(
+    () =>
+      createValidatedComposeOutput(format, {
+        services,
+        networks,
+        volumes,
+        vpn,
+        yaml,
+      }),
+    [format, services, networks, volumes, vpn, yaml],
+  );
+
+  const output = validatedOutput.ok
+    ? validatedOutput.output
+    : invalidPanelOutput(validatedOutput.issues);
 
   const filename =
     FORMATS.find((f) => f.id === format)?.filename || "compose.yml";
@@ -77,18 +77,23 @@ export function CodePanel({
     [output],
   );
 
+  const activeValidationError =
+    validationError ||
+    (!validatedOutput.ok ? formatValidationIssues(validatedOutput.issues) : null);
+  const canCopy = validatedOutput.ok && !activeValidationError;
+  const statusKind: "ok" | "err" = activeValidationError ? "err" : "ok";
+
   const handleCopy = async () => {
+    if (!canCopy) return;
     try {
-      await copyToClipboard(output);
+      await copyToClipboard(validatedOutput.output);
       setCopied(true);
       window.setTimeout(() => setCopied(false), 2000);
     } catch {
-      // ignore — browser may have denied clipboard access
+      // ignore - browser may have denied clipboard access
     }
   };
 
-  const statusKind: "ok" | "err" = validationError ? "err" : "ok";
-
   return (
     
@@ -102,7 +107,7 @@ export function CodePanel({ onClick={onValidate} title="Validate & reformat" > - {validationError ? ( + {activeValidationError ? ( <> Re-validate @@ -136,6 +141,7 @@ export function CodePanel({ type="button" className="code-copy" onClick={handleCopy} + disabled={!canCopy} aria-label="Copy to clipboard" > {copied ? : } @@ -144,11 +150,11 @@ export function CodePanel({
- {validationError ? validationError : "Valid"} + {activeValidationError ? activeValidationError : "Valid"} {lineCount} {lineCount === 1 ? "line" : "lines"} · UTF-8 · LF @@ -160,10 +166,20 @@ export function CodePanel({ ); } +function invalidPanelOutput(issues: { path: string; message: string }[]): string { + return [ + "# Output blocked by validation", + ...issues.map((issue) => { + const label = issue.path ? `${issue.path}: ` : ""; + return `# ${label}${issue.message}`; + }), + ].join("\n"); +} + /** * Lightweight syntax-aware preview. Uses simple regex to highlight YAML keys * and values without pulling in CodeMirror (which is reserved for the - * editable surfaces — TemplateDetailModal and the convert dialog). + * editable surfaces - TemplateDetailModal and the convert dialog). */ function CodeView({ text }: { text: string }) { const lines = useMemo(() => text.split("\n"), [text]); diff --git a/src/components/compose-builder/ConfigColumn/ServicesTab.tsx b/src/components/compose-builder/ConfigColumn/ServicesTab.tsx index 01efdcd..26957c9 100644 --- a/src/components/compose-builder/ConfigColumn/ServicesTab.tsx +++ b/src/components/compose-builder/ConfigColumn/ServicesTab.tsx @@ -55,10 +55,9 @@ export function ServicesTab({ const displayName = svc.name?.trim() || ""; const imageName = svc.image?.trim() || ""; const meta = imageName || "no image specified"; - // Unnamed-and-imageless rows are the placeholder slot; the user - // can't usefully delete it (we'd just regenerate one). Hide the - // trash icon to keep that affordance honest. - const showTrash = Boolean(displayName || imageName); + // Allow deleting manually added blank rows. Only the last + // remaining service is protected so the builder is never stranded. + const showTrash = services.length > 1; return (
{ + if (!template?.composeContent) return { content: "", removed: false }; + return sanitizeComposeVersion(template.composeContent); + }, [template?.composeContent]); + + const composeContent = sanitizedCompose.content; + // Pre-import validation: parse the compose YAML and flag any problems // before the user can pull it into their workspace. const validation = useMemo(() => { - if (!template?.composeContent) return { ok: true, errors: [] as string[] }; - return validateComposeYaml(template.composeContent); - }, [template?.composeContent]); + if (!composeContent) return { ok: true, errors: [] as string[] }; + return validateComposeYaml(composeContent); + }, [composeContent]); const handleCopy = async () => { - if (!template?.composeContent) return; + if (!template || !composeContent) return; try { - await copyToClipboard(template.composeContent); + await copyToClipboard(composeContent); toast({ title: "Copied to clipboard", description: "Docker Compose content has been copied", @@ -76,11 +85,11 @@ export function TemplateDetailModal({ }; const handleDownload = () => { - if (!template?.composeContent) return; + if (!template || !composeContent) return; try { downloadFile( - template.composeContent, + composeContent, `${template.name.toLowerCase().replace(/\s+/g, "-")}-compose.yml`, "text/yaml" ); @@ -111,7 +120,7 @@ export function TemplateDetailModal({ setImporting(true); try { - await onImport(template); + await onImport({ ...template, composeContent }); toast({ title: "Template imported", description: `${template.name} has been imported successfully`, @@ -287,12 +296,12 @@ export function TemplateDetailModal({ - {template.composeContent ? ( + {composeContent ? (
{}} height={undefined} width={undefined} diff --git a/src/components/templates/__tests__/TemplateDetailModal.test.tsx b/src/components/templates/__tests__/TemplateDetailModal.test.tsx new file mode 100644 index 0000000..b62c895 --- /dev/null +++ b/src/components/templates/__tests__/TemplateDetailModal.test.tsx @@ -0,0 +1,47 @@ +import { describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { TemplateDetailModal } from "../TemplateDetailModal"; +import { ToastProvider } from "../../ui/toast"; + +const VALID_TEMPLATE = { + id: "nginx", + name: "Nginx", + composeContent: `services: + nginx: + image: nginx:latest + ports: + - "8080:80" +`, +}; + +function renderModal(props: Partial[0]> = {}) { + const defaultProps = { + open: true, + onOpenChange: vi.fn(), + template: VALID_TEMPLATE, + onImport: vi.fn().mockResolvedValue(undefined), + }; + + const merged = { ...defaultProps, ...props }; + render( + + + , + ); + return merged; +} + +describe("TemplateDetailModal", () => { + it("closes after a successful import", async () => { + const user = userEvent.setup(); + const onOpenChange = vi.fn(); + const onImport = vi.fn().mockResolvedValue(undefined); + renderModal({ onOpenChange, onImport }); + + await user.click(screen.getByRole("button", { name: /import template/i })); + + expect(onImport).toHaveBeenCalledWith(VALID_TEMPLATE); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); +}); diff --git a/src/components/ui/alert.tsx b/src/components/ui/alert.tsx index 74f85d6..cd78036 100644 --- a/src/components/ui/alert.tsx +++ b/src/components/ui/alert.tsx @@ -10,7 +10,7 @@ const alertVariants = cva( variant: { default: "bg-card text-card-foreground", destructive: - "text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90", + "border-red-600 bg-red-50 text-red-950 dark:border-red-400 dark:bg-red-950 dark:text-red-50 [&>svg]:text-current *:data-[slot=alert-description]:text-red-900 dark:*:data-[slot=alert-description]:text-red-100", }, }, defaultVariants: { diff --git a/src/components/ui/toast.tsx b/src/components/ui/toast.tsx index bbf938d..e2f1d33 100644 --- a/src/components/ui/toast.tsx +++ b/src/components/ui/toast.tsx @@ -70,16 +70,16 @@ function ToastContainer() { function ToastItem({ toast, onDismiss }: { toast: Toast; onDismiss: (id: string) => void }) { const variantStyles = { - default: "bg-background border-border", - success: "bg-green-500/10 border-green-500/50 text-green-700 dark:text-green-400", - error: "bg-red-500/10 border-red-500/50 text-red-700 dark:text-red-400", - warning: "bg-yellow-500/10 border-yellow-500/50 text-yellow-700 dark:text-yellow-400", + default: "bg-background border-border text-foreground", + success: "bg-emerald-50 border-emerald-600 text-emerald-950 dark:bg-emerald-950 dark:border-emerald-400 dark:text-emerald-50", + error: "bg-red-50 border-red-600 text-red-950 dark:bg-red-950 dark:border-red-400 dark:text-red-50", + warning: "bg-amber-50 border-amber-600 text-amber-950 dark:bg-amber-950 dark:border-amber-400 dark:text-amber-50", } return (
{toast.title}
)} {toast.description && ( -
{toast.description}
+
{toast.description}
)}
{toast.action} diff --git a/src/hooks/__tests__/useTemplateStore.test.ts b/src/hooks/__tests__/useTemplateStore.test.ts index 198a900..a662de8 100644 --- a/src/hooks/__tests__/useTemplateStore.test.ts +++ b/src/hooks/__tests__/useTemplateStore.test.ts @@ -51,6 +51,31 @@ describe("useTemplateStore", () => { expect(fetchMock.mock.calls[0][0]).toContain("/meta.json"); }); + + it("loads marketplace metadata when optional links are invalid", async () => { + const payload = [ + { + id: "plex", + name: "Plex", + links: { website: "", docs: "not-a-url" }, + }, + ]; + const fetchMock = mockFetchOk(payload); + vi.stubGlobal("fetch", fetchMock); + + const { result } = renderHook(() => useTemplateStore()); + + await act(async () => { + result.current.setTemplateStoreOpen(true); + }); + + await waitFor(() => { + expect(result.current.templates).toHaveLength(1); + }); + expect(result.current.templates[0].links).toBeUndefined(); + expect(result.current.templateError).toBeNull(); + }); + it("surfaces an error message on fetch failure", async () => { const fetchMock = mockFetchFail("Not Found"); vi.stubGlobal("fetch", fetchMock); diff --git a/src/hooks/useConversionDialog.ts b/src/hooks/useConversionDialog.ts index b59646d..f8bb5a8 100644 --- a/src/hooks/useConversionDialog.ts +++ b/src/hooks/useConversionDialog.ts @@ -1,18 +1,22 @@ import { useState, useCallback } from "react"; -import type { ServiceConfig } from "../types/compose"; +import type { + NetworkConfig, + ServiceConfig, + VolumeConfig, +} from "../types/compose"; import type { VPNConfig } from "../types/vpn-configs"; import { - convertToDockerRun, - convertToSystemd, - generateKomodoToml, - generateEnvFile, -} from "../utils/converters"; -import { redactSensitiveData } from "../utils/validation"; + createValidatedComposeOutput, + invalidOutputMessage, + type ComposeOutputFormat, +} from "../utils/validation/outputs"; export type ConversionType = "docker-run" | "systemd" | "env" | "redact" | "komodo"; export interface UseConversionDialogOptions { services: ServiceConfig[]; + networks?: NetworkConfig[]; + volumes?: VolumeConfig[]; selectedIdx: number | null; yaml: string; vpnConfig: VPNConfig; @@ -20,6 +24,8 @@ export interface UseConversionDialogOptions { export function useConversionDialog({ services, + networks = [], + volumes = [], selectedIdx, yaml, vpnConfig, @@ -27,48 +33,26 @@ export function useConversionDialog({ const [conversionDialogOpen, setConversionDialogOpen] = useState(false); const [conversionType, setConversionType] = useState(""); const [conversionOutput, setConversionOutput] = useState(""); + const [conversionValid, setConversionValid] = useState(true); const handleConversion = useCallback( (type: ConversionType) => { setConversionType(type); - let output: string; - try { - switch (type) { - case "docker-run": - if (selectedIdx !== null && services[selectedIdx]) { - output = convertToDockerRun(services[selectedIdx]); - } else { - output = services.map((s) => convertToDockerRun(s)).join("\n\n"); - } - break; - case "systemd": - if (selectedIdx !== null && services[selectedIdx]) { - output = convertToSystemd(services[selectedIdx]); - } else { - output = services.map((s) => convertToSystemd(s)).join("\n\n"); - } - break; - case "env": - output = generateEnvFile(services, vpnConfig); - break; - case "redact": - output = redactSensitiveData(yaml); - break; - case "komodo": - output = generateKomodoToml(yaml); - break; - default: - output = "Unknown conversion type"; - } - setConversionOutput(output); - setConversionDialogOpen(true); - } catch (error: any) { - setConversionOutput(`Error: ${error.message}`); - setConversionDialogOpen(true); - } + const result = createValidatedComposeOutput(type as ComposeOutputFormat, { + services, + networks, + volumes, + selectedIdx, + yaml, + vpn: vpnConfig, + }); + + setConversionValid(result.ok); + setConversionOutput(result.ok ? result.output : invalidOutputMessage(result)); + setConversionDialogOpen(true); }, - [services, selectedIdx, yaml, vpnConfig] + [services, networks, volumes, selectedIdx, yaml, vpnConfig], ); const closeDialog = useCallback(() => { @@ -80,6 +64,7 @@ export function useConversionDialog({ setConversionDialogOpen, conversionType, conversionOutput, + conversionValid, handleConversion, closeDialog, }; diff --git a/src/hooks/useTemplateStore.ts b/src/hooks/useTemplateStore.ts index e8d3673..88a3cbf 100644 --- a/src/hooks/useTemplateStore.ts +++ b/src/hooks/useTemplateStore.ts @@ -1,21 +1,34 @@ -import { useCallback, useState } from "react"; +import { + useCallback, + useState, + type Dispatch, + type SetStateAction, +} from "react"; +import { + validateTemplateDetails, + validateTemplateMetaList, + type TemplateDetails, + type TemplateMeta, +} from "../utils/validation/templates"; +import { formatValidationIssues } from "../utils/validation/issues"; +import { sanitizeComposeVersion } from "../utils/compose-version"; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type Template = any; +type Template = TemplateMeta; +type SelectedTemplate = TemplateDetails | null; export interface UseTemplateStoreReturn { templateStoreOpen: boolean; setTemplateStoreOpen: (open: boolean) => void; templates: Template[]; - setTemplates: React.Dispatch>; + setTemplates: Dispatch>; templateLoading: boolean; setTemplateLoading: (loading: boolean) => void; templateError: string | null; setTemplateError: (error: string | null) => void; templateSearch: string; setTemplateSearch: (search: string) => void; - selectedTemplate: Template; - setSelectedTemplate: (template: Template) => void; + selectedTemplate: SelectedTemplate; + setSelectedTemplate: (template: SelectedTemplate) => void; templateDetailOpen: boolean; setTemplateDetailOpen: (open: boolean) => void; templateDetailTab: "overview" | "compose"; @@ -23,7 +36,7 @@ export interface UseTemplateStoreReturn { templateCache: Template[]; templateCacheTimestamp: number | null; fetchTemplatesFromGitHub: (backgroundUpdate?: boolean) => Promise; - fetchTemplateDetails: (templateId: string) => Promise