@simmalugnt-se/payload-editor-assistant 0.9.0 → 0.10.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 CHANGED
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.10.0
4
+
5
+ - Picks several images for upload fields with `hasMany`, such as a gallery: it adds after the
6
+ images already there, removes one when asked, and keeps to the field's `minRows` and `maxRows`,
7
+ saying so when the editor asks for more than fit. The quoted schema marks such fields with
8
+ `hasMany` and their limits.
9
+ - A proposal is refused before it reaches the form when an upload or relationship field it changes
10
+ holds the wrong shape: a list in a field for one, a repeated id, too many or too few, or a field
11
+ pointing at several collections.
12
+ - The change list names every image in a list ("img-1.png, img-3.png") instead of showing ids, and
13
+ shows an empty list as empty.
14
+
15
+ ## 0.9.1
16
+
17
+ - The assistant no longer shows on Payload's sign-in pages (login, logout, forgot and reset
18
+ password, first user, unauthorized). Right after signing in, the panel opened on the login page
19
+ before Admin loaded.
20
+
3
21
  ## 0.9.0
4
22
 
5
23
  - Translation: on a document with localized text that is empty in the open language but filled in
package/README.md CHANGED
@@ -117,6 +117,11 @@ The suggested change names the image ("Image: empty → office-2024.jpg"), and t
117
117
  Preview show it. Images with alt texts are easier to find. Fields with several images (`hasMany`)
118
118
  are not picked yet.
119
119
 
120
+ Galleries and other upload fields with `hasMany` work the same way: the assistant adds images after
121
+ the ones already there, removes one when asked, and stays within the field's `minRows` and
122
+ `maxRows`, saying so when the editor asks for more than fit. Fields that point at several
123
+ collections are not filled.
124
+
120
125
  ## Rich text
121
126
 
122
127
  With `@payloadcms/richtext-lexical` installed, the assistant reads Lexical rich text as Markdown,
@@ -1,6 +1,8 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { useAuth, useTranslation } from "@payloadcms/ui";
3
+ import { useAuth, useConfig, useTranslation } from "@payloadcms/ui";
4
+ import { usePathname } from "next/navigation";
5
+ import { isAuthView } from "../view.js";
4
6
  import { useAssistantState } from "./assistant-context.js";
5
7
  import { COPY, uiLanguage } from "./copy.js";
6
8
  /** The one control that opens and closes the assistant panel, in Admin's header. */
