@simmalugnt-se/payload-editor-assistant 0.12.0 → 0.12.2

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,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.12.2
4
+
5
+ - Batch links remove their parameters from Next’s router state as well as browser history, so
6
+ refreshing cannot reopen a finished batch. Field prompts use the same URL integration.
7
+ - Collapsing the assistant preserves batch progress instead of resetting completed cards.
8
+ - Batch revision checks treat schema-defined empty values consistently, so the first draft saved
9
+ in one language does not falsely invalidate another language’s proposal on Postgres. Real content
10
+ changes anywhere in the allowlisted form still refuse the save.
11
+
12
+ ## 0.12.1
13
+
14
+ - The review lists groups in the schema's order (a group's own position, blocks and rows by their
15
+ number) instead of alphabetically by path.
16
+
3
17
  ## 0.12.0
4
18
 
5
19
  - Proposals are grouped by block, row and named group, in document order, with collapsible sections
@@ -1 +1 @@
1
- export declare function EditorAssistantAside(): import("react").JSX.Element | null;
1
+ export declare function EditorAssistantAside(): import("react").JSX.Element;
@@ -441,11 +441,8 @@ export function EditorAssistantAside() {
441
441
  void send(draft);
442
442
  }
443
443
  };
444
- // Closed: nothing is shown. The component stays mounted, so an applied preview survives.
445
- if (!open) {
446
- return null;
447
- }
448
- return (_jsxs("aside", { className: "ea-aside", "aria-label": copy.assistant, [KEEP_SELECTION_ATTR]: "", children: [_jsxs("div", { className: "ea-aside__head", children: [_jsxs("div", { className: "ea-aside__title", children: [_jsx("h2", { children: copy.assistant }), bridge ? _jsx("p", { className: "ea-aside__context", children: contextLine(bridge, config) }) : null] }), !batch && (messages.length > 0 || pendingApproval) && !pendingApproval?.applied ? (_jsx(Button, { buttonStyle: "subtle", margin: false, onClick: () => resetConversation(), size: "medium", children: copy.newChat })) : null, _jsx(Button, { "aria-label": copy.collapseLabel, buttonStyle: "icon-label", className: "ea-aside__collapse", icon: _jsx(ChevronIcon, { direction: "right" }), margin: false, onClick: () => setOpen(false), tooltip: copy.collapse })] }), batch ? (_jsx("div", { className: "ea-aside__body", children: _jsx(BatchPanel, { batch: batch, onClose: () => {
444
+ // Keep batch cards mounted while collapsed, so reopening never resets saved cards to tasks.
445
+ return (_jsxs("aside", { className: "ea-aside", "aria-label": copy.assistant, hidden: !open, [KEEP_SELECTION_ATTR]: "", children: [_jsxs("div", { className: "ea-aside__head", children: [_jsxs("div", { className: "ea-aside__title", children: [_jsx("h2", { children: copy.assistant }), bridge ? _jsx("p", { className: "ea-aside__context", children: contextLine(bridge, config) }) : null] }), !batch && (messages.length > 0 || pendingApproval) && !pendingApproval?.applied ? (_jsx(Button, { buttonStyle: "subtle", margin: false, onClick: () => resetConversation(), size: "medium", children: copy.newChat })) : null, _jsx(Button, { "aria-label": copy.collapseLabel, buttonStyle: "icon-label", className: "ea-aside__collapse", icon: _jsx(ChevronIcon, { direction: "right" }), margin: false, onClick: () => setOpen(false), tooltip: copy.collapse })] }), batch ? (_jsx("div", { className: "ea-aside__body", children: _jsx(BatchPanel, { batch: batch, onClose: () => {
449
446
  releaseTakenBatches();
450
447
  setBatch(null);
451
448
  } }, batch.id) })) : (_jsxs("div", { className: "ea-aside__body", children: [_jsxs("div", { className: "ea-messages", ref: threadRef, onScroll: onThreadScroll, children: [messages.length === 0 ? (_jsx("div", { className: "ea-msg ea-msg--assistant", children: introText(view, bridge, collections, config, copy) })) : null, messages.map((message) => (_jsxs("div", { className: `ea-msg ea-msg--${message.role}`, children: [message.role === "assistant" ? _jsx(ReplyText, { text: message.text }) : message.text, message.candidates?.length ? (_jsx("div", { className: "ea-actions", children: message.candidates.map((candidate) => (_jsx(Button, { buttonStyle: "pill", margin: false, onClick: () => {
@@ -40,7 +40,7 @@ export function EditorAssistantDocumentBridge() {
40
40
  return;
41
41
  }
42
42
  url.searchParams.delete(ASK_PARAM);
43
- window.history.replaceState(window.history.state, "", url);
43
+ window.history.replaceState(null, "", url);
44
44
  if (prompt) {
45
45
  setPrefill(prompt);
46
46
  setOpen(true);
@@ -32,7 +32,8 @@ export function useBatchLink() {
32
32
  for (const name of [BATCH_PARAM, BATCH_SCOPE_PARAM, ASK_PARAM]) {
33
33
  url.searchParams.delete(name);
34
34
  }
35
- window.history.replaceState(window.history.state, "", url.toString());
35
+ // Next copies its internal state itself; passing it back skips the router URL update.
36
+ window.history.replaceState(null, "", url.toString());
36
37
  if (repeated) {
37
38
  // The batch is still in the panel, which the editor closed: show it again.
38
39
  setOpen(true);
@@ -158,8 +158,6 @@ export declare const BATCH_COPY: {
158
158
  readonly review: "Check the suggestions. Uncheck any you do not want, or change the wording before saving.";
159
159
  readonly savesDraft: "Saves as a draft; nothing is published.";
160
160
  readonly savesDirectly: "Saved directly: this collection has no drafts.";
161
- readonly before: "Before";
162
- readonly empty: "empty";
163
161
  readonly retry: "Try again";
164
162
  readonly retryWith: "What should be different? (optional)";
165
163
  readonly retrySend: "Ask again";
@@ -197,8 +195,6 @@ export declare const BATCH_COPY: {
197
195
  readonly review: "Granska förslagen. Kryssa ur dem du inte vill ha, eller ändra formuleringen innan du sparar.";
198
196
  readonly savesDraft: "Sparas som utkast; inget publiceras.";
199
197
  readonly savesDirectly: "Sparas direkt: den här samlingen har inga utkast.";
200
- readonly before: "Före";
201
- readonly empty: "tomt";
202
198
  readonly retry: "Försök igen";
203
199
  readonly retryWith: "Vad ska bli annorlunda? (valfritt)";
204
200
  readonly retrySend: "Fråga igen";
@@ -168,8 +168,6 @@ export const BATCH_COPY = {
168
168
  review: "Check the suggestions. Uncheck any you do not want, or change the wording before saving.",
169
169
  savesDraft: "Saves as a draft; nothing is published.",
170
170
  savesDirectly: "Saved directly: this collection has no drafts.",
171
- before: "Before",
172
- empty: "empty",
173
171
  retry: "Try again",
174
172
  retryWith: "What should be different? (optional)",
175
173
  retrySend: "Ask again",
@@ -207,8 +205,6 @@ export const BATCH_COPY = {
207
205
  review: "Granska förslagen. Kryssa ur dem du inte vill ha, eller ändra formuleringen innan du sparar.",
208
206
  savesDraft: "Sparas som utkast; inget publiceras.",
209
207
  savesDirectly: "Sparas direkt: den här samlingen har inga utkast.",
210
- before: "Före",
211
- empty: "tomt",
212
208
  retry: "Försök igen",
213
209
  retryWith: "Vad ska bli annorlunda? (valfritt)",
214
210
  retrySend: "Fråga igen",
@@ -22,8 +22,8 @@ export function groupProposalLines(lines) {
22
22
  ? a.kind === "field"
23
23
  ? -1
24
24
  : 1
25
- : a.kind === "group"
26
- ? a.path.localeCompare(b.path, "en", { numeric: true })
25
+ : a.kind === "group" && b.kind === "group"
26
+ ? a.group.order - b.group.order || a.path.localeCompare(b.path, "en", { numeric: true })
27
27
  : 0);
28
28
  for (const node of nodes)
29
29
  if (node.kind === "group")
@@ -1,3 +1,4 @@
1
+ import { hashBatchRevision } from "../form/batch-revision.js";
1
2
  import { changedPaths } from "../form/changed-paths.js";
2
3
  import { compileOperations } from "../form/compile.js";
3
4
  import { hashFormRevision } from "../form/hash.js";
@@ -188,10 +189,13 @@ export async function applyBatchTarget(req, options, input) {
188
189
  if (!saved) {
189
190
  return fail("tool_denied", "Document is not accessible.");
190
191
  }
191
- if ((await hashFormRevision(saved.form)) !== record.formRevision) {
192
+ const fields = entityFields(req, options, { collection: record.targetCollection });
193
+ const revision = record.formRevision?.startsWith("batch-v1:")
194
+ ? await hashBatchRevision(saved.form, fields)
195
+ : await hashFormRevision(saved.form);
196
+ if (revision !== record.formRevision) {
192
197
  return fail("stale_context", "The document changed after the proposal.");
193
198
  }
194
- const fields = entityFields(req, options, { collection: record.targetCollection });
195
199
  const edited = withEdits(stored, input.edits, fields, saved.form);
196
200
  if (!edited.ok) {
197
201
  // Not the editor's approval to spend: the card can still be saved without the edit.
@@ -1,3 +1,4 @@
1
+ import { hashBatchRevision } from "../form/batch-revision.js";
1
2
  import { changedPaths } from "../form/changed-paths.js";
2
3
  import { compileOperations } from "../form/compile.js";
3
4
  import { humanDiff, richTextLines } from "../form/diff.js";
@@ -85,7 +86,11 @@ export async function proposeChange(req, options, input) {
85
86
  if (!relations.ok) {
86
87
  return { ok: false, error: "tool_denied", message: relations.message };
87
88
  }
88
- const formRevision = input.capability === "draft.create" ? undefined : await hashFormRevision(current);
89
+ const formRevision = input.capability === "draft.create"
90
+ ? undefined
91
+ : input.capability === "content.update"
92
+ ? await hashBatchRevision(current, fields)
93
+ : await hashFormRevision(current);
89
94
  const canonicalHash = await hashFormRevision(typedOperations);
90
95
  const nonce = crypto.randomUUID();
91
96
  const expiresAt = new Date(Date.now() + TTL_MS).toISOString();
@@ -0,0 +1,6 @@
1
+ import type { FieldNode } from "../schema/fields.ts";
2
+ /**
3
+ * A first SQL draft may materialize absent localized columns as null. Hash their empty values
4
+ * consistently, without altering saved content or relaxing checks on nonempty fields or row ids.
5
+ */
6
+ export declare function hashBatchRevision(data: Record<string, unknown>, fields: FieldNode[]): Promise<string>;
@@ -0,0 +1,39 @@
1
+ import { hashFormRevision } from "./hash.js";
2
+ /**
3
+ * A first SQL draft may materialize absent localized columns as null. Hash their empty values
4
+ * consistently, without altering saved content or relaxing checks on nonempty fields or row ids.
5
+ */
6
+ export async function hashBatchRevision(data, fields) {
7
+ return `batch-v1:${await hashFormRevision(normalizeRecord(data, fields))}`;
8
+ }
9
+ function normalizeRecord(data, fields) {
10
+ const normalized = { ...data };
11
+ for (const field of fields) {
12
+ const value = data[field.name];
13
+ if (field.type === "group" && (value == null || isRecord(value))) {
14
+ normalized[field.name] = normalizeRecord(isRecord(value) ? value : {}, field.fields ?? []);
15
+ }
16
+ else if (field.type === "array" && Array.isArray(value)) {
17
+ normalized[field.name] = value.map((row) => isRecord(row) ? normalizeRecord(row, field.fields ?? []) : row);
18
+ }
19
+ else if (field.type === "blocks" && Array.isArray(value)) {
20
+ normalized[field.name] = value.map((row) => {
21
+ if (!isRecord(row))
22
+ return row;
23
+ const block = field.blocks?.find((entry) => entry.slug === row.blockType);
24
+ return block ? normalizeRecord(row, block.fields) : row;
25
+ });
26
+ }
27
+ else {
28
+ normalized[field.name] =
29
+ value === undefined ||
30
+ ((field.type === "text" || field.type === "textarea") && value === "")
31
+ ? null
32
+ : value;
33
+ }
34
+ }
35
+ return normalized;
36
+ }
37
+ function isRecord(value) {
38
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
39
+ }
@@ -4,6 +4,8 @@ export type FieldPathGroup = {
4
4
  label: string;
5
5
  kind: "container" | "block" | "row" | "group";
6
6
  position?: number;
7
+ /** Where the group sits among its siblings: the schema position, or the row's own position. */
8
+ order: number;
7
9
  };
8
10
  /** Schema labels and one-based row positions for a concrete form field path. */
9
11
  export declare function fieldPathLabels(fields: FieldNode[], path: string, data: unknown, language?: "en" | "sv"): {
@@ -26,6 +26,7 @@ export function fieldPathInfo(fields, path, data, language = "en") {
26
26
  label: name(block?.label ?? block?.slug ?? "block"),
27
27
  kind: "block",
28
28
  position,
29
+ order: position,
29
30
  });
30
31
  scope = block?.fields ?? [];
31
32
  }
@@ -37,22 +38,28 @@ export function fieldPathInfo(fields, path, data, language = "en") {
37
38
  label: `${name(row)} ${position}`,
38
39
  kind: "row",
39
40
  position,
41
+ order: position,
40
42
  });
41
43
  }
42
44
  continue;
43
45
  }
44
46
  schemaPath = schemaPath ? `${schemaPath}.${segment}` : segment;
45
- field = scope.find((entry) => entry.path === schemaPath);
47
+ const at = scope.findIndex((entry) => entry.path === schemaPath);
48
+ field = at >= 0 ? scope[at] : undefined;
46
49
  parts.push(name(field?.label ?? segment));
47
50
  if (field?.type === "group" || field?.type === "blocks" || field?.type === "array") {
48
51
  groups.push({
49
52
  path: concretePath,
50
53
  label: name(field.label ?? segment),
51
54
  kind: field.type === "group" ? "group" : "container",
55
+ order: at,
52
56
  });
53
57
  }
54
- else if (!field && scope.some((entry) => entry.path.startsWith(`${schemaPath}.`))) {
55
- groups.push({ path: concretePath, label: name(segment), kind: "group" });
58
+ else if (!field) {
59
+ const inside = scope.findIndex((entry) => entry.path.startsWith(`${schemaPath}.`));
60
+ if (inside >= 0) {
61
+ groups.push({ path: concretePath, label: name(segment), kind: "group", order: inside });
62
+ }
56
63
  }
57
64
  // Named tabs are flattened by walkFields, but their name remains in each child's path.
58
65
  if (field)
@@ -836,3 +836,8 @@ html[data-theme="dark"] .ea-trigger--open .ea-trigger__icon {
836
836
  padding: calc(var(--base) * 0.5) var(--base) calc(var(--base) * 0.8);
837
837
  border-top: 1px solid var(--theme-elevation-100);
838
838
  }
839
+
840
+ /* The batch keeps its progress when the editor collapses the panel. */
841
+ .ea-aside[hidden] {
842
+ display: none;
843
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simmalugnt-se/payload-editor-assistant",
3
- "version": "0.12.0",
3
+ "version": "0.12.2",
4
4
  "description": "Context-aware editor assistant plugin for Payload Admin",
5
5
  "keywords": [
6
6
  "payload",