From f7eb5a73c89cb79049d78af921470cd958d2010d Mon Sep 17 00:00:00 2001 From: gimenes Date: Thu, 8 Oct 2026 15:09:19 -0300 Subject: [PATCH] feat(web): first-run guide for deco serve Adds the first-run guide for `deco serve`: step-by-step states instead of errors. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WNwbSEePYNcY5YCgqZURig --- .../site-editor-guide.test.tsx | 260 ++++++++++++++++++ .../sections-editor/site-editor-guide.tsx | 256 +++++++++++++++++ knip.jsonc | 7 - 3 files changed, 516 insertions(+), 7 deletions(-) create mode 100644 apps/web/src/components/sections-editor/site-editor-guide.test.tsx create mode 100644 apps/web/src/components/sections-editor/site-editor-guide.tsx diff --git a/apps/web/src/components/sections-editor/site-editor-guide.test.tsx b/apps/web/src/components/sections-editor/site-editor-guide.test.tsx new file mode 100644 index 0000000000..f001e0c50d --- /dev/null +++ b/apps/web/src/components/sections-editor/site-editor-guide.test.tsx @@ -0,0 +1,260 @@ +import { setupComponentTest } from "../../../test/setup"; +setupComponentTest(); + +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + fireEvent, + render as renderBare, + waitFor, +} from "@testing-library/react"; +import type { ReactNode } from "react"; +import { PROTOCOL_NAME } from "@decocms/blocks/protocol"; +import { + DEFAULT_SERVE_ENDPOINT, + serveCandidates, +} from "./deco-serve-connection"; +import { + isServeLost, + LocalServeSwitch, + SiteEditorGuide, +} from "./site-editor-guide"; + +function wrapper({ children }: { children: ReactNode }) { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0 } }, + }); + return {children}; +} +const render = (ui: Parameters[0]) => + renderBare(ui, { wrapper }); + +/** A `deco serve` answering on `ports` (changeable later); everything else refuses. */ +function serveOn(ports: number[]) { + return mock(async (input: RequestInfo | URL) => { + const request = input instanceof Request ? input : new Request(input); + const port = Number(new URL(request.url).port); + if (!ports.includes(port)) throw new TypeError("Failed to fetch"); + const calls = (await request.json()) as { id: number; method: string }[]; + return Response.json( + calls.map((call) => + call.method === "describe" + ? { + jsonrpc: "2.0", + id: call.id, + result: { + protocol: PROTOCOL_NAME, + version: { major: 1, minor: 0 }, + readOnly: false, + }, + } + : { jsonrpc: "2.0", id: call.id, result: { schema: {} } }, + ), + ); + }); +} + +const originalFetch = globalThis.fetch; +beforeEach(() => { + sessionStorage.clear(); + localStorage.clear(); +}); +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +describe("SiteEditorGuide", () => { + test("with nothing answering, shows the guide, not an error", async () => { + globalThis.fetch = serveOn([]) as unknown as typeof fetch; + const view = render( + {}} + />, + ); + + // A short "Looking…" first, then the guide once nothing answered. + expect(view.getByText("Looking for deco serve…")).toBeInTheDocument(); + expect( + await view.findByRole("heading", { + level: 1, + name: "Edit your site's content on your computer", + }), + ).toBeInTheDocument(); + expect(view.queryByText(/incomplete/i)).toBeNull(); + // The two steps, in order; no address field and no way to disconnect. + const steps = view.getAllByRole("heading", { level: 2 }); + expect(steps.map((step) => step.textContent)).toEqual([ + "Start deco serve in your site's folder", + "This page connects on its own", + ]); + expect(view.queryByRole("textbox")).toBeNull(); + expect(view.queryByRole("button", { name: /disconnect/i })).toBeNull(); + // The same command on any Studio origin: deco serve answers them all. + expect( + view.getByText( + (_, element) => + element?.tagName === "CODE" && + element.textContent?.replace(/^\$\s*/, "") === + "npx @decocms/blocks serve", + ), + ).toBeInTheDocument(); + expect( + view.getByRole("button", { name: "Copy command" }), + ).toBeInTheDocument(); + expect( + await view.findByText("Looking for deco serve on localhost:4545…"), + ).toBeInTheDocument(); + // Docs links open in a new tab, from the one docs base. + const docs = view.getByRole("navigation", { name: "Learn more" }); + const links = [...docs.querySelectorAll("a")]; + expect(links.length).toBe(4); + for (const link of links) { + expect(link.getAttribute("href")).toStartWith( + "https://docs.decocms.com/storefront/blocks/next/", + ); + expect(link.getAttribute("target")).toBe("_blank"); + } + }); + + test("connects on its own when deco serve answers on 4545", async () => { + globalThis.fetch = serveOn([4545]) as unknown as typeof fetch; + const onConnect = mock(() => {}); + render( + , + ); + await waitFor(() => + expect(onConnect).toHaveBeenCalledWith({ + endpoint: "http://localhost:4545/rpc", + }), + ); + }); + + test("explains an invalid link above the steps", () => { + globalThis.fetch = serveOn([]) as unknown as typeof fetch; + const view = render( + {}} + />, + ); + expect( + view.getByText("This link doesn't point to deco serve on your computer"), + ).toBeInTheDocument(); + }); +}); + +/** The editor stand-in: names its server, and stops when the server does. */ +function editor( + connection: { endpoint: string }, + onLost: () => void, +): ReactNode { + return ( +
+

Editing {connection.endpoint}

+ +
+ ); +} + +const GUIDE_TITLE = "Edit your site's content on your computer"; + +describe("LocalServeSwitch", () => { + test("no link and nothing answering: the empty state", async () => { + globalThis.fetch = serveOn([]) as unknown as typeof fetch; + const view = render( + , + ); + expect( + await view.findByRole("heading", { level: 1, name: GUIDE_TITLE }), + ).toBeInTheDocument(); + expect(view.queryByText(/^Editing/)).toBeNull(); + }); + + test("the default port answering: the editor", async () => { + globalThis.fetch = serveOn([4545]) as unknown as typeof fetch; + const view = render( + , + ); + expect( + await view.findByText(`Editing ${DEFAULT_SERVE_ENDPOINT}`), + ).toBeInTheDocument(); + expect(view.queryByRole("heading", { name: GUIDE_TITLE })).toBeNull(); + }); + + test("the server stopping: the empty state, then the editor once it's back", async () => { + const ports = [4545]; + globalThis.fetch = serveOn(ports) as unknown as typeof fetch; + const view = render( + , + ); + await view.findByText(`Editing ${DEFAULT_SERVE_ENDPOINT}`); + + ports.length = 0; + fireEvent.click(view.getByRole("button", { name: "server stopped" })); + expect( + await view.findByRole("heading", { level: 1, name: GUIDE_TITLE }), + ).toBeInTheDocument(); + + ports.push(4545); + expect( + await view.findByText( + `Editing ${DEFAULT_SERVE_ENDPOINT}`, + {}, + { timeout: 4_000 }, + ), + ).toBeInTheDocument(); + }, 10_000); + + test("a link's endpoint wins over the default port", async () => { + globalThis.fetch = serveOn([4545, 4547]) as unknown as typeof fetch; + const view = render( + , + ); + expect( + await view.findByText("Editing http://localhost:4547/rpc"), + ).toBeInTheDocument(); + }); +}); + +describe("isServeLost", () => { + test("only a local server that stopped answering", () => { + expect( + isServeLost({ + kind: "unavailable", + source: "local", + problem: { reason: "not-answering" }, + }), + ).toBe(true); + expect( + isServeLost({ + kind: "unavailable", + source: "local", + problem: { reason: "outdated" }, + }), + ).toBe(false); + expect(isServeLost({ kind: "unavailable", source: "github" })).toBe(false); + expect(isServeLost({ kind: "pending" })).toBe(false); + }); +}); diff --git a/apps/web/src/components/sections-editor/site-editor-guide.tsx b/apps/web/src/components/sections-editor/site-editor-guide.tsx new file mode 100644 index 0000000000..dfde27eaf1 --- /dev/null +++ b/apps/web/src/components/sections-editor/site-editor-guide.tsx @@ -0,0 +1,256 @@ +/** + * `/site-editor`'s one rule: while `deco serve` answers, the editor; while it + * doesn't, this guide, which keeps looking for it (backing off, paused while + * the tab is hidden) and opens the editor as soon as it answers. The guide + * explains what the site editor is and how to start `deco serve`. + */ + +import { type ReactNode, useId, useState } from "react"; +import { Monitor04 } from "@untitledui/icons"; +import { + Alert, + AlertDescription, + AlertTitle, +} from "@decocms/ui/components/alert.tsx"; +import { Button } from "@decocms/ui/components/button.tsx"; +import { Spinner } from "@decocms/ui/components/spinner.tsx"; +import { useDecoServeDiscovery } from "@/hooks/use-deco-serve-discovery"; +import { usePreferences } from "@/hooks/use-preferences"; +import { useT } from "@/i18n/use-t.ts"; +import type { ContentBackend } from "./content-backend"; +import { + type DecoServeConnection, + endpointHost, + SERVE_COMMAND, +} from "./deco-serve-connection"; +import { + CommandSnippet, + DocsLinks, + RichCode, + ServeProblemAlert, +} from "./deco-serve-notices"; + +/** The editor's server stopped answering: back to the guide until it's back. */ +export function isServeLost(backend: ContentBackend): boolean { + return ( + backend.kind === "unavailable" && + backend.source === "local" && + (backend.problem?.reason ?? "not-answering") === "not-answering" + ); +} + +const bare = (guide: ReactNode) => guide; + +/** + * The guide until one of `candidates` answers, then `editor`, until it calls + * `onLost` (the server stopped answering), then the guide again. + */ +export function LocalServeSwitch({ + candidates, + invalidLink = false, + shell = bare, + editor, +}: { + candidates: readonly string[]; + /** The page was opened with a link that isn't a usable one. */ + invalidLink?: boolean; + /** Frames the guide (the app shell). */ + shell?: (guide: ReactNode) => ReactNode; + editor: (connection: DecoServeConnection, onLost: () => void) => ReactNode; +}) { + const [connection, setConnection] = useState( + null, + ); + if (connection) return editor(connection, () => setConnection(null)); + return shell( + , + ); +} + +function Step({ + index, + title, + children, +}: { + index: number; + title: string; + children: ReactNode; +}) { + const headingId = useId(); + return ( +
  • +
    + +

    + {title} +

    +
    +
    {children}
    +
  • + ); +} + +export function SiteEditorGuide({ + candidates, + invalidLink = false, + onConnect, +}: { + /** The endpoints looked for, in order (see `serveCandidates`). */ + candidates: readonly string[]; + /** The page was opened with a link that isn't a usable one. */ + invalidLink?: boolean; + onConnect: (connection: DecoServeConnection) => void; +}) { + const t = useT(); + const [{ language }] = usePreferences(); + const discovery = useDecoServeDiscovery({ + candidates, + onFound: (endpoint) => onConnect({ endpoint }), + }); + const { state, access, needsGesture } = discovery; + const hosts = new Intl.ListFormat(language, { + style: "long", + type: "conjunction", + }).format(candidates.map(endpointHost)); + + // While the first look runs, a short "Looking…", not a flash of the guide + // before the editor opens. + const firstLook = + !invalidLink && + access !== "denied" && + !needsGesture && + !state.firstRoundDone && + state.status !== "found"; + if (firstLook) { + return ( +
    + +

    + {t("decoServe.guide.checkingFirst")} +

    +
    + ); + } + + return ( +
    +
    +
    + +

    + {t("decoServe.guide.title")} +

    +

    + {t("decoServe.guide.lead")} +

    +
    + + {invalidLink && ( + +
    + + {t("decoServe.link.invalidTitle")} + + + {t("decoServe.link.invalidBody")} + +
    +
    + )} + {access === "denied" && ( + +
    + + {t("decoServe.lna.deniedTitle")} + + + {t("decoServe.lna.deniedBody")} + +
    +
    + )} + +
      + +

      + +

      + +

      + +

      +
      + + +

      + {t("decoServe.guide.step2.body")} +

      + {state.problem && ( + + )} + {needsGesture ? ( + + ) : ( + access !== "denied" && ( +

      + {state.status === "paused" ? ( + t("decoServe.guide.step2.paused") + ) : ( + <> + + {t("decoServe.guide.step2.looking", { hosts })} + + )} +

      + ) + )} +
      +
    + +

    + {t("decoServe.guide.v7")} +

    + + +
    +
    + ); +} diff --git a/knip.jsonc b/knip.jsonc index de48c57c6f..5996d8a27e 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -79,14 +79,7 @@ "**/*": ["types"], // Temporary while the v8 stack lands: each export below gets its // consumer in a later PR of the stack, which removes its entry. - "apps/web/src/components/sections-editor/deco-serve-connection.ts": [ - "exports" - ], "apps/web/src/hooks/use-deco-serve-connection.ts": ["exports"], - "apps/web/src/components/sections-editor/deco-serve-notices.tsx": [ - "exports" - ], - "apps/web/src/hooks/use-deco-serve-discovery.ts": ["exports"], "apps/web/src/components/sections-editor/deco-serve-chip.tsx": ["exports"] } }