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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/update-page-status.md
Original file line number Diff line number Diff line change
@@ -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.
123 changes: 123 additions & 0 deletions apps/studio/src/components/PageStatus.tsx
Original file line number Diff line number Diff line change
@@ -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<Page | undefined>();
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" ? (
<DropdownMenu.Item
icon={ArrowCounterClockwise}
disabled={reason !== undefined || change.isPending}
onClick={() => change.mutate({ ...page, status: "draft" })}
>
{t("editor.unpublish")}
</DropdownMenu.Item>
) : page.status === "archived" ? null : (
<DropdownMenu.Item
icon={RocketLaunch}
disabled={reason !== undefined}
onClick={() => setToPublish(page)}
data-testid="row-publish"
>
{t("common.publish")}
</DropdownMenu.Item>
);
const dialog = (
<ApprovalDialog
open={toPublish !== undefined}
onOpenChange={(open) => !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 (
<Banner
variant="secondary"
size="sm"
icon={<Info />}
title={t("pageStatus.leftOut", { count })}
description={t("pageStatus.leftOutDescription")}
action={
<Banner.Action
variant="secondary"
icon={<DirectionalIcon icon={ArrowRight} />}
onClick={() => {
onNavigate();
void navigate({ to: "/sites/$siteId/pages", params: { siteId } });
}}
>
{t("pageStatus.review")}
</Banner.Action>
}
data-testid="unpublished-pages"
/>
);
}
5 changes: 5 additions & 0 deletions apps/studio/src/components/RowMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
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). */
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;
Expand Down Expand Up @@ -38,6 +42,7 @@ export function RowMenu({
<DropdownMenu.Item icon={directional(ArrowSquareOut)} onClick={onOpen}>
{t("sites.open")}
</DropdownMenu.Item>
{items}
{onDelete &&
(deleteDisabledReason ? (
// A disabled item takes no pointer events, so the tooltip hangs on a wrapper.
Expand Down
5 changes: 4 additions & 1 deletion apps/studio/src/components/SitePublish.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -107,7 +108,9 @@ export function SitePublish({
cancelLabel={t("common.cancel")}
busy={publish.isPending}
onConfirm={() => publish.mutate({ label: t("snapshots.publishedFromStudio") })}
/>
>
<UnpublishedPagesNote siteId={siteId} open={open} onNavigate={() => setOpen(false)} />
</ApprovalDialog>
</>
);
}
76 changes: 41 additions & 35 deletions apps/studio/src/components/StatusActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -31,6 +34,7 @@ export function StatusActions({
allowed,
publishReason,
readOnlyReason,
directPublish,
}: {
status: PublishStatus;
busy?: boolean | undefined;
Expand All @@ -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") ? (
<Button
variant="secondary"
icon={<DirectionalIcon icon={PaperPlaneTilt} />}
onClick={() => onChange("review")}
disabled={busy || locked}
title={readOnlyReason}
data-testid="send-to-review"
>
{draftOnly ? t("editor.submitForReview") : t("editor.sendToReview")}
</Button>
) : status === "review" ? (
<Button
variant="secondary"
icon={<RocketLaunch />}
onClick={onPublish}
disabled={busy || locked || draftOnly}
title={readOnlyReason ?? (draftOnly ? publishReason : undefined)}
data-testid="publish"
>
{t("common.publish")}
</Button>
) : status === "archived" && may("draft") ? (
<Button
variant="secondary"
icon={<ArrowCounterClockwise />}
onClick={() => onChange("draft")}
disabled={busy || locked}
title={readOnlyReason}
>
{t("editor.restore")}
</Button>
) : null;
const publishNext = status === "review" || (status === "draft" && directPublish && !draftOnly);
const next = publishNext ? (
<Button
variant="secondary"
icon={<RocketLaunch />}
onClick={onPublish}
disabled={busy || locked || draftOnly}
title={readOnlyReason ?? (draftOnly ? publishReason : undefined)}
data-testid="publish"
>
{t("common.publish")}
</Button>
) : status === "draft" && may("review") ? (
<Button
variant="secondary"
icon={<DirectionalIcon icon={PaperPlaneTilt} />}
onClick={() => onChange("review")}
disabled={busy || locked}
title={readOnlyReason}
data-testid="send-to-review"
>
{draftOnly ? t("editor.submitForReview") : t("editor.sendToReview")}
</Button>
) : status === "archived" && may("draft") ? (
<Button
variant="secondary"
icon={<ArrowCounterClockwise />}
onClick={() => onChange("draft")}
disabled={busy || locked}
title={readOnlyReason}
>
{t("editor.restore")}
</Button>
) : 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) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export function PageEditorActions({
</Tooltip>
<StatusActions
status={draft.status}
directPublish
busy={save.isPending}
onChange={(next: PublishStatus) => {
if (valid()) save.mutate({ ...draft, status: next });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
15 changes: 14 additions & 1 deletion apps/studio/src/i18n/ar.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down Expand Up @@ -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: "مقالة جديدة",
Expand Down Expand Up @@ -374,6 +385,8 @@ export const messages: Messages = {
publishArticleDescription:
"تصبح ظاهرة على الموقع في المرة التالية التي تُلتقط فيها لقطة للموقع وتُنشر.",
publishPageTitle: "أتريد نشر هذه الصفحة؟",
publishPageDescription:
"تُنشر الصفحة عند نشر الموقع في المرة القادمة. تحتفظ الصفحات واللغات الأخرى بحالتها الخاصة.",
deleteArticle: "احذف المقالة",
deletePage: "احذف الصفحة",
article: "مقالة",
Expand Down
15 changes: 14 additions & 1 deletion apps/studio/src/i18n/de.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
12 changes: 12 additions & 0 deletions apps/studio/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
Loading
Loading