diff --git a/.changeset/studio-pending-badge.md b/.changeset/studio-pending-badge.md new file mode 100644 index 00000000..0493bd7f --- /dev/null +++ b/.changeset/studio-pending-badge.md @@ -0,0 +1,6 @@ +--- +"@cloudhal/api": patch +"@cloudhal/cli": patch +--- + +Studio shows changes that are not live yet: a badge beside Publish with the count ("2 changes", or "Not published" before the first publish) and a tooltip by kind ("3 changes aren't live yet · 2 articles and 1 page"). It comes from the new `GET /api/sites/:siteId/pending` (`site:read`): counts of what a publish would add, change and remove, by kind; adding or removing a page counts once. Studio refreshes it after every write to the site, on window focus and every 30 s. diff --git a/apps/studio/src/components/PendingChanges.tsx b/apps/studio/src/components/PendingChanges.tsx new file mode 100644 index 00000000..d3c9d19b --- /dev/null +++ b/apps/studio/src/components/PendingChanges.tsx @@ -0,0 +1,71 @@ +import { Badge, Tooltip } from "@cloudhal/ui"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useEffect } from "react"; +import { formatNumber, listOf, type TFunction, useI18n } from "../i18n/index.tsx"; +import { api, onApiWrite, type PendingChanges } from "../lib/api.ts"; + +type NodeKind = keyof PendingChanges["byKind"]; + +export const pendingKey = (siteId: string) => ["pending", siteId] as const; + +/** Writes that change nothing a publish would carry (draft renders, preview links). */ +const NOT_CONTENT = /\/(preview|preview-fragment|preview-links)$/; + +/** + * What a publish would carry (`GET …/pending`), for the badge beside Publish. Every successful + * write to this site through the API client invalidates it (`onApiWrite`), so saves, publishes, + * rollbacks, approvals, media, menus, redirects, settings and taxonomy all refresh it without each + * screen knowing; agents' changes show on the next window focus or after 30 s. + */ +export function usePendingChanges(siteId: string) { + const qc = useQueryClient(); + useEffect(() => { + const prefix = `/api/sites/${siteId}`; + return onApiWrite((path) => { + if ((path === prefix || path.startsWith(`${prefix}/`)) && !NOT_CONTENT.test(path)) + void qc.invalidateQueries({ queryKey: pendingKey(siteId) }); + }); + }, [qc, siteId]); + return useQuery({ + queryKey: pendingKey(siteId), + queryFn: () => api.sites.pending(siteId), + staleTime: 30_000, + refetchOnWindowFocus: true, + retry: false, + }); +} + +/** "3 changes aren't live yet · 2 articles and 1 page" (or the never-published sentence). */ +export function pendingText(t: TFunction, pending: PendingChanges): string { + const kinds = listOf( + (Object.entries(pending.byKind) as [NodeKind, number][]).map(([kind, count]) => + t("publishing.pendingKind", { kind, count }), + ), + ); + return pending.live + ? t("publishing.pendingSummary", { count: pending.counts.total, kinds }) + : t("publishing.firstPublish", { kinds }); +} + +/** + * The badge beside Publish (DESIGN.md §2d): the number of changes a publish would carry, or "Not + * published" for a site that never went live, with the breakdown in its tooltip; phones show the + * number only. Nothing when everything is live. + */ +export function PendingBadge({ pending }: { pending: PendingChanges | undefined }) { + const { t, locale } = useI18n(); + if (!pending || (pending.live && pending.counts.total === 0)) return null; + return ( + + + {/* The top bar is full on phones: the number alone, the words from `sm` up. */} + {formatNumber(locale, pending.counts.total)} + + {pending.live + ? t("publishing.pending", { count: pending.counts.total }) + : t("publishing.notPublished")} + + + + ); +} diff --git a/apps/studio/src/components/SitePublish.tsx b/apps/studio/src/components/SitePublish.tsx index 8c7065a8..8d32d96f 100644 --- a/apps/studio/src/components/SitePublish.tsx +++ b/apps/studio/src/components/SitePublish.tsx @@ -13,6 +13,7 @@ import { useT } from "../i18n/index.tsx"; import { api } from "../lib/api.ts"; import { apiErrorText } from "../lib/api-errors.ts"; import { UnpublishedPagesNote } from "./PageStatus.tsx"; +import { PendingBadge, pendingText, usePendingChanges } from "./PendingChanges.tsx"; import { usePublishMode, usePublishSite } from "./Publish.tsx"; import { useToast } from "./Toast.ts"; @@ -22,7 +23,8 @@ import { useToast } from "./Toast.ts"; * the person's role (§2s): an Author whose changes include others' work sends "Request publish" * (the server files a request an Editor approves, 202); a Contributor sees Publish disabled with * the reason, since an Editor publishes their work. The palette's Publish command opens the same - * confirmation (RFC-0029 §7), never publishing on its own. + * confirmation (RFC-0029 §7), never publishing on its own. Beside it, `PendingBadge` says how many + * changes are not live yet; the Publish button's accessible name carries the same sentence. */ export function SitePublish({ siteId, @@ -52,8 +54,12 @@ export function SitePublish({ onError: (e) => toast.error(t("snapshots.failed"), apiErrorText(t, e)), }); const request = mode === "request"; + const action = request ? t("publishing.request") : t("site.publish"); + const pending = usePendingChanges(siteId).data; + const waiting = pending && (!pending.live || pending.counts.total > 0); return ( <> + >; +} + /** A signed, expiring link (`POST /api/sites/:id/preview-links`). */ export interface PreviewLink { token: string; @@ -89,6 +101,7 @@ export const contentApi = { edition?: { pages: number; menus: number; skipped: { what: string; reason: string }[] }; }>("/api/sites", { method: "POST", ...json(input) }), get: (id: string) => request(`/api/sites/${id}`), + pending: (id: string) => request(`/api/sites/${id}/pending`), update: (site: Site) => request(`/api/sites/${site.id}`, { method: "PUT", ...json(site) }), }, diff --git a/apps/studio/src/lib/api/http.ts b/apps/studio/src/lib/api/http.ts index 411dc7e8..39e0b974 100644 --- a/apps/studio/src/lib/api/http.ts +++ b/apps/studio/src/lib/api/http.ts @@ -18,19 +18,40 @@ export class ApiRequestError extends Error { } } +const writeListeners = new Set<(path: string) => void>(); + +/** + * Calls `listener` with the path of every write (`POST`/`PUT`/`PATCH`/`DELETE`) the API answered + * with success, so a view of derived state (the pending changes beside Publish) refreshes after + * any save without each screen invalidating it. Returns the unsubscribe. + */ +export function onApiWrite(listener: (path: string) => void): () => void { + writeListeners.add(listener); + return () => writeListeners.delete(listener); +} + +/** Tells `onApiWrite` listeners about a write made outside `request` (a streamed Build turn). */ +export function wrote(path: string, method: string | undefined): void { + if (method && method !== "GET" && method !== "HEAD") for (const fn of writeListeners) fn(path); +} + export async function request(path: string, init: RequestInit = {}): Promise { const res = await fetch(path, { credentials: "include", ...init, headers: { "content-type": "application/json", ...(init.headers ?? {}) }, }); - if (res.status === 204) return undefined as T; + if (res.status === 204) { + wrote(path, init.method); + return undefined as T; + } const body = (await res .json() .catch(() => ({ error: "invalid_json", message: res.statusText }))) as T | ApiError; // The session ended on the server: the next navigation asks again (and lands on the login). if (res.status === 401) forgetSession(); if (!res.ok) throw new ApiRequestError(res.status, body as ApiError); + wrote(path, init.method); return body as T; } diff --git a/apps/studio/test/pending-changes.test.tsx b/apps/studio/test/pending-changes.test.tsx new file mode 100644 index 00000000..63fc31f9 --- /dev/null +++ b/apps/studio/test/pending-changes.test.tsx @@ -0,0 +1,167 @@ +/** + * The badge beside Publish: the count of changes a publish would carry, its pluralised summary + * (tooltip and the Publish button's accessible name), "Not published" for a site that never went + * live, nothing when everything is live, and a refetch after any write to the site. + */ +import { cleanup, render, renderHook, screen, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { pendingText } from "../src/components/PendingChanges.tsx"; +import { SitePublish } from "../src/components/SitePublish.tsx"; +import { I18nProvider, useT } from "../src/i18n/index.tsx"; +import { type PendingChanges, request } from "../src/lib/api.ts"; +import { asRole, on, serve, wrap } from "./roles-fixtures.tsx"; + +vi.mock("@tanstack/react-router", async (importOriginal) => ({ + ...(await importOriginal()), + useNavigate: () => vi.fn(), +})); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +const pending = (patch: Partial = {}): PendingChanges => ({ + live: true, + liveSnapshotId: "snap_live", + counts: { added: 1, changed: 2, removed: 0, total: 3 }, + byKind: { contentEntry: 2, page: 1 }, + ...patch, +}); + +const site = on("GET", "/api/sites/site_1", { + status: 200, + body: { id: "site_1", name: "Demo", publishedSnapshotId: "snap_live" }, +}); + +describe("pendingText", () => { + const tFor = (locale: "en" | "tr" | "ru") => + renderHook(() => useT(), { + wrapper: ({ children }: { children: ReactNode }) => ( + {children} + ), + }).result.current; + + it("pluralises the total and each kind", () => { + const t = tFor("en"); + expect(pendingText(t, pending())).toBe("3 changes aren't live yet · 2 articles and 1 page"); + expect( + pendingText( + t, + pending({ + counts: { added: 0, changed: 1, removed: 0, total: 1 }, + byKind: { contentEntry: 1 }, + }), + ), + ).toBe("1 change isn't live yet · 1 article"); + expect( + pendingText( + t, + pending({ + counts: { added: 0, changed: 2, removed: 1, total: 3 }, + byKind: { site: 1, menu: 2 }, + }), + ), + ).toBe("3 changes aren't live yet · site settings and 2 menus"); + }); + + it("says what the first publish takes when the site was never published", () => { + const t = tFor("en"); + const first = pending({ + live: false, + liveSnapshotId: undefined, + counts: { added: 5, changed: 0, removed: 0, total: 5 }, + byKind: { site: 1, page: 2, contentEntry: 2 }, + }); + expect(pendingText(t, first)).toBe( + "Not published yet. The first publish puts site settings, 2 pages, and 2 articles live.", + ); + }); +}); + +describe("PendingBadge beside Publish", { timeout: 30_000 }, () => { + it("shows the count and puts the summary in the Publish button's name", async () => { + serve( + ...asRole("owner"), + site, + on("GET", "/api/sites/site_1/pending", { status: 200, body: pending() }), + ); + render(wrap()); + expect(await screen.findByText("3 changes")).toBeInTheDocument(); + expect( + screen.getByRole("button", { + name: "Publish. 3 changes aren't live yet · 2 articles and 1 page", + }), + ).toBeInTheDocument(); + }); + + it("says Not published for a site that never went live", async () => { + serve( + ...asRole("owner"), + site, + on("GET", "/api/sites/site_1/pending", { + status: 200, + body: pending({ + live: false, + liveSnapshotId: undefined, + counts: { added: 4, changed: 0, removed: 0, total: 4 }, + byKind: { site: 1, page: 3 }, + }), + }), + ); + render(wrap()); + expect(await screen.findByText("Not published")).toBeInTheDocument(); + expect(screen.queryByText(/changes$/)).not.toBeInTheDocument(); + }); + + it("shows nothing when everything is live", async () => { + const fetch = serve( + ...asRole("owner"), + site, + on("GET", "/api/sites/site_1/pending", { + status: 200, + body: pending({ counts: { added: 0, changed: 0, removed: 0, total: 0 }, byKind: {} }), + }), + ); + render(wrap()); + await waitFor(() => + expect(fetch.mock.calls.some(([u]) => String(u).endsWith("/pending"))).toBe(true), + ); + expect(await screen.findByRole("button", { name: "Publish" })).toBeInTheDocument(); + expect(screen.queryByText("Not published")).not.toBeInTheDocument(); + expect(screen.queryByText(/changes?$/)).not.toBeInTheDocument(); + }); + + it("refetches after a save to this site, not after previews or other sites", async () => { + let total = 0; + const calls = { pending: 0 }; + serve( + ...asRole("owner"), + site, + on("GET", "/api/sites/site_1/pending", () => { + calls.pending++; + return { + status: 200, + body: pending({ + counts: { added: 0, changed: total, removed: 0, total }, + byKind: total ? { contentEntry: total } : {}, + }), + }; + }), + on("PUT", "/api/sites/site_1/entries/entry_1", { status: 200, body: {} }), + on("POST", "/api/sites/site_1/entries/entry_1/preview", { status: 200, body: {} }), + on("PUT", "/api/sites/site_2/entries/entry_9", { status: 200, body: {} }), + ); + render(wrap()); + await waitFor(() => expect(calls.pending).toBe(1)); + expect(screen.queryByText(/changes?$/)).not.toBeInTheDocument(); + + await request("/api/sites/site_1/entries/entry_1/preview", { method: "POST" }); + await request("/api/sites/site_2/entries/entry_9", { method: "PUT" }); + total = 1; + await request("/api/sites/site_1/entries/entry_1", { method: "PUT" }); + expect(await screen.findByText("1 change")).toBeInTheDocument(); + expect(calls.pending).toBe(2); + }); +}); diff --git a/packages/api/src/pending.ts b/packages/api/src/pending.ts new file mode 100644 index 00000000..8b6ac86c --- /dev/null +++ b/packages/api/src/pending.ts @@ -0,0 +1,74 @@ +import type { NodeRef, Site } from "@cloudhal/core-contracts"; +import { canonicalJson, type SiteGraphStore, type SnapshotDiff } from "@cloudhal/core-ts"; + +/** + * What a publish would carry (Studio's top bar badge next to Publish): the node counts of + * `SiteGraphStore.pendingChanges` — the current state against the live snapshot, held nodes at + * their live version, the site node's publish-only rewrite left out. Numbers only; the list is the + * approval dialog's diff. + * + * Adding or deleting a page also rewrites the site node's `pageIds`; that follows from the page + * change and is not counted again (a reorder or a settings change still counts as the site). + * + * A site that was never published has no live snapshot: `live` is false and every node the first + * publish would take, the site node included, counts as added. + */ +export interface PendingChanges { + live: boolean; + liveSnapshotId?: string; + counts: { added: number; changed: number; removed: number; total: number }; + /** The total by node kind; kinds without changes are left out. */ + byKind: Partial>; +} + +/** The pending counts of `site` (one diff of node refs; one blob read when the site node moved). */ +export async function pendingFor(store: SiteGraphStore, site: Site): Promise { + const diff = await store.pendingChanges(site.id); + const siteChange = diff.changed.find((c) => c.after.kind === "site"); + if (siteChange && (await onlyPageList(store, siteChange.before, site, diff))) + return pendingSummary( + { ...diff, changed: diff.changed.filter((c) => c !== siteChange) }, + site.publishedSnapshotId, + ); + return pendingSummary(diff, site.publishedSnapshotId); +} + +/** Does the site node differ from its live version only by the pages added and removed? */ +async function onlyPageList( + store: SiteGraphStore, + liveRef: NodeRef, + site: Site, + diff: SnapshotDiff, +): Promise { + const live = await store.readSnapshotNode(liveRef).catch(() => undefined); + if (!live) return false; + const added = new Set(diff.added.filter((n) => n.kind === "page").map((n) => n.id)); + const removed = new Set(diff.removed.filter((n) => n.kind === "page").map((n) => n.id)); + const strip = ({ pageIds: _i, publishedSnapshotId: _p, rev: _r, updatedAt: _u, ...rest }: Site) => + canonicalJson(rest); + const before = live.pageIds.filter((id) => !removed.has(id)); + const after = site.pageIds.filter((id) => !added.has(id)); + return strip(live) === strip(site) && canonicalJson(before) === canonicalJson(after); +} + +export function pendingSummary(diff: SnapshotDiff, liveSnapshotId?: string): PendingChanges { + const byKind: PendingChanges["byKind"] = {}; + const count = (kind: NodeRef["kind"]) => { + byKind[kind] = (byKind[kind] ?? 0) + 1; + }; + for (const n of diff.added) count(n.kind); + for (const n of diff.removed) count(n.kind); + for (const c of diff.changed) count(c.after.kind); + const { added, removed, changed } = diff; + return { + live: liveSnapshotId !== undefined, + ...(liveSnapshotId ? { liveSnapshotId } : {}), + counts: { + added: added.length, + changed: changed.length, + removed: removed.length, + total: added.length + changed.length + removed.length, + }, + byKind, + }; +} diff --git a/packages/api/src/playground.ts b/packages/api/src/playground.ts index 3c5a570f..2a9f84f8 100644 --- a/packages/api/src/playground.ts +++ b/packages/api/src/playground.ts @@ -108,6 +108,7 @@ export const PLAYGROUND_ALLOW: ReadonlySet = new Set([ "GET /api/sites", "GET /api/projects", `GET ${S}`, + `GET ${S}/pending`, `PUT ${S}`, `GET ${S}/pages`, `POST ${S}/pages`, diff --git a/packages/api/src/routes/sites.ts b/packages/api/src/routes/sites.ts index 96e8b0a0..03c606d1 100644 --- a/packages/api/src/routes/sites.ts +++ b/packages/api/src/routes/sites.ts @@ -4,6 +4,7 @@ import { Hono } from "hono"; import type { Env } from "../context.ts"; import { badRequest, notFound } from "../errors.ts"; import { param } from "../params.ts"; +import { pendingFor } from "../pending.ts"; import { createSiteRoute } from "./projects.ts"; export const sites: Hono = new Hono() @@ -23,6 +24,13 @@ export const sites: Hono = new Hono() if (!site) throw notFound("site not found"); return c.json(site); }) + // What a publish would carry, as counts (Studio's badge beside Publish). + .get("/:siteId/pending", async (c) => { + const store = c.get("store"); + const site = await store.getSite(param(c, "siteId")); + if (!site) throw notFound("site not found"); + return c.json(await pendingFor(store, site)); + }) .put("/:siteId", async (c) => { const body = (await c.req.json()) as Site; if (body.id !== param(c, "siteId")) throw badRequest("body.id must match the URL"); diff --git a/packages/api/src/scopes.ts b/packages/api/src/scopes.ts index f406d57a..cd5f0baa 100644 --- a/packages/api/src/scopes.ts +++ b/packages/api/src/scopes.ts @@ -117,6 +117,8 @@ export const ROUTE_SCOPES: readonly (readonly [Method, string, RouteRequirement] ["POST", "/api/sites", "site:create"], ["GET", "/api/projects", "site:read"], ["GET", S, "site:read"], + // what a publish would carry, as counts (Studio's badge beside Publish) + ["GET", `${S}/pending`, "site:read"], ["PUT", S, "site:settings"], ["GET", `${S}/pages`, "content:read"], ["POST", `${S}/pages`, "content:write"], diff --git a/packages/api/src/testing.ts b/packages/api/src/testing.ts index 64ec156a..acae47a7 100644 --- a/packages/api/src/testing.ts +++ b/packages/api/src/testing.ts @@ -227,6 +227,76 @@ export function describeApi(label: string, factory: ApiStoreFactory): void { ]); }); + it("pending: what a publish would carry, as counts", async () => { + type Pending = { + live: boolean; + liveSnapshotId?: string; + counts: { added: number; changed: number; removed: number; total: number }; + byKind: Record; + }; + const pending = async () => { + const res = await app.request(`/api/sites/${siteId}/pending`, auth); + expect(res.status).toBe(200); + expect(res.headers.get("cache-control")).toBe("no-store"); + return (await res.json()) as Pending; + }; + expect((await call(`/api/sites/${siteId}/pending`)).status).toBe(401); + expect((await call(`/api/sites/site_doesnotexist1/pending`, auth)).status).toBe(404); + + // Never published: no live snapshot, every node the first publish takes counts as added. + const first = await pending(); + const nodes = (await store.createSnapshot(siteId, { reason: "manual", actor: human })).nodes; + expect(first).toMatchObject({ live: false, counts: { changed: 0, removed: 0 } }); + expect(first.liveSnapshotId).toBeUndefined(); + expect(first.counts.added).toBe(nodes.length); + expect(first.counts.total).toBe(nodes.length); + expect(first.byKind.site).toBe(1); + expect(first.byKind.page).toBe(nodes.filter((n) => n.kind === "page").length); + + // Right after a publish nothing is pending. + const published = await call(`/api/sites/${siteId}/snapshots`, { + ...auth, + method: "POST", + body: JSON.stringify({ publish: true }), + }); + const live = (published.body as { site: Site }).site.publishedSnapshotId; + expect(await pending()).toEqual({ + live: true, + liveSnapshotId: live, + counts: { added: 0, changed: 0, removed: 0, total: 0 }, + byKind: {}, + }); + + // An edited entry is one change; an added page is one more. + const entry = (await store.listContentEntries(siteId, "posts"))[0] as ContentEntry; + await call(`/api/sites/${siteId}/entries/${entry.id}`, { + ...auth, + method: "PUT", + body: JSON.stringify({ ...entry, title: `${entry.title} (edited)` }), + }); + expect(await pending()).toMatchObject({ + counts: { added: 0, changed: 1, removed: 0, total: 1 }, + byKind: { contentEntry: 1 }, + }); + await call(`/api/sites/${siteId}/pages`, { + ...auth, + method: "POST", + body: JSON.stringify({ + path: "/pending", + title: "Pending", + locale: "en", + status: "draft", + sections: [], + }), + }); + expect(await pending()).toEqual({ + live: true, + liveSnapshotId: live, + counts: { added: 1, changed: 1, removed: 0, total: 2 }, + byKind: { contentEntry: 1, page: 1 }, + }); + }); + it("unknown routes and ids are 404", async () => { expect((await call("/api/nope", auth)).status).toBe(404); expect((await call(`/api/sites/site_doesnotexist1`, auth)).status).toBe(404);