@@ -8,7 +10,9 @@ export function EditorAssistantAction() {
8
10
  const { user } = useAuth();
9
11
  const { i18n } = useTranslation();
10
12
  const { open, setOpen } = useAssistantState();
11
- if (!user) {
13
+ const { config } = useConfig();
14
+ const pathname = usePathname();
15
+ if (!user || isAuthView(pathname ?? "", config.routes.admin, config.admin.routes)) {
12
16
  return null;
13
17
  }
14
18
  return (_jsxs("button", { "aria-expanded": open, className: open ? "ea-trigger ea-trigger--open" : "ea-trigger", onClick: (event) => {
@@ -1,14 +1,19 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { useAuth } from "@payloadcms/ui";
3
+ import { useAuth, useConfig } from "@payloadcms/ui";
4
+ import { usePathname } from "next/navigation";
4
5
  import "../styles/assistant.css";
6
+ import { isAuthView } from "../view.js";
5
7
  import { AssistantStateProvider, useAssistantState } from "./assistant-context.js";
6
8
  import { ChangedFieldsHighlight } from "./ChangedFieldsHighlight.js";
7
9
  import { EditorAssistantAside } from "./EditorAssistantAside.js";
8
10
  import { useNavRoom } from "./nav-room.js";
9
11
  export function EditorAssistantProvider({ children, shortcuts = [], collections = [], }) {
10
12
  const { user } = useAuth();
11
- return (_jsx(AssistantStateProvider, { shortcuts: shortcuts, collections: collections, userId: user?.id, children: user ? _jsx(EditorAssistantShell, { children: children }) : children }));
13
+ const { config } = useConfig();
14
+ const pathname = usePathname();
15
+ const signingIn = isAuthView(pathname ?? "", config.routes.admin, config.admin.routes);
16
+ return (_jsx(AssistantStateProvider, { shortcuts: shortcuts, collections: collections, userId: user?.id, children: user && !signingIn ? _jsx(EditorAssistantShell, { children: children }) : children }));
12
17
  }
13
18
  function EditorAssistantShell({ children }) {
14
19
  const { open } = useAssistantState();
@@ -4,6 +4,7 @@ import { humanDiff, richTextLines } from "../form/diff.js";
4
4
  import { hashFormRevision } from "../form/hash.js";
5
5
  import { isFormOperation, normalizeOperations } from "../form/operations.js";
6
6
  import { getAtPath, parseFieldPath } from "../form/path.js";
7
+ import { relationValueError } from "../form/relation-shape.js";
7
8
  import { loadRichText, toBlocks } from "../form/rich-text.js";
8
9
  import { finalizeCompiled, openRichText } from "../form/shape.js";
9
10
  import { DEFAULT_APPROVAL_SLUG } from "../package-name.js";
@@ -55,6 +56,10 @@ export async function proposeChange(req, options, input) {
55
56
  if (!finalized.ok) {
56
57
  return { ok: false, error: "tool_denied", message: finalized.error };
57
58
  }
59
+ const relationError = relationValueError(fields, finalized.data, changedPaths(typedOperations));
60
+ if (relationError) {
61
+ return { ok: false, error: "tool_denied", message: relationError };
62
+ }
58
63
  if (options.validateProposal) {
59
64
  const extra = await options.validateProposal({
60
65
  operations: typedOperations,
@@ -72,11 +77,9 @@ export async function proposeChange(req, options, input) {
72
77
  const canonicalHash = await hashFormRevision(typedOperations);
73
78
  const nonce = crypto.randomUUID();
74
79
  const expiresAt = new Date(Date.now() + TTL_MS).toISOString();
75
- // Ids set by this change, before and after, get names in the change list.
80
+ // Ids set by this change, before and after, get names in the change list; a gallery has several.
76
81
  const setIds = new Set(typedOperations.flatMap((operation) => operation.op === "set"
77
- ? [operation.value, getAtPath(current, parseFieldPath(operation.path))]
78
- .filter((value) => typeof value === "string" || typeof value === "number")
79
- .map(String)
82
+ ? [operation.value, getAtPath(current, parseFieldPath(operation.path))].flatMap(idsIn)
80
83
  : []));
81
84
  const labels = setIds.size
82
85
  ? await labelRelations(req, fields, [finalized.data, current], setIds)
@@ -200,3 +203,13 @@ function storedBlocks(fields, path, current) {
200
203
  const blocks = toBlocks(getAtPath(current, parseFieldPath(path)), field?.editor);
201
204
  return Array.isArray(blocks) ? blocks : [];
202
205
  }
206
+ /** The ids a set value holds: one, or a list as a `hasMany` field takes (ids or documents). */
207
+ function idsIn(value) {
208
+ if (typeof value === "string" || typeof value === "number") {
209
+ return [String(value)];
210
+ }
211
+ if (Array.isArray(value)) {
212
+ return value.flatMap((entry) => entry && typeof entry === "object" && "id" in entry ? idsIn(entry.id) : idsIn(entry));
213
+ }
214
+ return [];
215
+ }
package/dist/form/diff.js CHANGED
@@ -43,9 +43,21 @@ const FIELD_PREVIEW = 300;
43
43
  */
44
44
  export function humanDiff(current, operations, language = "en", labels) {
45
45
  const copy = COPY[language];
46
+ const nameOf = (value) => {
47
+ const id = typeof value === "string" || typeof value === "number"
48
+ ? String(value)
49
+ : value && typeof value === "object" && "id" in value
50
+ ? String(value.id)
51
+ : undefined;
52
+ return id !== undefined ? labels?.get(id) : undefined;
53
+ };
54
+ // A chosen image reads as its name; a list of them (a gallery) as their names in order.
46
55
  const named = (value) => {
47
- const id = typeof value === "string" || typeof value === "number" ? String(value) : undefined;
48
- return id !== undefined && labels?.has(id) ? labels.get(id) : value;
56
+ if (Array.isArray(value) && value.length > 0) {
57
+ const names = value.map(nameOf);
58
+ return names.every((name) => name !== undefined) ? names.join(", ") : value;
59
+ }
60
+ return nameOf(value) ?? value;
49
61
  };
50
62
  return operations.map((operation) => {
51
63
  switch (operation.op) {
@@ -154,6 +166,7 @@ function isEmpty(value) {
154
166
  return (value === undefined ||
155
167
  value === null ||
156
168
  value === "" ||
169
+ (Array.isArray(value) && value.length === 0) ||
157
170
  (isLexical(value) && !lexicalPlaintext(value)));
158
171
  }
159
172
  function preview(value, copy, max = HEADER_PREVIEW) {
@@ -0,0 +1,9 @@
1
+ import { type FieldNode } from "../schema/fields.ts";
2
+ /**
3
+ * Checks the upload and relationship fields a proposal changes (`paths`, as `changedPaths` gives
4
+ * them): one id for a single field; for a `hasMany` field a list of ids without repeats, within its
5
+ * `minRows` and `maxRows` (an empty list is allowed). Fields pointing at several collections are
6
+ * refused. Untouched fields are not checked, so a value the editor saved cannot block a proposal.
7
+ * Whether the ids exist and are readable is `verifyDocumentRelations`' job.
8
+ */
9
+ export declare function relationValueError(fields: FieldNode[], data: Record<string, unknown>, paths: string[]): string | null;
@@ -0,0 +1,74 @@
1
+ import { fieldAtPath } from "../schema/fields.js";
2
+ import { getAtPath, parseFieldPath } from "./path.js";
3
+ const ID = /^[\w-]+$/;
4
+ /**
5
+ * Checks the upload and relationship fields a proposal changes (`paths`, as `changedPaths` gives
6
+ * them): one id for a single field; for a `hasMany` field a list of ids without repeats, within its
7
+ * `minRows` and `maxRows` (an empty list is allowed). Fields pointing at several collections are
8
+ * refused. Untouched fields are not checked, so a value the editor saved cannot block a proposal.
9
+ * Whether the ids exist and are readable is `verifyDocumentRelations`' job.
10
+ */
11
+ export function relationValueError(fields, data, paths) {
12
+ const checked = new Set();
13
+ for (const changed of paths) {
14
+ const segments = changed.split(".");
15
+ // A change inside a list of ids (`images.2`) is checked on the whole field.
16
+ while (segments.length > 1 && /^\d+$/.test(segments.at(-1) ?? "")) {
17
+ segments.pop();
18
+ }
19
+ const path = segments.join(".");
20
+ if (checked.has(path)) {
21
+ continue;
22
+ }
23
+ checked.add(path);
24
+ const field = fieldAtPath(fields, path, data);
25
+ if (!field || (field.type !== "upload" && field.type !== "relationship")) {
26
+ continue;
27
+ }
28
+ const error = fieldError(field, path, getAtPath(data, parseFieldPath(path)));
29
+ if (error) {
30
+ return error;
31
+ }
32
+ }
33
+ return null;
34
+ }
35
+ function fieldError(field, path, value) {
36
+ const noun = field.type === "upload" ? "image" : "document";
37
+ if ((field.relationTo?.length ?? 0) > 1) {
38
+ return `"${path}" points at several collections; the assistant cannot choose for it yet.`;
39
+ }
40
+ if (value === null || value === undefined) {
41
+ return null;
42
+ }
43
+ if (!field.hasMany) {
44
+ return Array.isArray(value) ? `"${path}" takes one ${noun}: set it to a single id.` : null;
45
+ }
46
+ const ids = Array.isArray(value) ? value.map(idOf) : undefined;
47
+ if (!ids || ids.some((id) => id === undefined)) {
48
+ return `"${path}" takes a list of ${noun} ids, in the order they show.`;
49
+ }
50
+ if (new Set(ids).size !== ids.length) {
51
+ return `"${path}" has the same ${noun} more than once.`;
52
+ }
53
+ if (field.maxRows !== undefined && ids.length > field.maxRows) {
54
+ return `"${path}" takes at most ${field.maxRows} ${noun}s; the proposal has ${ids.length}.`;
55
+ }
56
+ if (field.minRows !== undefined && ids.length > 0 && ids.length < field.minRows) {
57
+ return `"${path}" takes at least ${field.minRows} ${noun}s; the proposal has ${ids.length}.`;
58
+ }
59
+ return null;
60
+ }
61
+ /** An id, or a document as Admin may hold a chosen one. */
62
+ function idOf(entry) {
63
+ if (typeof entry === "number") {
64
+ return String(entry);
65
+ }
66
+ if (typeof entry === "string") {
67
+ return ID.test(entry) ? entry : undefined;
68
+ }
69
+ if (entry && typeof entry === "object") {
70
+ const record = entry;
71
+ return idOf(record.id ?? record.value);
72
+ }
73
+ return undefined;
74
+ }
@@ -10,6 +10,10 @@ type CompactField = {
10
10
  type: string;
11
11
  required?: boolean;
12
12
  relationTo?: string[];
13
+ /** Upload and relationship fields that take several, and how many. */
14
+ hasMany?: true;
15
+ minRows?: number;
16
+ maxRows?: number;
13
17
  };
14
18
  export type TurnContext = {
15
19
  allowlist: Array<{
@@ -99,11 +99,19 @@ export function compactEntity(entity) {
99
99
  };
100
100
  }
101
101
  function compactField(field) {
102
+ const relation = field.type === "upload" || field.type === "relationship";
102
103
  return {
103
104
  name: field.name,
104
105
  type: field.type,
105
106
  required: field.required || undefined,
106
- relationTo: field.type === "upload" || field.type === "relationship" ? field.relationTo : undefined,
107
+ relationTo: relation ? field.relationTo : undefined,
108
+ ...(relation && field.hasMany
109
+ ? {
110
+ hasMany: true,
111
+ ...(field.minRows !== undefined ? { minRows: field.minRows } : {}),
112
+ ...(field.maxRows !== undefined ? { maxRows: field.maxRows } : {}),
113
+ }
114
+ : {}),
107
115
  };
108
116
  }
109
117
  function translationFor(req, bridge, fields, form, otherLanguages) {
@@ -36,7 +36,7 @@ function openDocumentLine(bridge, turnContext) {
36
36
  }
37
37
  return `${viewLine(turnContext)}No document is open. You can search, navigate, or create a draft. Form edits need an open document.`;
38
38
  }
39
- /** Where the assistant may search images and look at them, it picks one for an upload field. */
39
+ /** Where the assistant may search images and look at them, it picks them for upload fields. */
40
40
  function pickingLine(turnContext) {
41
41
  const libraries = turnContext?.allowlist.filter((entity) => entity.kind === "collection" &&
42
42
  entity.capabilities.includes("content.find") &&
@@ -45,7 +45,7 @@ function pickingLine(turnContext) {
45
45
  return "";
46
46
  }
47
47
  const names = libraries.map((entity) => entity.slug).join(", ");
48
- return `To pick an image for an upload field (a share image, a block image), search ${names} with content.find using words from the request or the page, then look at the likely candidates with media.view and their ids before proposing one. If the search finds nothing useful, list recent images (content.find without a query) and look at those. Propose only an image you have looked at, by its id. If none fits, say so and propose nothing. A share image (delningsbild, social image) is usually the upload field among the page's meta or SEO fields; prefer a landscape image at least 1200px wide for it.`;
48
+ return `To pick an image for an upload field (a share image, a block image), search ${names} with content.find using words from the request or the page, then look at the likely candidates with media.view and their ids before proposing one. If the search finds nothing useful, list recent images (content.find without a query) and look at those. Propose only an image you have looked at, by its id. If none fits, say so and propose nothing. A share image (delningsbild, social image) is usually the upload field among the page's meta or SEO fields; prefer a landscape image at least 1200px wide for it. An upload field marked hasMany (a gallery) takes several: set the whole field to the list of ids in the order they show. To add images, keep the images already there and add the new ones after them; to remove one, set the list without it; replace them all only when asked. Stay within its maxRows and minRows: if the editor asks for more than fit, say how many it takes and propose no more than that. Look at every image you add.`;
49
49
  }
50
50
  /** Without seeing the image, a model writes alt texts from the filename; say when it can look. */
51
51
  function imageLine(bridge, turnContext) {
@@ -7,6 +7,9 @@ export type FieldNode = {
7
7
  localized?: boolean;
8
8
  relationTo?: string[];
9
9
  hasMany?: boolean;
10
+ /** How many a `hasMany` field takes. */
11
+ minRows?: number;
12
+ maxRows?: number;
10
13
  options?: string[];
11
14
  label?: string;
12
15
  description?: string;
@@ -55,6 +55,8 @@ function walkField(field, parentPath, allowlist, denyFields) {
55
55
  required: field.required,
56
56
  localized: field.localized,
57
57
  hasMany: field.hasMany,
58
+ ...(field.hasMany && typeof field.minRows === "number" ? { minRows: field.minRows } : {}),
59
+ ...(field.hasMany && typeof field.maxRows === "number" ? { maxRows: field.maxRows } : {}),
58
60
  relationTo: normalizeRelationTo(field.relationTo),
59
61
  options: optionValues(field.options),
60
62
  label: asLabel(field.label) ?? field.name,
package/dist/view.d.ts CHANGED
@@ -15,6 +15,12 @@ export type AdminView = {
15
15
  };
16
16
  export type AdminViewKind = AdminView["kind"];
17
17
  export declare function parseAdminView(pathname: string, adminRoute?: string): AdminView;
18
+ /**
19
+ * Whether the path is one of Payload's sign-in pages (login, logout, forgot, reset, first user,
20
+ * unauthorized). The assistant does not show there, even in the moment after signing in when the
21
+ * user is known but the login page is still open.
22
+ */
23
+ export declare function isAuthView(pathname: string, adminRoute: string, routes?: Partial<Record<string, string>>): boolean;
18
24
  /** Keep only views that point at something the host allowlisted. */
19
25
  export declare function sanitizeAdminView(value: unknown, allowed: {
20
26
  collections: Record<string, unknown>;
package/dist/view.js CHANGED
@@ -24,6 +24,33 @@ export function parseAdminView(pathname, adminRoute = "/admin") {
24
24
  }
25
25
  return { kind: "other" };
26
26
  }
27
+ /** Payload's own sign-in routes (`admin.routes`), with Payload's defaults. */
28
+ const AUTH_ROUTES = {
29
+ login: "/login",
30
+ logout: "/logout",
31
+ inactivity: "/logout-inactivity",
32
+ forgot: "/forgot",
33
+ reset: "/reset",
34
+ createFirstUser: "/create-first-user",
35
+ unauthorized: "/unauthorized",
36
+ };
37
+ /**
38
+ * Whether the path is one of Payload's sign-in pages (login, logout, forgot, reset, first user,
39
+ * unauthorized). The assistant does not show there, even in the moment after signing in when the
40
+ * user is known but the login page is still open.
41
+ */
42
+ export function isAuthView(pathname, adminRoute, routes = {}) {
43
+ const base = trimSlashes(adminRoute);
44
+ const path = trimSlashes(pathname.split(/[?#]/)[0] ?? "");
45
+ const rest = base ? stripPrefix(path, base) : path;
46
+ if (!rest) {
47
+ return false;
48
+ }
49
+ return Object.entries(AUTH_ROUTES).some(([key, fallback]) => {
50
+ const route = trimSlashes(routes[key] ?? fallback);
51
+ return Boolean(route) && (rest === route || rest.startsWith(`${route}/`));
52
+ });
53
+ }
27
54
  /** Keep only views that point at something the host allowlisted. */
28
55
  export function sanitizeAdminView(value, allowed) {
29
56
  if (!value || typeof value !== "object") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simmalugnt-se/payload-editor-assistant",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Context-aware editor assistant plugin for Payload Admin",
5
5
  "keywords": [
6
6
  "payload",