From e4b1c834d212c76540e4e111c857b1ad3755f9c2 Mon Sep 17 00:00:00 2001 From: Madraka Date: Tue, 20 Oct 2026 15:46:34 +0300 Subject: [PATCH] feat(studio): publish and unpublish one page without a release RFC-0035. A draft page's next step in the editor is Publish (through the same ApprovalDialog); the Pages list's row menu adds Publish (approval) and Unpublish, disabled with the reason for roles without pages.write; the site Publish dialog says how many pages it leaves out, with a link to them. MCP update_page takes an optional status, so an agent can publish one finished translation; the store's review and release checks still apply. Ported from unfinished work on the pre-public history onto the grouped translation list (PageRow now takes the pages.write gate and the row's status items). Locale catalogs regenerated with pnpm i18n:import. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Madraka --- .changeset/update-page-status.md | 5 + apps/studio/src/components/PageStatus.tsx | 123 +++++++++++++ apps/studio/src/components/RowMenu.tsx | 5 + apps/studio/src/components/SitePublish.tsx | 5 +- apps/studio/src/components/StatusActions.tsx | 76 ++++---- .../page-editor/PageEditorActions.tsx | 1 + .../page-editor/PageEditorDialogs.tsx | 1 + apps/studio/src/i18n/ar.ts | 15 +- apps/studio/src/i18n/de.ts | 15 +- apps/studio/src/i18n/en.ts | 12 ++ apps/studio/src/i18n/es.ts | 15 +- apps/studio/src/i18n/fa.ts | 14 +- apps/studio/src/i18n/fr.ts | 15 +- apps/studio/src/i18n/generated/params.ts | 2 + apps/studio/src/i18n/he.ts | 15 +- apps/studio/src/i18n/hi.ts | 14 +- apps/studio/src/i18n/id.ts | 14 +- apps/studio/src/i18n/it.ts | 15 +- apps/studio/src/i18n/ja.ts | 14 +- apps/studio/src/i18n/ko.ts | 14 +- apps/studio/src/i18n/nl.ts | 15 +- apps/studio/src/i18n/pl.ts | 15 +- apps/studio/src/i18n/pt-BR.ts | 15 +- apps/studio/src/i18n/ru.ts | 15 +- apps/studio/src/i18n/tr.ts | 14 +- apps/studio/src/i18n/uk.ts | 15 +- apps/studio/src/i18n/zh-Hans.ts | 12 +- apps/studio/src/routes/PagesPage.tsx | 23 ++- apps/studio/test/page-status.test.tsx | 162 ++++++++++++++++++ .../0035-page-status-without-a-release.md | 61 +++++++ packages/mcp-server/src/page-tools.ts | 8 +- .../mcp-server/test/translation-tools.test.ts | 32 ++++ 32 files changed, 713 insertions(+), 64 deletions(-) create mode 100644 .changeset/update-page-status.md create mode 100644 apps/studio/src/components/PageStatus.tsx create mode 100644 apps/studio/test/page-status.test.tsx create mode 100644 docs/rfcs/0035-page-status-without-a-release.md diff --git a/.changeset/update-page-status.md b/.changeset/update-page-status.md new file mode 100644 index 00000000..055a438c --- /dev/null +++ b/.changeset/update-page-status.md @@ -0,0 +1,5 @@ +--- +"@cloudhal/mcp-server": minor +--- + +`update_page` takes an optional `status` (draft, review, published, archived; RFC-0035), so an agent can publish or unpublish one page, such as a finished translation, without a release. The store's checks still apply: a machine translation in a review-required collection, or one with failing checks, is refused. The page goes live with the next site publish. diff --git a/apps/studio/src/components/PageStatus.tsx b/apps/studio/src/components/PageStatus.tsx new file mode 100644 index 00000000..4e468d4a --- /dev/null +++ b/apps/studio/src/components/PageStatus.tsx @@ -0,0 +1,123 @@ +/** + * Publishing and unpublishing one page outside its editor (RFC-0035): the Pages list's row items + * and their approval, and the site Publish dialog's note about pages it leaves out. + */ +import type { Page } from "@cloudhal/core-contracts"; +import { ApprovalDialog, Banner, DirectionalIcon, DropdownMenu } from "@cloudhal/ui"; +import { ArrowCounterClockwise, ArrowRight, Info, RocketLaunch } from "@phosphor-icons/react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useNavigate } from "@tanstack/react-router"; +import { useState } from "react"; +import { useT } from "../i18n/index.tsx"; +import { api } from "../lib/api.ts"; +import { apiErrorText } from "../lib/api-errors.ts"; +import { useToast } from "./Toast.ts"; + +/** Pages a site publish leaves off the live site: drafts and pages in review. */ +export function unpublishedPages(pages: readonly Page[]): Page[] { + return pages.filter((p) => p.status === "draft" || p.status === "review"); +} + +/** + * A page's status from the Pages list: `items(page)` are the row menu's Publish / Unpublish + * entries (disabled with `reason` when the person may not change pages), `dialog` the approval + * Publish opens. Unpublishing needs no approval, as in the editor. + */ +export function usePageStatusActions(siteId: string, reason: string | undefined) { + const t = useT(); + const qc = useQueryClient(); + const toast = useToast(); + const [toPublish, setToPublish] = useState(); + const change = useMutation({ + mutationFn: (page: Page) => api.pages.update(page), + onSuccess: async (saved) => { + setToPublish(undefined); + await qc.invalidateQueries({ queryKey: ["pages", siteId] }); + await qc.invalidateQueries({ queryKey: ["page", siteId, saved.id] }); + toast.success( + saved.status === "published" ? t("pageStatus.published") : t("pageStatus.unpublished"), + ); + }, + onError: (e) => toast.error(t("pageStatus.failed"), apiErrorText(t, e)), + }); + const items = (page: Page) => + page.status === "published" ? ( + change.mutate({ ...page, status: "draft" })} + > + {t("editor.unpublish")} + + ) : page.status === "archived" ? null : ( + setToPublish(page)} + data-testid="row-publish" + > + {t("common.publish")} + + ); + const dialog = ( + !open && setToPublish(undefined)} + title={t("pageStatus.publishTitle", { title: toPublish?.title ?? "" })} + description={t("editor.publishPageDescription")} + confirmLabel={t("common.publish")} + cancelLabel={t("common.cancel")} + busy={change.isPending} + onConfirm={async () => { + if (toPublish) await change.mutateAsync({ ...toPublish, status: "published" }); + }} + /> + ); + return { items, dialog }; +} + +/** + * The site Publish dialog's note (RFC-0035): a publish takes each page as its own status says, so + * drafts — new translations among them — stay off the live site. Reads the pages only while open. + */ +export function UnpublishedPagesNote({ + siteId, + open, + onNavigate, +}: { + siteId: string; + open: boolean; + onNavigate: () => void; +}) { + const t = useT(); + const navigate = useNavigate(); + const pages = useQuery({ + queryKey: ["pages", siteId], + queryFn: () => api.pages.list(siteId), + enabled: open, + }); + const count = unpublishedPages(pages.data ?? []).length; + if (count === 0) return null; + return ( + } + title={t("pageStatus.leftOut", { count })} + description={t("pageStatus.leftOutDescription")} + action={ + } + onClick={() => { + onNavigate(); + void navigate({ to: "/sites/$siteId/pages", params: { siteId } }); + }} + > + {t("pageStatus.review")} + + } + data-testid="unpublished-pages" + /> + ); +} diff --git a/apps/studio/src/components/RowMenu.tsx b/apps/studio/src/components/RowMenu.tsx index cc959b25..778c9b8d 100644 --- a/apps/studio/src/components/RowMenu.tsx +++ b/apps/studio/src/components/RowMenu.tsx @@ -1,5 +1,6 @@ import { Button, DropdownMenu, directional, Tooltip } from "@cloudhal/ui"; import { ArrowSquareOut, DotsThree, Trash } from "@phosphor-icons/react"; +import type { ReactNode } from "react"; import { useT } from "../i18n/index.tsx"; /** The "…" menu at the end of a table row (DESIGN.md §2a). */ @@ -7,8 +8,11 @@ export function RowMenu({ onOpen, onDelete, deleteDisabledReason, + items, }: { onOpen: () => void; + /** Extra `DropdownMenu.Item`s between Open and Delete (a page's Publish / Unpublish). */ + items?: ReactNode; onDelete?: (() => void) | undefined; /** The person's role cannot delete this row: the item stays, disabled, with the reason (§2s). */ deleteDisabledReason?: string | undefined; @@ -38,6 +42,7 @@ export function RowMenu({ {t("sites.open")} + {items} {onDelete && (deleteDisabledReason ? ( // A disabled item takes no pointer events, so the tooltip hangs on a wrapper. diff --git a/apps/studio/src/components/SitePublish.tsx b/apps/studio/src/components/SitePublish.tsx index 7f4f556b..8c7065a8 100644 --- a/apps/studio/src/components/SitePublish.tsx +++ b/apps/studio/src/components/SitePublish.tsx @@ -12,6 +12,7 @@ import { useState } from "react"; 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 { usePublishMode, usePublishSite } from "./Publish.tsx"; import { useToast } from "./Toast.ts"; @@ -107,7 +108,9 @@ export function SitePublish({ cancelLabel={t("common.cancel")} busy={publish.isPending} onConfirm={() => publish.mutate({ label: t("snapshots.publishedFromStudio") })} - /> + > + setOpen(false)} /> + ); } diff --git a/apps/studio/src/components/StatusActions.tsx b/apps/studio/src/components/StatusActions.tsx index 61fedab1..3acebf75 100644 --- a/apps/studio/src/components/StatusActions.tsx +++ b/apps/studio/src/components/StatusActions.tsx @@ -21,6 +21,9 @@ import { TRANSITIONS } from "../lib/workflow.ts"; * Contributor: draft and review, so "Submit for review" and no Publish — the Publish step stays, * disabled, with `publishReason`); `readOnlyReason` disables the whole lifecycle (someone else's * article) with the reason as the tooltip. + * + * `directPublish` (pages, RFC-0035): a draft's next step is Publish itself, not review; "Send to + * review" moves into the menu. */ export function StatusActions({ status, @@ -31,6 +34,7 @@ export function StatusActions({ allowed, publishReason, readOnlyReason, + directPublish, }: { status: PublishStatus; busy?: boolean | undefined; @@ -44,50 +48,52 @@ export function StatusActions({ publishReason?: string | undefined; /** Nothing here is this person's to change: the reason. */ readOnlyReason?: string | undefined; + /** A draft publishes without the review step (pages). */ + directPublish?: boolean | undefined; }) { const t = useT(); const may = (s: PublishStatus) => !allowed || allowed.includes(s); const draftOnly = !may("published"); const transitions = TRANSITIONS[status].filter(may); const locked = readOnlyReason !== undefined; - const next = - status === "draft" && may("review") ? ( - - ) : status === "review" ? ( - - ) : status === "archived" && may("draft") ? ( - - ) : null; + const publishNext = status === "review" || (status === "draft" && directPublish && !draftOnly); + const next = publishNext ? ( + + ) : status === "draft" && may("review") ? ( + + ) : status === "archived" && may("draft") ? ( + + ) : null; const others = transitions.filter( (s) => s !== "published" && - !(status === "draft" && s === "review") && + !(status === "draft" && s === "review" && !publishNext) && !(status === "archived" && s === "draft"), ); const label = (s: PublishStatus) => diff --git a/apps/studio/src/components/page-editor/PageEditorActions.tsx b/apps/studio/src/components/page-editor/PageEditorActions.tsx index dea3b753..d257b8d1 100644 --- a/apps/studio/src/components/page-editor/PageEditorActions.tsx +++ b/apps/studio/src/components/page-editor/PageEditorActions.tsx @@ -39,6 +39,7 @@ export function PageEditorActions({ { if (valid()) save.mutate({ ...draft, status: next }); diff --git a/apps/studio/src/components/page-editor/PageEditorDialogs.tsx b/apps/studio/src/components/page-editor/PageEditorDialogs.tsx index a1d1f077..6a5ccaad 100644 --- a/apps/studio/src/components/page-editor/PageEditorDialogs.tsx +++ b/apps/studio/src/components/page-editor/PageEditorDialogs.tsx @@ -17,6 +17,7 @@ export function PageEditorDialogs({ ed, draft }: { ed: PageEditorState; draft: P open={ed.publishOpen} onOpenChange={ed.setPublishOpen} title={t("editor.publishPageTitle")} + description={t("editor.publishPageDescription")} confirmLabel={t("common.publish")} cancelLabel={t("common.cancel")} busy={save.isPending} diff --git a/apps/studio/src/i18n/ar.ts b/apps/studio/src/i18n/ar.ts index 0d60ff0b..01083193 100644 --- a/apps/studio/src/i18n/ar.ts +++ b/apps/studio/src/i18n/ar.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): ar core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 8d9196f38d2891b4 +// hash: 00788bd78f14efa0 import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -251,6 +251,17 @@ export const messages: Messages = { viewAll: "اعرض الكل", cli: "من الطرفية", }, + pageStatus: { + publishTitle: "هل تريد نشر «{title}»؟", + published: "نُشرت الصفحة", + unpublished: "أُعيدت الصفحة إلى مسودة", + failed: "لم تتغير حالة الصفحة", + leftOut: + "{count, plural, zero {لا توجد صفحات غير منشورة} one {صفحة واحدة غير منشورة} two {صفحتان غير منشورتين} few {# صفحات غير منشورة} many {# صفحة غير منشورة} other {# صفحة غير منشورة}}", + leftOutDescription: + "تبقى المسودات والصفحات قيد المراجعة، ومنها الترجمات الجديدة، خارج الموقع المباشر. انشرها أولًا.", + review: "مراجعة الصفحات", + }, articles: { description: "التدوينات في المجموعة «posts».", newArticle: "مقالة جديدة", @@ -374,6 +385,8 @@ export const messages: Messages = { publishArticleDescription: "تصبح ظاهرة على الموقع في المرة التالية التي تُلتقط فيها لقطة للموقع وتُنشر.", publishPageTitle: "أتريد نشر هذه الصفحة؟", + publishPageDescription: + "تُنشر الصفحة عند نشر الموقع في المرة القادمة. تحتفظ الصفحات واللغات الأخرى بحالتها الخاصة.", deleteArticle: "احذف المقالة", deletePage: "احذف الصفحة", article: "مقالة", diff --git a/apps/studio/src/i18n/de.ts b/apps/studio/src/i18n/de.ts index 8234ab15..3466039f 100644 --- a/apps/studio/src/i18n/de.ts +++ b/apps/studio/src/i18n/de.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): de core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: fc4d29fdd431f322 +// hash: 60015e56e0e5cd97 import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -256,6 +256,17 @@ export const messages: Messages = { viewAll: "Alle anzeigen", cli: "Im Terminal", }, + pageStatus: { + publishTitle: "„{title}“ veröffentlichen?", + published: "Seite veröffentlicht", + unpublished: "Seite wieder als Entwurf", + failed: "Der Status der Seite hat sich nicht geändert", + leftOut: + "{count, plural, one {# Seite ist nicht veröffentlicht} other {# Seiten sind nicht veröffentlicht}}", + leftOutDescription: + "Entwürfe und Seiten in Prüfung, auch neue Übersetzungen, erscheinen nicht auf der Live-Website. Veröffentliche sie zuerst.", + review: "Seiten prüfen", + }, articles: { description: "Beiträge in der Sammlung „posts“.", newArticle: "Neuer Artikel", @@ -386,6 +397,8 @@ export const messages: Messages = { publishArticleDescription: "Er wird auf der Website sichtbar, sobald das nächste Mal ein Snapshot erstellt und die Website veröffentlicht wird.", publishPageTitle: "Diese Seite veröffentlichen?", + publishPageDescription: + "Sie geht beim nächsten Veröffentlichen der Website live. Andere Seiten und Sprachen behalten ihren eigenen Status.", deleteArticle: "Artikel löschen", deletePage: "Seite löschen", article: "Artikel", diff --git a/apps/studio/src/i18n/en.ts b/apps/studio/src/i18n/en.ts index 8f048194..c29b2f20 100644 --- a/apps/studio/src/i18n/en.ts +++ b/apps/studio/src/i18n/en.ts @@ -246,6 +246,16 @@ export const en = { viewAll: "View all", cli: "From the terminal", }, + pageStatus: { + publishTitle: "Publish “{title}”?", + published: "Page published", + unpublished: "Page moved back to draft", + failed: "The page's status did not change", + leftOut: "{count, plural, one {# page is not published} other {# pages are not published}}", + leftOutDescription: + "Drafts and pages in review, new translations included, stay off the live site. Publish them first.", + review: "Review pages", + }, articles: { description: "Posts in the “posts” collection.", newArticle: "New article", @@ -365,6 +375,8 @@ export const en = { publishArticleDescription: "It becomes visible on the site the next time the site is snapshotted and published.", publishPageTitle: "Publish this page?", + publishPageDescription: + "It goes live the next time the site is published. Other pages and languages keep their own status.", deleteArticle: "Delete article", deletePage: "Delete page", article: "Article", diff --git a/apps/studio/src/i18n/es.ts b/apps/studio/src/i18n/es.ts index da6bd0b8..3fd69900 100644 --- a/apps/studio/src/i18n/es.ts +++ b/apps/studio/src/i18n/es.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): es core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 81517fe3e206b68b +// hash: eb9a3ca039b813b3 import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -256,6 +256,17 @@ export const messages: Messages = { viewAll: "Ver todo", cli: "Desde la terminal", }, + pageStatus: { + publishTitle: "¿Publicar «{title}»?", + published: "Página publicada", + unpublished: "La página volvió a borrador", + failed: "El estado de la página no cambió", + leftOut: + "{count, plural, one {# página no está publicada} many {# de páginas no están publicadas} other {# páginas no están publicadas}}", + leftOutDescription: + "Los borradores y las páginas en revisión, incluidas las traducciones nuevas, no aparecen en el sitio publicado. Publícalas primero.", + review: "Revisar páginas", + }, articles: { description: "Entradas de la colección “posts”.", newArticle: "Nuevo artículo", @@ -383,6 +394,8 @@ export const messages: Messages = { publishArticleDescription: "Será visible en el sitio la próxima vez que se cree una instantánea del sitio y se publique.", publishPageTitle: "¿Publicar esta página?", + publishPageDescription: + "Se publica la próxima vez que se publique el sitio. Las demás páginas e idiomas mantienen su propio estado.", deleteArticle: "Eliminar artículo", deletePage: "Eliminar página", article: "Artículo", diff --git a/apps/studio/src/i18n/fa.ts b/apps/studio/src/i18n/fa.ts index c91267b4..e3bea5b6 100644 --- a/apps/studio/src/i18n/fa.ts +++ b/apps/studio/src/i18n/fa.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): fa core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: cce12827d6511ec3 +// hash: 3d646b558dc42fa0 import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -254,6 +254,16 @@ export const messages: Messages = { viewAll: "مشاهدهٔ همه", cli: "از ترمینال", }, + pageStatus: { + publishTitle: "«{title}» منتشر شود؟", + published: "صفحه منتشر شد", + unpublished: "صفحه به پیش‌نویس برگشت", + failed: "وضعیت صفحه تغییر نکرد", + leftOut: "{count, plural, one {# صفحه منتشر نشده است} other {# صفحه منتشر نشده‌اند}}", + leftOutDescription: + "پیش‌نویس‌ها و صفحه‌های در حال بازبینی، از جمله ترجمه‌های تازه، در سایت زنده نمایش داده نمی‌شوند. اول آن‌ها را منتشر کنید.", + review: "بازبینی صفحه‌ها", + }, articles: { description: "نوشته‌های مجموعهٔ «posts».", newArticle: "مقالهٔ جدید", @@ -378,6 +388,8 @@ export const messages: Messages = { publishArticleDescription: "دفعهٔ بعد که از سایت اسنپ‌شات گرفته و منتشر شود، در سایت نمایان می‌شود.", publishPageTitle: "این صفحه منتشر شود؟", + publishPageDescription: + "دفعهٔ بعد که سایت منتشر شود، این صفحه منتشر می‌شود. صفحه‌ها و زبان‌های دیگر وضعیت خود را حفظ می‌کنند.", deleteArticle: "حذف مقاله", deletePage: "حذف صفحه", article: "مقاله", diff --git a/apps/studio/src/i18n/fr.ts b/apps/studio/src/i18n/fr.ts index 2652d85d..dadce49d 100644 --- a/apps/studio/src/i18n/fr.ts +++ b/apps/studio/src/i18n/fr.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): fr core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: c28d0327f6662ac8 +// hash: 75bc94d0e343c481 import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -258,6 +258,17 @@ export const messages: Messages = { viewAll: "Tout voir", cli: "Depuis le terminal", }, + pageStatus: { + publishTitle: "Publier « {title} » ?", + published: "Page publiée", + unpublished: "Page repassée en brouillon", + failed: "Le statut de la page n’a pas changé", + leftOut: + "{count, plural, one {# page n’est pas publiée} many {# pages ne sont pas publiées} other {# pages ne sont pas publiées}}", + leftOutDescription: + "Les brouillons et les pages en relecture, y compris les nouvelles traductions, restent hors du site en ligne. Publiez-les d’abord.", + review: "Voir les pages", + }, articles: { description: "Les billets de la collection « posts ».", newArticle: "Nouvel article", @@ -388,6 +399,8 @@ export const messages: Messages = { publishArticleDescription: "Il devient visible sur le site au prochain instantané et à la prochaine publication du site.", publishPageTitle: "Publier cette page ?", + publishPageDescription: + "Elle sera en ligne à la prochaine publication du site. Les autres pages et langues gardent leur propre statut.", deleteArticle: "Supprimer l’article", deletePage: "Supprimer la page", article: "Article", diff --git a/apps/studio/src/i18n/generated/params.ts b/apps/studio/src/i18n/generated/params.ts index 79a45050..428cd3f6 100644 --- a/apps/studio/src/i18n/generated/params.ts +++ b/apps/studio/src/i18n/generated/params.ts @@ -486,6 +486,8 @@ export interface MessageParams { "notifications.toast.endingBody": { time: string | number; from: string | number }; "notifications.toast.endingTitle": { name: string | number; mode: string | number; left: string | number }; "overview.commentsWaiting": { count: number }; + "pageStatus.leftOut": { count: number }; + "pageStatus.publishTitle": { title: string | number }; "passwords.advice.empty": { min: string | number }; "passwords.advice.tooShort": { min: string | number }; "passwords.errors.tooLong": { max: string | number }; diff --git a/apps/studio/src/i18n/he.ts b/apps/studio/src/i18n/he.ts index 23f8fca7..3ea73cb7 100644 --- a/apps/studio/src/i18n/he.ts +++ b/apps/studio/src/i18n/he.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): he core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 6c88780ef5986666 +// hash: a15e8f76b61934df import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -251,6 +251,17 @@ export const messages: Messages = { viewAll: "הצגת הכול", cli: "מהטרמינל", }, + pageStatus: { + publishTitle: "לפרסם את „{title}”?", + published: "הדף פורסם", + unpublished: "הדף הוחזר לטיוטה", + failed: "סטטוס הדף לא השתנה", + leftOut: + "{count, plural, one {דף אחד לא פורסם} two {שני דפים לא פורסמו} other {# דפים לא פורסמו}}", + leftOutDescription: + "טיוטות ודפים בבדיקה, כולל תרגומים חדשים, לא מופיעים באתר החי. יש לפרסם אותם קודם.", + review: "בדיקת הדפים", + }, articles: { description: "פוסטים באוסף “posts”.", newArticle: "מאמר חדש", @@ -373,6 +384,8 @@ export const messages: Messages = { publishArticleDescription: "הוא יהיה גלוי באתר בפעם הבאה שתיווצר תמונת מצב של האתר והיא תפורסם.", publishPageTitle: "לפרסם את העמוד הזה?", + publishPageDescription: + "הדף יעלה לאוויר בפעם הבאה שהאתר יפורסם. דפים ושפות אחרים שומרים על הסטטוס שלהם.", deleteArticle: "מחיקת המאמר", deletePage: "מחיקת העמוד", article: "מאמר", diff --git a/apps/studio/src/i18n/hi.ts b/apps/studio/src/i18n/hi.ts index 8bfde0bc..1921ec78 100644 --- a/apps/studio/src/i18n/hi.ts +++ b/apps/studio/src/i18n/hi.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): hi core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 9e59d726ee0ff8c1 +// hash: 17fc36df14eea135 import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -251,6 +251,16 @@ export const messages: Messages = { viewAll: "सभी देखें", cli: "टर्मिनल से", }, + pageStatus: { + publishTitle: "“{title}” प्रकाशित करें?", + published: "पेज प्रकाशित हुआ", + unpublished: "पेज वापस ड्राफ़्ट में", + failed: "पेज की स्थिति नहीं बदली", + leftOut: "{count, plural, one {# पेज प्रकाशित नहीं है} other {# पेज प्रकाशित नहीं हैं}}", + leftOutDescription: + "ड्राफ़्ट और समीक्षा वाले पेज, नए अनुवादों सहित, लाइव साइट पर नहीं दिखते। पहले उन्हें प्रकाशित करें।", + review: "पेज देखें", + }, articles: { description: "“posts” संग्रह की पोस्ट।", newArticle: "नया लेख", @@ -373,6 +383,8 @@ export const messages: Messages = { publishArticleDescription: "अगली बार जब साइट का स्नैपशॉट लेकर उसे प्रकाशित किया जाएगा, तब यह साइट पर दिखेगा।", publishPageTitle: "यह पेज प्रकाशित करें?", + publishPageDescription: + "अगली बार साइट प्रकाशित होने पर यह लाइव हो जाएगा। दूसरे पेज और भाषाएँ अपनी स्थिति बनाए रखते हैं।", deleteArticle: "लेख हटाएँ", deletePage: "पेज हटाएँ", article: "लेख", diff --git a/apps/studio/src/i18n/id.ts b/apps/studio/src/i18n/id.ts index 02c9b63d..a642eb11 100644 --- a/apps/studio/src/i18n/id.ts +++ b/apps/studio/src/i18n/id.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): id core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: df6d764546eba826 +// hash: 983422f32b48d9f8 import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -254,6 +254,16 @@ export const messages: Messages = { viewAll: "Lihat semua", cli: "Dari terminal", }, + pageStatus: { + publishTitle: "Terbitkan “{title}”?", + published: "Halaman diterbitkan", + unpublished: "Halaman kembali menjadi draf", + failed: "Status halaman tidak berubah", + leftOut: "{count, plural, other {# halaman belum diterbitkan}}", + leftOutDescription: + "Draf dan halaman dalam peninjauan, termasuk terjemahan baru, tidak tampil di situs live. Terbitkan dulu.", + review: "Tinjau halaman", + }, articles: { description: "Pos dalam koleksi “posts”.", newArticle: "Artikel baru", @@ -379,6 +389,8 @@ export const messages: Messages = { publishArticleDescription: "Artikel akan terlihat di situs saat snapshot situs berikutnya dibuat dan diterbitkan.", publishPageTitle: "Terbitkan halaman ini?", + publishPageDescription: + "Halaman ini tayang saat situs diterbitkan berikutnya. Halaman dan bahasa lain tetap dengan statusnya sendiri.", deleteArticle: "Hapus artikel", deletePage: "Hapus halaman", article: "Artikel", diff --git a/apps/studio/src/i18n/it.ts b/apps/studio/src/i18n/it.ts index f213ded0..01f2a135 100644 --- a/apps/studio/src/i18n/it.ts +++ b/apps/studio/src/i18n/it.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): it core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 7652a2beef11ea17 +// hash: 98d46828ae9fa54d import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -255,6 +255,17 @@ export const messages: Messages = { viewAll: "Vedi tutti", cli: "Dal terminale", }, + pageStatus: { + publishTitle: "Pubblicare «{title}»?", + published: "Pagina pubblicata", + unpublished: "Pagina tornata in bozza", + failed: "Lo stato della pagina non è cambiato", + leftOut: + "{count, plural, one {# pagina non è pubblicata} many {# di pagine non sono pubblicate} other {# pagine non sono pubblicate}}", + leftOutDescription: + "Le bozze e le pagine in revisione, comprese le nuove traduzioni, restano fuori dal sito online. Pubblicale prima.", + review: "Rivedi le pagine", + }, articles: { description: "Post nella raccolta “posts”.", newArticle: "Nuovo articolo", @@ -379,6 +390,8 @@ export const messages: Messages = { publishArticleDescription: "Diventa visibile sul sito alla prossima creazione di uno snapshot e pubblicazione del sito.", publishPageTitle: "Pubblicare questa pagina?", + publishPageDescription: + "Andrà online alla prossima pubblicazione del sito. Le altre pagine e lingue mantengono il proprio stato.", deleteArticle: "Elimina articolo", deletePage: "Elimina pagina", article: "Articolo", diff --git a/apps/studio/src/i18n/ja.ts b/apps/studio/src/i18n/ja.ts index aa66df83..37ceb757 100644 --- a/apps/studio/src/i18n/ja.ts +++ b/apps/studio/src/i18n/ja.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): ja core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 76d1e663ff6d2d31 +// hash: 00baa78616d9fb47 import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -253,6 +253,16 @@ export const messages: Messages = { viewAll: "すべて表示", cli: "ターミナルから", }, + pageStatus: { + publishTitle: "「{title}」を公開しますか?", + published: "ページを公開しました", + unpublished: "ページを下書きに戻しました", + failed: "ページのステータスは変更されませんでした", + leftOut: "{count, plural, other {#件のページが未公開です}}", + leftOutDescription: + "下書きとレビュー中のページ(新しい翻訳を含む)は公開サイトに表示されません。先に公開してください。", + review: "ページを確認", + }, articles: { description: "「posts」コレクションの投稿です。", newArticle: "新しい記事", @@ -377,6 +387,8 @@ export const messages: Messages = { publishArticleDescription: "次にサイトのスナップショットが作成されて公開されたときに、サイトに表示されます。", publishPageTitle: "このページを公開しますか?", + publishPageDescription: + "次にサイトを公開したときに公開されます。他のページや言語はそれぞれのステータスのままです。", deleteArticle: "記事を削除", deletePage: "ページを削除", article: "記事", diff --git a/apps/studio/src/i18n/ko.ts b/apps/studio/src/i18n/ko.ts index 43072ef9..1476f63b 100644 --- a/apps/studio/src/i18n/ko.ts +++ b/apps/studio/src/i18n/ko.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): ko core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: fafffdeff972b185 +// hash: 42f6b75a5559c9ed import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -251,6 +251,16 @@ export const messages: Messages = { viewAll: "모두 보기", cli: "터미널에서", }, + pageStatus: { + publishTitle: "“{title}”을(를) 게시할까요?", + published: "페이지를 게시했습니다", + unpublished: "페이지를 초안으로 되돌렸습니다", + failed: "페이지 상태가 바뀌지 않았습니다", + leftOut: "{count, plural, other {페이지 #개가 게시되지 않았습니다}}", + leftOutDescription: + "초안과 검토 중인 페이지(새 번역 포함)는 라이브 사이트에 나타나지 않습니다. 먼저 게시하세요.", + review: "페이지 검토", + }, articles: { description: "“posts” 컬렉션의 게시물이에요.", newArticle: "새 글", @@ -373,6 +383,8 @@ export const messages: Messages = { publishArticleTitle: "이 글을 게시할까요?", publishArticleDescription: "다음에 사이트 스냅샷을 만들어 게시할 때 사이트에 표시돼요.", publishPageTitle: "이 페이지를 게시할까요?", + publishPageDescription: + "다음에 사이트를 게시할 때 공개됩니다. 다른 페이지와 언어는 각자의 상태를 유지합니다.", deleteArticle: "글 삭제", deletePage: "페이지 삭제", article: "글", diff --git a/apps/studio/src/i18n/nl.ts b/apps/studio/src/i18n/nl.ts index f3c9aa0e..c2370b96 100644 --- a/apps/studio/src/i18n/nl.ts +++ b/apps/studio/src/i18n/nl.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): nl core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 09efa7ea93f73a53 +// hash: 3309d680818aee27 import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -256,6 +256,17 @@ export const messages: Messages = { viewAll: "Alles bekijken", cli: "Vanuit de terminal", }, + pageStatus: { + publishTitle: "‘{title}’ publiceren?", + published: "Pagina gepubliceerd", + unpublished: "Pagina terug naar concept", + failed: "De status van de pagina is niet gewijzigd", + leftOut: + "{count, plural, one {# pagina is niet gepubliceerd} other {# pagina's zijn niet gepubliceerd}}", + leftOutDescription: + "Concepten en pagina's in review, ook nieuwe vertalingen, blijven buiten de live site. Publiceer ze eerst.", + review: "Pagina's bekijken", + }, articles: { description: "Berichten in de collectie “posts”.", newArticle: "Nieuw artikel", @@ -382,6 +393,8 @@ export const messages: Messages = { publishArticleDescription: "Het wordt zichtbaar op de site zodra er weer een snapshot van de site wordt gemaakt en gepubliceerd.", publishPageTitle: "Deze pagina publiceren?", + publishPageDescription: + "Ze gaat live bij de volgende publicatie van de site. Andere pagina's en talen houden hun eigen status.", deleteArticle: "Artikel verwijderen", deletePage: "Pagina verwijderen", article: "Artikel", diff --git a/apps/studio/src/i18n/pl.ts b/apps/studio/src/i18n/pl.ts index ea86256c..17c30a8d 100644 --- a/apps/studio/src/i18n/pl.ts +++ b/apps/studio/src/i18n/pl.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): pl core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 14ced29ca46593b8 +// hash: 6ed50a7b491151d3 import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -253,6 +253,17 @@ export const messages: Messages = { viewAll: "Zobacz wszystkie", cli: "Z terminala", }, + pageStatus: { + publishTitle: "Opublikować „{title}”?", + published: "Strona opublikowana", + unpublished: "Strona wróciła do szkicu", + failed: "Status strony się nie zmienił", + leftOut: + "{count, plural, one {# strona nie jest opublikowana} few {# strony nie są opublikowane} many {# stron nie jest opublikowanych} other {# strony nie jest opublikowane}}", + leftOutDescription: + "Szkice i strony w przeglądzie, w tym nowe tłumaczenia, nie pojawiają się w witrynie na żywo. Najpierw je opublikuj.", + review: "Przejrzyj strony", + }, articles: { description: "Wpisy w kolekcji „posts”.", newArticle: "Nowy artykuł", @@ -377,6 +388,8 @@ export const messages: Messages = { publishArticleDescription: "Pojawi się na witrynie przy następnym snapshocie i publikacji witryny.", publishPageTitle: "Opublikować tę stronę?", + publishPageDescription: + "Trafi na stronę przy następnej publikacji witryny. Pozostałe strony i języki zachowują własny status.", deleteArticle: "Usuń artykuł", deletePage: "Usuń stronę", article: "Artykuł", diff --git a/apps/studio/src/i18n/pt-BR.ts b/apps/studio/src/i18n/pt-BR.ts index 696bea65..e0955840 100644 --- a/apps/studio/src/i18n/pt-BR.ts +++ b/apps/studio/src/i18n/pt-BR.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): pt-BR core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 1f8074c02cd1efe3 +// hash: 63975934a742cee6 import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -256,6 +256,17 @@ export const messages: Messages = { viewAll: "Ver tudo", cli: "Pelo terminal", }, + pageStatus: { + publishTitle: "Publicar “{title}”?", + published: "Página publicada", + unpublished: "Página voltou para rascunho", + failed: "O status da página não mudou", + leftOut: + "{count, plural, one {# página não está publicada} many {# de páginas não estão publicadas} other {# páginas não estão publicadas}}", + leftOutDescription: + "Rascunhos e páginas em revisão, incluindo traduções novas, ficam fora do site no ar. Publique-os primeiro.", + review: "Revisar páginas", + }, articles: { description: "Posts da coleção “posts”.", newArticle: "Novo artigo", @@ -380,6 +391,8 @@ export const messages: Messages = { publishArticleDescription: "Ele fica visível no site na próxima vez que o site tiver um snapshot criado e for publicado.", publishPageTitle: "Publicar esta página?", + publishPageDescription: + "Ela entra no ar na próxima publicação do site. As outras páginas e idiomas mantêm o próprio status.", deleteArticle: "Excluir artigo", deletePage: "Excluir página", article: "Artigo", diff --git a/apps/studio/src/i18n/ru.ts b/apps/studio/src/i18n/ru.ts index b4d1721d..56dd3405 100644 --- a/apps/studio/src/i18n/ru.ts +++ b/apps/studio/src/i18n/ru.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): ru core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 54b05781e083b871 +// hash: 9e34b290d38a8259 import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -256,6 +256,17 @@ export const messages: Messages = { viewAll: "Показать все", cli: "Из терминала", }, + pageStatus: { + publishTitle: "Опубликовать «{title}»?", + published: "Страница опубликована", + unpublished: "Страница возвращена в черновики", + failed: "Статус страницы не изменился", + leftOut: + "{count, plural, one {# страница не опубликована} few {# страницы не опубликованы} many {# страниц не опубликовано} other {# страницы не опубликовано}}", + leftOutDescription: + "Черновики и страницы на проверке, включая новые переводы, не попадают на живой сайт. Сначала опубликуйте их.", + review: "Проверить страницы", + }, articles: { description: "Записи в коллекции «posts».", newArticle: "Новая статья", @@ -383,6 +394,8 @@ export const messages: Messages = { publishArticleDescription: "Она появится на сайте, когда в следующий раз будет сделан снимок и сайт будет опубликован.", publishPageTitle: "Опубликовать эту страницу?", + publishPageDescription: + "Страница появится на сайте при следующей публикации. Другие страницы и языки сохраняют свой статус.", deleteArticle: "Удалить статью", deletePage: "Удалить страницу", article: "Статья", diff --git a/apps/studio/src/i18n/tr.ts b/apps/studio/src/i18n/tr.ts index 054df8bb..7d10fc24 100644 --- a/apps/studio/src/i18n/tr.ts +++ b/apps/studio/src/i18n/tr.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): tr core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 5ab379b22ac5f026 +// hash: 09baf80b31d816dd import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -253,6 +253,16 @@ export const messages: Messages = { viewAll: "Tümünü gör", cli: "Terminalden", }, + pageStatus: { + publishTitle: "“{title}” yayınlansın mı?", + published: "Sayfa yayınlandı", + unpublished: "Sayfa taslağa döndü", + failed: "Sayfanın durumu değişmedi", + leftOut: "{count, number} sayfa yayınlanmadı", + leftOutDescription: + "Taslaklar ve incelemedeki sayfalar, yeni çeviriler de dahil, canlı siteye girmez. Önce onları yayınla.", + review: "Sayfaları incele", + }, articles: { description: "“posts” koleksiyonundaki yazılar.", newArticle: "Yeni makale", @@ -375,6 +385,8 @@ export const messages: Messages = { publishArticleTitle: "Bu makale yayınlansın mı?", publishArticleDescription: "Site bir sonraki snapshot ve yayınla birlikte görünür olur.", publishPageTitle: "Bu sayfa yayınlansın mı?", + publishPageDescription: + "Site bir sonraki yayınlanışında yayına girer. Diğer sayfalar ve diller kendi durumlarını korur.", deleteArticle: "Makaleyi sil", deletePage: "Sayfayı sil", article: "Makale", diff --git a/apps/studio/src/i18n/uk.ts b/apps/studio/src/i18n/uk.ts index 35e9a222..964b2ff1 100644 --- a/apps/studio/src/i18n/uk.ts +++ b/apps/studio/src/i18n/uk.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): uk core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 1fb7133c059619bd +// hash: 201a1ecea7d6a936 import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -255,6 +255,17 @@ export const messages: Messages = { viewAll: "Переглянути всі", cli: "З термінала", }, + pageStatus: { + publishTitle: "Опублікувати «{title}»?", + published: "Сторінку опубліковано", + unpublished: "Сторінку повернуто в чернетки", + failed: "Статус сторінки не змінився", + leftOut: + "{count, plural, one {# сторінка не опублікована} few {# сторінки не опубліковані} many {# сторінок не опубліковано} other {# сторінки не опубліковано}}", + leftOutDescription: + "Чернетки та сторінки на перевірці, зокрема нові переклади, не потрапляють на живий сайт. Спершу опублікуйте їх.", + review: "Переглянути сторінки", + }, articles: { description: "Дописи в колекції «posts».", newArticle: "Нова стаття", @@ -380,6 +391,8 @@ export const messages: Messages = { publishArticleDescription: "Вона стане видимою на сайті, коли наступного разу буде зроблено знімок сайту й опубліковано його.", publishPageTitle: "Опублікувати цю сторінку?", + publishPageDescription: + "Сторінка з’явиться на сайті під час наступної публікації. Інші сторінки та мови зберігають свій статус.", deleteArticle: "Видалити статтю", deletePage: "Видалити сторінку", article: "Стаття", diff --git a/apps/studio/src/i18n/zh-Hans.ts b/apps/studio/src/i18n/zh-Hans.ts index 56071766..2c54d286 100644 --- a/apps/studio/src/i18n/zh-Hans.ts +++ b/apps/studio/src/i18n/zh-Hans.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): zh-Hans core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 1072e447529d4aa6 +// hash: febd3deeadc5d99e import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -247,6 +247,15 @@ export const messages: Messages = { viewAll: "查看全部", cli: "从终端", }, + pageStatus: { + publishTitle: "发布“{title}”?", + published: "页面已发布", + unpublished: "页面已退回草稿", + failed: "页面状态未更改", + leftOut: "{count, plural, other {# 个页面未发布}}", + leftOutDescription: "草稿和审核中的页面(包括新翻译)不会出现在线上网站。请先发布它们。", + review: "查看页面", + }, articles: { description: "“posts”集合中的帖子。", newArticle: "新建文章", @@ -367,6 +376,7 @@ export const messages: Messages = { publishArticleTitle: "发布此文章?", publishArticleDescription: "下次站点创建快照并发布时,它将在站点上可见。", publishPageTitle: "发布此页面?", + publishPageDescription: "下次发布网站时上线。其他页面和语言保留各自的状态。", deleteArticle: "删除文章", deletePage: "删除页面", article: "文章", diff --git a/apps/studio/src/routes/PagesPage.tsx b/apps/studio/src/routes/PagesPage.tsx index f7f8600d..72f069e2 100644 --- a/apps/studio/src/routes/PagesPage.tsx +++ b/apps/studio/src/routes/PagesPage.tsx @@ -17,7 +17,7 @@ import { import { EyeSlash, FileText, Plus } from "@phosphor-icons/react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useNavigate, useParams } from "@tanstack/react-router"; -import { useState } from "react"; +import { type ReactNode, useState } from "react"; import { SiteCrumbs } from "../components/Crumbs.tsx"; import { deleteResourceProps } from "../components/deleteLabels.ts"; import { ErrorBanner } from "../components/ErrorBanner.tsx"; @@ -34,6 +34,7 @@ import { useMenuReferences, usePageMenus, } from "../components/Menus.tsx"; +import { usePageStatusActions } from "../components/PageStatus.tsx"; import { RowMenu } from "../components/RowMenu.tsx"; import { Status } from "../components/Status.tsx"; import { TableSkeleton } from "../components/TableSkeleton.tsx"; @@ -60,6 +61,7 @@ export function PagesPage() { const [toDelete, setToDelete] = useState(); // Pages are structure: Editors and above (RFC-0005 §2.2). const pagesGate = useGate("pages.write"); + const status = usePageStatusActions(siteId, pagesGate.reason); const deleteRefs = useMenuReferences( siteId, toDelete ? { kind: "page", id: toDelete.id } : undefined, @@ -164,9 +166,9 @@ export function PagesPage() { list={list} menus={pageMenus.get(p.id)} views={viewsColumn} - canWrite={pagesGate.allowed} - deleteDisabledReason={pagesGate.reason} + gate={pagesGate} onDelete={() => setToDelete(p)} + statusItems={status.items(p)} /> ))} @@ -182,6 +184,7 @@ export function PagesPage() { setPage={paging.setPage} /> )} + {status.dialog} !o && setToDelete(undefined)} @@ -204,18 +207,19 @@ function PageRow({ list, menus, views, - canWrite, - deleteDisabledReason, + gate, onDelete, + statusItems, }: { page: Page; siteId: string; list: TranslationList; menus: string[] | undefined; views: ListViewsColumn; - canWrite: boolean; - deleteDisabledReason: string | undefined; + /** Changing pages (Editors and above): `reason` disables Delete, Publish and Unpublish. */ + gate: { allowed: boolean; reason: string | undefined }; onDelete: () => void; + statusItems: ReactNode; }) { const t = useT(); const { locale } = useI18n(); @@ -249,7 +253,7 @@ function PageRow({ {list.groupOf && ( - {group && } + {group && } )} @@ -266,8 +270,9 @@ function PageRow({ onOpen={() => navigate({ to: "/sites/$siteId/pages/$pageId", params: { siteId, pageId: p.id } }) } + items={statusItems} onDelete={onDelete} - deleteDisabledReason={deleteDisabledReason} + deleteDisabledReason={gate.reason} /> diff --git a/apps/studio/test/page-status.test.tsx b/apps/studio/test/page-status.test.tsx new file mode 100644 index 00000000..ce426602 --- /dev/null +++ b/apps/studio/test/page-status.test.tsx @@ -0,0 +1,162 @@ +/** + * RFC-0035: an owner publishes or unpublishes one page without scheduling it — from the editor + * (Publish is a draft's next step), from the Pages list's row menu, and the site Publish dialog + * says which pages it leaves out. + */ +import type { Page } from "@cloudhal/core-contracts"; +import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { SitePublish } from "../src/components/SitePublish.tsx"; +import { StatusActions } from "../src/components/StatusActions.tsx"; +import { PagesPage } from "../src/routes/PagesPage.tsx"; +import { asRole, bodyOf, on, serve, wrap } from "./roles-fixtures.tsx"; + +const navigate = vi.fn(); +vi.mock("@tanstack/react-router", async (importOriginal) => ({ + ...(await importOriginal()), + useNavigate: () => navigate, + useParams: () => ({ siteId: "site_1" }), + useSearch: () => ({}), +})); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + navigate.mockReset(); +}); + +const page = (patch: Partial = {}): Page => ({ + schemaVersion: 1, + id: "page_1", + siteId: "site_1", + path: "/about", + title: "About", + locale: "en", + status: "draft", + sections: [], + rev: 2, + createdAt: "2026-10-08T08:00:00Z", + updatedAt: "2026-10-08T08:00:00Z", + ...patch, +}); + +const PAGES = [ + page(), + page({ id: "page_2", path: "/tr/hakkinda", title: "Hakkında", locale: "tr" }), + page({ id: "page_3", path: "/", title: "Home", status: "published" }), +]; + +const routes = [ + on("GET", "/api/sites/site_1", { + status: 200, + body: { + id: "site_1", + name: "Demo", + locales: ["en", "tr"], + pageIds: ["page_3", "page_1", "page_2"], + }, + }), + on("GET", "/api/sites/site_1/pages", { status: 200, body: PAGES }), + on("GET", "/api/sites/site_1/snapshots", { status: 200, body: [] }), + on("GET", "/api/sites/site_1/menus", { status: 200, body: [] }), + (url: string, init?: RequestInit) => { + if (init?.method !== "PUT" || !url.startsWith("/api/sites/site_1/pages/")) return undefined; + const sent = JSON.parse(String(init.body)) as Page; + return { status: 200, body: { ...sent, rev: sent.rev + 1 } }; + }, +]; + +describe("StatusActions for pages (directPublish)", () => { + it("makes Publish a draft's next step and keeps review in the menu", async () => { + const onChange = vi.fn(); + const onPublish = vi.fn(); + render( + wrap( + , + ), + ); + fireEvent.click(screen.getByTestId("publish")); + expect(onPublish).toHaveBeenCalledOnce(); + expect(screen.queryByTestId("send-to-review")).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "More actions" })); + fireEvent.click(await screen.findByRole("menuitem", { name: /Send to review/ })); + expect(onChange).toHaveBeenCalledWith("review"); + }); + + it("still shows Publish disabled to someone who may only draft", () => { + render( + wrap( + , + ), + ); + expect(screen.getByTestId("send-to-review")).toBeInTheDocument(); + expect(screen.queryByTestId("publish")).not.toBeInTheDocument(); + }); +}); + +describe("Pages list row menu", { timeout: 45_000 }, () => { + const rowOf = async (title: string) => + (await screen.findByText(title)).closest("tr") as HTMLElement; + + it("publishes a draft page after approval", async () => { + const fetch = serve(...asRole("owner"), ...routes); + render(wrap()); + const row = await rowOf("Hakkında"); + fireEvent.click(within(row).getByRole("button", { name: "More actions" })); + fireEvent.click(await screen.findByTestId("row-publish")); + expect(await screen.findByText("Publish “Hakkında”?")).toBeInTheDocument(); + fireEvent.click(screen.getByTestId("approve")); + expect(await screen.findByText("Page published")).toBeInTheDocument(); + expect(bodyOf(fetch, "PUT", "/api/sites/site_1/pages/page_2")).toMatchObject({ + id: "page_2", + rev: 2, + status: "published", + }); + }); + + it("unpublishes a published page", async () => { + const fetch = serve(...asRole("owner"), ...routes); + render(wrap()); + const row = await rowOf("Home"); + fireEvent.click(within(row).getByRole("button", { name: "More actions" })); + fireEvent.click(await screen.findByRole("menuitem", { name: /Unpublish/ })); + expect(await screen.findByText("Page moved back to draft")).toBeInTheDocument(); + expect(bodyOf(fetch, "PUT", "/api/sites/site_1/pages/page_3")).toMatchObject({ + status: "draft", + }); + }); + + it("disables both for a role that may not change pages", async () => { + serve(...asRole("author"), ...routes); + render(wrap()); + const row = await rowOf("Hakkında"); + fireEvent.click(within(row).getByRole("button", { name: "More actions" })); + expect(await screen.findByTestId("row-publish")).toHaveAttribute("data-disabled"); + }); +}); + +describe("site Publish dialog", { timeout: 45_000 }, () => { + it("says how many pages the publish leaves out, with a way to them", async () => { + serve(...asRole("owner"), ...routes); + render(wrap()); + await waitFor(() => + expect(document.querySelector("[data-publish-mode]")).toHaveAttribute( + "data-publish-mode", + "publish", + ), + ); + fireEvent.click(screen.getByRole("button", { name: "Publish" })); + expect(await screen.findByText("2 pages are not published")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: /Review pages/ })); + expect(navigate).toHaveBeenCalledWith({ + to: "/sites/$siteId/pages", + params: { siteId: "site_1" }, + }); + }); +}); diff --git a/docs/rfcs/0035-page-status-without-a-release.md b/docs/rfcs/0035-page-status-without-a-release.md new file mode 100644 index 00000000..0627b89e --- /dev/null +++ b/docs/rfcs/0035-page-status-without-a-release.md @@ -0,0 +1,61 @@ +# RFC-0035: Publishing one page without a release + +- Status: Draft (2026-10-18). Implemented in the same PR so it can be tried; it takes effect when accepted. +- Author: Halil (Madraka), drafted with Claude · Date: 2026-10-18 +- Related: RFC-0001 (page `status`), RFC-0005 (roles: pages are Editor work), RFC-0008 (releases; §24 "approving a release publishes the drafts it holds"), RFC-0033 (§2 "Publish: per translation, through each node's existing `status`"), ADR-0035. + +## Summary + +A page's own `status` can be changed directly, like an article's: Studio shows **Publish** as a draft page's next step and **Publish / Unpublish** in the Pages list's row menu, and the MCP tool `update_page` takes an optional `status`. The site Publish dialog says how many pages are still drafts or in review, since a site publish takes each page as its status says. Nothing new is enforced: the store's existing invariants (a machine translation in a review-required collection, or one with failing checks, is not published; release holds) apply unchanged. + +## Motivation + +- On the demo newsroom, four Turkish translation pages stayed drafts after the owner pressed site Publish. The snapshot went live; the pages were left out, as their status said. +- Studio offered no direct way out. A draft page's next step was "Send to review", then "Publish": a two-step editorial flow designed for articles, with nothing on a page to suggest it. The visible alternative, "Schedule…", wraps the page in a release (RFC-0008), which is far more than "publish this now". +- An agent could not do it either: `update_page` said "Status changes are not allowed here", and `schedule_release` needs `schedules:write`, which a content key does not hold. +- The restriction was not a decision. The REST API (`PUT /pages/:id`) and the store already accepted any status; RFC-0008 routes *timed* publication through releases and never claimed the only path; RFC-0033 §2 says each translation publishes through its own status. Only the MCP tool's input schema and Studio's article-shaped flow left it out. + +## Proposal + +**MCP.** `update_page` gains one optional input: + +```jsonc +"status": { "type": "string", "enum": ["draft", "review", "published", "archived"], + "description": "The page's own status (RFC-0035); live after the next site publish" } +``` + +The handler copies it onto the page before `store.updatePage`, as `update_entry` does. Scopes stay `content:write`; the call snapshots afterwards as before. + +**Studio.** + +1. `StatusActions` gains `directPublish`. With it, a draft's primary next step is **Publish** (opening the same `ApprovalDialog`), and "Send to review" moves into the More actions menu. The page editor sets it; the article editor does not, so articles keep draft → review → published. +2. The Pages list's row menu adds **Publish** (draft or review; approval dialog) and **Unpublish** (published; back to draft, no approval, as in the editor). Both are disabled with the reason when the person may not change pages (`pages.write`). +3. The site Publish dialog shows "N pages are not published" (drafts and pages in review) with a **Review pages** action, read only while the dialog is open. +4. The page publish dialog says the page goes live with the next site publish and that other pages and languages keep their own status. + +**Invariants** (unchanged, restated): + +1. A page's status changes only through `store.updatePage`, so `checkLocalizedNode` still refuses `published` for a machine translation with failing checks (`invalid_translation`) or in a collection listed in `localization.review.required` without a review (`review_required`). +2. A page held by a pending release cannot be changed (`assertUnlocked`), so this never races a scheduled publish. +3. A page's status is working-copy state. Visitors see it only after a site publish (snapshot), which keeps its own approval point and role rules (RFC-0005 §4). + +## Alternatives considered + +| Option | Pros | Cons | Why not | +| --- | --- | --- | --- | +| "Publish now" through a one-item release (`publish-now`) | reuses RFC-0008 approvals and history | needs `schedules:write`/`releases:publish`; publishes the whole site at once; heavy for a status flag | a release is for timing; nothing here is timed | +| Site Publish also publishes every draft page | one click | publishes work nobody meant to ship; breaks per-translation review (RFC-0033) | silent and unsafe | +| Leave it; document "send to review, then publish" | no change | the gap that left four translations offline remains; agents still cannot do it | the problem is real | +| A separate `set_page_status` tool | narrow, auditable | one more tool for a field `update_entry` already takes inline | inconsistent with articles | + +## Compatibility & migration + +No contract change (`Page.status` exists), no data migration. `update_page` callers that never send `status` behave as before. Rolling back the code removes the input; pages keep whatever status they were given. + +## Security & policy impact + +No new scope. `update_page` already needs `content:write`, and the REST route `PUT /pages/:id` already accepted a status with the same scope; the MCP tool now matches it. Going live still requires a site publish, which is approval-gated for agents. + +## Open questions + +1. Should the article editor offer `directPublish` to Editors too (skipping review for their own articles)? Not proposed here. diff --git a/packages/mcp-server/src/page-tools.ts b/packages/mcp-server/src/page-tools.ts index b5d6fa7f..0d6cf4de 100644 --- a/packages/mcp-server/src/page-tools.ts +++ b/packages/mcp-server/src/page-tools.ts @@ -175,7 +175,7 @@ const updatePageTool: ToolDefinition = { name: "update_page", title: "Update page", description: - "Replace a page's title, path, sections or seo, or set its own motion level and chrome (RFC-0025: header default|transparent|hidden, footer default|minimal|hidden; null goes back to the site's). Requires the current `rev` (optimistic locking). Status changes are not allowed here. Snapshots afterwards.", + "Replace a page's title, path, sections or seo, or set its own motion level and chrome (RFC-0025: header default|transparent|hidden, footer default|minimal|hidden; null goes back to the site's). Set `status` (draft, review, published, archived; RFC-0035) to publish or unpublish this one page: a published page goes live with the next site publish, and a machine translation in a review-required collection needs a person's review first. Requires the current `rev` (optimistic locking). Snapshots afterwards.", inputSchema: obj( { siteId: id, @@ -185,6 +185,11 @@ const updatePageTool: ToolDefinition = { path: str("New path"), sections: sectionInput, seo: { type: "object" }, + status: { + type: "string", + enum: ["draft", "review", "published", "archived"], + description: "The page's own status (RFC-0035); live after the next site publish", + }, motion: { type: ["string", "null"], enum: ["none", "subtle", "standard", "expressive", null], @@ -241,6 +246,7 @@ const updatePageTool: ToolDefinition = { ...(a.path ? { path: a.path as string } : {}), ...(a.sections ? { sections: a.sections as Section[] } : {}), ...(a.seo ? { seo: a.seo as Seo } : {}), + ...(a.status ? { status: a.status as Page["status"] } : {}), }; try { const saved = await ctx.store.updatePage(next, { writer: mcpWriter(ctx) }); diff --git a/packages/mcp-server/test/translation-tools.test.ts b/packages/mcp-server/test/translation-tools.test.ts index 0a6fcd94..5ca8c997 100644 --- a/packages/mcp-server/test/translation-tools.test.ts +++ b/packages/mcp-server/test/translation-tools.test.ts @@ -204,6 +204,38 @@ describe("translation tools", () => { }); }); + it("publishes and unpublishes one translation with update_page (RFC-0035)", async () => { + const node = (await call("save_translation", spanish())).result.node as Page; + const published = ( + await call("update_page", { + siteId: site.id, + pageId: node.id, + rev: node.rev, + status: "published", + }) + ).result.page as Page; + expect(published).toMatchObject({ status: "published", title: "Lanzamiento" }); + const back = ( + await call("update_page", { + siteId: site.id, + pageId: node.id, + rev: published.rev, + status: "draft", + }) + ).result.page as Page; + expect(back.status).toBe("draft"); + // A collection whose machine translations need a person's review: refused until reviewed. + const latest = (await store.getSite(site.id)) as Site; + await store.updateSite({ + ...latest, + localization: { ...latest.localization, review: { required: ["pages"] } }, + }); + await expect( + call("update_page", { siteId: site.id, pageId: node.id, rev: back.rev, status: "published" }), + ).rejects.toMatchObject({ code: "review_required" }); + expect((await store.getPage(node.id))?.status).toBe("draft"); + }); + it("translates entries too, and refuses a locale the site does not have", async () => { const post = await store.createContentEntry({ siteId: site.id,