@asteby/metacore-runtime-react 49.2.1 → 49.3.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.
Files changed (32) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/dist/action-modal-dispatcher.d.ts.map +1 -1
  3. package/dist/action-modal-dispatcher.js +12 -3
  4. package/dist/business/document-editor-model.d.ts +12 -1
  5. package/dist/business/document-editor-model.d.ts.map +1 -1
  6. package/dist/business/document-editor-model.js +49 -3
  7. package/dist/business/document-editor.d.ts.map +1 -1
  8. package/dist/business/document-editor.js +14 -5
  9. package/dist/document-form-dialog.d.ts.map +1 -1
  10. package/dist/document-form-dialog.js +20 -12
  11. package/dist/dynamic-form-schema.d.ts +3 -26
  12. package/dist/dynamic-form-schema.d.ts.map +1 -1
  13. package/dist/dynamic-form-schema.js +4 -56
  14. package/dist/option-when.d.ts +27 -0
  15. package/dist/option-when.d.ts.map +1 -0
  16. package/dist/option-when.js +56 -0
  17. package/dist/validator.d.ts.map +1 -1
  18. package/dist/validator.js +17 -7
  19. package/package.json +1 -1
  20. package/src/__tests__/document-editor-model.test.ts +14 -1
  21. package/src/__tests__/document-editor.test.tsx +23 -1
  22. package/src/__tests__/document-form-dialog.no-stale-flash.test.tsx +169 -0
  23. package/src/__tests__/invoice-document-editor.test.tsx +4 -3
  24. package/src/__tests__/option-when-validation.test.tsx +87 -0
  25. package/src/__tests__/r6-editor-option-when.test.tsx +104 -0
  26. package/src/action-modal-dispatcher.tsx +14 -5
  27. package/src/business/document-editor-model.ts +48 -3
  28. package/src/business/document-editor.tsx +15 -4
  29. package/src/document-form-dialog.tsx +20 -11
  30. package/src/dynamic-form-schema.ts +5 -55
  31. package/src/option-when.ts +61 -0
  32. package/src/validator.ts +17 -5
