@omnitend/dashboard-for-laravel 0.43.0 → 0.44.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.
Files changed (38) hide show
  1. package/dist/components/base/DFormSelect.vue.d.ts +1 -1
  2. package/dist/components/extended/DXChoiceField.vue.d.ts +3 -3
  3. package/dist/components/extended/DXForm.vue.d.ts +66 -1
  4. package/dist/components/extended/DXFormErrorSummary.vue.d.ts +70 -0
  5. package/dist/components/extended/DXFormField.vue.d.ts +3 -0
  6. package/dist/components/extended/DXTable.vue.d.ts +20 -0
  7. package/dist/components/extended/DXTableEditorModal.vue.d.ts +14 -10
  8. package/dist/composables/useAsyncOptions.d.ts +14 -1
  9. package/dist/composables/useForm.d.ts +26 -0
  10. package/dist/dashboard-for-laravel.js +13374 -12823
  11. package/dist/dashboard-for-laravel.js.map +1 -1
  12. package/dist/dashboard-for-laravel.umd.cjs +8 -8
  13. package/dist/dashboard-for-laravel.umd.cjs.map +1 -1
  14. package/dist/index.d.ts +5 -1
  15. package/dist/style.css +1 -1
  16. package/dist/types/index.d.ts +12 -0
  17. package/dist/utils/formErrorTargets.d.ts +86 -0
  18. package/docs/public/api-reference.json +146 -6
  19. package/docs/public/docs-map.md +3 -2
  20. package/docs/public/llms.txt +3 -2
  21. package/package.json +9 -8
  22. package/resources/css/theme.scss +56 -0
  23. package/resources/js/components/base/DFormSelect.vue +5 -2
  24. package/resources/js/components/extended/DXChoiceField.vue +42 -26
  25. package/resources/js/components/extended/DXField.vue +15 -9
  26. package/resources/js/components/extended/DXForm.vue +676 -28
  27. package/resources/js/components/extended/DXFormErrorSummary.vue +232 -0
  28. package/resources/js/components/extended/DXFormField.vue +11 -2
  29. package/resources/js/components/extended/DXRepeater.vue +30 -4
  30. package/resources/js/components/extended/DXTable.vue +36 -4
  31. package/resources/js/components/extended/DXTableEditorModal.vue +29 -1
  32. package/resources/js/composables/useAsyncOptions.ts +30 -5
  33. package/resources/js/composables/useForm.ts +196 -18
  34. package/resources/js/composables/useResourceEditor.ts +140 -107
  35. package/resources/js/index.ts +15 -0
  36. package/resources/js/types/index.ts +13 -0
  37. package/resources/js/utils/formErrorTargets.ts +317 -0
  38. package/scripts/mcp-server.mjs +58 -6
@@ -7,6 +7,7 @@ export * from 'bootstrap-vue-next';
7
7
  // Extended components (custom functionality beyond Bootstrap Vue Next)
8
8
  export { default as DXDashboard } from "./components/extended/DXDashboard.vue";
9
9
  export { default as DXForm } from "./components/extended/DXForm.vue";
10
+ export { default as DXFormErrorSummary } from "./components/extended/DXFormErrorSummary.vue";
10
11
  export { default as DXField } from "./components/extended/DXField.vue";
11
12
  export { default as DXFieldLabel } from "./components/extended/DXFieldLabel.vue";
12
13
  export { default as DXModalActions } from "./components/extended/DXModalActions.vue";
@@ -130,6 +131,12 @@ export {
130
131
  isFieldVisible,
131
132
  isSubmittableField,
132
133
  } from "./utils/formSchema";
134
+ // Which field a validation-error key belongs to (#194): the one rule the
135
+ // error summary, tab selection and scrolling share.
136
+ export {
137
+ resolveErrorTargets,
138
+ humaniseErrorKey,
139
+ } from "./utils/formErrorTargets";
133
140
 
134
141
  // Types
