@omnitend/dashboard-for-laravel 0.46.0 → 0.47.1

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.
@@ -274,6 +274,20 @@ export interface FieldDefinition {
274
274
  */
275
275
  default?: any;
276
276
 
277
+ /**
278
+ * For a field whose value is a list of rows (a `repeater`, or a span
279
+ * field that renders its own rows and owns `key.N.*` error keys): the
280
+ * row's own name, called with the row (`model[key][index]`, or `{}` when
281
+ * the model has no such row) and its 0-based index. When it returns a
282
+ * non-empty string, the error summary lists that row's errors under it
283
+ * alone ("Chaucer Ale (Pint): Enter a price.") instead of
284
+ * "<sub-field label> (line N)". Returning `undefined`, `null` or `""`
285
+ * (a row with no name yet), or throwing, keeps the line-number label.
286
+ * Only labels: which field owns the error, and what a click on the
287
+ * summary row focuses, are unchanged.
288
+ */
289
+ rowLabel?: (row: Record<string, any>, index: number) => string | null | undefined;
290
+
277
291
  /** Label for a repeater's "add row" button (default: "Add"). */
278
292
  addLabel?: string;
279
293
 
@@ -364,6 +378,10 @@ export interface FieldDefinition {
364
378
  * every `editFields` key, so those decorative fields are POSTed alongside
365
379
  * the real ones (an empty string, an empty array) purely because they were
366
380
  * declared.
381
+ *
382
+ * Applies per KEY (#228): DXTable drops a key only when no field with that
383
+ * key is both submitting and visible, so a `submit: false` display twin
384
+ * does not strip the value of a visible editable field sharing its key.
367
385
  */
368
386
  submit?: boolean;
369
387
 
@@ -440,6 +458,8 @@ export interface BaseFieldDef {
440
458
  plaintext?: MaybeFn<boolean>;
441
459
  submit?: boolean;
442
460
  errorKeys?: string[];
461
+ /** On a repeater, or a span field that renders its own rows. */
462
+ rowLabel?: (row: Record<string, any>, index: number) => string | null | undefined;
443
463
  when?: MaybeFn<boolean>;
444
464
  show?: () => boolean;
445
465
  }
@@ -652,6 +672,54 @@ export interface FormTab {
652
672
  lazy?: boolean;
653
673
  }
654
674
 
675
+ /**
676
+ * Slot props of DXField's `password-actions` slot (DXForm:
677
+ * `password-actions(<key>)`): content placed beside a password input, such as
678
+ * a "Generate password" button. It renders after the input group (the input
679
+ * and the reveal (eye) toggle), in `.dx-password-field__actions`, and wraps
680
+ * onto its own line below the input when the row is too narrow to keep
681
+ * `--dx-password-input-min-width`. `setRevealed` drives the same state the
682
+ * eye toggles.
683
+ *
684
+ * ```vue
685
+ * <template #password-actions(password)="{ update, setRevealed, disabled }">
686
+ * <DButton variant="secondary" :disabled="disabled"
687
+ * @click="update(generatePassword()); setRevealed(true)">Generate</DButton>
688
+ * </template>
689
+ * ```
690
+ */
691
+ export interface PasswordActionsSlotProps {
692
+ /** The field definition being rendered. */
693
+ field: FieldDefinition;
694
+ /** The model passed to field predicates (defaults to the live form data). */
695
+ model: any;
696
+ /** The current field value. */
697
+ value: any;
698
+ /** Write a new value (also marks the field touched and clears its error). */
699
+ update: (value: any) => void;
700
+ /** Whether the password is currently shown as plain text. */
701
+ revealed: boolean;
702
+ /**
703
+ * Show (`true`) or mask (`false`) the password, exactly as the eye does.
704
+ * A no-op on a `revealable: false` field, which never unmasks.
705
+ */
706
+ setRevealed: (revealed: boolean) => void;
707
+ /** Whether the field is disabled or read-only. */
708
+ disabled: boolean;
709
+ }
710
+
711
+ /**
712
+ * Slot props of DXTable's `edit-password-actions(<key>)` slot, the edit/create
713
+ * modal's form of {@link PasswordActionsSlotProps}. `item` is the row being
714
+ * edited, `null` in create mode. (`model` is not passed, as with the modal's
715
+ * other field slots.)
716
+ */
717
+ export interface EditPasswordActionsSlotProps
718
+ extends Omit<PasswordActionsSlotProps, "model"> {
719
+ /** The row being edited; `null` in create mode. */
720
+ item: any;
721
+ }
722
+
655
723
  /**
656
724
  * The theme's extra colour variant, `pending`: waiting, and the next move is
657
725
  * not yours ("awaiting payment", "sent to printer, not yet confirmed").
@@ -7,8 +7,9 @@
7
7
  * `when`/`context`-aware predicates and a standalone summary can use the
8
8
  * defaults below.
9
9
  */
10
+ import { toRaw } from "vue";
10
11
  import type { FieldDefinition, FormTab } from "../types";
11
- import type { ValidationErrors } from "../composables/useForm";
12
+ import type { FailedRow, ValidationErrors } from "../composables/useForm";
12
13
  import {
13
14
  isFieldVisible as isFieldVisibleFor,
14
15
  resolvePredicate,
@@ -19,6 +20,14 @@ import { getByPath } from "./objectPath";
19
20
  export interface ErrorTarget {
20
21
  /** The key as the server sent it (`lines.0.price`). */
21
22
  errorKey: string;
23
+ /**
24
+ * Where to take the user for this error now. The same as `errorKey`,
25
+ * except for a row error resolved with `failedRows`: the key at the
26
+ * failed row's CURRENT index (`lines.1.price` once row 0 is removed),
27
+ * or the owning field's key when that row is gone. Always set by
28
+ * `resolveErrorTargets`; optional so targets built by hand still type.
29
+ */
30
+ focusKey?: string;
22
31
  /** The server's messages for that key, as sent. */
23
32
  messages: string[];
24
33
  /**
@@ -31,7 +40,10 @@ export interface ErrorTarget {
31
40
  fieldKey: string | null;
32
41
  /** The tab holding the owning field, or `null` (flat form, or unowned). */
33
42
  tabKey: string | null;
34
- /** A readable name for the key: "Price (line 1)", "Delivery date". */
43
+ /**
44
+ * A readable name for the key: "Price (line 1)", "Delivery date", or a
45
+ * row's own name from the field's `rowLabel`.
46
+ */
35
47
  label: string;
36
48
  }
37
49
 
@@ -62,6 +74,14 @@ export interface ResolveErrorTargetsOptions {
62
74
  field: FieldDefinition,
63
75
  labelModel: Record<string, any>,
64
76
  ) => string | null | undefined;
77
+ /**
78
+ * The rows a failed submit's errors named, as captured then
79
+ * (`SubmitFailure.failedRows`). Pass it when resolving that failure's
80
+ * errors after the form may have changed (the error summary does), so a
81
+ * row error keeps the name of the row that failed and points at where
82
+ * that row is now. Leave it out for the form's live errors.
83
+ */
84
+ failedRows?: Record<string, FailedRow>;
65
85
  }
66
86
 
67
87
  interface Candidate {
@@ -137,6 +157,26 @@ function defaultLabel(
137
157
  return typeof field.label === "function" ? field.label(model) : field.label;
138
158
  }
139
159
 
160
+ /**
161
+ * The field's `rowLabel` for one row, or `null` when it has none, names
162
+ * nothing (empty or not a string) or throws: the caller then falls back to
163
+ * "<label> (line N)", so a broken row name can never hide an error.
164
+ */
165
+ function rowLabelFor(
166
+ field: FieldDefinition,
167
+ row: Record<string, any>,
168
+ index: number,
169
+ ): string | null {
170
+ if (typeof field.rowLabel !== "function") return null;
171
+ let name: unknown;
172
+ try {
173
+ name = field.rowLabel(row, index);
174
+ } catch {
175
+ return null;
176
+ }
177
+ return typeof name === "string" && name.trim() !== "" ? name : null;
178
+ }
179
+
140
180
  /**
141
181
  * Resolve every key in `errors` to its owning field, tab and label, with the
142
182
  * display line for each message. Rows come in tab order, then field order
@@ -151,7 +191,11 @@ function defaultLabel(
151
191
  *
152
192
  * Labels: the owning field's label; for `field.N.sub` the sub-field's label
153
193
  * (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
194
+ * plus "(line N+1)" — unless the field's `rowLabel` names that row, when
195
+ * the row's name is the whole label. With `failedRows`, a row key's label
196
+ * reads the row as it was when the submit failed, and its `focusKey` and
197
+ * line number follow that row to where it is now (see `ownedTarget`); for
198
+ * any other nested key (a media map's
155
199
  * `image_media.<uuid>`) the field's label. An unowned key is humanised.
156
200
  *
157
201
  * Display lines: "Label: message", except that the label is left off when
@@ -242,20 +286,68 @@ export function resolveErrorTargets(
242
286
  return longest;
243
287
  };
244
288
 
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);
289
+ /**
290
+ * Where a failed row is now: its index in the field's live rows, found by
291
+ * identity, or -1 when it is gone (removed, or the rows were replaced).
292
+ */
293
+ const currentIndexOf = (field: FieldDefinition, failedRow: FailedRow): number => {
294
+ const liveRows = getByPath(model, field.key);
295
+ if (!Array.isArray(liveRows)) return -1;
296
+ return liveRows.findIndex((row) => toRaw(row) === failedRow.row);
297
+ };
298
+
299
+ /**
300
+ * The label and focus key for an owned error key. For a row key
301
+ * (`field.N.sub`) with a captured failed row, both follow that row: the
302
+ * label is read from the row as it was when the submit failed, and the
303
+ * focus key uses the row's current index, or is the field's key when the
304
+ * row is gone. "(line N)" then counts the row's CURRENT position while it
305
+ * exists (the line a click takes the user to), and the position it failed
306
+ * at once it is gone.
307
+ */
308
+ const ownedTarget = (
309
+ errorKey: string,
310
+ field: FieldDefinition,
311
+ ): { label: string; focusKey: string } => {
312
+ if (!isNestedUnder(errorKey, field.key)) {
313
+ return { label: labelFor(field), focusKey: errorKey };
314
+ }
315
+ const [first, second, ...deeper] = errorKey
316
+ .slice(field.key.length + 1)
317
+ .split(".");
318
+ if (!NUMERIC_SEGMENT.test(first)) {
319
+ return { label: labelFor(field), focusKey: errorKey };
320
+ }
321
+ const failedIndex = Number(first);
249
322
  const subField =
250
323
  second !== undefined
251
324
  ? field.fields?.find((candidate) => candidate.key === second)
252
325
  : 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 : {};
326
+ const failedRow = options.failedRows?.[`${field.key}.${first}`];
327
+
328
+ let rowModel: Record<string, any>;
329
+ let lineIndex = failedIndex;
330
+ let focusKey = errorKey;
331
+ if (failedRow !== undefined) {
332
+ rowModel = failedRow.snapshot;
333
+ const currentIndex = currentIndexOf(field, failedRow);
334
+ if (currentIndex >= 0) {
335
+ lineIndex = currentIndex;
336
+ const rest = second !== undefined ? [second, ...deeper] : [];
337
+ focusKey = [field.key, String(currentIndex), ...rest].join(".");
338
+ } else {
339
+ focusKey = field.key;
340
+ }
341
+ } else {
342
+ // DXRepeater renders a sub-field with the ROW as its model, so its
343
+ // label resolves against the row here too.
344
+ const row = getByPath(model, `${field.key}.${first}`);
345
+ rowModel = row !== null && typeof row === "object" ? row : {};
346
+ }
347
+ const rowName = rowLabelFor(field, rowModel, failedIndex);
348
+ if (rowName !== null) return { label: rowName, focusKey };
257
349
  const base = subField ? labelFor(subField, rowModel) : labelFor(field);
258
- return `${base} (line ${Number(first) + 1})`;
350
+ return { label: `${base} (line ${lineIndex + 1})`, focusKey };
259
351
  };
260
352
 
261
353
  // ——— one target per error key with at least one message
@@ -272,15 +364,15 @@ export function resolveErrorTargets(
272
364
  );
273
365
  if (messages.length === 0) return;
274
366
  const owner = ownerOf(errorKey);
367
+ const owned = owner ? ownedTarget(errorKey, owner.field) : null;
275
368
  pending.push({
276
369
  target: {
277
370
  errorKey,
371
+ focusKey: owned ? owned.focusKey : errorKey,
278
372
  messages,
279
373
  fieldKey: owner ? owner.field.key : null,
280
374
  tabKey: owner ? owner.tabKey : null,
281
- label: owner
282
- ? ownedLabel(errorKey, owner.field)
283
- : humaniseErrorKey(errorKey),
375
+ label: owned ? owned.label : humaniseErrorKey(errorKey),
284
376
  },
285
377
  rank: owner ? owner.rank : [UNOWNED_RANK, UNOWNED_RANK],
286
378
  order,
@@ -124,3 +124,30 @@ export function isFieldVisible(
124
124
  export function isSubmittableField(field: { submit?: boolean }): boolean {
125
125
  return field.submit !== false;
126
126
  }
127
+
128
+ /**
129
+ * The payload keys to drop on submit: every field key for which NO field with
130
+ * that key is both submittable (`submit !== false`) and visible (#228).
131
+ *
132
+ * Decided per KEY, across all the fields that share it, never per field. Two
133
+ * fields may share a key: an editable select plus a read-only display twin
134
+ * (`submit: false`) that shows instead for system-created rows. Stripping a
135
+ * key because ONE of its fields is presentational or hidden removed it from
136
+ * every save, including while the select was the visible field in use. A key
137
+ * whose fields are all presentational (#110) or all hidden (#117) is still
138
+ * dropped.
139
+ */
140
+ export function nonSubmittedFieldKeys<
141
+ TField extends { key: string; submit?: boolean },
142
+ >(fields: readonly TField[], isVisible: (field: TField) => boolean): Set<string> {
143
+ const submittedKeys = new Set(
144
+ fields
145
+ .filter((field) => isSubmittableField(field) && isVisible(field))
146
+ .map((field) => field.key),
147
+ );
148
+ return new Set(
149
+ fields
150
+ .map((field) => field.key)
151
+ .filter((key) => !submittedKeys.has(key)),
152
+ );
153
+ }