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
6 changes: 6 additions & 0 deletions .changeset/studio-pending-badge.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@cloudhal/api": patch
"@cloudhal/cli": patch
---

Studio shows changes that are not live yet: a badge beside Publish with the count ("2 changes", or "Not published" before the first publish) and a tooltip by kind ("3 changes aren't live yet · 2 articles and 1 page"). It comes from the new `GET /api/sites/:siteId/pending` (`site:read`): counts of what a publish would add, change and remove, by kind; adding or removing a page counts once. Studio refreshes it after every write to the site, on window focus and every 30 s.
71 changes: 71 additions & 0 deletions apps/studio/src/components/PendingChanges.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { Badge, Tooltip } from "@cloudhal/ui";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect } from "react";
import { formatNumber, listOf, type TFunction, useI18n } from "../i18n/index.tsx";
import { api, onApiWrite, type PendingChanges } from "../lib/api.ts";

type NodeKind = keyof PendingChanges["byKind"];

export const pendingKey = (siteId: string) => ["pending", siteId] as const;

/** Writes that change nothing a publish would carry (draft renders, preview links). */
const NOT_CONTENT = /\/(preview|preview-fragment|preview-links)$/;

/**
* What a publish would carry (`GET …/pending`), for the badge beside Publish. Every successful
* write to this site through the API client invalidates it (`onApiWrite`), so saves, publishes,
* rollbacks, approvals, media, menus, redirects, settings and taxonomy all refresh it without each
* screen knowing; agents' changes show on the next window focus or after 30 s.
*/
export function usePendingChanges(siteId: string) {
const qc = useQueryClient();
useEffect(() => {
const prefix = `/api/sites/${siteId}`;
return onApiWrite((path) => {
if ((path === prefix || path.startsWith(`${prefix}/`)) && !NOT_CONTENT.test(path))
void qc.invalidateQueries({ queryKey: pendingKey(siteId) });
});
}, [qc, siteId]);
return useQuery({
queryKey: pendingKey(siteId),
queryFn: () => api.sites.pending(siteId),
staleTime: 30_000,
refetchOnWindowFocus: true,
retry: false,
});
}

/** "3 changes aren't live yet · 2 articles and 1 page" (or the never-published sentence). */
export function pendingText(t: TFunction, pending: PendingChanges): string {
const kinds = listOf(
(Object.entries(pending.byKind) as [NodeKind, number][]).map(([kind, count]) =>
t("publishing.pendingKind", { kind, count }),
),
);
return pending.live
? t("publishing.pendingSummary", { count: pending.counts.total, kinds })
: t("publishing.firstPublish", { kinds });
}

/**
* The badge beside Publish (DESIGN.md §2d): the number of changes a publish would carry, or "Not
* published" for a site that never went live, with the breakdown in its tooltip; phones show the
* number only. Nothing when everything is live.
*/
export function PendingBadge({ pending }: { pending: PendingChanges | undefined }) {
const { t, locale } = useI18n();
if (!pending || (pending.live && pending.counts.total === 0)) return null;
return (
<Tooltip content={pendingText(t, pending)} side="bottom">
<Badge variant="warning" appearance="dot">
{/* The top bar is full on phones: the number alone, the words from `sm` up. */}
<span className="sm:hidden">{formatNumber(locale, pending.counts.total)}</span>
<span className="max-sm:hidden">
{pending.live
? t("publishing.pending", { count: pending.counts.total })
: t("publishing.notPublished")}
</span>
</Badge>
</Tooltip>
);
}
15 changes: 13 additions & 2 deletions apps/studio/src/components/SitePublish.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { useT } from "../i18n/index.tsx";
import { api } from "../lib/api.ts";
import { apiErrorText } from "../lib/api-errors.ts";
import { UnpublishedPagesNote } from "./PageStatus.tsx";
import { PendingBadge, pendingText, usePendingChanges } from "./PendingChanges.tsx";
import { usePublishMode, usePublishSite } from "./Publish.tsx";
import { useToast } from "./Toast.ts";