135
142
  export type {
@@ -168,8 +175,16 @@ export type {
168
175
  FormSubmitOptions,
169
176
  FormState,
170
177
  UseFormReturn,
178
+ SubmitFailure,
171
179
  } from "./composables/useForm";
172
180
 
181
+ export type {
182
+ ErrorTarget,
183
+ ResolveErrorTargetsOptions,
184
+ } from "./utils/formErrorTargets";
185
+
186
+ export type { ErrorSummarySelection } from "./components/extended/DXFormErrorSummary.vue";
187
+
173
188
  export type {
174
189
  FormFieldDefinition,
175
190
  DefineFormReturn,
@@ -367,6 +367,18 @@ export interface FieldDefinition {
367
367
  */
368
368
  submit?: boolean;
369
369
 
370
+ /**
371
+ * Extra validation-error keys this field owns (#194), for a field whose
372
+ * server-side errors arrive under keys other than its own: a span or
373
+ * component field that edits nested data, say. Patterns, where `*`
374
+ * matches exactly one dot segment: `["lines.*", "lines.*.*"]` claims
375
+ * `lines.0` and `lines.0.price` but not `lines` or `lines.0.a.b`.
376
+ * Checked before the usual matching (the field's own key, then any key
377
+ * nested under it), so it decides which field an error belongs to: the
378
+ * label it is listed under in the error summary and the tab it selects.
379
+ */
380
+ errorKeys?: string[];
381
+
370
382
  /**
371
383
  * Conditionally show or hide this field. When omitted the field is
372
384
  * always visible. Boolean or a function of the form model; evaluated
@@ -427,6 +439,7 @@ export interface BaseFieldDef {
427
439
  readonly?: MaybeFn<boolean>;
428
440
  plaintext?: MaybeFn<boolean>;
429
441
  submit?: boolean;
442
+ errorKeys?: string[];
430
443
  when?: MaybeFn<boolean>;
431
444
  show?: () => boolean;
432
445
  }
@@ -0,0 +1,317 @@
1
+ /**
2
+ * Which field a validation-error key belongs to (#194).
3
+ *
4
+ * One pure function decides, so the error summary's rows, the tab a failed
5
+ * submit selects and the field it scrolls to can never disagree. No Vue: the
6
+ * caller injects visibility and label resolution, so DXForm can pass its own
7
+ * `when`/`context`-aware predicates and a standalone summary can use the
8
+ * defaults below.
9
+ */
10
+ import type { FieldDefinition, FormTab } from "../types";
11
+ import type { ValidationErrors } from "../composables/useForm";
12
+ import {
13
+ isFieldVisible as isFieldVisibleFor,
14
+ resolvePredicate,
15
+ } from "./formSchema";
16
+ import { getByPath } from "./objectPath";
17
+
18
+ /** One error key and where it belongs. */
19
+ export interface ErrorTarget {
20
+ /** The key as the server sent it (`lines.0.price`). */
21
+ errorKey: string;
22
+ /** The server's messages for that key, as sent. */
23
+ messages: string[];
24
+ /**
25
+ * One display line per message: the message itself, or `"Label: message"`
26
+ * when the message does not already say which field it is about (see
27
+ * `resolveErrorTargets`).
28
+ */
29
+ lines: string[];
30
+ /** The owning field's key, or `null` when no visible field owns it. */
31
+ fieldKey: string | null;
32
+ /** The tab holding the owning field, or `null` (flat form, or unowned). */
33
+ tabKey: string | null;
34
+ /** A readable name for the key: "Price (line 1)", "Delivery date". */
35
+ label: string;
36
+ }
37
+
38
+ export interface ResolveErrorTargetsOptions {
39
+ /** The form's field definitions. */
40
+ fields?: FieldDefinition[];
41
+ /**
42
+ * The form's tabs. When given (non-empty), only fields listed in a
43
+ * visible tab can own an error, as only those render. A tab key with no
44
+ * field definition (consumer-rendered content) can still own its own key
45
+ * and keys nested under it.
46
+ */
47
+ tabs?: FormTab[];
48
+ /** Model for the default predicates and labels (form data + context). */
49
+ model?: Record<string, any>;
50
+ /** Field visibility. Default: `when` and `show` against `model`. */
51
+ isFieldVisible?: (field: FieldDefinition) => boolean;
52
+ /** Tab visibility. Default: the tab's `when` against `model`. */
53
+ isTabVisible?: (tab: FormTab) => boolean;
54
+ /**
55
+ * A field's label. Default: its `label`, called with `labelModel` when it
56
+ * is a function. `labelModel` is `model` for a form field and the ROW
57
+ * (`model.lines[0]`) for a repeater sub-field, as DXRepeater renders it.
58
+ * An empty result, or a label function that throws, falls back to the
59
+ * humanised key, so a broken label can never hide an error.
60
+ */
61
+ resolveLabel?: (
62
+ field: FieldDefinition,
63
+ labelModel: Record<string, any>,
64
+ ) => string | null | undefined;
65
+ }
66
+
67
+ interface Candidate {
68
+ field: FieldDefinition;
69
+ tabKey: string | null;
70
+ /** Position for ordering: [tab index, position within the tab / form]. */
71
+ rank: [number, number];
72
+ }
73
+
74
+ /** Sorts after every owned key. */
75
+ const UNOWNED_RANK = Number.MAX_SAFE_INTEGER;
76
+
77
+ const NUMERIC_SEGMENT = /^\d+$/;
78
+
79
+ /**
80
+ * A message that names a raw key (`delivery_date`, `lines.0.price`) is not
81
+ * readable on its own, so it keeps its label prefix. A sentence-ending full
82
+ * stop or a decimal (`5.5`) is not a key.
83
+ */
84
+ const RAW_KEY_IN_MESSAGE = /\w_\w|[A-Za-z0-9]\.[A-Za-z]|[A-Za-z]\.[0-9]/;
85
+
86
+ /**
87
+ * `delivery_date` → "Delivery date"; `lines.0.price` → "Lines price (line 1)".
88
+ * A trailing `id` word names the record, not the thing, so it is dropped
89
+ * (`supplier_id`, `supplierId` → "Supplier"); an `id` word that is the whole
90
+ * segment, or not at its end, reads "ID" (`id` → "ID"). Words that merely end
91
+ * in "id" (`paid`, `valid`) are untouched.
92
+ */
93
+ export function humaniseErrorKey(key: string): string {
94
+ const words: string[] = [];
95
+ const lineNumbers: number[] = [];
96
+ for (const segment of key.split(".")) {
97
+ if (NUMERIC_SEGMENT.test(segment)) {
98
+ lineNumbers.push(Number(segment) + 1);
99
+ } else {
100
+ const segmentWords = segment
101
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
102
+ .split(/[_\-\s]+/)
103
+ .filter((word) => word !== "")
104
+ .map((word) => word.toLowerCase());
105
+ if (segmentWords.length > 1 && segmentWords[segmentWords.length - 1] === "id") {
106
+ segmentWords.pop();
107
+ }
108
+ words.push(...segmentWords.map((word) => (word === "id" ? "ID" : word)));
109
+ }
110
+ }
111
+ let label = words.join(" ");
112
+ label = label.charAt(0).toUpperCase() + label.slice(1);
113
+ if (lineNumbers.length > 0) {
114
+ label = `${label} (line ${lineNumbers.join(", ")})`.trim();
115
+ }
116
+ return label === "" ? key : label;
117
+ }
118
+
119
+ /** `*` matches exactly one dot segment; everything else matches literally. */
120
+ function patternMatches(pattern: string, errorKey: string): boolean {
121
+ const patternSegments = pattern.split(".");
122
+ const keySegments = errorKey.split(".");
123
+ if (patternSegments.length !== keySegments.length) return false;
124
+ return patternSegments.every(
125
+ (segment, index) => segment === "*" || segment === keySegments[index],
126
+ );
127
+ }
128
+
129
+ function isNestedUnder(errorKey: string, fieldKey: string): boolean {
130
+ return errorKey.startsWith(`${fieldKey}.`);
131
+ }
132
+
133
+ function defaultLabel(
134
+ field: FieldDefinition,
135
+ model: Record<string, any>,
136
+ ): string | null | undefined {
137
+ return typeof field.label === "function" ? field.label(model) : field.label;
138
+ }
139
+
140
+ /**
141
+ * Resolve every key in `errors` to its owning field, tab and label, with the
142
+ * display line for each message. Rows come in tab order, then field order
143
+ * (the order of `tab.fieldKeys`, or of `fields` in a flat form), then the
144
+ * order the server sent the keys; keys no visible field owns come last.
145
+ *
146
+ * Ownership, first match wins: a field whose `errorKeys` pattern matches; a
147
+ * field whose key equals the error key; the field with the longest key that
148
+ * is a dot prefix of it (repeater rows, media maps). Hidden fields, and
149
+ * fields on hidden tabs or on no tab of a tabbed form, never own a key; it
150
+ * falls through to the next rule, or is unowned.
151
+ *
152
+ * Labels: the owning field's label; for `field.N.sub` the sub-field's label
153
+ * (when the field defines `fields` and one has that key) or the field's own,
154
+ * plus "(line N+1)"; for any other nested key (a media map's
155
+ * `image_media.<uuid>`) the field's label. An unowned key is humanised.
156
+ *
157
+ * Display lines: "Label: message", except that the label is left off when
158
+ * the message already contains it (case-insensitive) — unless the message
159
+ * contains a raw key (`_`, or a dot inside a word) or another row has the
160
+ * same message text, when the label is what tells the rows apart.
161
+ */
162
+ export function resolveErrorTargets(
163
+ errors: ValidationErrors | null | undefined,
164
+ options: ResolveErrorTargetsOptions = {},
165
+ ): ErrorTarget[] {
166
+ const fields = options.fields ?? [];
167
+ const model = options.model ?? {};
168
+ const isFieldVisible =
169
+ options.isFieldVisible ??
170
+ ((field: FieldDefinition) => isFieldVisibleFor(field, model));
171
+ const isTabVisible =
172
+ options.isTabVisible ??
173
+ ((tab: FormTab) => resolvePredicate(tab.when, model, true));
174
+ const resolveLabel = options.resolveLabel ?? defaultLabel;
175
+
176
+ const labelFor = (
177
+ field: FieldDefinition,
178
+ labelModel: Record<string, any> = model,
179
+ ): string => {
180
+ let label: string | null | undefined;
181
+ try {
182
+ label = resolveLabel(field, labelModel);
183
+ } catch {
184
+ // A consumer's label function threw (a row missing a property
185
+ // it reads): the error must still be listed, under its key.
186
+ label = undefined;
187
+ }
188
+ return typeof label === "string" && label.trim() !== ""
189
+ ? label
190
+ : humaniseErrorKey(field.key);
191
+ };
192
+
193
+ // ——— which fields can own an error, in display order
194
+ const candidates: Candidate[] = [];
195
+ const tabs = options.tabs ?? [];
196
+ if (tabs.length > 0) {
197
+ const fieldByKey = new Map(fields.map((field) => [field.key, field]));
198
+ const placed = new Set<string>();
199
+ tabs.forEach((tab, tabIndex) => {
200
+ if (!isTabVisible(tab)) return;
201
+ tab.fieldKeys.forEach((key, position) => {
202
+ if (placed.has(key)) return;
203
+ const field = fieldByKey.get(key);
204
+ // A key with no definition is consumer-rendered content: the
205
+ // tab decides whether it shows.
206
+ if (field && !isFieldVisible(field)) return;
207
+ placed.add(key);
208
+ candidates.push({
209
+ field: field ?? ({ key, type: "text" } as FieldDefinition),
210
+ tabKey: tab.key,
211
+ rank: [tabIndex, position],
212
+ });
213
+ });
214
+ });
215
+ } else {
216
+ fields.forEach((field, index) => {
217
+ if (!isFieldVisible(field)) return;
218
+ candidates.push({ field, tabKey: null, rank: [0, index] });
219
+ });
220
+ }
221
+
222
+ const ownerOf = (errorKey: string): Candidate | null => {
223
+ const byPattern = candidates.find((candidate) =>
224
+ (candidate.field.errorKeys ?? []).some((pattern) =>
225
+ patternMatches(pattern, errorKey),
226
+ ),
227
+ );
228
+ if (byPattern) return byPattern;
229
+ const exact = candidates.find(
230
+ (candidate) => candidate.field.key === errorKey,
231
+ );
232
+ if (exact) return exact;
233
+ let longest: Candidate | null = null;
234
+ for (const candidate of candidates) {
235
+ if (
236
+ isNestedUnder(errorKey, candidate.field.key) &&
237
+ (!longest || candidate.field.key.length > longest.field.key.length)
238
+ ) {
239
+ longest = candidate;
240
+ }
241
+ }
242
+ return longest;
243
+ };
244
+
245
+ const ownedLabel = (errorKey: string, field: FieldDefinition): string => {
246
+ if (!isNestedUnder(errorKey, field.key)) return labelFor(field);
247
+ const [first, second] = errorKey.slice(field.key.length + 1).split(".");
248
+ if (!NUMERIC_SEGMENT.test(first)) return labelFor(field);
249
+ const subField =
250
+ second !== undefined
251
+ ? field.fields?.find((candidate) => candidate.key === second)
252
+ : undefined;
253
+ // DXRepeater renders a sub-field with the ROW as its model, so its
254
+ // label resolves against the row here too.
255
+ const row = getByPath(model, `${field.key}.${first}`);
256
+ const rowModel = row !== null && typeof row === "object" ? row : {};
257
+ const base = subField ? labelFor(subField, rowModel) : labelFor(field);
258
+ return `${base} (line ${Number(first) + 1})`;
259
+ };
260
+
261
+ // ——— one target per error key with at least one message
262
+ interface Pending {
263
+ target: Omit<ErrorTarget, "lines">;
264
+ rank: [number, number];
265
+ order: number;
266
+ }
267
+ const pending: Pending[] = [];
268
+ Object.entries(errors ?? {}).forEach(([errorKey, value], order) => {
269
+ const messages = (Array.isArray(value) ? value : [value]).filter(
270
+ (message): message is string =>
271
+ typeof message === "string" && message !== "",
272
+ );
273
+ if (messages.length === 0) return;
274
+ const owner = ownerOf(errorKey);
275
+ pending.push({
276
+ target: {
277
+ errorKey,
278
+ messages,
279
+ fieldKey: owner ? owner.field.key : null,
280
+ tabKey: owner ? owner.tabKey : null,
281
+ label: owner
282
+ ? ownedLabel(errorKey, owner.field)
283
+ : humaniseErrorKey(errorKey),
284
+ },
285
+ rank: owner ? owner.rank : [UNOWNED_RANK, UNOWNED_RANK],
286
+ order,
287
+ });
288
+ });
289
+
290
+ pending.sort(
291
+ (left, right) =>
292
+ left.rank[0] - right.rank[0] ||
293
+ left.rank[1] - right.rank[1] ||
294
+ left.order - right.order,
295
+ );
296
+
297
+ // ——— display lines, with the label prefix where it is needed
298
+ const messageCounts = new Map<string, number>();
299
+ for (const { target } of pending) {
300
+ for (const message of target.messages) {
301
+ messageCounts.set(message, (messageCounts.get(message) ?? 0) + 1);
302
+ }
303
+ }
304
+ const needsLabel = (message: string, label: string): boolean =>
305
+ (messageCounts.get(message) ?? 0) > 1 ||
306
+ RAW_KEY_IN_MESSAGE.test(message) ||
307
+ !message.toLowerCase().includes(label.toLowerCase());
308
+
309
+ return pending.map(({ target }) => ({
310
+ ...target,
311
+ lines: target.messages.map((message) =>
312
+ needsLabel(message, target.label)
313
+ ? `${target.label}: ${message}`
314
+ : message,
315
+ ),
316
+ }));
317
+ }
@@ -7,15 +7,67 @@
7
7
  * Works with Claude Desktop, Claude Code, and any MCP-compatible client.
8
8
  */
9
9
 
10
- import { Server } from '@modelcontextprotocol/sdk/server/index.js';
11
- import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
12
- import {
13
- CallToolRequestSchema,
14
- ListToolsRequestSchema,
15
- } from '@modelcontextprotocol/sdk/types.js';
16
10
  import { readFileSync, readdirSync, existsSync } from 'fs';
17
11
  import { join, dirname } from 'path';
18
12
  import { fileURLToPath } from 'url';
13
+ import { createRequire } from 'module';
14
+
15
+ // The MCP SDK is an OPTIONAL peer dependency (#181): consumers of the
16
+ // component library should not install express/hono/ajv just to get Vue
17
+ // components. Load it dynamically so a missing SDK produces an instruction
18
+ // rather than an ERR_MODULE_NOT_FOUND stack trace.
19
+ //
20
+ // Absence is decided by RESOLVING the SDK's own entry point (which loads
21
+ // nothing), not by matching an import error: an error raised while the SDK
22
+ // loads (a missing transitive dependency, a syntax error) also mentions the
23
+ // SDK's path, and must surface as itself rather than as "install the SDK".
24
+ const SDK_ENTRY = '@modelcontextprotocol/sdk/server/index.js';
25
+
26
+ function sdkIsInstalled() {
27
+ // Synchronous import.meta.resolve needs Node 18.19 / 20.6; it resolves
28
+ // exactly as the import below will (the `import` condition).
29
+ if (typeof import.meta.resolve === 'function') {
30
+ try {
31
+ import.meta.resolve(SDK_ENTRY);
32
+ return true;
33
+ } catch (error) {
34
+ if (error?.code === 'ERR_MODULE_NOT_FOUND') return false;
35
+ throw error;
36
+ }
37
+ }
38
+ // Older Node 18/20: resolve through CommonJS. The SDK's `exports` map has a
39
+ // `require` condition for every subpath ("./*" -> "./dist/cjs/*"). Only
40
+ // "the package itself was not found" counts as absent: CommonJS reports a
41
+ // missing package with the specifier we passed, while a missing file
42
+ // inside an installed SDK names an absolute path and propagates, as does
43
+ // ERR_PACKAGE_PATH_NOT_EXPORTED.
44
+ try {
45
+ createRequire(import.meta.url).resolve(SDK_ENTRY);
46
+ return true;
47
+ } catch (error) {
48
+ if (
49
+ error?.code === 'MODULE_NOT_FOUND' &&
50
+ String(error.message).startsWith(`Cannot find module '${SDK_ENTRY}'`)
51
+ ) {
52
+ return false;
53
+ }
54
+ throw error;
55
+ }
56
+ }
57
+
58
+ if (!sdkIsInstalled()) {
59
+ console.error(
60
+ 'dashboard-docs-mcp needs @modelcontextprotocol/sdk, which is an optional peer dependency.\n' +
61
+ 'Install @modelcontextprotocol/sdk to use dashboard-docs-mcp:\n\n' +
62
+ ' npm install --save-dev @modelcontextprotocol/sdk',
63
+ );
64
+ process.exit(1);
65
+ }
66
+ const { Server } = await import('@modelcontextprotocol/sdk/server/index.js');
67
+ const { StdioServerTransport } = await import('@modelcontextprotocol/sdk/server/stdio.js');
68
+ const { CallToolRequestSchema, ListToolsRequestSchema } = await import(
69
+ '@modelcontextprotocol/sdk/types.js'
70
+ );
19
71
 
20
72
  const __dirname = dirname(fileURLToPath(import.meta.url));
21
73
  const rootDir = join(__dirname, '..');