@@ -48,6 +48,8 @@ import {
48
48
  splitEditorFields,
49
49
  toOpenDocuments,
50
50
  friendlyOptionLabel,
51
+ gatedOptionFixes,
52
+ gatedOptions,
51
53
  withFriendlyOptions,
52
54
  type EditorIssue,
53
55
  type SeedLabel,
@@ -215,6 +217,15 @@ export function DocumentEditor({
215
217
  const fieldKeys = useRef<string[]>([])
216
218
  fieldKeys.current = allFields.map((f) => f.key)
217
219
 
220
+ // Opciones condicionadas (`options[].when`): al cambiar el campo del que
221
+ // dependen —a mano, por la contraparte o por «Cargar desde…»— el valor que
222
+ // dejó de aplicar se ajusta solo (método PPD → forma de pago 99) y viaja así
223
+ // en el alta, no solo en el diálogo de timbrado.
224
+ useEffect(() => {
225
+ const fixes = gatedOptionFixes(allFields, header)
226
+ if (Object.keys(fixes).length > 0) setHeader((h) => ({ ...h, ...gatedOptionFixes(allFields, h) }))
227
+ }, [allFields, header])
228
+
218
229
  // Contraparte: tarjeta + defaults de las extensiones con la misma clave.
219
230
  const partyId = type.party ? header[type.party.field] : undefined
220
231
  useEffect(() => {
@@ -435,11 +446,11 @@ export function DocumentEditor({
435
446
  setSourceNote(
436
447
  loaded.length === 0 && covered > 0
437
448
  ? t('documentEditor.source_all_covered', {
438
- defaultValue: 'Todo el documento ya está registrado en otros documentos; no queda nada pendiente.',
449
+ defaultValue: 'Ese documento ya se registró completo; no queda nada pendiente.',
439
450
  })
440
451
  : covered > 0
441
452
  ? t('documentEditor.source_loaded_partial', {
442
- defaultValue: '{{count}} renglones con lo pendiente · {{covered}} ya cubiertos se omitieron',
453
+ defaultValue: '{{count}} renglones con lo que falta · {{covered}} ya completos (omitidos)',
443
454
  count: loaded.length,
444
455
  covered,
445
456
  })
@@ -579,7 +590,7 @@ export function DocumentEditor({
579
590
  <FieldLabel htmlFor={field.key} required={field.required}>
580
591
  {tl(field.label)}
581
592
  </FieldLabel>
582
- {renderField(withFriendlyOptions(field), header[field.key], (v: any) => updateField(field.key, v), header, fieldRecord, fieldErrors)}
593
+ {renderField(withFriendlyOptions(gatedOptions(field, header)), header[field.key], (v: any) => updateField(field.key, v), header, fieldRecord, fieldErrors)}
583
594
  {fieldErrors[field.key] && <p className="mt-1 text-xs text-destructive">{fieldErrors[field.key]}</p>}
584
595
  </FieldCell>
585
596
  ) : null
@@ -654,7 +665,7 @@ export function DocumentEditor({
654
665
  {sources.length > 1 && (
655
666
  <div
656
667
  role="radiogroup"
657
- aria-label={t('documentEditor.source_kind', { defaultValue: 'Tipo de documento origen' })}
668
+ aria-label={t('documentEditor.source_kind', { defaultValue: 'Documento de origen' })}
658
669
  className="inline-flex flex-wrap gap-1 rounded-md border p-1"
659
670
  >
660
671
  {sources.map((s) => (
@@ -211,17 +211,26 @@ export function DocumentFormDialog({
211
211
  )
212
212
  const lineCfg = type ? editorLinesConfig(type, forms) : undefined
213
213
 
214
- // Reset every time the dialog (re)opens.
215
- useEffect(() => {
216
- if (!open) return
217
- const start = initialType ?? single?.key ?? null
218
- setTypeKey(start)
219
- setStep(start ? 'fields' : 'type')
220
- setLines([])
221
- setFieldErrors({})
222
- setServerLineErrors({})
223
- setFormError(undefined)
224
- }, [open, initialType, single?.key])
214
+ // Reset every time the dialog (re)opens — DURING render, not in an effect.
215
+ // The host keeps this dialog mounted while closed (and across views whose
216
+ // forms change, e.g. the same /m/$model route going Facturas → Notas de
217
+ // crédito); an effect reset ran after the first open frame was already
218
+ // painted with the previous type/step, so the wizard (or the previous
219
+ // type's editor) flashed before the right DocumentEditor (QA Pitsline r6).
220
+ const openKey = open ? `${initialType ?? ''}\u0000${single?.key ?? ''}` : null
221
+ const [resetFor, setResetFor] = useState<string | null>(null)
222
+ if (openKey !== resetFor) {
223
+ setResetFor(openKey)
224
+ if (openKey !== null) {
225
+ const start = initialType ?? single?.key ?? null
226
+ setTypeKey(start)
227
+ setStep(start ? 'fields' : 'type')
228
+ setLines([])
229
+ setFieldErrors({})
230
+ setServerLineErrors({})
231
+ setFormError(undefined)
232
+ }
233
+ }
225
234
 
226
235
  // Seed field defaults for the chosen type.
227
236
  useEffect(() => {
@@ -4,8 +4,13 @@
4
4
  import { z, type ZodTypeAny } from 'zod'
5
5
  import type { ActionFieldDef, FieldOptionsConfig, OptionDef, VisibleWhen } from './types'
6
6
  import { fieldValidationOf } from './validator'
7
+ import { applyOptionWhen, getDependsOn } from './option-when'
7
8
  import { resolveValidatorToken } from './use-org-config-bridge'
8
9
 
10
+ // Viven en option-when.ts (el validador también los usa); se re-exportan aquí
11
+ // para no romper a quien los importaba de este módulo.
12
+ export { applyOptionWhen, getDependsOn }
13
+
9
14
  /**
10
15
  * Built-in validators the SDK knows how to apply by symbolic name. Apps
11
16
  * that wire `OrgConfigProvider` map `$org.<key>` references to one of
@@ -389,18 +394,6 @@ export function fieldHasRef(field: ActionFieldDef): boolean {
389
394
  return getFieldRef(field) !== undefined
390
395
  }
391
396
 
392
- /**
393
- * Resolves a field's cascade dependency — the key of another form field whose
394
- * current value scopes this picker's options (`filter_value`). Tolerates the
395
- * camelCase `dependsOn` (authored SDK shape) and the snake_case `depends_on`
396
- * the kernel manifest serves. Returns the trimmed field key, or `undefined`
397
- * when the field declares no dependency.
398
- */
399
- export function getDependsOn(field: ActionFieldDef): string | undefined {
400
- const dep = field.dependsOn ?? field.depends_on
401
- if (typeof dep === 'string' && dep.trim() !== '') return dep.trim()
402
- return undefined
403
- }
404
397
 
405
398
  /**
406
399
  * Resolves the cascade `filter_value` for a field from the surrounding form
@@ -424,49 +417,6 @@ export function resolveDependsValue(
424
417
  return String(raw)
425
418
  }
426
419
 
427
- /**
428
- * Filters a STATIC enum's `options[]` by each option's `when` gate against the
429
- * current form values. Pure — no React, no side effects.
430
- *
431
- * Rule per option:
432
- * - No `when` → always included (retrocompat; existing enums untouched).
433
- * - With `when`: the gating field is `when.field ?? dependsOn`. If neither is
434
- * present the option is included (nothing to gate on). Otherwise the form's
435
- * value for that field is compared AS STRING: included when (no `in`, or
436
- * value ∈ `in`) AND (no `not_in`, or value ∉ `not_in`). Tolerates the
437
- * snake_case `not_in` the kernel serves alongside camelCase `notIn`.
438
- *
439
- * `formValues` is the flat map of the surrounding form/row values the gating
440
- * field is read from; `dependsOn` is the containing field's declared dependency
441
- * used as the default gating field.
442
- */
443
- export function applyOptionWhen(
444
- options: OptionDef[] | undefined,
445
- formValues: Record<string, any> | null | undefined,
446
- dependsOn?: string,
447
- ): OptionDef[] {
448
- if (!Array.isArray(options)) return []
449
- return options.filter((opt) => {
450
- const when = opt?.when
451
- if (!when) return true
452
- const gate = (typeof when.field === 'string' && when.field.trim() !== '')
453
- ? when.field.trim()
454
- : dependsOn
455
- if (!gate) return true
456
- const raw = formValues ? formValues[gate] : undefined
457
- const current = raw == null ? '' : String(raw)
458
- const inList = when.in
459
- const notIn = when.notIn ?? when.not_in
460
- if (Array.isArray(inList) && inList.length > 0) {
461
- if (!inList.some((v) => String(v) === current)) return false
462
- }
463
- if (Array.isArray(notIn) && notIn.length > 0) {
464
- if (notIn.some((v) => String(v) === current)) return false
465
- }
466
- return true
467
- })
468
- }
469
-
470
420
  /**
471
421
  * Reads a field's `visible_when` predicate, tolerating the camelCase alias
472
422
  * (`visibleWhen`) an app may author and the snake_case (`visible_when`) the
@@ -0,0 +1,61 @@
1
+ // option-when.ts — opciones estáticas de un select condicionadas por el valor
2
+ // de un campo hermano (`options[].when`) y la dependencia declarada del campo
3
+ // (`dependsOn`). Módulo hoja: lo usan el formulario (dynamic-form-schema), el
4
+ // render compartido de campos (renderField) y el validador, sin ciclos.
5
+ import type { ActionFieldDef, OptionDef } from './types'
6
+
7
+ /**
8
+ * Resolves a field's cascade dependency — the key of another form field whose
9
+ * current value scopes this picker's options (`filter_value`). Tolerates the
10
+ * camelCase `dependsOn` (authored SDK shape) and the snake_case `depends_on`
11
+ * the kernel manifest serves. Returns the trimmed field key, or `undefined`
12
+ * when the field declares no dependency.
13
+ */
14
+ export function getDependsOn(field: ActionFieldDef): string | undefined {
15
+ const dep = field.dependsOn ?? field.depends_on
16
+ if (typeof dep === 'string' && dep.trim() !== '') return dep.trim()
17
+ return undefined
18
+ }
19
+
20
+ /**
21
+ * Filters a STATIC enum's `options[]` by each option's `when` gate against the
22
+ * current form values. Pure — no React, no side effects.
23
+ *
24
+ * Rule per option:
25
+ * - No `when` → always included (retrocompat; existing enums untouched).
26
+ * - With `when`: the gating field is `when.field ?? dependsOn`. If neither is
27
+ * present the option is included (nothing to gate on). Otherwise the form's
28
+ * value for that field is compared AS STRING: included when (no `in`, or
29
+ * value ∈ `in`) AND (no `not_in`, or value ∉ `not_in`). Tolerates the
30
+ * snake_case `not_in` the kernel serves alongside camelCase `notIn`.
31
+ *
32
+ * `formValues` is the flat map of the surrounding form/row values the gating
33
+ * field is read from; `dependsOn` is the containing field's declared dependency
34
+ * used as the default gating field.
35
+ */
36
+ export function applyOptionWhen(
37
+ options: OptionDef[] | undefined,
38
+ formValues: Record<string, any> | null | undefined,
39
+ dependsOn?: string,
40
+ ): OptionDef[] {
41
+ if (!Array.isArray(options)) return []
42
+ return options.filter((opt) => {
43
+ const when = opt?.when
44
+ if (!when) return true
45
+ const gate = (typeof when.field === 'string' && when.field.trim() !== '')
46
+ ? when.field.trim()
47
+ : dependsOn
48
+ if (!gate) return true
49
+ const raw = formValues ? formValues[gate] : undefined
50
+ const current = raw == null ? '' : String(raw)
51
+ const inList = when.in
52
+ const notIn = when.notIn ?? when.not_in
53
+ if (Array.isArray(inList) && inList.length > 0) {
54
+ if (!inList.some((v) => String(v) === current)) return false
55
+ }
56
+ if (Array.isArray(notIn) && notIn.length > 0) {
57
+ if (notIn.some((v) => String(v) === current)) return false
58
+ }
59
+ return true
60
+ })
61
+ }
package/src/validator.ts CHANGED
@@ -4,6 +4,7 @@
4
4
  // the same keys.
5
5
  import type { ActionFieldDef, FieldValidation } from './types'
6
6
  import type { FieldIssue } from './server-error'
7
+ import { applyOptionWhen, getDependsOn } from './option-when'
7
8
 
8
9
  const NIL_UUID = '00000000-0000-0000-0000-000000000000'
9
10
  const NUMERIC_TYPES = new Set(['int', 'integer', 'bigint', 'decimal', 'numeric', 'number', 'float', 'double'])
@@ -207,7 +208,13 @@ function checkBuiltin(slug: string, value: unknown): FieldIssue | undefined {
207
208
  }
208
209
  }
209
210
 
210
- function specFromField(field: ActionFieldDef): ValidationSpec {
211
+ /**
212
+ * `context` son los valores hermanos contra los que se evalúa el `when` de
213
+ * cada opción: una opción que su `when` descarta con el valor actual (forma de
214
+ * pago «99» con método PUE) es `invalid_option`, igual que una fuera de catálogo
215
+ * — y como la rechaza el kernel al guardar.
216
+ */
217
+ function specFromField(field: ActionFieldDef, context?: Record<string, unknown>): ValidationSpec {
211
218
  const v = fieldValidationOf(field)
212
219
  const spec: ValidationSpec = {
213
220
  required: !!field.required,
@@ -217,7 +224,10 @@ function specFromField(field: ActionFieldDef): ValidationSpec {
217
224
  max: v.max,
218
225
  custom: v.custom,
219
226
  }
220
- if (field.options?.length) spec.options = field.options.map(o => String(o.value))
227
+ if (field.options?.length) {
228
+ const applicable = context ? applyOptionWhen(field.options, context, getDependsOn(field)) : field.options
229
+ spec.options = applicable.map(o => String(o.value))
230
+ }
221
231
  return spec
222
232
  }
223
233
 
@@ -238,7 +248,7 @@ export function validateValues(
238
248
  values: Record<string, unknown>,
239
249
  ): Record<string, FieldIssue[]> {
240
250
  const bag: Record<string, FieldIssue[]> = {}
241
- walk(fields, values ?? {}, '', bag)
251
+ walk(fields, values ?? {}, '', bag, values ?? {})
242
252
  return bag
243
253
  }
244
254
 
@@ -247,6 +257,7 @@ function walk(
247
257
  values: Record<string, unknown>,
248
258
  prefix: string,
249
259
  bag: Record<string, FieldIssue[]>,
260
+ context: Record<string, unknown>,
250
261
  ): void {
251
262
  for (const field of fields) {
252
263
  const key = field.key
@@ -261,11 +272,12 @@ function walk(
261
272
  }
262
273
  rows.forEach((row, i) => {
263
274
  const obj = row && typeof row === 'object' ? (row as Record<string, unknown>) : {}
264
- walk(itemFieldsOf(field), obj, `${path}.${i}`, bag)
275
+ // Una celda puede depender de otra del renglón o del encabezado.
276
+ walk(itemFieldsOf(field), obj, `${path}.${i}`, bag, { ...context, ...obj })
265
277
  })
266
278
  continue
267
279
  }
268
- const issues = checkValue(raw, specFromField(field))
280
+ const issues = checkValue(raw, specFromField(field, context))
269
281
  if (issues.length) bag[path] = issues
270
282
  }
271
283
  }