Expand All @@ -22,7 +23,8 @@ import { useToast } from "./Toast.ts";
* the person's role (§2s): an Author whose changes include others' work sends "Request publish"
* (the server files a request an Editor approves, 202); a Contributor sees Publish disabled with
* the reason, since an Editor publishes their work. The palette's Publish command opens the same
* confirmation (RFC-0029 §7), never publishing on its own.
* confirmation (RFC-0029 §7), never publishing on its own. Beside it, `PendingBadge` says how many
* changes are not live yet; the Publish button's accessible name carries the same sentence.
*/
export function SitePublish({
siteId,
Expand Down Expand Up @@ -52,8 +54,12 @@ export function SitePublish({
onError: (e) => toast.error(t("snapshots.failed"), apiErrorText(t, e)),
});
const request = mode === "request";
const action = request ? t("publishing.request") : t("site.publish");
const pending = usePendingChanges(siteId).data;
const waiting = pending && (!pending.live || pending.counts.total > 0);
return (
<>
<PendingBadge pending={pending} />
<ButtonGroup aria-label={t("site.publish")}>
<Button
variant="primary"
Expand All @@ -63,9 +69,14 @@ export function SitePublish({
loading={publish.isPending || snapshot.isPending}
disabled={mode === "none"}
title={reason}
aria-label={
waiting
? t("publishing.withPending", { action, summary: pendingText(t, pending) })
: undefined
}
data-publish-mode={mode}
>
{request ? t("publishing.request") : t("site.publish")}
{action}
</Button>
<DropdownMenu>
<DropdownMenu.Trigger
Expand Down
11 changes: 10 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: 4197f5e07bd13c5e
// hash: 73826adcc08f4e01
import type { Messages } from "./en.ts";

export const messages: Messages = {
Expand Down Expand Up @@ -2730,6 +2730,15 @@ export const messages: Messages = {
"تتضمن تغييراتك عملًا ليس لك، لذا يجب أن يوافق محرر على النشر. ستجد الطلب في الموافقات.",
openRequest: "افتح الموافقات",
refused: "لا يمكنك النشر",
pending:
"{count, plural, zero {لا تغييرات} one {تغيير واحد} two {تغييران} few {# تغييرات} many {# تغييرًا} other {# تغيير}}",
pendingSummary:
"{count, plural, zero {لا تغييرات غير منشورة} one {تغيير واحد لم يُنشر بعد} two {تغييران لم يُنشرا بعد} few {# تغييرات لم تُنشر بعد} many {# تغييرًا لم يُنشر بعد} other {# تغيير لم يُنشر بعد}} · {kinds}",
firstPublish: "غير منشور بعد. أول نشر سيجعل هذه مباشرة: {kinds}.",
pendingKind:
"{kind, select, page {{count, plural, zero {لا صفحات} one {صفحة واحدة} two {صفحتان} few {# صفحات} many {# صفحة} other {# صفحة}}} contentEntry {{count, plural, zero {لا مقالات} one {مقالة واحدة} two {مقالتان} few {# مقالات} many {# مقالة} other {# مقالة}}} site {إعدادات الموقع} taxonomy {{count, plural, zero {لا تصنيفات} one {تصنيف واحد} two {تصنيفان} few {# تصنيفات} many {# تصنيفًا} other {# تصنيف}}} term {{count, plural, zero {لا مصطلحات} one {مصطلح واحد} two {مصطلحان} few {# مصطلحات} many {# مصطلحًا} other {# مصطلح}}} redirect {{count, plural, zero {لا عمليات إعادة توجيه} one {إعادة توجيه واحدة} two {إعادتا توجيه} few {# عمليات إعادة توجيه} many {# إعادة توجيه} other {# إعادة توجيه}}} menu {{count, plural, zero {لا قوائم} one {قائمة واحدة} two {قائمتان} few {# قوائم} many {# قائمة} other {# قائمة}}} asset {{count, plural, zero {لا ملفات وسائط} one {ملف وسائط واحد} two {ملفا وسائط} few {# ملفات وسائط} many {# ملف وسائط} other {# ملف وسائط}}} glossary {المسرد} other {{count, plural, zero {لا عناصر} one {عنصر واحد} two {عنصران} few {# عناصر} many {# عنصرًا} other {# عنصر}}}}",
withPending: "{action}. {summary}",
notPublished: "غير منشور",
},
liveSite: {
view: "عرض الموقع",
Expand Down
10 changes: 9 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: 26ca067e6f1fbc17
// hash: 44b05f74914d641c
import type { Messages } from "./en.ts";

export const messages: Messages = {
Expand Down Expand Up @@ -2858,6 +2858,14 @@ export const messages: Messages = {
"Deine Änderungen enthalten Arbeit, die nicht deine ist, daher muss ein Redakteur die Veröffentlichung freigeben. Du findest die Anfrage in Freigaben.",
openRequest: "Freigaben öffnen",
refused: "Du kannst nicht veröffentlichen",
pending: "{count, plural, one {# Änderung} other {# Änderungen}}",
pendingSummary:
"{count, plural, one {# Änderung ist noch nicht live} other {# Änderungen sind noch nicht live}} · {kinds}",
firstPublish: "Noch nicht veröffentlicht. Die erste Veröffentlichung bringt live: {kinds}.",
pendingKind:
"{kind, select, page {{count, plural, one {# Seite} other {# Seiten}}} contentEntry {{count, plural, one {# Artikel} other {# Artikel}}} site {Website-Einstellungen} taxonomy {{count, plural, one {# Taxonomie} other {# Taxonomien}}} term {{count, plural, one {# Begriff} other {# Begriffe}}} redirect {{count, plural, one {# Weiterleitung} other {# Weiterleitungen}}} menu {{count, plural, one {# Menü} other {# Menüs}}} asset {{count, plural, one {# Mediendatei} other {# Mediendateien}}} glossary {das Glossar} other {{count, plural, one {# Element} other {# Elemente}}}}",
withPending: "{action}. {summary}",
notPublished: "Nicht veröffentlicht",
},
liveSite: {
view: "Website ansehen",
Expand Down
14 changes: 14 additions & 0 deletions apps/studio/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2654,6 +2654,20 @@ export const en = {
"Your changes include work that isn't yours, so an Editor must approve the publish. You'll find the request in Approvals.",
openRequest: "Open Approvals",
refused: "You can't publish",
/** The badge beside Publish: how many changes a publish would carry. */
pending: "{count, plural, one {# change} other {# changes}}",
/** The badge's tooltip; `kinds` is a list of `pendingKind`, e.g. "2 articles and 1 page". */
pendingSummary:
"{count, plural, one {# change isn't live yet} other {# changes aren't live yet}} · {kinds}",
/** The tooltip of a site that was never published; `kinds` as in `pendingSummary`. */
firstPublish: "Not published yet. The first publish puts {kinds} live.",
/** One kind of node in the pending changes, with its count. */
pendingKind:
"{kind, select, page {{count, plural, one {# page} other {# pages}}} contentEntry {{count, plural, one {# article} other {# articles}}} site {site settings} taxonomy {{count, plural, one {# taxonomy} other {# taxonomies}}} term {{count, plural, one {# term} other {# terms}}} redirect {{count, plural, one {# redirect} other {# redirects}}} menu {{count, plural, one {# menu} other {# menus}}} asset {{count, plural, one {# media file} other {# media files}}} glossary {the glossary} other {{count, plural, one {# item} other {# items}}}}",
/** The Publish button's accessible name while changes wait: the label, then the tooltip text. */
withPending: "{action}. {summary}",
/** The badge beside Publish for a site that was never published. */
notPublished: "Not published",
},
liveSite: {
view: "View site",
Expand Down
10 changes: 9 additions & 1 deletion apps/studio/src/i18n/es.ts
Original file line number Diff line number Diff line change
@@ -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: 716940210f07c125
// hash: 5a723f044877f0c7
import type { Messages } from "./en.ts";

export const messages: Messages = {
Expand Down Expand Up @@ -2839,6 +2839,14 @@ export const messages: Messages = {
"Tus cambios incluyen trabajo que no es tuyo, así que un Editor debe aprobar la publicación. Encontrarás la solicitud en Aprobaciones.",
openRequest: "Abrir Aprobaciones",
refused: "No puedes publicar",
pending: "{count, plural, one {# cambio} many {# de cambios} other {# cambios}}",
pendingSummary:
"{count, plural, one {# cambio aún no está publicado} many {# de cambios aún no están publicados} other {# cambios aún no están publicados}} · {kinds}",
firstPublish: "Aún sin publicar. La primera publicación pone en línea: {kinds}.",
pendingKind:
"{kind, select, page {{count, plural, one {# página} many {# de páginas} other {# páginas}}} contentEntry {{count, plural, one {# artículo} many {# de artículos} other {# artículos}}} site {la configuración del sitio} taxonomy {{count, plural, one {# taxonomía} many {# de taxonomías} other {# taxonomías}}} term {{count, plural, one {# término} many {# de términos} other {# términos}}} redirect {{count, plural, one {# redirección} many {# de redirecciones} other {# redirecciones}}} menu {{count, plural, one {# menú} many {# de menús} other {# menús}}} asset {{count, plural, one {# archivo multimedia} many {# de archivos multimedia} other {# archivos multimedia}}} glossary {el glosario} other {{count, plural, one {# elemento} many {# de elementos} other {# elementos}}}}",
withPending: "{action}. {summary}",
notPublished: "Sin publicar",
},
liveSite: {
view: "Ver sitio",
Expand Down
10 changes: 9 additions & 1 deletion apps/studio/src/i18n/fa.ts
Original file line number Diff line number Diff line change
@@ -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: b5bc1a7e882dfb4f
// hash: 1b955626801b1c9a
import type { Messages } from "./en.ts";

export const messages: Messages = {
Expand Down Expand Up @@ -2798,6 +2798,14 @@ export const messages: Messages = {
"تغییرات شما شامل کاری است که مال شما نیست، بنابراین یک ویرایشگر باید انتشار را تأیید کند. درخواست را در تأییدها پیدا می‌کنید.",
openRequest: "باز کردن تأییدها",
refused: "شما نمی‌توانید منتشر کنید",
pending: "{count, plural, one {# تغییر} other {# تغییر}}",
pendingSummary:
"{count, plural, one {# تغییر هنوز منتشر نشده است} other {# تغییر هنوز منتشر نشده‌اند}} · {kinds}",
firstPublish: "هنوز منتشر نشده است. اولین انتشار این‌ها را منتشر می‌کند: {kinds}.",
pendingKind:
"{kind, select, page {{count, plural, one {# صفحه} other {# صفحه}}} contentEntry {{count, plural, one {# مقاله} other {# مقاله}}} site {تنظیمات سایت} taxonomy {{count, plural, one {# طبقه‌بندی} other {# طبقه‌بندی}}} term {{count, plural, one {# واژه} other {# واژه}}} redirect {{count, plural, one {# تغییر مسیر} other {# تغییر مسیر}}} menu {{count, plural, one {# منو} other {# منو}}} asset {{count, plural, one {# فایل رسانه} other {# فایل رسانه}}} glossary {واژه‌نامه} other {{count, plural, one {# مورد} other {# مورد}}}}",
withPending: "{action}. {summary}",
notPublished: "منتشر نشده",
},
liveSite: {
view: "مشاهدهٔ سایت",
Expand Down
11 changes: 10 additions & 1 deletion apps/studio/src/i18n/fr.ts
Original file line number Diff line number Diff line change
@@ -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: 0bf11932d888cc28
// hash: 50175a555a425cfc
import type { Messages } from "./en.ts";

export const messages: Messages = {
Expand Down Expand Up @@ -2879,6 +2879,15 @@ export const messages: Messages = {
"Vos modifications incluent du travail qui n’est pas le vôtre ; un Éditeur doit donc approuver la publication. Vous trouverez la demande dans Approbations.",
openRequest: "Ouvrir Approbations",
refused: "Vous ne pouvez pas publier",
pending:
"{count, plural, one {# modification} many {# de modifications} other {# modifications}}",
pendingSummary:
"{count, plural, one {# modification n’est pas encore en ligne} many {# de modifications ne sont pas encore en ligne} other {# modifications ne sont pas encore en ligne}} · {kinds}",
firstPublish: "Pas encore publié. La première publication met en ligne : {kinds}.",
pendingKind:
"{kind, select, page {{count, plural, one {# page} many {# de pages} other {# pages}}} contentEntry {{count, plural, one {# article} many {# d’articles} other {# articles}}} site {les paramètres du site} taxonomy {{count, plural, one {# taxonomie} many {# de taxonomies} other {# taxonomies}}} term {{count, plural, one {# terme} many {# de termes} other {# termes}}} redirect {{count, plural, one {# redirection} many {# de redirections} other {# redirections}}} menu {{count, plural, one {# menu} many {# de menus} other {# menus}}} asset {{count, plural, one {# fichier média} many {# de fichiers média} other {# fichiers média}}} glossary {le glossaire} other {{count, plural, one {# élément} many {# d’éléments} other {# éléments}}}}",
withPending: "{action}. {summary}",
notPublished: "Non publié",
},
liveSite: {
view: "Voir le site",
Expand Down
5 changes: 5 additions & 0 deletions apps/studio/src/i18n/generated/params.ts
Original file line number Diff line number Diff line change
Expand Up @@ -513,6 +513,11 @@ export interface MessageParams {
"proposals.step.move": { before: string | number };
"proposals.step.replace": { before: string | number };
"proposals.step.replaceText": { before: string | number; after: string | number };
"publishing.firstPublish": { kinds: string | number };
"publishing.pending": { count: number };
"publishing.pendingKind": { kind: "page" | "contentEntry" | "site" | "taxonomy" | "term" | "redirect" | "menu" | "asset" | "glossary"; count: number };
"publishing.pendingSummary": { count: number; kinds: string | number };
"publishing.withPending": { action: string | number; summary: string | number };
"readers.chart.description": { period: string | number; total: string | number };
"readers.delta": { delta: string | number; days: string | number };
"readers.figures": { period: string | number };
Expand Down
Loading
Loading