@simmalugnt-se/payload-content-health 0.4.1 → 0.5.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.
- package/CHANGELOG.md +13 -0
- package/README.md +11 -1
- package/dist/admin/ContentHealthFieldBridge.js +28 -4
- package/dist/admin/reveal-plan.d.ts +10 -2
- package/dist/admin/reveal-plan.js +41 -21
- package/dist/copy.d.ts +8 -0
- package/dist/copy.js +74 -0
- package/dist/links.d.ts +23 -0
- package/dist/links.js +46 -0
- package/dist/styles/view.css +9 -0
- package/dist/view/ContentHealthView.js +10 -3
- package/dist/view/WorkList.d.ts +6 -1
- package/dist/view/WorkList.js +7 -3
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.0
|
|
4
|
+
|
|
5
|
+
- "Fix N with the assistant" on the work list, when it is filtered to missing alt texts, meta titles
|
|
6
|
+
or descriptions (missing or the wrong length) or missing translations, in collections where the
|
|
7
|
+
editor assistant (0.11.0 or later) allows `content.update`. It opens the assistant's panel with up
|
|
8
|
+
to 20 of the documents shown and one prompt for them all (`BATCH_ASK`), through `?batch=`,
|
|
9
|
+
`?batchScope=` and `?ask=`.
|
|
10
|
+
|
|
11
|
+
## 0.4.2
|
|
12
|
+
|
|
13
|
+
- Work-list links open collapsed Payload `collapsible` fields around the target field, found by the
|
|
14
|
+
id Payload gives them (the same fix as visual editing's).
|
|
15
|
+
|
|
3
16
|
## 0.4.1
|
|
4
17
|
|
|
5
18
|
- Signing in from the work list leads back to it. The login link's `redirect` lacked the Admin route
|
package/README.md
CHANGED
|
@@ -139,11 +139,21 @@ other; they share these names:
|
|
|
139
139
|
| `?field=<path>` | link → this plugin | Form path to bring into view |
|
|
140
140
|
| `?ask=<prompt>` | link → assistant | Prompt to put in the message box |
|
|
141
141
|
| `content-health:select` (`detail: { path }`) | this plugin → assistant | The field brought into view is selected |
|
|
142
|
-
| `config.admin.custom.editorAssistant` | assistant → this plugin | `{ collections }`
|
|
142
|
+
| `config.admin.custom.editorAssistant` | assistant → this plugin | `{ collections, batchCollections }` |
|
|
143
|
+
| `?batch=…&batchScope=…&ask=…` | link → assistant | Documents to fix at once, and the prompt |
|
|
143
144
|
|
|
144
145
|
Both parameters are removed from the address once read. `editURL(adminRoute, finding, prompt?)` and
|
|
145
146
|
`listURL(adminRoute, filters)` build these links.
|
|
146
147
|
|
|
148
|
+
### Many documents at once
|
|
149
|
+
|
|
150
|
+
Where the assistant allows `content.update` (0.11.0 or later), a list filtered to missing alt texts,
|
|
151
|
+
meta titles or descriptions (missing or the wrong length) or missing translations offers "Fix N with
|
|
152
|
+
the assistant" above the rows. It opens the assistant's panel beside the list with up to 20 of the
|
|
153
|
+
documents shown and one prompt for all of them; the assistant proposes a change per document, and
|
|
154
|
+
the editor saves the ones they want. Saved documents drop out of the list. `batchURL(adminRoute,
|
|
155
|
+
filters, targets, prompt)` and `batchTarget(finding)` build the link.
|
|
156
|
+
|
|
147
157
|
## Running the checks yourself
|
|
148
158
|
|
|
149
159
|
```ts
|
|
@@ -120,8 +120,8 @@ function formRoot(anchor) {
|
|
|
120
120
|
return view?.querySelector(".collection-edit__form") ?? document;
|
|
121
121
|
}
|
|
122
122
|
/**
|
|
123
|
-
* Open the rows and
|
|
124
|
-
* Returns the innermost scope
|
|
123
|
+
* Open the rows, tabs and collapsibles the plan needs, one level at a time.
|
|
124
|
+
* Returns the innermost scope, where the target renders.
|
|
125
125
|
*/
|
|
126
126
|
async function openSteps(steps, root, current) {
|
|
127
127
|
let scope = root;
|
|
@@ -137,6 +137,26 @@ async function openSteps(steps, root, current) {
|
|
|
137
137
|
scope = row;
|
|
138
138
|
continue;
|
|
139
139
|
}
|
|
140
|
+
if (step.kind === "collapsible") {
|
|
141
|
+
const collapsibleScope = scope;
|
|
142
|
+
const field = await waitForRendered(() => collapsibleScope.querySelector(`[id="${collapsibleDomId(step.path)}"]`), collapsibleScope);
|
|
143
|
+
const element = field?.querySelector(":scope > .collapsible");
|
|
144
|
+
const button = element?.querySelector(":scope > .collapsible__toggle-wrap > .collapsible__toggle");
|
|
145
|
+
if (!element || !button) {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
if (element.classList.contains("collapsible--collapsed")) {
|
|
149
|
+
button.click();
|
|
150
|
+
}
|
|
151
|
+
const content = await waitFor(() => element.classList.contains("collapsible--collapsed")
|
|
152
|
+
? null
|
|
153
|
+
: element.querySelector(".collapsible__content"));
|
|
154
|
+
if (!content) {
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
scope = content;
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
140
160
|
const tabsScope = scope;
|
|
141
161
|
const tabs = await waitForRendered(() => tabsFieldsIn(tabsScope)[step.ordinal] ?? null, tabsScope);
|
|
142
162
|
const button = tabs?.querySelectorAll(":scope > .tabs-field__tabs-wrap > .tabs-field__tabs > .tabs-field__tab-button")[step.index];
|
|
@@ -171,13 +191,17 @@ async function waitForRendered(find, scope, timeoutMs = WAIT_MS) {
|
|
|
171
191
|
}
|
|
172
192
|
return waitFor(find, timeoutMs);
|
|
173
193
|
}
|
|
174
|
-
/** Tabs fields at this level of the scope, in DOM order; nested
|
|
194
|
+
/** Tabs fields at this level of the scope, in DOM order; nested containers are other scopes. */
|
|
175
195
|
function tabsFieldsIn(scope) {
|
|
176
196
|
return Array.from(scope.querySelectorAll(".tabs-field")).filter((element) => {
|
|
177
|
-
const outer = element.parentElement?.closest(".tabs-field, .blocks-field, .array-field");
|
|
197
|
+
const outer = element.parentElement?.closest(".tabs-field, .collapsible-field, .blocks-field, .array-field");
|
|
178
198
|
return !outer || !scope.contains(outer);
|
|
179
199
|
});
|
|
180
200
|
}
|
|
201
|
+
/** Payload's id for a collapsible field: `field-collapsible-` and its path with `__` for dots. */
|
|
202
|
+
function collapsibleDomId(path) {
|
|
203
|
+
return `field-collapsible-${path.replace(/\./g, "__")}`;
|
|
204
|
+
}
|
|
181
205
|
function waitFor(find, timeoutMs = WAIT_MS) {
|
|
182
206
|
const deadline = performance.now() + timeoutMs;
|
|
183
207
|
return new Promise((resolve) => {
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Work out which tabs must be opened to show a form path.
|
|
3
|
-
* so a field in another tab has no DOM element until its tab button is clicked.
|
|
2
|
+
* Work out which tabs and collapsibles must be opened to show a form path.
|
|
4
3
|
* Pure: takes client field config, returns steps for the Admin bridge to perform.
|
|
5
4
|
*/
|
|
6
5
|
export type FieldLike = {
|
|
@@ -33,6 +32,15 @@ export type RevealStep =
|
|
|
33
32
|
ordinal: number;
|
|
34
33
|
index: number;
|
|
35
34
|
}
|
|
35
|
+
/**
|
|
36
|
+
* Open the collapsible field Payload renders at this path, then enter its content. The path is
|
|
37
|
+
* Payload's own for an unnamed field (`meta._index-0-1`) and gives its DOM id, so a collapsible
|
|
38
|
+
* hidden by a condition does not shift which one is opened.
|
|
39
|
+
*/
|
|
40
|
+
| {
|
|
41
|
+
kind: "collapsible";
|
|
42
|
+
path: string;
|
|
43
|
+
}
|
|
36
44
|
/** Enter a block or array row. The row element becomes the scope. */
|
|
37
45
|
| {
|
|
38
46
|
kind: "row";
|
|
@@ -3,8 +3,7 @@
|
|
|
3
3
|
* clicked in the preview. The packages do not import each other; keep fixes in step with that copy.
|
|
4
4
|
*/
|
|
5
5
|
/**
|
|
6
|
-
* Work out which tabs must be opened to show a form path.
|
|
7
|
-
* so a field in another tab has no DOM element until its tab button is clicked.
|
|
6
|
+
* Work out which tabs and collapsibles must be opened to show a form path.
|
|
8
7
|
* Pure: takes client field config, returns steps for the Admin bridge to perform.
|
|
9
8
|
*/
|
|
10
9
|
export function planReveal(rootFields, path, lookup) {
|
|
@@ -18,22 +17,22 @@ function walkPath(rootFields, path, lookup) {
|
|
|
18
17
|
const segments = path.split(".");
|
|
19
18
|
const steps = [];
|
|
20
19
|
let fields = rootFields;
|
|
21
|
-
let counter = {
|
|
20
|
+
let counter = { tabs: 0 };
|
|
22
21
|
let walked = [];
|
|
23
22
|
let last = null;
|
|
24
23
|
for (let i = 0; i < segments.length;) {
|
|
25
24
|
const segment = segments[i] ?? "";
|
|
26
|
-
const found = findIn(fields, segment, counter);
|
|
25
|
+
const found = findIn(fields, segment, counter, { dataPath: walked.join("."), indexPath: "" });
|
|
27
26
|
if (!found) {
|
|
28
27
|
return null;
|
|
29
28
|
}
|
|
30
|
-
steps.push(...found.
|
|
29
|
+
steps.push(...found.steps);
|
|
31
30
|
counter = found.counter;
|
|
32
31
|
walked = [...walked, segment];
|
|
33
32
|
i += 1;
|
|
34
33
|
if (found.kind === "namedTab") {
|
|
35
34
|
fields = found.fields;
|
|
36
|
-
counter = {
|
|
35
|
+
counter = { tabs: 0 };
|
|
37
36
|
continue;
|
|
38
37
|
}
|
|
39
38
|
const field = found.field;
|
|
@@ -46,7 +45,7 @@ function walkPath(rootFields, path, lookup) {
|
|
|
46
45
|
steps.push({ kind: "row", parentPath, index: Number(next) });
|
|
47
46
|
walked = [...walked, next];
|
|
48
47
|
i += 1;
|
|
49
|
-
counter = {
|
|
48
|
+
counter = { tabs: 0 };
|
|
50
49
|
if (field.type === "array") {
|
|
51
50
|
fields = field.fields ?? [];
|
|
52
51
|
continue;
|
|
@@ -59,7 +58,7 @@ function walkPath(rootFields, path, lookup) {
|
|
|
59
58
|
continue;
|
|
60
59
|
}
|
|
61
60
|
if (field.fields) {
|
|
62
|
-
// Named group: same DOM scope, keep counting
|
|
61
|
+
// Named group: same DOM scope, keep counting revealable fields.
|
|
63
62
|
fields = field.fields;
|
|
64
63
|
continue;
|
|
65
64
|
}
|
|
@@ -68,51 +67,72 @@ function walkPath(rootFields, path, lookup) {
|
|
|
68
67
|
}
|
|
69
68
|
return { steps, field: last };
|
|
70
69
|
}
|
|
71
|
-
function findIn(fields, name, counter) {
|
|
72
|
-
for (const field of fields) {
|
|
70
|
+
function findIn(fields, name, counter, place) {
|
|
71
|
+
for (const [position, field] of fields.entries()) {
|
|
73
72
|
if (field.type === "tabs") {
|
|
74
|
-
const ordinal = counter.
|
|
75
|
-
counter.
|
|
73
|
+
const ordinal = counter.tabs;
|
|
74
|
+
counter.tabs += 1;
|
|
75
|
+
const tabsPlace = unnamedChild(place, position);
|
|
76
76
|
for (const [index, tab] of (field.tabs ?? []).entries()) {
|
|
77
77
|
const step = { kind: "tab", ordinal, index };
|
|
78
78
|
if (tab.name === name) {
|
|
79
79
|
return {
|
|
80
80
|
kind: "namedTab",
|
|
81
81
|
fields: tab.fields ?? [],
|
|
82
|
-
|
|
83
|
-
counter: {
|
|
82
|
+
steps: [step],
|
|
83
|
+
counter: { tabs: 0 },
|
|
84
84
|
};
|
|
85
85
|
}
|
|
86
86
|
if (!tab.name) {
|
|
87
|
-
const inner = findIn(tab.fields ?? [], name, {
|
|
87
|
+
const inner = findIn(tab.fields ?? [], name, { tabs: 0 }, unnamedChild(tabsPlace, index));
|
|
88
88
|
if (inner) {
|
|
89
|
-
return { ...inner,
|
|
89
|
+
return { ...inner, steps: [step, ...inner.steps] };
|
|
90
90
|
}
|
|
91
91
|
}
|
|
92
92
|
}
|
|
93
93
|
continue;
|
|
94
94
|
}
|
|
95
95
|
if (field.name === name) {
|
|
96
|
-
return { kind: "field", field,
|
|
96
|
+
return { kind: "field", field, steps: [], counter };
|
|
97
97
|
}
|
|
98
98
|
if (!field.name && field.fields) {
|
|
99
|
-
// Row, collapsible or unnamed group:
|
|
100
|
-
|
|
99
|
+
// Row, collapsible or unnamed group: no segment in the form path. A collapsible is its own
|
|
100
|
+
// DOM scope, found by the path Payload gives it.
|
|
101
|
+
const own = unnamedChild(place, position);
|
|
102
|
+
if (field.type === "collapsible") {
|
|
103
|
+
const inner = findIn(field.fields, name, { tabs: 0 }, own);
|
|
104
|
+
if (inner) {
|
|
105
|
+
const path = [place.dataPath, `_index-${own.indexPath}`].filter(Boolean).join(".");
|
|
106
|
+
return { ...inner, steps: [{ kind: "collapsible", path }, ...inner.steps] };
|
|
107
|
+
}
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
const inner = findIn(field.fields, name, counter, own);
|
|
101
111
|
if (inner) {
|
|
102
112
|
return inner;
|
|
103
113
|
}
|
|
104
114
|
continue;
|
|
105
115
|
}
|
|
106
|
-
counter.
|
|
116
|
+
counter.tabs += countTabs(field);
|
|
107
117
|
}
|
|
108
118
|
return null;
|
|
109
119
|
}
|
|
120
|
+
/** The place of the unnamed field at `position`, whose own fields render there too. */
|
|
121
|
+
function unnamedChild(place, position) {
|
|
122
|
+
return {
|
|
123
|
+
dataPath: place.dataPath,
|
|
124
|
+
indexPath: place.indexPath ? `${place.indexPath}-${position}` : String(position),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
110
127
|
/** Tabs fields rendered inside a field that is skipped, so later ordinals stay aligned with the DOM. */
|
|
111
128
|
function countTabs(field) {
|
|
112
129
|
if (field.type === "tabs") {
|
|
113
130
|
return 1;
|
|
114
131
|
}
|
|
115
|
-
if (field.type === "blocks" ||
|
|
132
|
+
if (field.type === "blocks" ||
|
|
133
|
+
field.type === "array" ||
|
|
134
|
+
field.type === "collapsible" ||
|
|
135
|
+
!field.fields) {
|
|
116
136
|
return 0;
|
|
117
137
|
}
|
|
118
138
|
return field.fields.reduce((sum, child) => sum + countTabs(child), 0);
|
package/dist/copy.d.ts
CHANGED
|
@@ -50,6 +50,12 @@ type Ask = (finding: Finding) => string;
|
|
|
50
50
|
* open in the finding's locale with the field selected, so the prompt does not name either.
|
|
51
51
|
*/
|
|
52
52
|
export declare const ASK: Record<UiLanguage, Record<CheckId, Ask>>;
|
|
53
|
+
type BatchAsk = (finding: Finding) => string;
|
|
54
|
+
/**
|
|
55
|
+
* The prompt behind "Fix N with the assistant", once for every document of a check. The assistant
|
|
56
|
+
* opens each document with the field selected; the numbers are the check's, not one document's.
|
|
57
|
+
*/
|
|
58
|
+
export declare const BATCH_ASK: Record<UiLanguage, Partial<Record<CheckId, BatchAsk>>>;
|
|
53
59
|
export declare const LIST_COPY: {
|
|
54
60
|
readonly en: {
|
|
55
61
|
readonly title: "Content health";
|
|
@@ -65,6 +71,7 @@ export declare const LIST_COPY: {
|
|
|
65
71
|
readonly issue: "Issue";
|
|
66
72
|
readonly draft: "Draft";
|
|
67
73
|
readonly fix: "Fix with the assistant";
|
|
74
|
+
readonly fixMany: (count: number, total: number) => string;
|
|
68
75
|
readonly none: "No issues found.";
|
|
69
76
|
readonly noneFiltered: "No issues match these filters.";
|
|
70
77
|
};
|
|
@@ -82,6 +89,7 @@ export declare const LIST_COPY: {
|
|
|
82
89
|
readonly issue: "Problem";
|
|
83
90
|
readonly draft: "Utkast";
|
|
84
91
|
readonly fix: "Åtgärda med assistenten";
|
|
92
|
+
readonly fixMany: (count: number, total: number) => string;
|
|
85
93
|
readonly none: "Inga problem hittades.";
|
|
86
94
|
readonly noneFiltered: "Inga problem matchar filtren.";
|
|
87
95
|
};
|
package/dist/copy.js
CHANGED
|
@@ -183,6 +183,74 @@ export const ASK = {
|
|
|
183
183
|
: "Översätt de tomma fälten.",
|
|
184
184
|
},
|
|
185
185
|
};
|
|
186
|
+
/** The range a length finding aims for, from either kind of meta finding. */
|
|
187
|
+
const aim = ({ length, range }) => length ?? range;
|
|
188
|
+
/**
|
|
189
|
+
* The prompt behind "Fix N with the assistant", once for every document of a check. The assistant
|
|
190
|
+
* opens each document with the field selected; the numbers are the check's, not one document's.
|
|
191
|
+
*/
|
|
192
|
+
export const BATCH_ASK = {
|
|
193
|
+
en: {
|
|
194
|
+
"media.alt-missing": () => "Write an alt text for the image.",
|
|
195
|
+
"seo.title-missing": (finding) => {
|
|
196
|
+
const range = aim(finding);
|
|
197
|
+
return range
|
|
198
|
+
? `Write a meta title for the page, ${range.min}–${range.max} characters.`
|
|
199
|
+
: "Write a meta title for the page.";
|
|
200
|
+
},
|
|
201
|
+
"seo.title-length": (finding) => {
|
|
202
|
+
const range = aim(finding);
|
|
203
|
+
return range
|
|
204
|
+
? `Rewrite the meta title to ${range.min}–${range.max} characters.`
|
|
205
|
+
: "Rewrite the meta title to a better length.";
|
|
206
|
+
},
|
|
207
|
+
"seo.description-missing": (finding) => {
|
|
208
|
+
const range = aim(finding);
|
|
209
|
+
return range
|
|
210
|
+
? `Write a meta description for the page, ${range.min}–${range.max} characters.`
|
|
211
|
+
: "Write a meta description for the page.";
|
|
212
|
+
},
|
|
213
|
+
"seo.description-length": (finding) => {
|
|
214
|
+
const range = aim(finding);
|
|
215
|
+
return range
|
|
216
|
+
? `Rewrite the meta description to ${range.min}–${range.max} characters.`
|
|
217
|
+
: "Rewrite the meta description to a better length.";
|
|
218
|
+
},
|
|
219
|
+
"content.untranslated": ({ untranslated }) => untranslated
|
|
220
|
+
? `Translate the empty fields from ${languageName("en", untranslated.source)}.`
|
|
221
|
+
: "Translate the empty fields.",
|
|
222
|
+
},
|
|
223
|
+
sv: {
|
|
224
|
+
"media.alt-missing": () => "Skriv en alt-text för bilden.",
|
|
225
|
+
"seo.title-missing": (finding) => {
|
|
226
|
+
const range = aim(finding);
|
|
227
|
+
return range
|
|
228
|
+
? `Skriv en metatitel för sidan, ${range.min}–${range.max} tecken.`
|
|
229
|
+
: "Skriv en metatitel för sidan.";
|
|
230
|
+
},
|
|
231
|
+
"seo.title-length": (finding) => {
|
|
232
|
+
const range = aim(finding);
|
|
233
|
+
return range
|
|
234
|
+
? `Skriv om metatiteln till ${range.min}–${range.max} tecken.`
|
|
235
|
+
: "Skriv om metatiteln till en bättre längd.";
|
|
236
|
+
},
|
|
237
|
+
"seo.description-missing": (finding) => {
|
|
238
|
+
const range = aim(finding);
|
|
239
|
+
return range
|
|
240
|
+
? `Skriv en metabeskrivning för sidan, ${range.min}–${range.max} tecken.`
|
|
241
|
+
: "Skriv en metabeskrivning för sidan.";
|
|
242
|
+
},
|
|
243
|
+
"seo.description-length": (finding) => {
|
|
244
|
+
const range = aim(finding);
|
|
245
|
+
return range
|
|
246
|
+
? `Skriv om metabeskrivningen till ${range.min}–${range.max} tecken.`
|
|
247
|
+
: "Skriv om metabeskrivningen till en bättre längd.";
|
|
248
|
+
},
|
|
249
|
+
"content.untranslated": ({ untranslated }) => untranslated
|
|
250
|
+
? `Översätt de tomma fälten från ${languageName("sv", untranslated.source)}.`
|
|
251
|
+
: "Översätt de tomma fälten.",
|
|
252
|
+
},
|
|
253
|
+
};
|
|
186
254
|
export const LIST_COPY = {
|
|
187
255
|
en: {
|
|
188
256
|
title: "Content health",
|
|
@@ -198,6 +266,9 @@ export const LIST_COPY = {
|
|
|
198
266
|
issue: "Issue",
|
|
199
267
|
draft: "Draft",
|
|
200
268
|
fix: "Fix with the assistant",
|
|
269
|
+
fixMany: (count, total) => count < total
|
|
270
|
+
? `Fix ${count} of ${total} with the assistant`
|
|
271
|
+
: `Fix ${count} with the assistant`,
|
|
201
272
|
none: "No issues found.",
|
|
202
273
|
noneFiltered: "No issues match these filters.",
|
|
203
274
|
},
|
|
@@ -215,6 +286,9 @@ export const LIST_COPY = {
|
|
|
215
286
|
issue: "Problem",
|
|
216
287
|
draft: "Utkast",
|
|
217
288
|
fix: "Åtgärda med assistenten",
|
|
289
|
+
fixMany: (count, total) => count < total
|
|
290
|
+
? `Åtgärda ${count} av ${total} med assistenten`
|
|
291
|
+
: `Åtgärda ${count} med assistenten`,
|
|
218
292
|
none: "Inga problem hittades.",
|
|
219
293
|
noneFiltered: "Inga problem matchar filtren.",
|
|
220
294
|
},
|
package/dist/links.d.ts
CHANGED
|
@@ -21,6 +21,20 @@ export declare const SELECT_EVENT = "content-health:select";
|
|
|
21
21
|
* `config.admin.custom.editorAssistant.collections`. The work list offers the assistant only there.
|
|
22
22
|
*/
|
|
23
23
|
export declare const ASSISTANT_CUSTOM_KEY = "editorAssistant";
|
|
24
|
+
/**
|
|
25
|
+
* Parameters read by `@simmalugnt-se/payload-editor-assistant` on any Admin address: the documents
|
|
26
|
+
* to fix at once (`collection:id:locale:path`, comma separated), how far each fix may reach, and the
|
|
27
|
+
* prompt in `ask`. The assistant's panel opens in batch mode and removes them.
|
|
28
|
+
*/
|
|
29
|
+
export declare const BATCH_PARAM = "batch";
|
|
30
|
+
export declare const BATCH_SCOPE_PARAM = "batchScope";
|
|
31
|
+
/** Documents per batch, as the assistant takes them. */
|
|
32
|
+
export declare const MAX_BATCH_TARGETS = 20;
|
|
33
|
+
/**
|
|
34
|
+
* Checks the assistant can fix in many documents at once, and what each fix may reach: the
|
|
35
|
+
* finding's field, or every field a translation still has to fill.
|
|
36
|
+
*/
|
|
37
|
+
export declare const BATCH_CHECKS: Partial<Record<CheckId, "field" | "translation">>;
|
|
24
38
|
/**
|
|
25
39
|
* Where to send a visitor who may not use Admin, or null to show the work list. Payload leaves
|
|
26
40
|
* custom views public, so the view checks this itself, as Payload does for its own: to the login
|
|
@@ -37,6 +51,15 @@ export declare function accessRedirect(args: {
|
|
|
37
51
|
searchParams?: Record<string, string | string[] | undefined>;
|
|
38
52
|
}): string | null;
|
|
39
53
|
export declare function assistantCollections(adminCustom: unknown): string[];
|
|
54
|
+
/** Collections where the assistant allows batch changes: `editorAssistant.batchCollections`. */
|
|
55
|
+
export declare function batchCollections(adminCustom: unknown): string[];
|
|
56
|
+
/** A finding as one document of a batch: `collection:id:locale:path`. */
|
|
57
|
+
export declare function batchTarget(finding: Finding): string;
|
|
58
|
+
/**
|
|
59
|
+
* The work list with its filters, asking the assistant to fix the first `MAX_BATCH_TARGETS` of
|
|
60
|
+
* `targets` with `ask`. The check filter says how far each fix may reach.
|
|
61
|
+
*/
|
|
62
|
+
export declare function batchURL(adminRoute: string, filters: ListFilters, targets: string[], ask: string): string;
|
|
40
63
|
/** The document in the finding's locale with its field brought into view, and a prompt when given. */
|
|
41
64
|
export declare function editURL(adminRoute: string, finding: Finding, ask?: string): string;
|
|
42
65
|
export type ListFilters = {
|
package/dist/links.js
CHANGED
|
@@ -27,6 +27,27 @@ export const SELECT_EVENT = "content-health:select";
|
|
|
27
27
|
* `config.admin.custom.editorAssistant.collections`. The work list offers the assistant only there.
|
|
28
28
|
*/
|
|
29
29
|
export const ASSISTANT_CUSTOM_KEY = "editorAssistant";
|
|
30
|
+
/**
|
|
31
|
+
* Parameters read by `@simmalugnt-se/payload-editor-assistant` on any Admin address: the documents
|
|
32
|
+
* to fix at once (`collection:id:locale:path`, comma separated), how far each fix may reach, and the
|
|
33
|
+
* prompt in `ask`. The assistant's panel opens in batch mode and removes them.
|
|
34
|
+
*/
|
|
35
|
+
export const BATCH_PARAM = "batch";
|
|
36
|
+
export const BATCH_SCOPE_PARAM = "batchScope";
|
|
37
|
+
/** Documents per batch, as the assistant takes them. */
|
|
38
|
+
export const MAX_BATCH_TARGETS = 20;
|
|
39
|
+
/**
|
|
40
|
+
* Checks the assistant can fix in many documents at once, and what each fix may reach: the
|
|
41
|
+
* finding's field, or every field a translation still has to fill.
|
|
42
|
+
*/
|
|
43
|
+
export const BATCH_CHECKS = {
|
|
44
|
+
"media.alt-missing": "field",
|
|
45
|
+
"seo.title-missing": "field",
|
|
46
|
+
"seo.title-length": "field",
|
|
47
|
+
"seo.description-missing": "field",
|
|
48
|
+
"seo.description-length": "field",
|
|
49
|
+
"content.untranslated": "translation",
|
|
50
|
+
};
|
|
30
51
|
/**
|
|
31
52
|
* Where to send a visitor who may not use Admin, or null to show the work list. Payload leaves
|
|
32
53
|
* custom views public, so the view checks this itself, as Payload does for its own: to the login
|
|
@@ -61,6 +82,31 @@ export function assistantCollections(adminCustom) {
|
|
|
61
82
|
? collections.filter((slug) => typeof slug === "string")
|
|
62
83
|
: [];
|
|
63
84
|
}
|
|
85
|
+
/** Collections where the assistant allows batch changes: `editorAssistant.batchCollections`. */
|
|
86
|
+
export function batchCollections(adminCustom) {
|
|
87
|
+
const entry = adminCustom?.[ASSISTANT_CUSTOM_KEY];
|
|
88
|
+
const collections = entry?.batchCollections;
|
|
89
|
+
return Array.isArray(collections)
|
|
90
|
+
? collections.filter((slug) => typeof slug === "string")
|
|
91
|
+
: [];
|
|
92
|
+
}
|
|
93
|
+
/** A finding as one document of a batch: `collection:id:locale:path`. */
|
|
94
|
+
export function batchTarget(finding) {
|
|
95
|
+
return [finding.collection, String(finding.id), finding.locale ?? "", finding.path].join(":");
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* The work list with its filters, asking the assistant to fix the first `MAX_BATCH_TARGETS` of
|
|
99
|
+
* `targets` with `ask`. The check filter says how far each fix may reach.
|
|
100
|
+
*/
|
|
101
|
+
export function batchURL(adminRoute, filters, targets, ask) {
|
|
102
|
+
const params = new URLSearchParams(filterQuery(filters));
|
|
103
|
+
params.set(BATCH_PARAM, targets.slice(0, MAX_BATCH_TARGETS).join(","));
|
|
104
|
+
if (filters.check && BATCH_CHECKS[filters.check] === "translation") {
|
|
105
|
+
params.set(BATCH_SCOPE_PARAM, "translation");
|
|
106
|
+
}
|
|
107
|
+
params.set(ASK_PARAM, ask);
|
|
108
|
+
return `${formatAdminURL({ adminRoute, path: VIEW_PATH })}?${params}`;
|
|
109
|
+
}
|
|
64
110
|
/** The document in the finding's locale with its field brought into view, and a prompt when given. */
|
|
65
111
|
export function editURL(adminRoute, finding, ask) {
|
|
66
112
|
const path = `/collections/${finding.collection}/${encodeURIComponent(String(finding.id))}`;
|
package/dist/styles/view.css
CHANGED
|
@@ -49,11 +49,20 @@ html[data-theme="dark"] .content-health-list {
|
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
.content-health-list__summary {
|
|
52
|
+
display: flex;
|
|
53
|
+
flex-wrap: wrap;
|
|
54
|
+
gap: calc(var(--base) * 0.5) var(--base);
|
|
55
|
+
align-items: center;
|
|
56
|
+
justify-content: space-between;
|
|
52
57
|
min-height: 1.5em;
|
|
53
58
|
margin: var(--base) 0 calc(var(--base) * 0.5);
|
|
54
59
|
color: var(--theme-elevation-600);
|
|
55
60
|
}
|
|
56
61
|
|
|
62
|
+
.content-health-list__summary p {
|
|
63
|
+
margin: 0;
|
|
64
|
+
}
|
|
65
|
+
|
|
57
66
|
/* One grid for all rows, so the columns line up; each row takes its tracks through subgrid. */
|
|
58
67
|
.content-health-list__rows {
|
|
59
68
|
display: grid;
|
|
@@ -2,8 +2,8 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import { DefaultTemplate } from "@payloadcms/next/templates";
|
|
3
3
|
import { Gutter, SetStepNav } from "@payloadcms/ui";
|
|
4
4
|
import { redirect } from "next/navigation";
|
|
5
|
-
import { ASK, CHECK_LABELS, findingDetail, LIST_COPY, uiLanguage } from "../copy.js";
|
|
6
|
-
import { accessRedirect, assistantCollections, editURL, readFilters } from "../links.js";
|
|
5
|
+
import { ASK, BATCH_ASK, CHECK_LABELS, findingDetail, LIST_COPY, uiLanguage } from "../copy.js";
|
|
6
|
+
import { accessRedirect, assistantCollections, BATCH_CHECKS, batchCollections, batchTarget, editURL, readFilters, } from "../links.js";
|
|
7
7
|
import { scanContentHealth } from "../scan.js";
|
|
8
8
|
import { CHECK_IDS } from "../types.js";
|
|
9
9
|
import { WorkList } from "./WorkList.js";
|
|
@@ -31,8 +31,14 @@ export async function ContentHealthView({ initPageResult, params, searchParams,
|
|
|
31
31
|
const adminRoute = config.routes.admin;
|
|
32
32
|
const result = await scanContentHealth({ payload: req.payload, user: req.user });
|
|
33
33
|
const withAssistant = new Set(assistantCollections(config.admin?.custom));
|
|
34
|
+
const inBatches = new Set(batchCollections(config.admin?.custom));
|
|
35
|
+
const batchAsk = {};
|
|
34
36
|
const rows = sortFindings(result.findings).map((finding, index) => {
|
|
35
37
|
const detail = findingDetail(language, finding);
|
|
38
|
+
const batchable = Boolean(BATCH_CHECKS[finding.check]) && inBatches.has(finding.collection);
|
|
39
|
+
if (batchable && !batchAsk[finding.check]) {
|
|
40
|
+
batchAsk[finding.check] = BATCH_ASK[language][finding.check]?.(finding);
|
|
41
|
+
}
|
|
36
42
|
return {
|
|
37
43
|
key: `${index}:${finding.collection}:${finding.id}:${finding.check}:${finding.path}:${finding.locale ?? ""}`,
|
|
38
44
|
check: finding.check,
|
|
@@ -46,6 +52,7 @@ export async function ContentHealthView({ initPageResult, params, searchParams,
|
|
|
46
52
|
...(withAssistant.has(finding.collection)
|
|
47
53
|
? { askHref: editURL(adminRoute, finding, ASK[language][finding.check](finding)) }
|
|
48
54
|
: {}),
|
|
55
|
+
...(batchable ? { batchTarget: batchTarget(finding) } : {}),
|
|
49
56
|
};
|
|
50
57
|
});
|
|
51
58
|
const checks = result.checks.map((check) => ({
|
|
@@ -63,7 +70,7 @@ export async function ContentHealthView({ initPageResult, params, searchParams,
|
|
|
63
70
|
}))
|
|
64
71
|
: [];
|
|
65
72
|
// Payload wraps only its own views in the Admin shell; a custom view brings nav and header itself.
|
|
66
|
-
return (_jsx(DefaultTemplate, { i18n: req.i18n, locale: locale, params: params, payload: req.payload, permissions: permissions, req: req, searchParams: searchParams, user: req.user ?? undefined, visibleEntities: visibleEntities, children: _jsxs(Gutter, { className: "content-health-list", children: [_jsx(SetStepNav, { nav: [{ label: copy.title }] }), _jsxs("header", { className: "content-health-list__header", children: [_jsx("h1", { children: copy.title }), _jsx("p", { className: "content-health-list__intro", children: copy.intro })] }), _jsx(WorkList, { checks: checks, collections: collections, initialFilters: readFilters(searchParams), language: language, locales: locales, rows: rows }), result.problems.length ? (_jsx("ul", { className: "content-health-list__problems", children: result.problems.map((problem) => (_jsx("li", { children: problem }, problem))) })) : null] }) }));
|
|
73
|
+
return (_jsx(DefaultTemplate, { i18n: req.i18n, locale: locale, params: params, payload: req.payload, permissions: permissions, req: req, searchParams: searchParams, user: req.user ?? undefined, visibleEntities: visibleEntities, children: _jsxs(Gutter, { className: "content-health-list", children: [_jsx(SetStepNav, { nav: [{ label: copy.title }] }), _jsxs("header", { className: "content-health-list__header", children: [_jsx("h1", { children: copy.title }), _jsx("p", { className: "content-health-list__intro", children: copy.intro })] }), _jsx(WorkList, { adminRoute: adminRoute, batchAsk: batchAsk, checks: checks, collections: collections, initialFilters: readFilters(searchParams), language: language, locales: locales, rows: rows }), result.problems.length ? (_jsx("ul", { className: "content-health-list__problems", children: result.problems.map((problem) => (_jsx("li", { children: problem }, problem))) })) : null] }) }));
|
|
67
74
|
}
|
|
68
75
|
/** By check, then document, then locale, so a document's locales sit together. */
|
|
69
76
|
function sortFindings(findings) {
|
package/dist/view/WorkList.d.ts
CHANGED
|
@@ -19,12 +19,17 @@ export type WorkListRow = {
|
|
|
19
19
|
href: string;
|
|
20
20
|
/** The same with a prompt for the editor assistant; only where the assistant works. */
|
|
21
21
|
askHref?: string;
|
|
22
|
+
/** The finding as one document of a batch; only where the assistant allows batch changes. */
|
|
23
|
+
batchTarget?: string;
|
|
22
24
|
};
|
|
23
25
|
/**
|
|
24
26
|
* The findings with filters on check, collection and locale. Filters apply in the browser and are
|
|
25
27
|
* kept in the address, so the view can be linked to and reloaded with them.
|
|
26
28
|
*/
|
|
27
|
-
export declare function WorkList({ checks, collections, initialFilters, language, locales, rows, }: {
|
|
29
|
+
export declare function WorkList({ adminRoute, batchAsk, checks, collections, initialFilters, language, locales, rows, }: {
|
|
30
|
+
adminRoute: string;
|
|
31
|
+
/** The assistant's prompt per check that it can fix in many documents at once. */
|
|
32
|
+
batchAsk: Partial<Record<CheckId, string>>;
|
|
28
33
|
checks: WorkListOption[];
|
|
29
34
|
collections: WorkListOption[];
|
|
30
35
|
initialFilters: ListFilters;
|
package/dist/view/WorkList.js
CHANGED
|
@@ -3,23 +3,27 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
3
3
|
import { Button, Link } from "@payloadcms/ui";
|
|
4
4
|
import { useMemo, useState } from "react";
|
|
5
5
|
import { LIST_COPY } from "../copy.js";
|
|
6
|
-
import { filterQuery, matchesFilters } from "../links.js";
|
|
6
|
+
import { BATCH_CHECKS, batchURL, filterQuery, MAX_BATCH_TARGETS, matchesFilters, } from "../links.js";
|
|
7
7
|
/**
|
|
8
8
|
* The findings with filters on check, collection and locale. Filters apply in the browser and are
|
|
9
9
|
* kept in the address, so the view can be linked to and reloaded with them.
|
|
10
10
|
*/
|
|
11
|
-
export function WorkList({ checks, collections, initialFilters, language, locales, rows, }) {
|
|
11
|
+
export function WorkList({ adminRoute, batchAsk, checks, collections, initialFilters, language, locales, rows, }) {
|
|
12
12
|
const copy = LIST_COPY[language];
|
|
13
13
|
const [filters, setFilters] = useState(initialFilters);
|
|
14
14
|
const shown = useMemo(() => rows.filter((row) => matchesFilters(row, filters)), [rows, filters]);
|
|
15
15
|
const documents = new Set(shown.map((row) => row.href.split("?")[0])).size;
|
|
16
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;
|
|
17
21
|
const update = (next) => {
|
|
18
22
|
setFilters(next);
|
|
19
23
|
const query = filterQuery(next);
|
|
20
24
|
window.history.replaceState(window.history.state, "", `${window.location.pathname}${query ? `?${query}` : ""}`);
|
|
21
25
|
};
|
|
22
|
-
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", margin: false, onClick: () => update({}), size: "small", children: copy.clear })) : null] }),
|
|
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", 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 }))] }));
|
|
23
27
|
}
|
|
24
28
|
function Filter({ all, label, onChange, options, value, }) {
|
|
25
29
|
return (_jsxs("label", { className: "content-health-list__filter", children: [_jsx("span", { children: label }), _jsxs("select", { onChange: (event) => onChange(event.target.value || undefined), value: value ?? "", children: [_jsx("option", { value: "", children: all }), options.map((option) => (_jsx("option", { value: option.value, children: option.label }, option.value)))] })] }));
|
package/package.json
CHANGED