@jini-ai/admin 0.4.12 → 0.6.10

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.
Files changed (151) hide show
  1. package/CHANGELOG.md +70 -2
  2. package/dist/agent-plugins/controllers/agent-plugin-files.controller.js +14 -9
  3. package/dist/agent-plugins/controllers/agent-plugins.controller.js +12 -9
  4. package/dist/agent-plugins/package-file-tree.js +54 -51
  5. package/dist/agent-plugins/react/components/AgentPluginRow.d.ts +1 -1
  6. package/dist/agent-plugins/react/components/AgentPluginRow.js +9 -2
  7. package/dist/agent-plugins/react/hooks/AgentPluginInspector.hooks.d.ts +5 -5
  8. package/dist/agent-plugins/react/hooks/AgentPluginInspector.hooks.js +14 -5
  9. package/dist/agent-plugins/react/hooks/AgentPluginPanel.hooks.d.ts +15 -13
  10. package/dist/agent-plugins/react/hooks/AgentPluginPanel.hooks.js +54 -40
  11. package/dist/agent-plugins/react/hooks/AgentPluginsPage.hooks.d.ts +13 -13
  12. package/dist/agent-plugins/react/hooks/AgentPluginsPage.hooks.js +23 -11
  13. package/dist/browser/navigation.js +44 -27
  14. package/dist/comments/adapters/memory.js +30 -12
  15. package/dist/comments/conformance/comments-api.conformance.js +55 -33
  16. package/dist/comments/controllers/comment-queue.controller.js +13 -4
  17. package/dist/comments/react/hooks/use-comment-queue.hooks.d.ts +17 -2
  18. package/dist/comments/react/hooks/use-comment-queue.hooks.js +79 -30
  19. package/dist/comments/react/hooks/use-comment-settings.hooks.js +9 -5
  20. package/dist/comments/react/index.d.ts +2 -1
  21. package/dist/comments/react/index.js +1 -1
  22. package/dist/consent/react/ConsentFields.d.ts +35 -0
  23. package/dist/consent/react/ConsentFields.js +76 -0
  24. package/dist/consent/react/ConsentPreviewFrame.d.ts +14 -0
  25. package/dist/consent/react/ConsentPreviewFrame.js +14 -0
  26. package/dist/consent/react/ConsentSettingsPanel.d.ts +27 -4
  27. package/dist/consent/react/ConsentSettingsPanel.js +6 -17
  28. package/dist/consent/react/index.d.ts +6 -1
  29. package/dist/consent/react/index.js +2 -0
  30. package/dist/consent/react/settings-panel.hooks.d.ts +28 -1
  31. package/dist/consent/react/settings-panel.hooks.js +26 -0
  32. package/dist/core/entities/rules.js +126 -81
  33. package/dist/core/manifest/rules.js +10 -10
  34. package/dist/core/module/create-admin.js +195 -117
  35. package/dist/core/ports/redirects.d.ts +8 -6
  36. package/dist/core/ports/redirects.js +8 -6
  37. package/dist/core/routing/rules.js +9 -4
  38. package/dist/core/transport/http.js +37 -20
  39. package/dist/forms/adapters/memory.js +22 -11
  40. package/dist/forms/conformance/forms-api.conformance.d.ts +6 -4
  41. package/dist/forms/conformance/forms-api.conformance.js +42 -46
  42. package/dist/forms/field-settings.js +150 -115
  43. package/dist/forms/html-rules.js +45 -28
  44. package/dist/forms/react/components/FieldSettingsRows.d.ts +22 -0
  45. package/dist/forms/react/components/FieldSettingsRows.js +29 -0
  46. package/dist/forms/react/components/FieldSettingsSection.d.ts +1 -1
  47. package/dist/forms/react/components/FieldSettingsSection.js +5 -6
  48. package/dist/forms/react/components/FormEditorFieldsBody.d.ts +1 -1
  49. package/dist/forms/react/components/FormEditorFieldsBody.js +4 -15
  50. package/dist/forms/react/components/FormEditorFieldsBodyParts.d.ts +18 -0
  51. package/dist/forms/react/components/FormEditorFieldsBodyParts.js +32 -0
  52. package/dist/forms/react/components/FormSubmissions.d.ts +1 -1
  53. package/dist/forms/react/components/FormSubmissions.js +4 -20
  54. package/dist/forms/react/components/FormSubmissionsTable.d.ts +19 -0
  55. package/dist/forms/react/components/FormSubmissionsTable.js +22 -0
  56. package/dist/media/adapters/memory.js +31 -26
  57. package/dist/media/controllers/edit-media.controller.js +2 -1
  58. package/dist/media/controllers/library.controller.js +48 -35
  59. package/dist/media/controllers/providers.controller.js +2 -1
  60. package/dist/media/html-attributes.js +29 -15
  61. package/dist/media/index.d.ts +1 -1
  62. package/dist/media/index.js +1 -1
  63. package/dist/media/react/components/MediaIcons.d.ts +0 -3
  64. package/dist/media/react/components/MediaIcons.js +0 -9
  65. package/dist/media/react/components/MediaLightbox.d.ts +2 -1
  66. package/dist/media/react/components/MediaLightbox.js +11 -5
  67. package/dist/media/react/hooks/LibraryTab.hooks.d.ts +55 -45
  68. package/dist/media/react/hooks/LibraryTab.hooks.js +107 -70
  69. package/dist/media/react/hooks/MediaCard.hooks.d.ts +27 -31
  70. package/dist/media/react/hooks/MediaCard.hooks.js +52 -26
  71. package/dist/media/react/hooks/MediaGrid.hooks.d.ts +1 -1
  72. package/dist/media/react/hooks/MediaLightbox.hooks.d.ts +10 -14
  73. package/dist/media/react/hooks/MediaLightbox.hooks.js +9 -56
  74. package/dist/media/react/hooks/MediaPage.hooks.d.ts +6 -0
  75. package/dist/media/react/hooks/MediaPage.hooks.js +29 -12
  76. package/dist/media/react/hooks/ProvidersTab.hooks.d.ts +33 -33
  77. package/dist/media/react/hooks/ProvidersTab.hooks.js +119 -79
  78. package/dist/media/react/hooks/UploadButton.hooks.d.ts +7 -5
  79. package/dist/media/react/hooks/UploadButton.hooks.js +36 -24
  80. package/dist/media/react/index.js +4 -0
  81. package/dist/media/react/tabs/LibraryTab.d.ts +1 -1
  82. package/dist/media/react/tabs/LibraryTab.js +2 -2
  83. package/dist/media/rules.d.ts +7 -0
  84. package/dist/media/rules.js +25 -2
  85. package/dist/react/components/ConfirmDialog/ConfirmDialog.js +5 -8
  86. package/dist/react/components/ConfirmDialog/confirm-dialog-attrs.d.ts +20 -0
  87. package/dist/react/components/ConfirmDialog/confirm-dialog-attrs.js +24 -0
  88. package/dist/react/components/InteractiveHtmlEditor/InteractiveHtmlEditor.js +16 -7
  89. package/dist/react/components/RowMenu/RowMenu.js +16 -10
  90. package/dist/react/components/Sidebar.js +32 -26
  91. package/dist/react/entities/EntityEdit.js +17 -14
  92. package/dist/react/entities/EntityField.d.ts +5 -2
  93. package/dist/react/entities/EntityField.js +38 -23
  94. package/dist/react/entities/EntityList.js +6 -5
  95. package/dist/react/entities/RelationTruncationNotice.d.ts +9 -0
  96. package/dist/react/entities/RelationTruncationNotice.js +9 -0
  97. package/dist/react/entities/use-entity-edit.d.ts +4 -2
  98. package/dist/react/entities/use-entity-edit.js +52 -33
  99. package/dist/react/shell/AdminShell.js +40 -23
  100. package/dist/react/shell/model.d.ts +4 -1
  101. package/dist/react/shell/model.js +3 -2
  102. package/dist/react/shell/use-admin-shell-session.js +25 -26
  103. package/dist/redirects/adapters/memory.js +9 -2
  104. package/dist/redirects/conformance/redirects-api.conformance.js +66 -36
  105. package/dist/redirects/react/hooks/use-import-redirects-form.hooks.d.ts +1 -1
  106. package/dist/redirects/react/hooks/use-import-redirects-form.hooks.js +10 -3
  107. package/dist/redirects/react/hooks/wired.hooks.d.ts +1 -1
  108. package/dist/security/adapters/http.js +25 -10
  109. package/dist/security/adapters/memory.js +19 -5
  110. package/dist/security/controllers/access-tokens.controller.js +27 -26
  111. package/dist/security/controllers/other-credentials.controller.js +4 -2
  112. package/dist/security/controllers/root-key.controller.js +17 -9
  113. package/dist/security/react/hooks/AccessTokensTab.hooks.d.ts +40 -38
  114. package/dist/security/react/hooks/AccessTokensTab.hooks.js +36 -9
  115. package/dist/security/react/hooks/RootKeyTab.hooks.d.ts +14 -14
  116. package/dist/security/react/hooks/RootKeyTab.hooks.js +12 -3
  117. package/dist/security/react/tabs/RootKeyTab.d.ts +1 -1
  118. package/dist/security/react/tabs/RootKeyTab.js +12 -3
  119. package/dist/security/rules.js +45 -25
  120. package/dist/seo/controllers/seo.controller.js +4 -2
  121. package/dist/seo/react/components/MediaRefField.d.ts +1 -1
  122. package/dist/seo/react/components/MediaRefField.js +19 -2
  123. package/dist/seo/react/hooks/MediaRefField.hooks.js +3 -1
  124. package/dist/seo/react/hooks/SeoPage.hooks.d.ts +1 -1
  125. package/dist/seo/react/hooks/use-share-image-warning.hooks.js +48 -34
  126. package/dist/seo/react/pages/SeoPage.d.ts +4 -4
  127. package/dist/seo/react/pages/SeoPage.js +26 -6
  128. package/dist/seo/rules.js +39 -10
  129. package/dist/source-control/conformance/source-control-api.conformance.js +68 -38
  130. package/dist/source-control/controllers/source-control.controller.js +20 -8
  131. package/dist/source-control/react/hooks/ProvidersTab.hooks.d.ts +2 -1
  132. package/dist/source-control/react/hooks/ProvidersTab.hooks.js +18 -11
  133. package/dist/widgets/conformance/widgets-api.conformance.js +15 -1
  134. package/dist/widgets/controllers/instance-editor.controller.js +33 -24
  135. package/dist/widgets/controllers/library.controller.js +18 -12
  136. package/dist/widgets/controllers/region-editor.controller.js +6 -5
  137. package/dist/widgets/react/hooks/page-views.hooks.d.ts +1 -0
  138. package/dist/widgets/react/hooks/page-views.hooks.js +3 -2
  139. package/dist/widgets/react/hooks/use-widget-regions.hooks.d.ts +1 -1
  140. package/dist/widgets/react/hooks/use-widget-regions.hooks.js +9 -3
  141. package/dist/widgets/react/hooks/use-widgets-library.hooks.js +9 -2
  142. package/dist/widgets/react/pages/WidgetInstanceEditor.d.ts +1 -1
  143. package/dist/widgets/react/pages/WidgetInstanceEditor.js +7 -8
  144. package/dist/widgets/rules.d.ts +14 -2
  145. package/dist/widgets/rules.js +9 -6
  146. package/package.json +23 -14
  147. package/src/themes/default/admin.css +51 -0
  148. package/src/themes/default/chat.css +21 -0
  149. package/src/themes/default/manifest.json +29 -0
  150. package/dist/media/styles.d.ts +0 -4
  151. package/dist/media/styles.js +0 -5
