diff --git a/apps/web/src/components/projects/project-apps.test.ts b/apps/web/src/components/projects/project-apps.test.ts index 8f9b510e0b..8ff13ae61f 100644 --- a/apps/web/src/components/projects/project-apps.test.ts +++ b/apps/web/src/components/projects/project-apps.test.ts @@ -78,6 +78,7 @@ describe("launchableApps", () => { analytics: false, cdn: false, experiments: false, + releases: false, }; expect( launchableApps( diff --git a/apps/web/src/components/projects/project-apps.tsx b/apps/web/src/components/projects/project-apps.tsx index 53928ce776..e9e676798e 100644 --- a/apps/web/src/components/projects/project-apps.tsx +++ b/apps/web/src/components/projects/project-apps.tsx @@ -6,6 +6,7 @@ import { Link } from "@tanstack/react-router"; import { + ClockRewind, BarChartSquare02, BezierCurve02, FileSearch02, @@ -79,6 +80,13 @@ const APPS: Record< to: PROJECT_ROUTE.assets, tone: "bg-chart-2/15 text-chart-2", }, + releases: { + labelKey: "projects.apps.releases", + captionKey: "projects.apps.releasesCaption", + Icon: ClockRewind, + to: PROJECT_ROUTE.releases, + tone: "bg-chart-3/15 text-chart-3", + }, hosting: { labelKey: "projects.apps.hosting", captionKey: "projects.apps.hostingCaption", @@ -256,7 +264,8 @@ function ProjectAppTilesBody({ const apps = launchableApps(project, native ?? undefined).filter( (id) => /* Both need a repo, as in the sidebar's presence rules. */ - ((id !== "site-editor" && id !== "experiments") || hasSource) && + ((id !== "site-editor" && id !== "experiments" && id !== "releases") || + hasSource) && /* Every project has these two, so across an org they bury the apps someone actually chose. They stay on each project's own screen. */ !(showProject && ORG_HIDDEN_APPS.has(id)), diff --git a/apps/web/src/components/sections-editor/serve-save-error.test.ts b/apps/web/src/components/sections-editor/serve-save-error.test.ts index e619cddf8f..75660fb391 100644 --- a/apps/web/src/components/sections-editor/serve-save-error.test.ts +++ b/apps/web/src/components/sections-editor/serve-save-error.test.ts @@ -9,7 +9,13 @@ import { saveErrorDetail, saveErrorMessage, } from "./serve-save-error"; -import { SITE_EDITOR_SAVE_TOAST, toastSaveError } from "./site-editor-toast"; +import { + SITE_EDITOR_PUBLISH_TOAST, + SITE_EDITOR_SAVE_TOAST, + toastPublishFailed, + toastPublished, + toastSaveError, +} from "./site-editor-toast"; const t: TFunction = (key, vars) => interpolate(en[key], vars); @@ -85,3 +91,75 @@ describe("one toast per save failure", () => { expect(shown).toHaveLength(1); }); }); + +describe("one toast per publish", () => { + afterEach(() => { + toast.dismiss(); + }); + + test("a failure, its retry and the success share one toast", () => { + let retried = 0; + toastPublishFailed(t, { + headline: "Couldn't publish this version.", + detail: "latest-update-failed", + retry: () => { + retried++; + }, + }); + const failed = toast + .getToasts() + .filter((item) => item.id === SITE_EDITOR_PUBLISH_TOAST); + expect(failed).toHaveLength(1); + const action = (failed[0] as { action?: { label: string } }).action; + expect(action?.label).toBe("Try again"); + // The headline never carries the developer detail. + expect((failed[0] as { title?: unknown }).title).toBe( + "Couldn't publish this version.", + ); + toastPublished(t, "This version is live."); + const shown = toast + .getToasts() + .filter((item) => item.id === SITE_EDITOR_PUBLISH_TOAST); + expect(shown).toHaveLength(1); + 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(retried).toBe(0); + }); + + test("Try again keeps the toast, as Publishing…, until the outcome", () => { + let retried = 0; + toastPublishFailed(t, { + headline: "Couldn't publish this version.", + retry: () => { + retried++; + }, + }); + const [failed] = toast + .getToasts() + .filter((item) => item.id === SITE_EDITOR_PUBLISH_TOAST); + const action = ( + failed as { action?: { onClick: (event: unknown) => void } } + ).action; + let prevented = false; + action?.onClick({ + preventDefault: () => { + prevented = true; + }, + }); + // Sonner skips deleting a toast whose action prevented the default. + expect(prevented).toBe(true); + expect(retried).toBe(1); + const shown = toast + .getToasts() + .filter((item) => item.id === SITE_EDITOR_PUBLISH_TOAST); + expect(shown).toHaveLength(1); + expect((shown[0] as { type?: string }).type).toBe("loading"); + expect((shown[0] as { action?: unknown }).action).toBeUndefined(); + toastPublished(t, "This version is live."); + const [done] = toast + .getToasts() + .filter((item) => item.id === SITE_EDITOR_PUBLISH_TOAST); + expect((done as { title?: unknown }).title).toBe("Published"); + }); +}); diff --git a/apps/web/src/components/sections-editor/site-editor-toast.tsx b/apps/web/src/components/sections-editor/site-editor-toast.tsx index 818f477b56..c2e1da6c67 100644 --- a/apps/web/src/components/sections-editor/site-editor-toast.tsx +++ b/apps/web/src/components/sections-editor/site-editor-toast.tsx @@ -13,9 +13,18 @@ import { saveErrorDetail, saveErrorMessage } from "./serve-save-error"; /** Every save failure in the site editor shares this toast. */ export const SITE_EDITOR_SAVE_TOAST = "site-editor-save"; +/** Publishing (from the editor or the Versions screen), its outcome and a + * retry of it share this toast. */ +export const SITE_EDITOR_PUBLISH_TOAST = "site-editor-publish"; /** A collapsed "Details" line holding the developer detail of a failure. */ -function ErrorDetails({ label, detail }: { label: string; detail: string }) { +export function ErrorDetails({ + label, + detail, +}: { + label: string; + detail: string; +}) { return (
@@ -47,6 +56,82 @@ 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; + +/** Published: what the site shows now. `description` says what went live. */ +export function toastPublished(t: TFunction, description: string) { + toast.success(t("siteEditor.publish.published"), { + ...FRESH_TOAST, + id: SITE_EDITOR_PUBLISH_TOAST, + description, + }); +} + +/** + * Publishing didn't finish: `headline` says so in plain words, `detail` (the + * error's own text) sits behind Details, and `retry` offers "Try again". + * `tone: "warning"` when nothing is lost (the changes are saved). + */ +export function toastPublishFailed( + t: TFunction, + { + headline, + body, + detail, + retry, + tone = "error", + }: { + headline: string; + body?: string; + detail?: string | null; + retry?: () => void; + tone?: "error" | "warning"; + }, +) { + const details = errorDetailsDescription(t, detail ?? null); + toast[tone](headline, { + id: SITE_EDITOR_PUBLISH_TOAST, + description: + body && details ? ( + <> +

{body}

+ {details} + + ) : ( + (body ?? details) + ), + action: retry + ? { + label: t("siteEditor.tryAgain"), + onClick: (event) => { + // Sonner deletes a toast 200ms after its action is clicked, which + // would take a fast retry's outcome (same id) down with it. Keep + // the toast: it turns into "Publishing…" (also blocking a second + // click) until the retry's outcome replaces it. + event.preventDefault(); + toast.loading(t("thread.publishPopover.publishing"), { + ...FRESH_TOAST, + id: SITE_EDITOR_PUBLISH_TOAST, + description: undefined, + }); + retry(); + }, + } + : undefined, + // A failure waits for the user: it carries the next step. + duration: retry ? Number.POSITIVE_INFINITY : undefined, + }); +} + +/** The publish toast gives way to a dialog (a confirm asks the next step). */ +export function dismissPublishToast() { + toast.dismiss(SITE_EDITOR_PUBLISH_TOAST); +} + /** The developer detail of a failed request, for Details. */ export function errorDetail(error: unknown): string | null { if (error instanceof Error) return error.message || null; diff --git a/apps/web/src/components/sidebar/project-nav.tsx b/apps/web/src/components/sidebar/project-nav.tsx index 2e0e52e36e..31338812d6 100644 --- a/apps/web/src/components/sidebar/project-nav.tsx +++ b/apps/web/src/components/sidebar/project-nav.tsx @@ -8,6 +8,7 @@ import type { ReactNode } from "react"; import { + ClockRewind, BarChartSquare02, Beaker02, CheckDone01, @@ -115,6 +116,10 @@ export function ProjectNav({ onNavigate }: { onNavigate?: () => void }) { label: t("common.mainPanelTabs.assets"), icon: , }, + releases: { + label: t("common.mainPanelTabs.releases"), + icon: , + }, hosting: { label: t("common.mainPanelTabs.hosting"), icon: , diff --git a/apps/web/src/hooks/use-destination-route.ts b/apps/web/src/hooks/use-destination-route.ts index 139c7ecde3..b6173bc830 100644 --- a/apps/web/src/hooks/use-destination-route.ts +++ b/apps/web/src/hooks/use-destination-route.ts @@ -50,6 +50,7 @@ export const PROJECT_ROUTE = { connectSources: "/$org/projects/$agentId/connect-sources", settings: "/$org/projects/$agentId/settings", assets: "/$org/projects/$agentId/assets", + releases: "/$org/projects/$agentId/releases", hosting: "/$org/projects/$agentId/hosting", e2e: "/$org/projects/$agentId/e2e", analytics: "/$org/projects/$agentId/analytics", diff --git a/apps/web/src/i18n/en/common.ts b/apps/web/src/i18n/en/common.ts index 3475966356..c87b349e8e 100644 --- a/apps/web/src/i18n/en/common.ts +++ b/apps/web/src/i18n/en/common.ts @@ -269,6 +269,7 @@ export const common = { "common.mainPanelTabs.code": "Code", "common.mainPanelTabs.content": "Content", "common.mainPanelTabs.assets": "Assets", + "common.mainPanelTabs.releases": "Versions", "common.mainPanelTabs.hosting": "Hosting", "common.mainPanelTabs.e2e": "E2E", "common.mainPanelTabs.analytics": "Deco Analytics", diff --git a/apps/web/src/i18n/en/index.ts b/apps/web/src/i18n/en/index.ts index fa23f95b84..79b2c0e833 100644 --- a/apps/web/src/i18n/en/index.ts +++ b/apps/web/src/i18n/en/index.ts @@ -30,6 +30,7 @@ import { discover } from "./discover.ts"; import { commandPalette } from "./command-palette.ts"; import { connections } from "./connections.ts"; import { experiments } from "./experiments.ts"; +import { releases } from "./releases.ts"; import { common } from "./common.ts"; import { reportsOnboarding } from "./reports-onboarding.ts"; import { collections } from "./collections.ts"; @@ -66,6 +67,7 @@ export const en = { ...monitoring, ...mainPanelTabs, ...experiments, + ...releases, ...markdownEditor, ...library, ...layouts, diff --git a/apps/web/src/i18n/en/projects.ts b/apps/web/src/i18n/en/projects.ts index b8d6498959..28b05fdc4c 100644 --- a/apps/web/src/i18n/en/projects.ts +++ b/apps/web/src/i18n/en/projects.ts @@ -76,6 +76,8 @@ export const projects = { "projects.apps.siteEditorCaption": "Pages, sections and content", "projects.apps.assets": "Assets", "projects.apps.assetsCaption": "Images and files the site uses", + "projects.apps.releases": "Versions", + "projects.apps.releasesCaption": "What's live, and earlier versions", "projects.apps.hosting": "Hosting", "projects.apps.hostingCaption": "Deploys, domains and environments", "projects.apps.e2e": "End-to-end tests", diff --git a/apps/web/src/i18n/en/releases.ts b/apps/web/src/i18n/en/releases.ts new file mode 100644 index 0000000000..e98b7f0184 --- /dev/null +++ b/apps/web/src/i18n/en/releases.ts @@ -0,0 +1,27 @@ +/** + * The Versions screen of a hosted site (the `releases` route). Business-user + * copy: "Published" is the version the site shows; no CDN, git, commit, sha + * or "current" (`site-editor-copy.test.ts` enforces it). + */ +export const releases = { + "releases.title": "Versions", + "releases.subtitle": + "Every version of your site, newest first. You can publish an earlier one to bring it back.", + "releases.loadFailed": "Couldn't load your versions.", + "releases.publishedAgo": "Published {when}", + "releases.nothingPublished": "Nothing published yet", + "releases.published": "Published", + "releases.actions": "Version actions", + "releases.publishVersion": "Publish this version", + "releases.publishVersionTitle": "Publish this version?", + "releases.publishVersionBody": + "Your site will show this version from now on. Your next Publish replaces it with your latest changes.", + "releases.schemaMismatchBody": + "This version was made for an earlier design of your site. Your site keeps showing what it shows now until its design matches this version again. Publish anyway?", + "releases.publishAnyway": "Publish anyway", + "releases.versionLive": "This version is live.", + "releases.publishVersionFailed": "Couldn't publish this version.", + "releases.siteUpdate": "Site update", + "releases.loadMore": "Load more", + "releases.cancel": "Cancel", +}; diff --git a/apps/web/src/i18n/en/site-editor.ts b/apps/web/src/i18n/en/site-editor.ts index 9794583287..de9b567ba3 100644 --- a/apps/web/src/i18n/en/site-editor.ts +++ b/apps/web/src/i18n/en/site-editor.ts @@ -6,6 +6,8 @@ */ export const siteEditor = { "siteEditor.details": "Details", + "siteEditor.tryAgain": "Try again", + "siteEditor.publish.published": "Published", "siteEditor.save.failed": "Couldn't save your change. Try again.", "siteEditor.save.conflict": "Not saved: someone else changed this at the same time. We're loading their version, so make your change again.", diff --git a/apps/web/src/i18n/pt-br/common.ts b/apps/web/src/i18n/pt-br/common.ts index 470d105ed2..0a73f49c6b 100644 --- a/apps/web/src/i18n/pt-br/common.ts +++ b/apps/web/src/i18n/pt-br/common.ts @@ -280,6 +280,7 @@ export const common = { "common.mainPanelTabs.code": "Código", "common.mainPanelTabs.content": "Conteúdo", "common.mainPanelTabs.assets": "Assets", + "common.mainPanelTabs.releases": "Versões", "common.mainPanelTabs.hosting": "Hospedagem", "common.mainPanelTabs.e2e": "E2E", "common.mainPanelTabs.analytics": "Deco Analytics", diff --git a/apps/web/src/i18n/pt-br/index.ts b/apps/web/src/i18n/pt-br/index.ts index 920bf2bfbb..55b96cad22 100644 --- a/apps/web/src/i18n/pt-br/index.ts +++ b/apps/web/src/i18n/pt-br/index.ts @@ -30,6 +30,7 @@ import { discover } from "./discover.ts"; import { commandPalette } from "./command-palette.ts"; import { connections } from "./connections.ts"; import { experiments } from "./experiments.ts"; +import { releases } from "./releases.ts"; import { common } from "./common.ts"; import { reportsOnboarding } from "./reports-onboarding.ts"; import { collections } from "./collections.ts"; @@ -64,6 +65,7 @@ export const ptBR = { ...monitoring, ...mainPanelTabs, ...experiments, + ...releases, ...markdownEditor, ...library, ...layouts, diff --git a/apps/web/src/i18n/pt-br/projects.ts b/apps/web/src/i18n/pt-br/projects.ts index e89387942e..cf0747f3e5 100644 --- a/apps/web/src/i18n/pt-br/projects.ts +++ b/apps/web/src/i18n/pt-br/projects.ts @@ -70,6 +70,8 @@ export const projects = { "projects.apps.siteEditorCaption": "Páginas, seções e conteúdo", "projects.apps.assets": "Assets", "projects.apps.assetsCaption": "Imagens e arquivos que o site usa", + "projects.apps.releases": "Versões", + "projects.apps.releasesCaption": "O que está no ar e versões anteriores", "projects.apps.hosting": "Hosting", "projects.apps.hostingCaption": "Deploys, domínios e ambientes", "projects.apps.e2e": "Testes ponta a ponta", diff --git a/apps/web/src/i18n/pt-br/releases.ts b/apps/web/src/i18n/pt-br/releases.ts new file mode 100644 index 0000000000..90a099ed15 --- /dev/null +++ b/apps/web/src/i18n/pt-br/releases.ts @@ -0,0 +1,24 @@ +import type { releases as enReleases } from "../en/releases.ts"; + +export const releases = { + "releases.title": "Versões", + "releases.subtitle": + "Todas as versões do seu site, da mais recente para a mais antiga. Você pode publicar uma anterior para trazê-la de volta.", + "releases.loadFailed": "Não foi possível carregar suas versões.", + "releases.publishedAgo": "Publicada {when}", + "releases.nothingPublished": "Nada publicado ainda", + "releases.published": "Publicada", + "releases.actions": "Ações da versão", + "releases.publishVersion": "Publicar esta versão", + "releases.publishVersionTitle": "Publicar esta versão?", + "releases.publishVersionBody": + "Seu site passa a mostrar esta versão. A próxima publicação a substitui pelas suas alterações mais recentes.", + "releases.schemaMismatchBody": + "Esta versão foi feita para um design anterior do seu site. O site continua mostrando o que mostra agora até o design voltar a corresponder a esta versão. Publicar mesmo assim?", + "releases.publishAnyway": "Publicar mesmo assim", + "releases.versionLive": "Esta versão está no ar.", + "releases.publishVersionFailed": "Não foi possível publicar esta versão.", + "releases.siteUpdate": "Atualização do site", + "releases.loadMore": "Carregar mais", + "releases.cancel": "Cancelar", +} satisfies Record; diff --git a/apps/web/src/i18n/pt-br/site-editor.ts b/apps/web/src/i18n/pt-br/site-editor.ts index ea0629746b..c2be242ba8 100644 --- a/apps/web/src/i18n/pt-br/site-editor.ts +++ b/apps/web/src/i18n/pt-br/site-editor.ts @@ -2,6 +2,8 @@ import type { siteEditor as siteEditorEn } from "../en/site-editor.ts"; export const siteEditor = { "siteEditor.details": "Detalhes", + "siteEditor.tryAgain": "Tentar de novo", + "siteEditor.publish.published": "Publicado", "siteEditor.save.failed": "Não foi possível salvar sua alteração. Tente de novo.", "siteEditor.save.conflict": diff --git a/apps/web/src/i18n/site-editor-copy.test.ts b/apps/web/src/i18n/site-editor-copy.test.ts index 6d1cd0fca3..279115eced 100644 --- a/apps/web/src/i18n/site-editor-copy.test.ts +++ b/apps/web/src/i18n/site-editor-copy.test.ts @@ -8,7 +8,12 @@ import { en } from "./en/index.ts"; import { ptBR } from "./pt-br/index.ts"; /** Namespaces whose every string a business user reads in the site editor. */ -const SITE_EDITOR_PREFIXES = ["siteEditor."]; +const SITE_EDITOR_PREFIXES = [ + "siteEditor.", + "releases.", + "common.mainPanelTabs.releases", + "projects.apps.releases", +]; const BANNED: RegExp[] = [ /\bCDN\b/i, @@ -22,6 +27,7 @@ const BANNED: RegExp[] = [ /latest\.json/i, /\brevision/i, /\bsha\b/i, + /\bcurrent\b/i, /make current/i, /tornar atual/i, ]; diff --git a/apps/web/src/layouts/legacy-main-redirect.tsx b/apps/web/src/layouts/legacy-main-redirect.tsx index f8641379bd..89d8baf1c9 100644 --- a/apps/web/src/layouts/legacy-main-redirect.tsx +++ b/apps/web/src/layouts/legacy-main-redirect.tsx @@ -161,6 +161,16 @@ export function LegacyCanonicalNavigate({ replace /> ); + case PROJECT_ROUTE.releases: + return ( + + ); case PROJECT_ROUTE.hosting: return ( , ): ProjectNativeViewPresence => ({ assets: present.includes("assets"), + releases: present.includes("releases"), hosting: present.includes("hosting"), e2e: present.includes("e2e"), analytics: present.includes("analytics"), @@ -271,31 +272,35 @@ describe("project sidebar views", () => { projectSidebarViewUnavailable("assets", none, { assets: true, siteAccess: false, + releases: false, }), ).toBe(false); expect( projectSidebarViewUnavailable("hosting", none, { assets: false, siteAccess: true, + releases: false, }), ).toBe(false); expect( projectSidebarViewUnavailable("assets", none, { assets: false, siteAccess: false, + releases: false, }), ).toBe(true); expect( projectSidebarViewUnavailable("git", none, { assets: false, siteAccess: false, + releases: false, }), ).toBe(false); }); test("rejects source-backed views immediately when source is absent", () => { const none = projectSidebarViewPresence(false, nativePresence([])); - const pending = { assets: true, siteAccess: true }; + const pending = { assets: true, siteAccess: true, releases: false }; for (const viewId of ["overview", "reports", "board", "site-editor"]) { expect(projectSidebarViewUnavailable(viewId, none, pending)).toBe(true); @@ -308,7 +313,7 @@ describe("project sidebar views", () => { test("rejects unavailable Site Editor and retired surface defaults", () => { const absent = projectSidebarViewPresence(false, nativePresence([])); const present = projectSidebarViewPresence(true, nativePresence([])); - const settled = { assets: false, siteAccess: false }; + const settled = { assets: false, siteAccess: false, releases: false }; expect( projectDefaultViewUnavailable("preview", absent, settled, [], true), diff --git a/apps/web/src/layouts/main-panel-tabs/project-sidebar-views.ts b/apps/web/src/layouts/main-panel-tabs/project-sidebar-views.ts index 0cddcc0ca5..b92bf8656b 100644 --- a/apps/web/src/layouts/main-panel-tabs/project-sidebar-views.ts +++ b/apps/web/src/layouts/main-panel-tabs/project-sidebar-views.ts @@ -7,6 +7,7 @@ export const PROJECT_SIDEBAR_VIEW_IDS = [ "board", "site-editor", "assets", + "releases", "hosting", "e2e", "analytics", @@ -33,6 +34,7 @@ export type ProjectSidebarViewId = (typeof PROJECT_SIDEBAR_VIEW_IDS)[number]; /** Native panels whose availability is discovered from project resources. */ export const PROJECT_NATIVE_VIEW_IDS = [ "assets", + "releases", "hosting", "e2e", "analytics", @@ -48,6 +50,7 @@ export type ProjectSidebarViewPresence = Record; export interface ProjectNativeViewPending { assets: boolean; siteAccess: boolean; + releases: boolean; } export interface ProjectSidebarViewsMetadata { @@ -132,8 +135,8 @@ export function isProjectNativeViewId( return PROJECT_NATIVE_VIEW_IDS.some((viewId) => viewId === value); } -/** Combine permanent project capabilities with the five resource-backed - * native panels. */ +/** Combine permanent project capabilities with the resource-backed native + * panels. */ export function projectSidebarViewPresence( hasClonableSource: boolean, native: ProjectNativeViewPresence, @@ -144,6 +147,8 @@ export function projectSidebarViewPresence( board: hasClonableSource, "site-editor": hasClonableSource, assets: native.assets, + // Hosted Deco CMS releases: a v8 site's repo history. + releases: hasClonableSource && native.releases, hosting: native.hosting, e2e: native.e2e, analytics: native.analytics, @@ -212,7 +217,11 @@ export function projectSidebarViewUnavailable( ): boolean { if (!isProjectSidebarViewId(viewId) || presence[viewId]) return false; if (!isProjectNativeViewId(viewId)) return true; - return !(viewId === "assets" ? pending.assets : pending.siteAccess); + return !(viewId === "assets" + ? pending.assets + : viewId === "releases" + ? pending.releases + : pending.siteAccess); } /** Validate a persisted built-in landing view, including retired Preview and diff --git a/apps/web/src/layouts/main-panel-tabs/releases-api.ts b/apps/web/src/layouts/main-panel-tabs/releases-api.ts new file mode 100644 index 0000000000..cdc043191d --- /dev/null +++ b/apps/web/src/layouts/main-panel-tabs/releases-api.ts @@ -0,0 +1,106 @@ +/** + * The Releases screen's reads and writes (`/api/:org/hosted/:vmcp/*`): the + * timeline of main's commits with the ones that have a companion release, + * what latest.json names (Current), and "Make current". + */ + +import { + useInfiniteQuery, + useMutation, + useQueryClient, +} from "@tanstack/react-query"; +import { KEYS } from "@/lib/query-keys"; + +export interface ReleasePointer { + revision: string; + schemaHash: string; + publishedAt: string; +} + +export interface ReleaseCommit { + sha: string; + date: string; + message: string; + author: string | null; + /** Has a companion release on the CDN: it can be made current. */ + hasRelease: boolean; +} + +export interface ReleasesPage { + /** What latest.json names; null when there is none. */ + current: ReleasePointer | null; + commits: ReleaseCommit[]; + nextCursor: string | null; +} + +/** A refused write, with the API's error code (`schema-mismatch`, + * `latest-update-failed`). */ +export class HostedRequestError extends Error { + constructor( + message: string, + readonly code: string | null, + ) { + super(message); + this.name = "HostedRequestError"; + } +} + +function hostedUrl(orgSlug: string, virtualMcpId: string, path: string) { + return `/api/${orgSlug}/hosted/${encodeURIComponent(virtualMcpId)}/${path}`; +} + +async function readJson(res: Response): Promise { + const json = (await res.json().catch(() => ({}))) as { error?: string }; + if (!res.ok) { + throw new HostedRequestError( + json.error ?? `HTTP ${res.status}`, + json.error ?? null, + ); + } + return json as T; +} + +export function useReleases(orgSlug: string, virtualMcpId: string) { + return useInfiniteQuery({ + queryKey: KEYS.hostedReleases(orgSlug, virtualMcpId), + queryFn: async ({ pageParam }) => { + const query = pageParam ? `?cursor=${encodeURIComponent(pageParam)}` : ""; + return readJson( + await fetch(hostedUrl(orgSlug, virtualMcpId, `releases${query}`), { + cache: "no-store", + }), + ); + }, + initialPageParam: null as string | null, + getNextPageParam: (last) => last.nextCursor, + }); +} + +function useHostedWrite( + orgSlug: string, + virtualMcpId: string, + request: (vars: TVars) => Promise, +) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (vars: TVars) => readJson(await request(vars)), + onSettled: () => + queryClient.invalidateQueries({ + queryKey: KEYS.hostedReleases(orgSlug, virtualMcpId), + }), + }); +} + +/** "Make current": point latest.json at a commit's companion release. */ +export function useMakeCurrent(orgSlug: string, virtualMcpId: string) { + return useHostedWrite( + orgSlug, + virtualMcpId, + (vars: { sha: string; confirm: boolean }) => + fetch(hostedUrl(orgSlug, virtualMcpId, "releases/current"), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(vars), + }), + ); +} diff --git a/apps/web/src/layouts/main-panel-tabs/releases-tab.tsx b/apps/web/src/layouts/main-panel-tabs/releases-tab.tsx new file mode 100644 index 0000000000..638d66f0f2 --- /dev/null +++ b/apps/web/src/layouts/main-panel-tabs/releases-tab.tsx @@ -0,0 +1,277 @@ +/** + * Versions tab (the `releases` route) — a hosted v8 site's timeline: main's + * commits, newest first. A commit with a companion release on the CDN can be + * published ("Make current" on the API); the one latest.json names is + * Published (read, never inferred). A commit without a companion (a + * developer push, or a Publish whose release write failed) is listed with no + * badge and no action, as "Site update" (its own message, which may name + * git, is developer detail in the tooltip). + * + * Business-user copy: no sha, CDN or git words on screen; one toast per + * action (shared with Publish), with the error's own text behind Details. + */ + +import { useState } from "react"; +import { ClockRewind, DotsVertical } from "@untitledui/icons"; +import { Button } from "@decocms/ui/components/button.tsx"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@decocms/ui/components/alert-dialog.tsx"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@decocms/ui/components/dropdown-menu.tsx"; +import { Spinner } from "@decocms/ui/components/spinner.tsx"; +import { cn } from "@decocms/ui/lib/utils.ts"; +import { useT } from "@/i18n/use-t.ts"; +import { usePreferences } from "@/hooks/use-preferences.ts"; +import { formatRelativeTime } from "@/lib/format-time.ts"; +import { useProjectContext } from "@/sdk"; +import { + ErrorDetails, + dismissPublishToast, + errorDetail, + toastPublishFailed, + toastPublished, +} from "@/components/sections-editor/site-editor-toast.tsx"; +import { + HostedRequestError, + type ReleaseCommit, + useMakeCurrent, + useReleases, +} from "./releases-api"; + +const short = (sha: string) => sha.slice(0, 7); +/** A commit message's title line: the version note a Publish wrote. */ +const titleLine = (message: string) => message.split("\n")[0]?.trim() ?? ""; + +/** What the confirmation dialog is asking about. */ +type PendingConfirm = { + commit: ReleaseCommit; + /** The target's schema differs from main's: the second, explicit ask. */ + schemaMismatch: boolean; +}; + +export function ReleasesTab({ virtualMcpId }: { virtualMcpId: string }) { + const t = useT(); + const [{ language }] = usePreferences(); + const { org } = useProjectContext(); + const releases = useReleases(org.slug, virtualMcpId); + const makeCurrent = useMakeCurrent(org.slug, virtualMcpId); + const [confirm, setConfirm] = useState(null); + + if (releases.isPending) { + return ( +
+ +
+ ); + } + if (releases.isError) { + return ( +
+

{t("releases.loadFailed")}

+ + {releases.error.message ? ( +
+ +
+ ) : null} +
+ ); + } + + const commits = releases.data.pages.flatMap((page) => page.commits); + // What latest.json names, as the first page read it. + const { current } = releases.data.pages[0]!; + + const runMakeCurrent = async (commit: ReleaseCommit, confirmed: boolean) => { + try { + await makeCurrent.mutateAsync({ sha: commit.sha, confirm: confirmed }); + toastPublished(t, t("releases.versionLive")); + } catch (error) { + if ( + error instanceof HostedRequestError && + error.code === "schema-mismatch" + ) { + // The confirm dialog asks the next step; a retry's "Publishing…" + // toast must not stay behind it. + dismissPublishToast(); + setConfirm({ commit, schemaMismatch: true }); + return; + } + // Failed: the badge stays where latest.json says (the list refetches). + toastPublishFailed(t, { + headline: t("releases.publishVersionFailed"), + detail: errorDetail(error), + retry: () => void runMakeCurrent(commit, confirmed), + }); + } + }; + + const onConfirm = () => { + const pending = confirm; + setConfirm(null); + if (pending) void runMakeCurrent(pending.commit, pending.schemaMismatch); + }; + + return ( +
+
+

{t("releases.title")}

+

+ {t("releases.subtitle")} +

+

+ + {current + ? t("releases.publishedAgo", { + when: formatRelativeTime( + new Date(current.publishedAt), + language, + ), + }) + : t("releases.nothingPublished")} + +

+
+ +
+
    + {commits.map((commit) => { + const isCurrent = commit.sha === current?.revision; + return ( +
  • + {/* The sha (and a developer push's own message) is developer + detail: a tooltip, never on screen. */} +
    +

    + {commit.hasRelease + ? titleLine(commit.message) + : t("releases.siteUpdate")} +

    +

    + {[ + commit.author, + formatRelativeTime(new Date(commit.date), language), + ] + .filter(Boolean) + .join(" · ")} +

    +
    + {isCurrent ? ( + + {t("releases.published")} + + ) : null} + {commit.hasRelease && !isCurrent ? ( + + + + + + + setConfirm({ commit, schemaMismatch: false }) + } + > + + {t("releases.publishVersion")} + + + + ) : null} +
  • + ); + })} +
+ {releases.hasNextPage ? ( +
+ +
+ ) : null} +
+ + { + if (!open) setConfirm(null); + }} + > + + + + {t("releases.publishVersionTitle")} + + + {confirm?.schemaMismatch + ? t("releases.schemaMismatchBody") + : t("releases.publishVersionBody")} + + + + {t("releases.cancel")} + + {confirm?.schemaMismatch + ? t("releases.publishAnyway") + : t("releases.publishVersion")} + + + + +
+ ); +} diff --git a/apps/web/src/layouts/main-panel-tabs/resolve-tab-icon.ts b/apps/web/src/layouts/main-panel-tabs/resolve-tab-icon.ts index 722cd93920..3c198ebe2c 100644 --- a/apps/web/src/layouts/main-panel-tabs/resolve-tab-icon.ts +++ b/apps/web/src/layouts/main-panel-tabs/resolve-tab-icon.ts @@ -1,5 +1,6 @@ import type { ComponentType, SVGProps } from "react"; import { + ClockRewind, BarChartSquare02, CheckDone01, Code02, @@ -32,6 +33,7 @@ export type SystemTabId = | "code" | "content" | "assets" + | "releases" | "hosting" | "e2e" | "analytics" @@ -46,6 +48,7 @@ export const SYSTEM_TAB_ICONS: Record = { code: Code02, content: File02, assets: Package, + releases: ClockRewind, hosting: Server01, e2e: CheckDone01, analytics: BarChartSquare02, diff --git a/apps/web/src/layouts/main-panel-tabs/tab-route.ts b/apps/web/src/layouts/main-panel-tabs/tab-route.ts index 85a28c98d0..bdd2364fa9 100644 --- a/apps/web/src/layouts/main-panel-tabs/tab-route.ts +++ b/apps/web/src/layouts/main-panel-tabs/tab-route.ts @@ -40,6 +40,7 @@ export type TabRouteLocation = section: | "settings" | "assets" + | "releases" | "hosting" | "e2e" | "analytics" @@ -96,6 +97,7 @@ export type TabRouteTarget = to: | typeof PROJECT_ROUTE.settings | typeof PROJECT_ROUTE.assets + | typeof PROJECT_ROUTE.releases | typeof PROJECT_ROUTE.hosting | typeof PROJECT_ROUTE.e2e | typeof PROJECT_ROUTE.analytics @@ -232,6 +234,7 @@ const AGENT_SECTION_BY_TAB: Readonly< > = { settings: "settings", assets: "assets", + releases: "releases", hosting: "hosting", e2e: "e2e", analytics: "analytics", @@ -632,6 +635,7 @@ export function navigateToTabRouteTarget( return; case PROJECT_ROUTE.settings: case PROJECT_ROUTE.assets: + case PROJECT_ROUTE.releases: case PROJECT_ROUTE.hosting: case PROJECT_ROUTE.e2e: case PROJECT_ROUTE.analytics: diff --git a/apps/web/src/layouts/main-panel-tabs/use-main-panel-tabs.ts b/apps/web/src/layouts/main-panel-tabs/use-main-panel-tabs.ts index 538cf0ec7d..9f5f7ecdfb 100644 --- a/apps/web/src/layouts/main-panel-tabs/use-main-panel-tabs.ts +++ b/apps/web/src/layouts/main-panel-tabs/use-main-panel-tabs.ts @@ -224,6 +224,7 @@ export function useMainPanelTabs(ctx: { const nativeViewPending = { assets: nativeViews.assetsPending, siteAccess: nativeViews.siteAccessPending, + releases: nativeViews.releasesPending, }; const { activeTab: rawActiveTab, mainOpen } = resolveActiveTabAndOpen({ diff --git a/apps/web/src/layouts/main-panel-tabs/use-project-native-view-presence.ts b/apps/web/src/layouts/main-panel-tabs/use-project-native-view-presence.ts index 3797fc1288..28cb7502ab 100644 --- a/apps/web/src/layouts/main-panel-tabs/use-project-native-view-presence.ts +++ b/apps/web/src/layouts/main-panel-tabs/use-project-native-view-presence.ts @@ -2,7 +2,10 @@ import { useQuery } from "@tanstack/react-query"; import type { VirtualMCPEntity } from "@decocms/shared/sdk/types"; import { matchSiteSlugConfig } from "@/components/file-picker/match-site-slug-config"; import { useFileConfigsQuery } from "@/hooks/use-file-configs"; -import { useControlPlaneViews } from "@/hooks/use-organization-settings"; +import { + useControlPlaneViews, + useOrgFlagState, +} from "@/hooks/use-organization-settings"; import { usePublicConfig } from "@/hooks/use-public-config"; import { KEYS } from "@/lib/query-keys"; import { useProjectContext } from "@/sdk"; @@ -16,10 +19,11 @@ export interface ProjectNativeViewPresenceResult { presence: ProjectNativeViewPresence; assetsPending: boolean; siteAccessPending: boolean; + releasesPending: boolean; } /** - * Resolve the five native per-project views once for every surface that needs + * Resolve the native per-project views once for every surface that needs * them. Product rollout, deployment wiring, tenant ownership, and per-site * resources are presence; the Layout preference is deliberately layered on by * the sidebar only. @@ -31,6 +35,8 @@ export function useProjectNativeViewPresence( const config = usePublicConfig(); const controlPlaneViews = useControlPlaneViews(); const fileConfigs = useFileConfigsQuery(); + // The hosted Deco CMS (Blocks v8) is behind the content-protocol flag. + const hostedCms = useOrgFlagState("site_editor_content_protocol"); const siteSlug = resolveAgentSiteSlug(project); const hostingEnabled = config.hostingEnabled === true; const monitorEnabled = @@ -56,6 +62,7 @@ export function useProjectNativeViewPresence( return { presence: { assets: !!matchSiteSlugConfig(fileConfigs.data?.configs ?? [], siteSlug), + releases: hostedCms === true && !!project?.metadata?.siteSlug, hosting: hostingEnabled && ownsSite && controlPlaneViews.hosting, e2e: hostingEnabled && ownsSite && controlPlaneViews.e2e, analytics: hostingEnabled && ownsSite && controlPlaneViews.analytics, @@ -63,6 +70,7 @@ export function useProjectNativeViewPresence( experiments: controlPlaneViews.experiments, }, assetsPending: !!siteSlug && fileConfigs.isPending, + releasesPending: hostedCms === undefined, // A disabled TanStack query is still pending; it is only unresolved when a // probe can actually run. siteAccessPending: diff --git a/apps/web/src/lib/format-time.test.ts b/apps/web/src/lib/format-time.test.ts index b120b33edc..21762b0b55 100644 --- a/apps/web/src/lib/format-time.test.ts +++ b/apps/web/src/lib/format-time.test.ts @@ -3,6 +3,7 @@ import { subSeconds } from "date-fns"; import { computeElapsedMs, formatDuration, + formatRelativeTime, formatTimeAgo, toEpochMs, } from "./format-time"; @@ -126,3 +127,24 @@ describe("computeElapsedMs", () => { expect(computeElapsedMs(1_500, 1_200)).toBe(0); }); }); + +describe("formatRelativeTime", () => { + const now = Date.parse("2026-10-09T12:00:00Z"); + const ago = (seconds: number) => new Date(now - seconds * 1000); + + test("speaks the viewer's language", () => { + expect(formatRelativeTime(ago(2 * 3600), "en", now)).toBe("2 hours ago"); + expect(formatRelativeTime(ago(2 * 3600), "pt-BR", now)).toBe("há 2 horas"); + }); + + test("under a minute is now", () => { + expect(formatRelativeTime(ago(20), "en", now)).toBe("now"); + expect(formatRelativeTime(ago(20), "pt-BR", now)).toBe("agora"); + }); + + test("picks the largest whole unit", () => { + expect(formatRelativeTime(ago(90), "en", now)).toBe("1 minute ago"); + expect(formatRelativeTime(ago(86_400), "en", now)).toBe("yesterday"); + expect(formatRelativeTime(ago(3 * 86_400), "pt-BR", now)).toBe("há 3 dias"); + }); +}); diff --git a/apps/web/src/lib/format-time.ts b/apps/web/src/lib/format-time.ts index 5432c5e179..a664301e25 100644 --- a/apps/web/src/lib/format-time.ts +++ b/apps/web/src/lib/format-time.ts @@ -37,6 +37,34 @@ export function formatTimeAgo(date: Date): string { return `${Math.floor(seconds / 31536000)}y ago`; } +/** + * Relative time in the viewer's language ("2 hours ago", "há 2 horas", + * "now"), for copy a business user reads. {@link formatTimeAgo} is English + * only. + */ +export function formatRelativeTime( + date: Date, + locale: string, + now: number = Date.now(), +): string { + const seconds = Math.round((now - date.getTime()) / 1000); + const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" }); + const units: [Intl.RelativeTimeFormatUnit, number][] = [ + ["year", 31_536_000], + ["month", 2_592_000], + ["week", 604_800], + ["day", 86_400], + ["hour", 3_600], + ["minute", 60], + ]; + for (const [unit, size] of units) { + if (Math.abs(seconds) >= size) { + return rtf.format(-Math.floor(seconds / size), unit); + } + } + return rtf.format(0, "second"); +} + /** * Convert a server-provided timestamp (ISO string, Date, null, or undefined) * into a finite epoch-ms number, or `null` if the value is missing/unparseable. diff --git a/apps/web/src/router.tsx b/apps/web/src/router.tsx index 61749c6289..a6d1c92d74 100644 --- a/apps/web/src/router.tsx +++ b/apps/web/src/router.tsx @@ -815,6 +815,21 @@ const agentAssetsRoute = createRoute({ ), }); +const agentReleasesRoute = createRoute({ + pendingComponent: ChatLayoutPending, + errorComponent: ChatLayoutError, + getParentRoute: () => agentWorkspaceRoute, + path: "/releases", + staticData: { + pageTitle: "common.mainPanelTabs.releases", + defaultMain: "releases", + mainView: "releases", + }, + component: lazyRouteComponent( + () => import("./routes/workspace/agent-releases.tsx"), + ), +}); + const agentHostingRoute = createRoute({ pendingComponent: ChatLayoutPending, errorComponent: ChatLayoutError, @@ -1598,6 +1613,7 @@ const agentWorkspaceWithChildren = agentWorkspaceRoute.addChildren([ agentSettingsRoute, agentLibraryRoute, agentAssetsRoute, + agentReleasesRoute, agentHostingRoute, agentE2eRoute, agentAnalyticsRoute, diff --git a/apps/web/src/routes/workspace/agent-releases.tsx b/apps/web/src/routes/workspace/agent-releases.tsx new file mode 100644 index 0000000000..a612b789c4 --- /dev/null +++ b/apps/web/src/routes/workspace/agent-releases.tsx @@ -0,0 +1,21 @@ +import { ChatLayout } from "@/components/chat-layout"; +import { AgentViewGuard } from "./agent-view-guard"; +import { ReleasesTab } from "@/layouts/main-panel-tabs/releases-tab"; +import { useRouteVirtualMcpId } from "@/layouts/thread-route"; + +function AgentReleasesContent() { + const virtualMcpId = useRouteVirtualMcpId(); + return ( + + + + ); +} + +export default function AgentReleasesRoute() { + return ( + + + + ); +} diff --git a/apps/web/src/views/virtual-mcp/settings/use-project-views.ts b/apps/web/src/views/virtual-mcp/settings/use-project-views.ts index 215bb65927..592a79812b 100644 --- a/apps/web/src/views/virtual-mcp/settings/use-project-views.ts +++ b/apps/web/src/views/virtual-mcp/settings/use-project-views.ts @@ -414,6 +414,7 @@ export function useProjectViews({ board: t("sidebar.navDestinations.tasks"), "site-editor": t("virtualMcp.layoutTabContent.siteEditor"), assets: t("common.mainPanelTabs.assets"), + releases: t("common.mainPanelTabs.releases"), hosting: t("common.mainPanelTabs.hosting"), e2e: t("common.mainPanelTabs.e2e"), analytics: t("common.mainPanelTabs.analytics"), diff --git a/apps/web/src/views/virtual-mcp/settings/view-row-actions.tsx b/apps/web/src/views/virtual-mcp/settings/view-row-actions.tsx index f19ea18700..0fda8d90f6 100644 --- a/apps/web/src/views/virtual-mcp/settings/view-row-actions.tsx +++ b/apps/web/src/views/virtual-mcp/settings/view-row-actions.tsx @@ -4,6 +4,7 @@ */ import { + ClockRewind, ArrowUpRight, BarChartSquare02, CheckDone01, @@ -39,6 +40,7 @@ export function SidebarViewIcon({ viewId }: { viewId: ProjectSidebarViewId }) { if (viewId === "board") return ; if (viewId === "site-editor") return ; if (viewId === "assets") return ; + if (viewId === "releases") return ; if (viewId === "hosting") return ; if (viewId === "e2e") return ; if (viewId === "analytics") return ; diff --git a/packages/shared/src/sdk/types/virtual-mcp.ts b/packages/shared/src/sdk/types/virtual-mcp.ts index 13ec929e91..fb4209fa8f 100644 --- a/packages/shared/src/sdk/types/virtual-mcp.ts +++ b/packages/shared/src/sdk/types/virtual-mcp.ts @@ -145,6 +145,7 @@ export const VirtualMcpSidebarViewSchema = z.enum([ "board", "site-editor", "assets", + "releases", "hosting", "e2e", "analytics", diff --git a/packages/shared/src/tools/tool-io.ts b/packages/shared/src/tools/tool-io.ts index 089219a7fa..f5c4add63e 100644 --- a/packages/shared/src/tools/tool-io.ts +++ b/packages/shared/src/tools/tool-io.ts @@ -2516,6 +2516,7 @@ export interface StudioToolIO { | "board" | "site-editor" | "assets" + | "releases" | "hosting" | "e2e" | "analytics" @@ -2575,6 +2576,7 @@ export interface StudioToolIO { | "board" | "site-editor" | "assets" + | "releases" | "hosting" | "e2e" | "analytics" @@ -2783,6 +2785,7 @@ export interface StudioToolIO { | "board" | "site-editor" | "assets" + | "releases" | "hosting" | "e2e" | "analytics" @@ -2842,6 +2845,7 @@ export interface StudioToolIO { | "board" | "site-editor" | "assets" + | "releases" | "hosting" | "e2e" | "analytics" @@ -2974,6 +2978,7 @@ export interface StudioToolIO { | "board" | "site-editor" | "assets" + | "releases" | "hosting" | "e2e" | "analytics" @@ -3033,6 +3038,7 @@ export interface StudioToolIO { | "board" | "site-editor" | "assets" + | "releases" | "hosting" | "e2e" | "analytics" @@ -3202,6 +3208,7 @@ export interface StudioToolIO { | "board" | "site-editor" | "assets" + | "releases" | "hosting" | "e2e" | "analytics" @@ -3261,6 +3268,7 @@ export interface StudioToolIO { | "board" | "site-editor" | "assets" + | "releases" | "hosting" | "e2e" | "analytics" @@ -3421,6 +3429,7 @@ export interface StudioToolIO { | "board" | "site-editor" | "assets" + | "releases" | "hosting" | "e2e" | "analytics" @@ -3480,6 +3489,7 @@ export interface StudioToolIO { | "board" | "site-editor" | "assets" + | "releases" | "hosting" | "e2e" | "analytics" @@ -3640,6 +3650,7 @@ export interface StudioToolIO { | "board" | "site-editor" | "assets" + | "releases" | "hosting" | "e2e" | "analytics" @@ -3699,6 +3710,7 @@ export interface StudioToolIO { | "board" | "site-editor" | "assets" + | "releases" | "hosting" | "e2e" | "analytics" @@ -3839,6 +3851,7 @@ export interface StudioToolIO { | "board" | "site-editor" | "assets" + | "releases" | "hosting" | "e2e" | "analytics" @@ -3898,6 +3911,7 @@ export interface StudioToolIO { | "board" | "site-editor" | "assets" + | "releases" | "hosting" | "e2e" | "analytics" @@ -4056,6 +4070,7 @@ export interface StudioToolIO { | "board" | "site-editor" | "assets" + | "releases" | "hosting" | "e2e" | "analytics" @@ -4115,6 +4130,7 @@ export interface StudioToolIO { | "board" | "site-editor" | "assets" + | "releases" | "hosting" | "e2e" | "analytics" @@ -5415,6 +5431,7 @@ export interface StudioToolIO { | "board" | "site-editor" | "assets" + | "releases" | "hosting" | "e2e" | "analytics" @@ -5474,6 +5491,7 @@ export interface StudioToolIO { | "board" | "site-editor" | "assets" + | "releases" | "hosting" | "e2e" | "analytics"