@simmalugnt-se/payload-content-health 0.5.2 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,28 +2,64 @@
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { Button, Link, ReactSelect } from "@payloadcms/ui";
4
4
  import { useId, useMemo, useState } from "react";
5
+ import { categoryOf } from "../categories.js";
5
6
  import { LIST_COPY } from "../copy.js";
6
7
  import { BATCH_CHECKS, batchURL, filterQuery, MAX_BATCH_TARGETS, matchesFilters, } from "../links.js";
8
+ import { CHECK_IDS } from "../types.js";
7
9
  /**
8
- * The findings with filters on check, collection and locale. Filters apply in the browser and are
9
- * kept in the address, so the view can be linked to and reloaded with them.
10
+ * The findings, one category at a time and one section per check. The category and the collection
11
+ * and locale filters apply in the browser and are kept in the address, so the view can be linked to
12
+ * and reloaded with them.
10
13
  */
11
- export function WorkList({ adminRoute, batchAsk, checks, collections, initialFilters, language, locales, rows, }) {
14
+ export function WorkList({ adminRoute, batchAsk, categories, collections, initialFilters, language, locales, rows, }) {
12
15
  const copy = LIST_COPY[language];
16
+ const panelId = useId();
13
17
  const [filters, setFilters] = useState(initialFilters);
14
- const shown = useMemo(() => rows.filter((row) => matchesFilters(row, filters)), [rows, filters]);
15
- const documents = new Set(shown.map((row) => row.href.split("?")[0])).size;
16
- const filtered = Boolean(filters.check || filters.collection || filters.locale);
17
- // One check at a time: the assistant fixes a batch with one prompt.
18
- const ask = filters.check && BATCH_CHECKS[filters.check] ? batchAsk[filters.check] : undefined;
19
- const batch = ask ? shown.flatMap((row) => (row.batchTarget ? [row.batchTarget] : [])) : [];
20
- const batchHref = ask && batch.length > 1 ? batchURL(adminRoute, filters, batch, ask) : undefined;
18
+ const category = filters.category ?? categories[0]?.id;
19
+ // Tab counts follow the collection and locale filters, so they match what the tab then shows.
20
+ const narrowed = useMemo(() => rows.filter((row) => matchesFilters(row, { collection: filters.collection, locale: filters.locale })), [rows, filters.collection, filters.locale]);
21
+ const counts = useMemo(() => {
22
+ const result = new Map();
23
+ for (const row of narrowed) {
24
+ const id = categoryOf(row.check);
25
+ result.set(id, (result.get(id) ?? 0) + 1);
26
+ }
27
+ return result;
28
+ }, [narrowed]);
29
+ const shown = narrowed.filter((row) => categoryOf(row.check) === category);
30
+ const sections = CHECK_IDS.flatMap((check) => {
31
+ const own = shown.filter((row) => row.check === check);
32
+ return own.length ? [{ check, rows: own }] : [];
33
+ });
34
+ const everywhere = rows.some((row) => categoryOf(row.check) === category);
21
35
  const update = (next) => {
22
36
  setFilters(next);
23
37
  const query = filterQuery(next);
24
38
  window.history.replaceState(window.history.state, "", `${window.location.pathname}${query ? `?${query}` : ""}`);
25
39
  };
26
- return (_jsxs("div", { className: "content-health-list__body", children: [_jsxs("div", { className: "content-health-list__filters", children: [_jsx(Filter, { label: copy.check, all: copy.all, onChange: (check) => update({ ...filters, check: check }), options: checks, value: filters.check }), collections.length > 1 ? (_jsx(Filter, { label: copy.collection, all: copy.all, onChange: (collection) => update({ ...filters, collection }), options: collections, value: filters.collection })) : null, locales.length > 1 ? (_jsx(Filter, { label: copy.locale, all: copy.all, onChange: (locale) => update({ ...filters, locale }), options: locales, value: filters.locale })) : null, filtered ? (_jsx(Button, { buttonStyle: "subtle", className: "content-health-list__clear", margin: false, onClick: () => update({}), size: "small", children: copy.clear })) : null] }), _jsxs("div", { className: "content-health-list__summary", children: [_jsx("p", { "aria-live": "polite", children: shown.length ? copy.summary(shown.length, documents) : null }), batchHref ? (_jsx(Button, { buttonStyle: "primary", el: "link", margin: false, size: "small", url: batchHref, children: copy.fixMany(Math.min(batch.length, MAX_BATCH_TARGETS), batch.length) })) : null] }), shown.length ? (_jsx("ul", { className: "content-health-list__rows", children: shown.map((row) => (_jsxs("li", { className: "content-health-list__row", children: [_jsxs("div", { className: "content-health-list__what", children: [_jsx("span", { className: "content-health-list__check", children: row.checkLabel }), row.detail ? (_jsx("span", { className: "content-health-list__detail", children: row.detail })) : null] }), _jsxs("div", { className: "content-health-list__document", children: [_jsx(Link, { href: row.href, prefetch: false, children: row.title }), row.locale ? (_jsx("span", { className: "content-health-list__pill", children: row.locale })) : null, row.draft ? (_jsx("span", { className: "content-health-list__pill content-health-list__pill--draft", children: copy.draft })) : null] }), _jsx("div", { className: "content-health-list__actions", children: row.askHref ? (_jsx(Button, { buttonStyle: "secondary", el: "link", margin: false, size: "small", url: row.askHref, children: copy.fix })) : null })] }, row.key))) })) : (_jsx("p", { className: "content-health-list__empty", children: filtered ? copy.noneFiltered : copy.none }))] }));
40
+ const moveTab = (event, index) => {
41
+ const step = event.key === "ArrowRight" ? 1 : event.key === "ArrowLeft" ? -1 : 0;
42
+ const target = categories[(index + step + categories.length) % categories.length];
43
+ if (!step || !target)
44
+ return;
45
+ event.preventDefault();
46
+ update({ ...filters, category: target.id });
47
+ document.getElementById(`${panelId}-tab-${target.id}`)?.focus();
48
+ };
49
+ return (_jsxs("div", { className: "content-health-list__body", children: [_jsx("div", { "aria-label": copy.categories, className: "content-health-list__tabs", role: "tablist", children: categories.map((entry, index) => {
50
+ const selected = entry.id === category;
51
+ return (_jsxs("button", { "aria-controls": `${panelId}-panel`, "aria-selected": selected, className: `content-health-list__tab${selected ? " content-health-list__tab--selected" : ""}`, id: `${panelId}-tab-${entry.id}`, onClick: () => update({ ...filters, category: entry.id }), onKeyDown: (event) => moveTab(event, index), role: "tab", tabIndex: selected ? 0 : -1, type: "button", children: [_jsxs("span", { className: "content-health-list__tab-name", children: [_jsx("span", { "aria-hidden": "true", className: `content-health-list__dot content-health-list__dot--${entry.tone}` }), entry.label] }), _jsx("span", { className: "content-health-list__tab-count", children: counts.get(entry.id) ?? 0 })] }, entry.id));
52
+ }) }), collections.length > 1 || locales.length > 1 ? (_jsxs("div", { className: "content-health-list__filters", children: [collections.length > 1 ? (_jsx(Filter, { label: copy.collection, all: copy.all, onChange: (collection) => update({ ...filters, category, collection }), options: collections, value: filters.collection })) : null, locales.length > 1 ? (_jsx(Filter, { label: copy.locale, all: copy.all, onChange: (locale) => update({ ...filters, category, locale }), options: locales, value: filters.locale })) : null] })) : null, _jsx("div", { "aria-labelledby": category ? `${panelId}-tab-${category}` : undefined, className: "content-health-list__panel", id: `${panelId}-panel`, role: "tabpanel", children: sections.length ? (sections.map((section) => (_jsx(Section, { adminRoute: adminRoute, ask: batchAsk[section.check], copy: copy, filters: { ...filters, category }, rows: section.rows }, section.check)))) : (_jsx("p", { className: "content-health-list__empty", children: everywhere ? copy.noneFiltered : copy.categoryOk })) })] }));
53
+ }
54
+ function Section({ adminRoute, ask, copy, filters, rows, }) {
55
+ const check = rows[0]?.check;
56
+ const documents = new Set(rows.map((row) => row.href.split("?")[0])).size;
57
+ const targets = ask && BATCH_CHECKS[check] ? rows.flatMap((row) => row.batchTarget ?? []) : [];
58
+ // The check says how far each fix may reach, so the batch link carries it.
59
+ const batchHref = ask && targets.length > 1
60
+ ? batchURL(adminRoute, { ...filters, check }, targets, ask)
61
+ : undefined;
62
+ return (_jsxs("section", { className: "content-health-list__section", children: [_jsxs("header", { className: "content-health-list__section-head", children: [_jsx("h2", { children: rows[0]?.checkLabel }), _jsx("span", { className: "content-health-list__pill", children: copy.documents(documents) }), batchHref ? (_jsx(Button, { buttonStyle: "secondary", className: "content-health-list__batch", el: "link", margin: false, size: "small", url: batchHref, children: copy.fixMany(Math.min(targets.length, MAX_BATCH_TARGETS), targets.length) })) : null] }), _jsx("ul", { className: "content-health-list__items", children: rows.map((row) => (_jsxs("li", { className: "content-health-list__item", children: [_jsx(Link, { href: row.href, prefetch: false, children: row.title }), row.locale ? _jsx("span", { className: "content-health-list__pill", children: row.locale }) : null, row.draft ? (_jsx("span", { className: "content-health-list__pill content-health-list__pill--draft", children: copy.draft })) : null, row.detail ? _jsx("span", { className: "content-health-list__detail", children: row.detail }) : null, row.askHref ? (_jsx(Link, { className: "content-health-list__fix", href: row.askHref, prefetch: false, children: copy.fix })) : null] }, row.key))) })] }));
27
63
  }
28
64
  function Filter({ all, label, onChange, options, value, }) {
29
65
  const inputId = useId();
@@ -1,7 +1,8 @@
1
1
  import type { WidgetServerProps } from "payload";
2
+ import "../styles/tokens.css";
2
3
  import "../styles/widget.css";
3
4
  /**
4
- * Dashboard widget: the share of checked fields in order, overall and per check, and the documents
5
- * that need work, each linking to the document in the right locale.
5
+ * Dashboard widget: the share of checked fields in order, overall and per category. Each category
6
+ * links to its part of the work list, where the documents are.
6
7
  */
7
8
  export declare function ContentHealthWidget({ req, widgetData }: WidgetServerProps): Promise<import("react").JSX.Element>;
@@ -1,15 +1,17 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { COPY, findingDetail, STATUS, TILE_LABELS, uiLanguage } from "../copy.js";
2
+ import { Button } from "@payloadcms/ui";
3
+ import { categoryStats, categoryTone } from "../categories.js";
4
+ import { CATEGORY_LABELS, COPY, uiLanguage } from "../copy.js";
3
5
  import { checkStats, countDocuments } from "../group.js";
4
- import { editURL, listURL } from "../links.js";
6
+ import { listURL } from "../links.js";
5
7
  import { scanContentHealth } from "../scan.js";
6
8
  import { CHECK_IDS } from "../types.js";
7
9
  import { Ring } from "./Ring.js";
10
+ import "../styles/tokens.css";
8
11
  import "../styles/widget.css";
9
- const ROWS_PER_CHECK = 4;
10
12
  /**
11
- * Dashboard widget: the share of checked fields in order, overall and per check, and the documents
12
- * that need work, each linking to the document in the right locale.
13
+ * Dashboard widget: the share of checked fields in order, overall and per category. Each category
14
+ * links to its part of the work list, where the documents are.
13
15
  */
14
16
  export async function ContentHealthWidget({ req, widgetData }) {
15
17
  const language = uiLanguage(req.i18n?.language);
@@ -23,34 +25,19 @@ export async function ContentHealthWidget({ req, widgetData }) {
23
25
  checks: selected,
24
26
  });
25
27
  const stats = checkStats(result.checks, result.checked, result.findings);
28
+ const categories = categoryStats(stats);
26
29
  const total = stats.reduce((sum, stat) => sum + stat.checked, 0);
27
30
  const failing = stats.reduce((sum, stat) => sum + stat.failing, 0);
28
- const passing = stats.filter((stat) => stat.checked > 0 && stat.failing === 0).length;
29
31
  const documents = Object.values(result.documents).reduce((sum, count) => sum + count, 0);
30
32
  const adminRoute = req.payload.config.routes.admin;
31
- return (_jsxs("section", { className: "content-health", children: [_jsx("h2", { className: "content-health__heading", children: copy.title }), _jsxs("div", { className: "content-health__panel", children: [_jsxs("div", { className: "content-health__overview", children: [_jsx(Ring, { className: "content-health__ring content-health__ring--large", failing: failing, label: copy.ok(total - failing, total), total: total }), _jsxs("div", { children: [_jsx("p", { className: "content-health__overall", children: copy.overall }), _jsxs("p", { className: "content-health__summary", children: [result.findings.length
32
- ? copy.summary(countDocuments(result.findings), documents)
33
- : copy.allGood, passing > 0 && result.findings.length > 0 ? ` · ${copy.passing(passing)}` : null] })] })] }), _jsx("ul", { className: "content-health__tiles", children: stats.map((stat) => (_jsx(CheckTile, { adminRoute: adminRoute, language: language, stat: stat }, stat.check))) }), result.problems.length ? (_jsxs("div", { className: "content-health__problems", children: [_jsx("p", { children: copy.problems }), _jsx("ul", { children: result.problems.map((problem) => (_jsx("li", { children: problem }, problem))) })] })) : null] })] }));
33
+ return (_jsxs("section", { className: "content-health", children: [_jsx("h2", { className: "content-health__heading", children: copy.title }), _jsxs("div", { className: "content-health__panel", children: [_jsxs("div", { className: "content-health__overview", children: [_jsx(Ring, { className: "content-health__ring", failing: failing, label: copy.ok(total - failing, total), total: total }), _jsxs("div", { className: "content-health__overview-text", children: [_jsx("p", { className: "content-health__overall", children: copy.overall }), _jsx("p", { className: "content-health__summary", children: result.findings.length
34
+ ? copy.summary(countDocuments(result.findings), documents)
35
+ : copy.allGood })] }), _jsx(Button, { buttonStyle: "secondary", el: "link", margin: false, size: "small", url: listURL(adminRoute), children: copy.openDetails })] }), _jsx("ul", { className: "content-health__categories", children: categories.map((stat) => (_jsx(CategoryRow, { adminRoute: adminRoute, language: language, stat: stat }, stat.category))) }), result.problems.length ? (_jsxs("div", { className: "content-health__problems", children: [_jsx("p", { children: copy.problems }), _jsx("ul", { children: result.problems.map((problem) => (_jsx("li", { children: problem }, problem))) })] })) : null] })] }));
34
36
  }
35
- function CheckTile({ adminRoute, language, stat, }) {
37
+ function CategoryRow({ adminRoute, language, stat, }) {
36
38
  const copy = COPY[language];
37
- const status = !stat.checked
38
- ? copy.nothingYet
39
- : stat.failing
40
- ? STATUS[language][stat.check](stat.checked - stat.failing, stat.checked)
41
- : copy.checkPasses;
42
- return (_jsxs("li", { className: "content-health__tile", children: [_jsxs("div", { className: "content-health__tile-head", children: [_jsx(Ring, { className: "content-health__ring", failing: stat.failing, label: status, total: stat.checked }), _jsxs("div", { children: [_jsx("h3", { className: "content-health__tile-title", children: TILE_LABELS[language][stat.check] }), _jsx("p", { className: "content-health__tile-status", children: status })] })] }), stat.documents.length ? (_jsxs("ul", { className: "content-health__rows", children: [stat.documents.slice(0, ROWS_PER_CHECK).map((row) => (_jsx(FindingRow, { adminRoute: adminRoute, language: language, row: row }, `${row.collection}:${row.id}`))), stat.documents.length > ROWS_PER_CHECK ? (_jsx("li", { className: "content-health__more", children: _jsx("a", { href: listURL(adminRoute, { check: stat.check }), children: copy.more(stat.documents.length - ROWS_PER_CHECK) }) })) : null] })) : null] }));
43
- }
44
- /**
45
- * A document on its own line, then its locales: one per line when they carry details (lengths, word
46
- * counts), otherwise side by side.
47
- */
48
- function FindingRow({ adminRoute, language, row, }) {
49
- const locales = row.findings.map((finding) => ({
50
- finding,
51
- detail: findingDetail(language, finding),
52
- }));
53
- const stacked = locales.some(({ detail }) => detail);
54
- const shown = locales.filter(({ finding, detail }) => finding.locale || detail);
55
- return (_jsxs("li", { className: "content-health__row", children: [_jsx("a", { href: editURL(adminRoute, row.findings[0]), children: row.title }), shown.length ? (_jsx("ul", { className: `content-health__locales${stacked ? " content-health__locales--stacked" : ""}`, children: shown.map(({ finding, detail }) => (_jsxs("li", { className: "content-health__locale", children: [finding.locale ? (_jsx("a", { className: "content-health__pill", href: editURL(adminRoute, finding), children: finding.locale })) : null, detail ? _jsx("span", { className: "content-health__detail", children: detail }) : null] }, finding.locale ?? ""))) })) : null] }));
39
+ const tone = categoryTone(stat);
40
+ const share = stat.checked ? (stat.checked - stat.failing) / stat.checked : 0;
41
+ const status = tone === "none" ? copy.nothingYet : stat.failing ? copy.toFix(stat.failing) : copy.checkPasses;
42
+ return (_jsx("li", { children: _jsxs("a", { className: "content-health__category", href: listURL(adminRoute, { category: stat.category }), children: [_jsx("span", { className: "content-health__category-name", children: CATEGORY_LABELS[language][stat.category] }), _jsx("span", { "aria-hidden": "true", className: "content-health__bar", children: _jsx("span", { className: `content-health__bar-fill content-health__bar-fill--${tone}`, style: { width: `${Math.round(share * 100)}%` } }) }), _jsx("span", { className: `content-health__category-status content-health__tone--${tone}`, children: status })] }) }));
56
43
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simmalugnt-se/payload-content-health",
3
- "version": "0.5.2",
3
+ "version": "0.6.0",
4
4
  "description": "Content health checks and dashboard widgets for Payload Admin: alt texts, SEO fields and more",
5
5
  "keywords": [
6
6
  "payload",