@@ -42,40 +42,59 @@ const MAX_JSON_NODES = 10_000;
42
42
  * {@link MAX_JSON_DEPTH}.
43
43
  * @overallScore 100
44
44
  */
45
- function describeNonJsonValue(value, depth = 0, budget = { nodes: 0 }) {
45
+ function describeNonJsonValue({ value, depth, budget }, _optional = {}) {
46
46
  if (depth > MAX_JSON_DEPTH)
47
47
  return `nests deeper than ${MAX_JSON_DEPTH} levels`;
48
48
  if (++budget.nodes > MAX_JSON_NODES)
49
49
  return `holds more than ${MAX_JSON_NODES} values`;
50
- if (value === null)
51
- return null;
52
- const type = typeof value;
53
- if (type === 'string' || type === 'boolean')
54
- return null;
55
- if (type === 'number')
56
- return Number.isFinite(value) ? null : 'is a non-finite number';
57
- if (type !== 'object')
58
- return `is a ${type}`;
59
- if (Array.isArray(value)) {
60
- for (const item of value) {
61
- const reason = describeNonJsonValue(item, depth + 1, budget);
62
- if (reason !== null)
63
- return reason;
64
- }
65
- return null;
66
- }
50
+ if (value === null || typeof value !== 'object')
51
+ return describeNonJsonScalar(value);
67
52
  // Rejects class instances, `Map`, `Date`, and anything else that survives `typeof === 'object'`
68
53
  // but does not round-trip through JSON as itself.
69
- const proto = Object.getPrototypeOf(value);
70
- if (proto !== Object.prototype && proto !== null)
54
+ if (!Array.isArray(value) && !isPlainObject(value))
71
55
  return 'is a non-plain object';
72
56
  for (const item of Object.values(value)) {
73
- const reason = describeNonJsonValue(item, depth + 1, budget);
57
+ const reason = describeNonJsonValue({ value: item, depth: depth + 1, budget });
74
58
  if (reason !== null)
75
59
  return reason;
76
60
  }
77
61
  return null;
78
62
  }
63
+ /** The leaf half of {@link describeNonJsonValue}: `null` or a non-object `value`. */
64
+ function describeNonJsonScalar(value) {
65
+ if (value === null)
66
+ return null;
67
+ const type = typeof value;
68
+ if (type === 'string' || type === 'boolean')
69
+ return null;
70
+ if (type === 'number')
71
+ return Number.isFinite(value) ? null : 'is a non-finite number';
72
+ return `is a ${type}`;
73
+ }
74
+ function isPlainObject(value) {
75
+ const proto = Object.getPrototypeOf(value);
76
+ return proto === Object.prototype || proto === null;
77
+ }
78
+ /** `null` when `typeof value` is `type`, otherwise "expected <label>, got <typeof value>". */
79
+ function describeWrongType({ value, type, label }, _optional = {}) {
80
+ return typeof value === type ? null : `expected ${label}, got ${typeof value}`;
81
+ }
82
+ function describeNonDatetime(value) {
83
+ if (typeof value !== 'string')
84
+ return `expected an ISO-8601 string, got ${typeof value}`;
85
+ // A `datetime` that is not parseable renders fine and orders wrongly — the exact class of
86
+ // fault this whole check exists for, so it is a mismatch rather than a pass.
87
+ return Number.isNaN(Date.parse(value)) ? 'is not a parseable ISO-8601 timestamp' : null;
88
+ }
89
+ function describeNonNumber({ value, accepts, reason }, _optional = {}) {
90
+ if (typeof value !== 'number')
91
+ return `expected a number, got ${typeof value}`;
92
+ return accepts(value) ? null : reason;
93
+ }
94
+ function describeNonJson(value) {
95
+ const reason = describeNonJsonValue({ value, depth: 0, budget: { nodes: 0 } });
96
+ return reason === null ? null : `is not a JSON value: it ${reason}`;
97
+ }
79
98
  /**
80
99
  * Whether `value` is a legal runtime value for `field`.
81
100
  *
@@ -98,27 +117,21 @@ export function describeAdminEntityFieldMismatch(field, value) {
98
117
  switch (field.kind) {
99
118
  case 'text':
100
119
  case 'relation':
101
- return typeof value === 'string' ? null : `expected a string, got ${typeof value}`;
120
+ return describeWrongType({ value, type: 'string', label: 'a string' });
102
121
  case 'datetime':
103
- if (typeof value !== 'string')
104
- return `expected an ISO-8601 string, got ${typeof value}`;
105
- // A `datetime` that is not parseable renders fine and orders wrongly — the exact class of
106
- // fault this whole check exists for, so it is a mismatch rather than a pass.
107
- return Number.isNaN(Date.parse(value)) ? 'is not a parseable ISO-8601 timestamp' : null;
122
+ return describeNonDatetime(value);
108
123
  case 'integer':
109
- if (typeof value !== 'number')
110
- return `expected a number, got ${typeof value}`;
111
- return Number.isInteger(value) ? null : 'is not an integer';
124
+ return describeNonNumber({ value, accepts: Number.isInteger, reason: 'is not an integer' });
112
125
  case 'real':
113
- if (typeof value !== 'number')
114
- return `expected a number, got ${typeof value}`;
115
- return Number.isFinite(value) ? null : 'is not a finite number';
126
+ return describeNonNumber({
127
+ value,
128
+ accepts: Number.isFinite,
129
+ reason: 'is not a finite number',
130
+ });
116
131
  case 'boolean':
117
- return typeof value === 'boolean' ? null : `expected a boolean, got ${typeof value}`;
118
- case 'json': {
119
- const reason = describeNonJsonValue(value);
120
- return reason === null ? null : `is not a JSON value: it ${reason}`;
121
- }
132
+ return describeWrongType({ value, type: 'boolean', label: 'a boolean' });
133
+ case 'json':
134
+ return describeNonJson(value);
122
135
  }
123
136
  }
124
137
  /**
@@ -132,49 +145,63 @@ export function describeAdminEntityFieldMismatch(field, value) {
132
145
  * @complexity Time O(fields + row keys) plus the `json` walk; space O(violations).
133
146
  * @overallScore 100
134
147
  */
135
- function findRowViolations(descriptor, operation, row) {
148
+ function findRowViolations({ descriptor, operation, row, }, _optional = {}) {
149
+ const hasId = typeof row.id === 'string' && row.id !== '';
150
+ const rowId = hasId ? row.id : '<no id>';
151
+ const at = { entity: descriptor.name, operation, rowId };
136
152
  const found = [];
137
- const rowId = typeof row.id === 'string' && row.id !== '' ? row.id : '<no id>';
138
- const push = (field, problem, detail) => {
139
- found.push({ entity: descriptor.name, operation, rowId, field, problem, detail });
140
- };
141
- if (typeof row.id !== 'string' || row.id === '') {
142
- push(IMPLICIT_ROW_KEY, 'wrong-type', 'every row must carry a non-empty string `id`');
153
+ if (!hasId) {
154
+ found.push({
155
+ ...at,
156
+ field: IMPLICIT_ROW_KEY,
157
+ problem: 'wrong-type',
158
+ detail: 'every row must carry a non-empty string `id`',
159
+ });
143
160
  }
144
- const declared = new Set([IMPLICIT_ROW_KEY]);
145
161
  for (const field of descriptor.fields) {
146
- declared.add(field.name);
147
- const value = row[field.name];
148
- if (value === undefined) {
149
- if (field.required === true) {
150
- push(field.name, 'missing-required', 'declared `required: true` but absent from the row');
151
- }
152
- continue;
153
- }
154
- const mismatch = describeAdminEntityFieldMismatch(field, value);
155
- if (mismatch !== null)
156
- push(field.name, 'wrong-type', `declared \`${field.kind}\`; ${mismatch}`);
162
+ const problem = describeFieldProblem({ field, value: row[field.name] });
163
+ if (problem !== null)
164
+ found.push({ ...at, field: field.name, ...problem });
157
165
  }
166
+ const declared = new Set([IMPLICIT_ROW_KEY, ...descriptor.fields.map((field) => field.name)]);
158
167
  for (const key of Object.keys(row)) {
159
168
  if (declared.has(key))
160
169
  continue;
161
- push(key, 'undeclared-field', 'present on the row but not declared on the descriptor');
170
+ found.push({
171
+ ...at,
172
+ field: key,
173
+ problem: 'undeclared-field',
174
+ detail: 'present on the row but not declared on the descriptor',
175
+ });
162
176
  }
163
177
  return found;
164
178
  }
179
+ /** What is wrong with one declared field's cell, or `null` when it conforms or is legally absent. */
180
+ function describeFieldProblem({ field, value }, _optional = {}) {
181
+ if (value === undefined) {
182
+ return field.required === true
183
+ ? { problem: 'missing-required', detail: 'declared `required: true` but absent from the row' }
184
+ : null;
185
+ }
186
+ const mismatch = describeAdminEntityFieldMismatch(field, value);
187
+ return mismatch === null
188
+ ? null
189
+ : { problem: 'wrong-type', detail: `declared \`${field.kind}\`; ${mismatch}` };
190
+ }
165
191
  /**
166
192
  * Runs `row` past its descriptor and reports anything wrong, then returns it unchanged.
167
193
  *
168
194
  * Deliberately identity-returning: this is an observation point, never a repair point. Coercing a
169
195
  * bad value here would hide the adapter bug it exists to expose.
170
196
  *
171
- * @complexity Time O(rows × fields); no-op and O(1) when `onViolation` is absent.
197
+ * Only {@link eraseEntityPort}'s checking wrapper calls this, and that wrapper exists only when
198
+ * `onViolation` is set — so the sink is required here rather than re-checked.
199
+ *
200
+ * @complexity Time O(rows × fields).
172
201
  * @overallScore 100
173
202
  */
174
- function checkRow(descriptor, operation, row, onViolation) {
175
- if (onViolation === undefined)
176
- return row;
177
- for (const violation of findRowViolations(descriptor, operation, row))
203
+ function checkRow({ descriptor, operation, row, onViolation, }, _optional = {}) {
204
+ for (const violation of findRowViolations({ descriptor, operation, row }))
178
205
  onViolation(violation);
179
206
  return row;
180
207
  }
@@ -208,18 +235,20 @@ export function eraseEntityPort(port, options = {}) {
208
235
  async list(query) {
209
236
  const page = await erased.list(query);
210
237
  for (const row of page.items)
211
- checkRow(descriptor, 'list', row, onViolation);
238
+ checkRow({ descriptor, operation: 'list', row, onViolation });
212
239
  return page;
213
240
  },
214
241
  async get(id) {
215
242
  const row = await erased.get(id);
216
- return row === null ? null : checkRow(descriptor, 'get', row, onViolation);
243
+ return row === null ? null : checkRow({ descriptor, operation: 'get', row, onViolation });
217
244
  },
218
245
  async create(input) {
219
- return checkRow(descriptor, 'create', await erased.create(input), onViolation);
246
+ const row = await erased.create(input);
247
+ return checkRow({ descriptor, operation: 'create', row, onViolation });
220
248
  },
221
249
  async update(id, patch) {
222
- return checkRow(descriptor, 'update', await erased.update(id, patch), onViolation);
250
+ const row = await erased.update(id, patch);
251
+ return checkRow({ descriptor, operation: 'update', row, onViolation });
223
252
  },
224
253
  };
225
254
  // Preserved by presence, not by a forwarding stub: a screen decides whether to render a delete
@@ -241,44 +270,60 @@ export function eraseEntityPort(port, options = {}) {
241
270
  * @complexity Time O(fields); space O(problems).
242
271
  * @overallScore 100
243
272
  */
244
- function findDescriptorProblems(descriptor, registryKey, registeredNames) {
245
- const problems = [];
273
+ function findDescriptorProblems({ descriptor, registryKey, registeredNames, }, _optional = {}) {
246
274
  const at = `'${registryKey}'`;
275
+ return [
276
+ ...findHeaderProblems({ descriptor, registryKey }),
277
+ ...findFieldProblems({ descriptor, registeredNames }),
278
+ ...findTitleFieldProblems({ descriptor }),
279
+ ].map((problem) => `${at}: ${problem}`);
280
+ }
281
+ function findHeaderProblems({ descriptor, registryKey }, _optional = {}) {
282
+ const problems = [];
247
283
  if (descriptor.name !== registryKey) {
248
284
  // One entity, one name. Allowing these to differ would leave the URL segment, the relation
249
285
  // target, and the registry key as three identifiers with no stated relationship between them.
250
- problems.push(`${at}: registered under '${registryKey}' but its descriptor is named '${descriptor.name}'`);
286
+ problems.push(`registered under '${registryKey}' but its descriptor is named '${descriptor.name}'`);
251
287
  }
252
288
  if (descriptor.labelSingular === '' || descriptor.labelPlural === '') {
253
- problems.push(`${at}: labelSingular and labelPlural must both be non-empty`);
289
+ problems.push('labelSingular and labelPlural must both be non-empty');
254
290
  }
255
291
  if (descriptor.fields.length === 0) {
256
- problems.push(`${at}: declares no fields, so nothing but its id could ever be shown or edited`);
292
+ problems.push('declares no fields, so nothing but its id could ever be shown or edited');
257
293
  }
294
+ return problems;
295
+ }
296
+ function findFieldProblems({ descriptor, registeredNames, }, _optional = {}) {
297
+ const problems = [];
258
298
  const seen = new Set();
259
299
  for (const field of descriptor.fields) {
260
300
  if (field.name === '') {
261
- problems.push(`${at}: a field has an empty name`);
301
+ problems.push('a field has an empty name');
262
302
  continue;
263
303
  }
264
304
  if (field.name === IMPLICIT_ROW_KEY) {
265
- problems.push(`${at}: '${IMPLICIT_ROW_KEY}' is implicit on every row and must not be declared as a field`);
305
+ problems.push(`'${IMPLICIT_ROW_KEY}' is implicit on every row and must not be declared as a field`);
266
306
  }
267
307
  if (seen.has(field.name))
268
- problems.push(`${at}: field '${field.name}' is declared more than once`);
308
+ problems.push(`field '${field.name}' is declared more than once`);
269
309
  seen.add(field.name);
270
310
  if (field.kind === 'relation' && !registeredNames.has(field.target)) {
271
- problems.push(`${at}: field '${field.name}' targets '${field.target}', which is not a registered entity`);
311
+ problems.push(`field '${field.name}' targets '${field.target}', which is not a registered entity`);
272
312
  }
273
313
  }
314
+ return problems;
315
+ }
316
+ function findTitleFieldProblems({ descriptor }, _optional = {}) {
274
317
  const titleField = descriptor.fields.find((field) => field.name === descriptor.titleField);
275
318
  if (titleField === undefined) {
276
- problems.push(`${at}: titleField '${descriptor.titleField}' does not name a declared field`);
319
+ return [`titleField '${descriptor.titleField}' does not name a declared field`];
277
320
  }
278
- else if (titleField.kind !== 'text') {
279
- problems.push(`${at}: titleField '${descriptor.titleField}' is kind '${titleField.kind}'; a title must be 'text'`);
321
+ if (titleField.kind !== 'text') {
322
+ return [
323
+ `titleField '${descriptor.titleField}' is kind '${titleField.kind}'; a title must be 'text'`,
324
+ ];
280
325
  }
281
- return problems;
326
+ return [];
282
327
  }
283
328
  /**
284
329
  * Assembles entity ports into a registry, validating every descriptor first.
@@ -306,7 +351,7 @@ function findDescriptorProblems(descriptor, registryKey, registeredNames) {
306
351
  export function createAdminEntityRegistry(ports) {
307
352
  const entries = Object.entries(ports);
308
353
  const registeredNames = new Set(entries.map(([, port]) => port.descriptor.name));
309
- const problems = entries.flatMap(([key, port]) => findDescriptorProblems(port.descriptor, key, registeredNames));
354
+ const problems = entries.flatMap(([key, port]) => findDescriptorProblems({ descriptor: port.descriptor, registryKey: key, registeredNames }));
310
355
  if (problems.length > 0) {
311
356
  throw new Error(`createAdminEntityRegistry: invalid descriptors\n- ${problems.join('\n- ')}`);
312
357
  }
@@ -35,8 +35,9 @@ export function resolvePanels({ panels }, context = {}) {
35
35
  * @complexity O(n log n) on the items within each group; O(n) otherwise.
36
36
  */
37
37
  export function buildNav({ panels }, options = {}) {
38
+ // A `Map` iterates in first-insertion order, so it alone records the order each group's first
39
+ // member was registered — no separate order list (and no lookup that could miss) is needed.
38
40
  const groups = new Map();
39
- const groupOrder = [];
40
41
  // Sentinel for "no group". A panel cannot collide with it: `group` is `string | undefined`, and
41
42
  // an explicit empty-string group would be a labelless heading, which is the same thing.
42
43
  const UNGROUPED = '';
@@ -44,20 +45,19 @@ export function buildNav({ panels }, options = {}) {
44
45
  if (!panel.nav)
45
46
  return;
46
47
  const key = panel.nav.group ?? UNGROUPED;
47
- if (!groups.has(key)) {
48
- groups.set(key, []);
49
- groupOrder.push(key);
50
- }
51
- groups.get(key)?.push({ ...panel.nav, id: panel.id, href: panelHref({ panelId: panel.id }, options) });
48
+ const group = groups.get(key) ?? [];
49
+ groups.set(key, group);
50
+ group.push({ ...panel.nav, id: panel.id, href: panelHref({ panelId: panel.id }, options) });
52
51
  });
53
52
  // Ungrouped first regardless of when it was registered — it is the top row, not a peer group.
53
+ const entries = [...groups.entries()];
54
54
  const ordered = [
55
- ...groupOrder.filter((k) => k === UNGROUPED),
56
- ...groupOrder.filter((k) => k !== UNGROUPED),
55
+ ...entries.filter(([k]) => k === UNGROUPED),
56
+ ...entries.filter(([k]) => k !== UNGROUPED),
57
57
  ];
58
- return ordered.map((key) => ({
58
+ return ordered.map(([key, items]) => ({
59
59
  ...(key === UNGROUPED ? {} : { label: key }),
60
- items: stableSortByOrder(groups.get(key) ?? []),
60
+ items: stableSortByOrder(items),
61
61
  }));
62
62
  }
63
63
  /** `Array.prototype.sort` is specified as stable, so equal `order` values keep registration