Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 5 additions & 9 deletions apps/web/src/components/sections-editor/serve-save-error.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { afterEach, describe, expect, test } from "bun:test";
import { describe, expect, test } from "bun:test";
import { ContentProtocolError, ErrorCode } from "@decocms/blocks/protocol";
import { toast } from "sonner";
import { en } from "@/i18n/en/index.ts";
Expand Down Expand Up @@ -77,28 +77,23 @@ describe("save messages for a local deco serve (a developer)", () => {
});

describe("one toast per save failure", () => {
afterEach(() => {
toast.dismiss();
});

test("a burst of failing autosaves shows a single toast", () => {
const before = toast.getToasts().length;
for (let i = 0; i < 3; i++) {
toastSaveError(t, new Error(`HTTP 50${i}`), "github");
}
const shown = toast
.getToasts()
.filter((item) => item.id === SITE_EDITOR_SAVE_TOAST);
expect(shown).toHaveLength(1);
expect(toast.getToasts().length - before).toBeLessThanOrEqual(1);
});
});

describe("one toast per publish", () => {
afterEach(() => {
toast.dismiss();
});

test("a failure, its retry and the success share one toast", () => {
let retried = 0;
const before = toast.getToasts().length;
toastPublishFailed(t, {
headline: "Couldn't publish this version.",
detail: "latest-update-failed",
Expand All @@ -124,6 +119,7 @@ describe("one toast per publish", () => {
expect((shown[0] as { title?: unknown }).title).toBe("Published");
// The success doesn't keep the failure's "Try again".
expect((shown[0] as { action?: unknown }).action).toBeUndefined();
expect(toast.getToasts().length - before).toBeLessThanOrEqual(1);
expect(retried).toBe(0);
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ export function toastSaveError(
* Sonner merges an update into the toast with the same id, so an outcome
* clears what an earlier one set (a failure's "Try again", its stay-open).
*/
const FRESH_TOAST = { action: undefined, duration: undefined } as const;
export const FRESH_TOAST = { action: undefined, duration: undefined } as const;

/** Published: what the site shows now. `description` says what went live. */
export function toastPublished(t: TFunction, description: string) {
Expand Down
18 changes: 15 additions & 3 deletions apps/web/src/components/thread/repository/cms-header-actions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
import { KEYS } from "@/lib/query-keys";
import { useProjectContext, useVirtualMCP } from "@/sdk";
import { useSessionRuntime } from "@/hooks/use-session-runtime";
import { useContentBackend } from "../../sections-editor/use-content-backend.ts";
import { useDecofileWriting } from "../../sections-editor/use-decofile-writing.ts";
import {
useDraftPointer,
Expand Down Expand Up @@ -250,9 +251,10 @@ export function CmsHeaderActions({ virtualMcpId }: Props) {
await deleteRelease(published);
}
},
/** The dialog is already closed by now, so a toast is the only surface. */
/** The publish already succeeded and showed its one toast; this clean-up
* is nothing the user can act on, so it is logged, not toasted. */
onError: (err: unknown) => {
toast.error(err instanceof Error ? err.message : String(err));
console.error("site editor: settling after publish failed", err);
},
});

Expand Down Expand Up @@ -311,7 +313,11 @@ export function CmsHeaderActions({ virtualMcpId }: Props) {
*/
const publishing = publishCompletion.isPending;

const button = selectCmsHeaderButton({
// A hosted v8 draft is not a branch: there is no pull request to submit
// for review, so its header offers Publish only.
const backend = useContentBackend(virtualMcpId, branch);
const hosted = backend.kind === "protocol" && backend.source === "github";
const selected = selectCmsHeaderButton({
branch: branchMeta,
pr,
checks: checksQuery.data ?? [],
Expand All @@ -330,6 +336,12 @@ export function CmsHeaderActions({ virtualMcpId }: Props) {
publishableChangeCount,
t,
});
const button = hosted
? {
...selected,
menu: selected.menu.filter((item) => item.key !== "request-approval"),
}
: selected;

/** Keyed by head so a failed sync isn't retried until the branch moves. */
const autoGetLatestKey =
Expand Down
65 changes: 65 additions & 0 deletions apps/web/src/components/thread/repository/hosted-publish-api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/**
* Publish for a hosted v8 site (Blocks v8 on GitHub): commits the project's
* CDN draft straight to main, writes that commit's companion release and
* makes it current on the CDN. `release` says how far the CDN steps got:
* `current`, `created` (making it current failed) or `none` (no release was
* written). Either way the changes are saved on main, and
* {@link publishMainHead} retries the CDN steps ("Try again").
*/

export type HostedPublishResult =
| { result: "merged"; sha: string; release: "current" | "created" | "none" }
| { result: "up-to-date" };

/** A refused publish, with the API's error code (`main-moved`). */
export class HostedPublishError extends Error {
constructor(
message: string,
readonly code: string | null,
) {
super(message);
this.name = "HostedPublishError";
}
}

async function post<T>(url: string, body: unknown): Promise<T> {
const res = await fetch(url, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
const json = (await res.json().catch(() => ({}))) as { error?: string };
if (!res.ok) {
throw new HostedPublishError(
json.error ?? `HTTP ${res.status}`,
json.error ?? null,
);
}
return json as T;
}

export function publishHostedDraft(
params: { orgSlug: string; virtualMcpId: string; branch: string },
note: string,
): Promise<HostedPublishResult> {
return post<HostedPublishResult>(
`/api/${params.orgSlug}/decofile/${encodeURIComponent(params.virtualMcpId)}/${encodeURIComponent(params.branch)}/publish`,
{ note },
);
}

/**
* "Try again" after a Publish that saved the changes but didn't put them
* live: writes the companion release of main's head when it is missing and
* makes it current. Main's head holds every published change, this one's
* included, so it never rolls back a newer Publish.
*/
export function publishMainHead(params: {
orgSlug: string;
virtualMcpId: string;
}): Promise<unknown> {
return post<unknown>(
`/api/${params.orgSlug}/hosted/${encodeURIComponent(params.virtualMcpId)}/releases/current`,
{ head: true },
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { describe, expect, test } from "bun:test";
import { toast } from "sonner";
import { en } from "@/i18n/en/index.ts";
import { interpolate } from "@/i18n/interpolate.ts";
import type { TFunction } from "@/i18n/use-t.ts";
import { SITE_EDITOR_PUBLISH_TOAST } from "@/components/sections-editor/site-editor-toast.tsx";
import {
HostedPublishError,
type HostedPublishResult,
} from "./hosted-publish-api.ts";
import {
hostedPublishFailure,
notifyHostedPublish,
notifySavedNotPublished,
} from "./hosted-publish-feedback.ts";

const t: TFunction = (key, vars) => interpolate(en[key], vars);

type Shown = {
id?: string | number;
title?: unknown;
type?: string;
action?: { label: string; onClick: (event: unknown) => void };
};
/** The publish toast(s) on screen. Sonner's state is global and outlives a
* test, so "one toast" is: one with the publish id, and at most one new. */
const shown = () =>
(toast.getToasts() as Shown[]).filter(
(item) => item.id === SITE_EDITOR_PUBLISH_TOAST,
);
const total = () => toast.getToasts().length;

const JARGON = [/\bCDN\b/i, /merged?/i, /\bcurrent\b/i, /\bmain\b/i, /commit/i];

describe("hosted Publish: one toast per Publish", () => {
const cases: [HostedPublishResult, string, boolean][] = [
[
{ result: "merged", sha: "a".repeat(40), release: "current" },
"Published",
false,
],
[
{ result: "merged", sha: "a".repeat(40), release: "created" },
"Saved, but not published yet",
true,
],
[
{ result: "merged", sha: "a".repeat(40), release: "none" },
"Saved, but not published yet",
true,
],
[{ result: "up-to-date" }, "Nothing to publish", false],
];

for (const [result, title, offersRetry] of cases) {
test(`${result.result}${"release" in result ? `/${result.release}` : ""} → "${title}"`, () => {
let retries = 0;
const before = total();
notifyHostedPublish(t, result, () => {
retries++;
});
expect(total() - before).toBeLessThanOrEqual(1);
const toasts = shown();
expect(toasts).toHaveLength(1);
expect(toasts[0]!.id).toBe(SITE_EDITOR_PUBLISH_TOAST);
expect(toasts[0]!.title).toBe(title);
for (const word of JARGON)
expect(String(toasts[0]!.title)).not.toMatch(word);
if (offersRetry) {
expect(toasts[0]!.action?.label).toBe("Try again");
toasts[0]!.action!.onClick({ preventDefault: () => {} });
expect(retries).toBe(1);
// The same toast stays, as "Publishing…", until the outcome.
expect(shown()).toHaveLength(1);
expect(shown()[0]!.title).toBe("Publishing…");
} else {
expect(toasts[0]!.action).toBeUndefined();
}
});
}

test("a failed Try again replaces the toast, it doesn't stack", () => {
const retry = () => {};
const before = total();
notifyHostedPublish(
t,
{ result: "merged", sha: "a".repeat(40), release: "created" },
retry,
);
notifySavedNotPublished(t, new HostedPublishError("HTTP 502", null), retry);
expect(total() - before).toBeLessThanOrEqual(1);
expect(shown()).toHaveLength(1);
expect(shown()[0]!.title).toBe("Saved, but not published yet");
});
});

describe("hosted Publish: the dialog's error", () => {
test("someone else published meanwhile", () => {
const failure = hostedPublishFailure(
t,
new HostedPublishError("main-moved", "main-moved"),
);
expect(failure.message).toMatch(/Someone else published/);
expect(failure.detail).toBeNull();
});

test("any other failure: plain words, the API's text only in Details", () => {
for (const raw of [
"hosted delivery not configured",
"Project has no repository",
"HTTP 500",
]) {
const failure = hostedPublishFailure(t, new HostedPublishError(raw, raw));
expect(failure.message).toBe(
"Couldn't publish. Your changes are saved. Try again in a moment.",
);
expect(failure.detail).toBe(raw);
}
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/**
* What a hosted Publish tells the user, in business words: one toast per
* Publish (sonner id `site-editor-publish`, shared with its "Try again" and
* with the Versions screen), and the dialog's inline error when nothing was
* published. "Published" means live; the API's codes and texts only ever
* reach the Details disclosure.
*/

import { toast } from "sonner";
import type { TFunction } from "@/i18n/use-t.ts";
import {
FRESH_TOAST,
SITE_EDITOR_PUBLISH_TOAST,
errorDetail,
toastPublishFailed,
toastPublished,
} from "@/components/sections-editor/site-editor-toast.tsx";
import {
HostedPublishError,
type HostedPublishResult,
} from "./hosted-publish-api.ts";

/**
* The one toast for a finished Publish. `retry` puts saved-but-not-live
* changes live (see `publishMainHead`); it is offered whenever the changes
* reached main but the site doesn't show them yet.
*/
export function notifyHostedPublish(
t: TFunction,
result: HostedPublishResult,
retry: () => void,
) {
if (result.result === "up-to-date") {
// toast.message, not toast(): only the typed calls replace a toast with
// the same id; a bare toast() always adds one.
toast.message(t("siteEditor.publish.nothingToPublish"), {
...FRESH_TOAST,
id: SITE_EDITOR_PUBLISH_TOAST,
description: t("siteEditor.publish.everythingLive"),
});
return;
}
if (result.release === "current") {
toastPublished(t, t("siteEditor.publish.changesLive"));
return;
}
notifySavedNotPublished(t, null, retry);
}

/** The changes are saved but not live yet: a warning with "Try again". */
export function notifySavedNotPublished(
t: TFunction,
error: unknown,
retry: () => void,
) {
toastPublishFailed(t, {
tone: "warning",
headline: t("siteEditor.publish.savedNotPublished"),
body: t("siteEditor.publish.savedNotPublishedBody"),
detail: errorDetail(error),
retry,
});
}

/** A Publish that published nothing: the dialog's inline error. */
export function hostedPublishFailure(
t: TFunction,
error: unknown,
): { message: string; detail: string | null } {
if (error instanceof HostedPublishError && error.code === "main-moved") {
return {
message: t("siteEditor.publish.publishedMeanwhile"),
detail: null,
};
}
return {
message: t("siteEditor.publish.failed"),
detail: errorDetail(error),
};
}
Loading
Loading