@omnitend/dashboard-for-laravel 0.46.0 → 0.47.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.
@@ -7,6 +7,7 @@ import {
7
7
  resolveFieldDefault,
8
8
  isFieldVisible as isFieldVisibleFor,
9
9
  isSubmittableField,
10
+ nonSubmittedFieldKeys as nonSubmittedFieldKeysFor,
10
11
  } from "../utils/formSchema";
11
12
 
12
13
  /**
@@ -207,24 +208,67 @@ export function useResourceEditor<T = any>(
207
208
  // Enforced at SUBMIT, not just at seeding: the modal still renders every field,
208
209
  // so a `submit: false` control — or a `span` slot calling `update` — can write
209
210
  // its key back into the form data after seeding. Strip them on the way out.
210
- const nonSubmittedFieldKeys = computed(
211
- () =>
212
- new Set(
213
- (props.editFields ?? [])
214
- .filter(
215
- (field) =>
216
- // Presentational: lays the form out, holds no data (#110).
217
- !isSubmittableField(field) ||
218
- // Hidden by `when` at submit time (#117). Submitting a field
219
- // the user cannot see is a silent write — and with `default`
220
- // set it writes a value they never chose. Omitting the key
221
- // leaves the stored value alone.
222
- !isEditFieldVisible(field),
223
- )
224
- .map((field) => field.key),
225
- ),
211
+ //
212
+ // Decided per KEY (#228): a key is dropped only when no field with that key
213
+ // is both submitting and visible at submit time. A presentational field
214
+ // (#110) holds no data, and a field hidden by `when` (#117) must not write a
215
+ // value the user cannot see — but a display-only twin sharing its key with
216
+ // the visible editable field must not strip the editable field's value.
217
+ const nonSubmittedFieldKeys = computed(() =>
218
+ nonSubmittedFieldKeysFor(props.editFields ?? [], isEditFieldVisible),
226
219
  );
227
220
 
221
+ /*
222
+ * Seed the form's data, ONE value per key (#228). Presentational fields are
223
+ * never seeded. When several submitting fields share a key (alternative
224
+ * controls for one value, switched by `when`), the key is seeded from the
225
+ * first of them that is visible against the seeded model, else the first
226
+ * declared. This used to be last-declared-wins, so a hidden alternative
227
+ * declared after the visible field replaced its default.
228
+ *
229
+ * Two submitting fields with one key that are visible TOGETHER are almost
230
+ * certainly a mistake (two controls bound to one value): the first declared
231
+ * wins, and DXTable warns once per key.
232
+ */
233
+ const warnedSharedVisibleKeys = new Set<string>();
234
+ const seedFormData = (
235
+ valueFor: (field: FieldDefinition) => any,
236
+ context: Record<string, any>,
237
+ ): Record<string, any> => {
238
+ const fieldsByKey = new Map<string, FieldDefinition[]>();
239
+ submittableEditFields.value.forEach((field) => {
240
+ const sharing = fieldsByKey.get(field.key);
241
+ if (sharing) sharing.push(field);
242
+ else fieldsByKey.set(field.key, [field]);
243
+ });
244
+
245
+ const formData: Record<string, any> = {};
246
+ fieldsByKey.forEach((fields, key) => {
247
+ formData[key] = valueFor(fields[0]);
248
+ });
249
+
250
+ const seededModel = { ...context, ...formData };
251
+ fieldsByKey.forEach((fields, key) => {
252
+ if (fields.length < 2) return;
253
+ const visibleFields = fields.filter((field) =>
254
+ isFieldVisibleFor(field, seededModel),
255
+ );
256
+ if (visibleFields.length > 1 && !warnedSharedVisibleKeys.has(key)) {
257
+ warnedSharedVisibleKeys.add(key);
258
+ // eslint-disable-next-line no-console
259
+ console.warn(
260
+ `[DXTable] ${visibleFields.length} submitting edit fields share the key "${key}" and are visible together, ` +
261
+ 'so they edit one value; the first declared seeds it. ' +
262
+ 'Give alternatives mutually exclusive `when` predicates, or mark a display-only one `submit: false`.',
263
+ );
264
+ }
265
+ if (visibleFields.length > 0 && visibleFields[0] !== fields[0]) {
266
+ formData[key] = valueFor(visibleFields[0]);
267
+ }
268
+ });
269
+ return formData;
270
+ };
271
+
228
272
  const stripNonSubmittedFields = (data: Record<string, any>): Record<string, any> => {
229
273
  if (nonSubmittedFieldKeys.value.size === 0) return data;
230
274
  return Object.fromEntries(
@@ -265,11 +309,9 @@ export function useResourceEditor<T = any>(
265
309
  // between successive row opens. Presentational fields (submit: false)
266
310
  // lay the form out; they hold no data and must not be POSTed just
267
311
  // because they were declared.
268
- const formData: Record<string, any> = {};
269
- submittableEditFields.value.forEach(field => {
270
- formData[field.key] = seedValueFor(field, item);
271
- });
272
- startSession(formData);
312
+ startSession(
313
+ seedFormData((field) => seedValueFor(field, item), item as any),
314
+ );
273
315
 
274
316
  // Open modal
275
317
  editFormInstanceKey.value++;
@@ -332,11 +374,7 @@ export function useResourceEditor<T = any>(
332
374
  editLoading.value = false;
333
375
 
334
376
  // A fresh form seeded with the field defaults (see `startSession`).
335
- const formData: Record<string, any> = {};
336
- submittableEditFields.value.forEach(field => {
337
- formData[field.key] = resolveFieldDefault(field);
338
- });
339
- startSession(formData);
377
+ startSession(seedFormData(resolveFieldDefault, {}));
340
378
  editFormInstanceKey.value++;
341
379
  showEditModal.value = true;
342
380
  };
@@ -148,6 +148,8 @@ export type {
148
148
  FormTab,
149
149
  MaybeFn,
150
150
  OptionsLoader,
151
+ PasswordActionsSlotProps,
152
+ EditPasswordActionsSlotProps,
151
153
  // Strict, discriminated field types (#131) — opt-in; canonical in a future
152
154
  // major. `FieldDef` is the union; the rest are its per-type members.
153
155
  FieldDef,
@@ -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
 
@@ -579,6 +597,7 @@ export interface ComponentFieldDef extends BaseFieldDef {
579
597
  export interface RepeaterFieldDef extends BaseFieldDef {
580
598
  type: "repeater";
581
599
  fields?: FieldDef[];
600
+ rowLabel?: (row: Record<string, any>, index: number) => string | null | undefined;
582
601
  addLabel?: string;
583
602
  minItems?: number;
584
603
  maxItems?: number;
@@ -652,6 +671,51 @@ export interface FormTab {
652
671
  lazy?: boolean;
653
672
  }
654
673
 
674
+ /**
675
+ * Slot props of DXField's `password-actions` slot (DXForm:
676
+ * `password-actions(<key>)`): content placed beside a password input, inside
677
+ * its input group after the reveal (eye) toggle, such as a "Generate password"
678
+ * button. `setRevealed` drives the same state the eye toggles.
679
+ *
680
+ * ```vue
681
+ * <template #password-actions(password)="{ update, setRevealed, disabled }">
682
+ * <DButton variant="outline-secondary" :disabled="disabled"
683
+ * @click="update(generatePassword()); setRevealed(true)">Generate</DButton>
684
+ * </template>
685
+ * ```
686
+ */
687
+ export interface PasswordActionsSlotProps {
688
+ /** The field definition being rendered. */
689
+ field: FieldDefinition;
690
+ /** The model passed to field predicates (defaults to the live form data). */
691
+ model: any;
692
+ /** The current field value. */
693
+ value: any;
694
+ /** Write a new value (also marks the field touched and clears its error). */
695
+ update: (value: any) => void;
696
+ /** Whether the password is currently shown as plain text. */
697
+ revealed: boolean;
698
+ /**
699
+ * Show (`true`) or mask (`false`) the password, exactly as the eye does.
700
+ * A no-op on a `revealable: false` field, which never unmasks.
701
+ */
702
+ setRevealed: (revealed: boolean) => void;
703
+ /** Whether the field is disabled or read-only. */
704
+ disabled: boolean;
705
+ }
706
+
707
+ /**
708
+ * Slot props of DXTable's `edit-password-actions(<key>)` slot, the edit/create
709
+ * modal's form of {@link PasswordActionsSlotProps}. `item` is the row being
710
+ * edited, `null` in create mode. (`model` is not passed, as with the modal's
711
+ * other field slots.)
712
+ */
713
+ export interface EditPasswordActionsSlotProps
714
+ extends Omit<PasswordActionsSlotProps, "model"> {
715
+ /** The row being edited; `null` in create mode. */
716
+ item: any;
717
+ }
718
+
655
719
  /**
656
720
  * The theme's extra colour variant, `pending`: waiting, and the next move is
657
721
  * not yours ("awaiting payment", "sent to printer, not yet confirmed").
@@ -31,7 +31,10 @@ export interface ErrorTarget {
31
31
  fieldKey: string | null;
32
32
  /** The tab holding the owning field, or `null` (flat form, or unowned). */
33
33
  tabKey: string | null;
34
- /** A readable name for the key: "Price (line 1)", "Delivery date". */
34
+ /**
35
+ * A readable name for the key: "Price (line 1)", "Delivery date", or a
36
+ * row's own name from the field's `rowLabel`.
37
+ */
35
38
  label: string;
36
39
  }
37
40
 
@@ -137,6 +140,26 @@ function defaultLabel(
137
140
  return typeof field.label === "function" ? field.label(model) : field.label;
138
141
  }
139
142
 
143
+ /**
144
+ * The field's `rowLabel` for one row, or `null` when it has none, names
145
+ * nothing (empty or not a string) or throws: the caller then falls back to
146
+ * "<label> (line N)", so a broken row name can never hide an error.
147
+ */
148
+ function rowLabelFor(
149
+ field: FieldDefinition,
150
+ row: Record<string, any>,
151
+ index: number,
152
+ ): string | null {
153
+ if (typeof field.rowLabel !== "function") return null;
154
+ let name: unknown;
155
+ try {
156
+ name = field.rowLabel(row, index);
157
+ } catch {
158
+ return null;
159
+ }
160
+ return typeof name === "string" && name.trim() !== "" ? name : null;
161
+ }
162
+
140
163
  /**
141
164
  * Resolve every key in `errors` to its owning field, tab and label, with the
142
165
  * display line for each message. Rows come in tab order, then field order
@@ -151,7 +174,8 @@ function defaultLabel(
151
174
  *
152
175
  * Labels: the owning field's label; for `field.N.sub` the sub-field's label
153
176
  * (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
177
+ * plus "(line N+1)" — unless the field's `rowLabel` names that row, when
178
+ * the row's name is the whole label; for any other nested key (a media map's
155
179
  * `image_media.<uuid>`) the field's label. An unowned key is humanised.
156
180
  *
157
181
  * Display lines: "Label: message", except that the label is left off when
@@ -254,6 +278,8 @@ export function resolveErrorTargets(
254
278
  // label resolves against the row here too.
255
279
  const row = getByPath(model, `${field.key}.${first}`);
256
280
  const rowModel = row !== null && typeof row === "object" ? row : {};
281
+ const rowName = rowLabelFor(field, rowModel, Number(first));
282
+ if (rowName !== null) return rowName;
257
283
  const base = subField ? labelFor(subField, rowModel) : labelFor(field);
258
284
  return `${base} (line ${Number(first) + 1})`;
259
285
  };
@@ -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
+ }