@asteby/metacore-runtime-react 47.0.0 → 47.1.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 (51) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/audit-info.d.ts +54 -0
  3. package/dist/audit-info.d.ts.map +1 -0
  4. package/dist/audit-info.js +142 -0
  5. package/dist/dialogs/dynamic-record.d.ts.map +1 -1
  6. package/dist/dialogs/dynamic-record.js +5 -1
  7. package/dist/dynamic-form.d.ts.map +1 -1
  8. package/dist/dynamic-form.js +3 -0
  9. package/dist/dynamic-kanban.d.ts.map +1 -1
  10. package/dist/dynamic-kanban.js +8 -1
  11. package/dist/dynamic-line-items.d.ts.map +1 -1
  12. package/dist/dynamic-line-items.js +3 -0
  13. package/dist/dynamic-multi-select-field.d.ts.map +1 -1
  14. package/dist/dynamic-multi-select-field.js +2 -0
  15. package/dist/dynamic-select-field.d.ts.map +1 -1
  16. package/dist/dynamic-select-field.js +4 -0
  17. package/dist/index.d.ts +3 -0
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +4 -0
  20. package/dist/option-filter.d.ts +31 -0
  21. package/dist/option-filter.d.ts.map +1 -0
  22. package/dist/option-filter.js +49 -0
  23. package/dist/types.d.ts +40 -0
  24. package/dist/types.d.ts.map +1 -1
  25. package/dist/use-options-resolver.d.ts +8 -0
  26. package/dist/use-options-resolver.d.ts.map +1 -1
  27. package/dist/use-options-resolver.js +8 -2
  28. package/package.json +3 -3
  29. package/src/__tests__/action-modal-grid-layout.test.tsx +1 -1
  30. package/src/__tests__/apply-sidebar-layout.test.ts +1 -1
  31. package/src/__tests__/audit-info.test.tsx +103 -0
  32. package/src/__tests__/dependent-options.test.tsx +12 -5
  33. package/src/__tests__/dynamic-table-infinite-scroll.test.tsx +4 -0
  34. package/src/__tests__/line-items-lock-rows.test.tsx +2 -1
  35. package/src/__tests__/option-filter.test.tsx +115 -0
  36. package/src/__tests__/structured-view-value.test.tsx +5 -1
  37. package/src/__tests__/wizard-action-modal.test.tsx +1 -1
  38. package/src/audit-info.tsx +219 -0
  39. package/src/dialogs/dynamic-record.tsx +13 -0
  40. package/src/dropdown-menu-entry.test.ts +23 -0
  41. package/src/dynamic-form.tsx +3 -0
  42. package/src/dynamic-kanban.tsx +7 -3
  43. package/src/dynamic-line-items.tsx +3 -0
  44. package/src/dynamic-multi-select-field.tsx +2 -0
  45. package/src/dynamic-select-field.tsx +4 -0
  46. package/src/index.ts +13 -0
  47. package/src/option-filter.ts +88 -0
  48. package/src/types.ts +41 -0
  49. package/src/use-options-resolver.ts +19 -1
  50. package/vitest.config.ts +1 -0
  51. package/vitest.setup.ts +7 -0
@@ -0,0 +1,219 @@
1
+ /**
2
+ * audit-info.tsx
3
+ *
4
+ * <AuditInfo> — discreet "when and who" footer for a record: created, modified
5
+ * and (if tombstoned) deleted, each with the actor and a relative + absolute
6
+ * date. Driven only by the kernel's `TableMetadata.audit` (column names) and
7
+ * the record row — manifests declare nothing.
8
+ *
9
+ * Actor resolution (no extra requests), in order:
10
+ * 1. the host's `resolveActor(id)` when it returns a name;
11
+ * 2. the record's expanded sibling (`created_by_id` → `row.created_by`,
12
+ * `{ label | name | title }` or a plain string);
13
+ * 3. the system actor id → "Sistema" / "System";
14
+ * 4. a short id (first 8 chars) — never a long uuid.
15
+ *
16
+ * Renders nothing when `audit` is absent or the record has none of its values.
17
+ */
18
+ import * as React from 'react'
19
+ import { useTranslation } from 'react-i18next'
20
+ import { formatDistanceToNow } from 'date-fns'
21
+ import { es, enUS } from 'date-fns/locale'
22
+ import { ChevronDown, ChevronRight, History } from 'lucide-react'
23
+ import { cn } from '@asteby/metacore-ui/lib'
24
+ import { formatDateCell } from './dynamic-columns'
25
+ import { objectLabel } from './dynamic-relation-helpers'
26
+ import { isNilUuid } from './nil-uuid'
27
+ import { useTimeZone } from './org-runtime-context'
28
+ import type { AuditMeta } from './types'
29
+
30
+ /** The kernel's actor for unattended work (schedules, webhooks, connectors). */
31
+ export const SYSTEM_ACTOR_ID = '00000000-0000-0000-0000-000000000001'
32
+
33
+ const AUDIT_KEYS: (keyof AuditMeta)[] = [
34
+ 'created_at',
35
+ 'created_by',
36
+ 'updated_at',
37
+ 'updated_by',
38
+ 'deleted_at',
39
+ 'deleted_by',
40
+ ]
41
+
42
+ /**
43
+ * Defensive reader for the served `audit` block: keeps only known keys whose
44
+ * value is a non-empty string; returns `undefined` when nothing usable remains.
45
+ */
46
+ export function readAuditMeta(raw: unknown): AuditMeta | undefined {
47
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined
48
+ const src = raw as Record<string, unknown>
49
+ const out: AuditMeta = {}
50
+ for (const k of AUDIT_KEYS) {
51
+ const v = src[k]
52
+ if (typeof v === 'string' && v.trim() !== '') out[k] = v
53
+ }
54
+ return Object.keys(out).length ? out : undefined
55
+ }
56
+
57
+ export interface AuditActor {
58
+ /** Display text. */
59
+ name: string
60
+ /** True when the name is a real resolved label (not a short id). */
61
+ resolved: boolean
62
+ }
63
+
64
+ /** Resolves the actor stored in `row[idColumn]` to something human. */
65
+ export function resolveAuditActor(
66
+ row: Record<string, unknown>,
67
+ idColumn: string | undefined,
68
+ opts: { resolveActor?: (id: string) => string | undefined; systemLabel: string },
69
+ ): AuditActor | null {
70
+ if (!idColumn) return null
71
+ const raw = row[idColumn]
72
+ // The column itself may already be an expanded object.
73
+ const own = objectLabel(raw)
74
+ const id = typeof raw === 'string' ? raw.trim() : ''
75
+ if (!own && (!id || isNilUuid(id))) return null
76
+ if (id.toLowerCase() === SYSTEM_ACTOR_ID) return { name: opts.systemLabel, resolved: true }
77
+ if (id) {
78
+ const fromHost = opts.resolveActor?.(id)
79
+ if (fromHost) return { name: fromHost, resolved: true }
80
+ }
81
+ if (own) return { name: own, resolved: true }
82
+ const base = idColumn.endsWith('_id') ? idColumn.slice(0, -3) : idColumn
83
+ const sibling = base === idColumn ? undefined : row[base]
84
+ const siblingName =
85
+ objectLabel(sibling) ?? (typeof sibling === 'string' && sibling.trim() !== '' ? sibling : undefined)
86
+ if (siblingName) return { name: siblingName, resolved: true }
87
+ return { name: id.length > 8 ? id.slice(0, 8) : id, resolved: false }
88
+ }
89
+
90
+ const COPY = {
91
+ es: {
92
+ title: 'Historial del registro',
93
+ created: 'Creado por {{who}}',
94
+ updated: 'Modificado por {{who}}',
95
+ deleted: 'Eliminado por {{who}}',
96
+ createdAt: 'Creado',
97
+ updatedAt: 'Modificado',
98
+ deletedAt: 'Eliminado',
99
+ system: 'Sistema',
100
+ },
101
+ en: {
102
+ title: 'Record history',
103
+ created: 'Created by {{who}}',
104
+ updated: 'Modified by {{who}}',
105
+ deleted: 'Deleted by {{who}}',
106
+ createdAt: 'Created',
107
+ updatedAt: 'Modified',
108
+ deletedAt: 'Deleted',
109
+ system: 'System',
110
+ },
111
+ } as const
112
+
113
+ export interface AuditInfoProps {
114
+ /** The record row (keys are the column names `audit` points at). */
115
+ record: Record<string, unknown> | null | undefined
116
+ /** `TableMetadata.audit`. Absent → renders nothing. */
117
+ audit: AuditMeta | null | undefined
118
+ /** Optional host resolver uuid → display name (e.g. from a users cache). */
119
+ resolveActor?: (id: string) => string | undefined
120
+ /** IANA timezone; defaults to the org runtime timezone. */
121
+ timeZone?: string
122
+ /** BCP-47 locale; defaults to the i18n language. */
123
+ locale?: string
124
+ /** Start expanded. Default: collapsed. */
125
+ defaultOpen?: boolean
126
+ className?: string
127
+ }
128
+
129
+ interface Line {
130
+ key: string
131
+ text: string
132
+ absolute: string
133
+ title?: string
134
+ relative: string
135
+ }
136
+
137
+ export function AuditInfo({
138
+ record,
139
+ audit,
140
+ resolveActor,
141
+ timeZone,
142
+ locale,
143
+ defaultOpen = false,
144
+ className,
145
+ }: AuditInfoProps) {
146
+ const { t, i18n } = useTranslation()
147
+ const ctxTz = useTimeZone()
148
+ const [open, setOpen] = React.useState(defaultOpen)
149
+
150
+ const meta = readAuditMeta(audit)
151
+ if (!meta || !record) return null
152
+
153
+ const lang = (locale || i18n?.language || 'es').toLowerCase().startsWith('en') ? 'en' : 'es'
154
+ const copy = COPY[lang]
155
+ const dfLocale = lang === 'en' ? enUS : es
156
+ const tz = timeZone ?? ctxTz
157
+ const tt = (key: keyof typeof copy, vars?: Record<string, string>) =>
158
+ t(`audit.${key}`, { defaultValue: copy[key], ...vars }) as string
159
+
160
+ const build = (
161
+ key: string,
162
+ atCol: string | undefined,
163
+ byCol: string | undefined,
164
+ whoKey: 'created' | 'updated' | 'deleted',
165
+ whenKey: 'createdAt' | 'updatedAt' | 'deletedAt',
166
+ ): Line | null => {
167
+ const f = atCol ? formatDateCell(record[atCol], undefined, dfLocale, tz) : null
168
+ if (!f) return null
169
+ const actor = resolveAuditActor(record, byCol, { resolveActor, systemLabel: tt('system') })
170
+ const date = new Date(record[atCol as string] as string)
171
+ const relative = formatDistanceToNow(date, { addSuffix: true, locale: dfLocale })
172
+ return {
173
+ key,
174
+ text: actor ? tt(whoKey, { who: actor.name }) : tt(whenKey),
175
+ absolute: f.display,
176
+ title: f.title,
177
+ relative,
178
+ }
179
+ }
180
+
181
+ const lines = [
182
+ build('created', meta.created_at, meta.created_by, 'created', 'createdAt'),
183
+ // Hide "modified" when it is the creation instant itself (never edited).
184
+ meta.updated_at && record[meta.updated_at] !== record[meta.created_at ?? '']
185
+ ? build('updated', meta.updated_at, meta.updated_by, 'updated', 'updatedAt')
186
+ : null,
187
+ build('deleted', meta.deleted_at, meta.deleted_by, 'deleted', 'deletedAt'),
188
+ ].filter((l): l is Line => l !== null)
189
+
190
+ if (lines.length === 0) return null
191
+
192
+ const Chevron = open ? ChevronDown : ChevronRight
193
+ return (
194
+ <div className={cn('text-xs text-muted-foreground', className)} data-testid="audit-info">
195
+ <button
196
+ type="button"
197
+ className="flex items-center gap-1.5 hover:text-foreground"
198
+ aria-expanded={open}
199
+ onClick={() => setOpen(o => !o)}
200
+ >
201
+ <Chevron className="h-3.5 w-3.5" />
202
+ <History className="h-3.5 w-3.5" />
203
+ <span>{tt('title')}</span>
204
+ </button>
205
+ {open && (
206
+ <ul className="mt-2 space-y-1 pl-6">
207
+ {lines.map(l => (
208
+ <li key={l.key} data-audit-line={l.key}>
209
+ <span>{l.text}</span>
210
+ {' · '}
211
+ <time title={l.title}>{l.absolute}</time>
212
+ <span className="opacity-70"> ({l.relative})</span>
213
+ </li>
214
+ ))}
215
+ </ul>
216
+ )}
217
+ </div>
218
+ )
219
+ }
@@ -60,6 +60,8 @@ import { toastServerError, extractFieldErrors, localizeFieldIssue, localizeField
60
60
  import { DynamicSelectField, OptionLead, OptionThumb } from '../dynamic-select-field'
61
61
  import { DynamicMultiSelectField } from '../dynamic-multi-select-field'
62
62
  import { DynamicRelations } from '../dynamic-relations'
63
+ import { AuditInfo, readAuditMeta } from '../audit-info'
64
+ import type { AuditMeta } from '../types'
63
65
  import { useOptionsResolver, type ResolvedOption } from '../use-options-resolver'
64
66
  import { getFieldRef, getVisibleWhen, evaluateVisibleWhen, ATTRIBUTE_CLASSES_KEY } from '../dynamic-form-schema'
65
67
  import { useAttributeClasses, type AttributeClass } from '../attribute-classes'
@@ -643,6 +645,7 @@ export function DynamicRecordDialog({
643
645
  schema ? (schema as ModalMetadata) : null,
644
646
  )
645
647
  const [relations, setRelations] = useState<RelationMeta[]>([])
648
+ const [audit, setAudit] = useState<AuditMeta | undefined>(undefined)
646
649
  const [record, setRecord] = useState<any | null>(null)
647
650
  const [formValues, setFormValues] = useState<Record<string, any>>({})
648
651
  // Classes of the record's category (plus those its saved data carries), for
@@ -784,6 +787,7 @@ export function DynamicRecordDialog({
784
787
  useEffect(() => {
785
788
  if (!open || mode === 'create' || !recordId) {
786
789
  setRelations([])
790
+ setAudit(undefined)
787
791
  return
788
792
  }
789
793
  let cancelled = false
@@ -792,6 +796,7 @@ export function DynamicRecordDialog({
792
796
  if (cancelled) return
793
797
  const meta = res.data?.data ?? res.data
794
798
  const rels: RelationMeta[] = Array.isArray(meta?.relations) ? meta.relations : []
799
+ setAudit(readAuditMeta(meta?.audit))
795
800
  // Localize each panel header: the backend serves `label` as an
796
801
  // i18n key (addon bundle, loaded live) and the SDK renders it verbatim.
797
802
  setRelations(
@@ -1189,6 +1194,14 @@ export function DynamicRecordDialog({
1189
1194
  />
1190
1195
  </div>
1191
1196
  )}
1197
+ {!isCreate && record && audit && (
1198
+ <AuditInfo
1199
+ record={record}
1200
+ audit={audit}
1201
+ timeZone={timeZone}
1202
+ className="mt-6 border-t pt-3"
1203
+ />
1204
+ )}
1192
1205
  </CurrencyContext.Provider>
1193
1206
  </TimeZoneContext.Provider>
1194
1207
  </ImageUrlContext.Provider>
@@ -0,0 +1,23 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { readFileSync, readdirSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+
5
+ // Radix context guard: a component that renders RowActionMenuItem (whose
6
+ // DropdownMenuItem is imported from the ROOT '@asteby/metacore-ui' entry) must
7
+ // import its DropdownMenu shell from the root entry too. Hosts share the root
8
+ // entry as a Module Federation singleton but bundle '/primitives' locally, so
9
+ // mixing entries creates two Menu contexts → "`MenuItem` must be used within
10
+ // `Menu`" at runtime (Pitsline kanban card menu crash, 2026-10-05).
11
+ describe('dropdown menu entry consistency', () => {
12
+ const dir = join(__dirname)
13
+ const files = readdirSync(dir).filter((f) => f.endsWith('.tsx'))
14
+ for (const f of files) {
15
+ const src = readFileSync(join(dir, f), 'utf8')
16
+ if (!src.includes('<RowActionMenuItem')) continue
17
+ it(`${f} imports DropdownMenu from the root ui entry`, () => {
18
+ const primitivesImport = src.match(/import\s*{([^}]*)}\s*from\s*'@asteby\/metacore-ui\/primitives'/)
19
+ const fromPrimitives = primitivesImport ? primitivesImport[1] : ''
20
+ expect(fromPrimitives).not.toMatch(/\bDropdownMenu(Content|Trigger)?\b/)
21
+ })
22
+ }
23
+ })
@@ -30,6 +30,7 @@ import {
30
30
  } from './dynamic-form-schema'
31
31
  import { ScanLine } from 'lucide-react'
32
32
  import { BarcodeScanner } from './barcode-scanner'
33
+ import { getOptionFilter } from './option-filter'
33
34
  import { useOptionsResolver, type ResolvedOption } from './use-options-resolver'
34
35
  import { DynamicLineItems } from './dynamic-line-items'
35
36
  import { DynamicSelectField } from './dynamic-select-field'
@@ -530,6 +531,8 @@ function RefSelect({ field, value, onChange }: FieldRendererProps) {
530
531
  modelKey: '', // unused — `ref` drives the URL
531
532
  fieldKey: 'id',
532
533
  ref: field.ref,
534
+ optionFilter: getOptionFilter(field),
535
+ keepValue: value,
533
536
  })
534
537
  return (
535
538
  <Select value={value || ''} onValueChange={onChange} disabled={loading}>
@@ -94,9 +94,6 @@ import {
94
94
  Button,
95
95
  Card,
96
96
  CardContent,
97
- DropdownMenu,
98
- DropdownMenuContent,
99
- DropdownMenuTrigger,
100
97
  Input,
101
98
  Popover,
102
99
  PopoverContent,
@@ -113,6 +110,13 @@ import {
113
110
  SheetTrigger,
114
111
  Skeleton,
115
112
  } from '@asteby/metacore-ui/primitives'
113
+ // The card "…" menu renders RowActionMenuItem (dynamic-columns), whose
114
+ // DropdownMenuItem comes from the ROOT '@asteby/metacore-ui' entry. The menu
115
+ // shell MUST come from that same entry: hosts share the root entry as a Module
116
+ // Federation singleton while '/primitives' is bundled locally, so mixing them
117
+ // yields two Radix Menu contexts and the click crashes the app with
118
+ // "`MenuItem` must be used within `Menu`" (Pitsline taller kanban, 2026-10-05).
119
+ import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@asteby/metacore-ui'
116
120
  import { ColumnFilterControl, FilterValueCombobox, type ColumnFilterType } from '@asteby/metacore-ui/data-table'
117
121
  import { generateBadgeStyles, optionColor } from '@asteby/metacore-ui/lib'
118
122
  import { useApi } from './api-context'
@@ -36,6 +36,7 @@ import {
36
36
  applyOptionWhen,
37
37
  } from './dynamic-form-schema'
38
38
  import { DynamicSelectField, DEFAULT_DEPENDS_HINT } from './dynamic-select-field'
39
+ import { getOptionFilter } from './option-filter'
39
40
  import { useOptionsResolver, type ResolvedOption } from './use-options-resolver'
40
41
 
41
42
  export interface DynamicLineItemsProps {
@@ -575,6 +576,8 @@ function RefCell({ field, value, onChange, onPick, disabled, formValues, rowValu
575
576
  ref: optSource.ref,
576
577
  endpoint: optSource.endpoint,
577
578
  filterValue: dependsOn ? scope : undefined,
579
+ optionFilter: getOptionFilter(field),
580
+ keepValue: value,
578
581
  enabled: !blockedByDependency,
579
582
  })
580
583
 
@@ -19,6 +19,7 @@
19
19
  // value instead.
20
20
  import { useMemo } from 'react'
21
21
  import { MultiSelect } from '@asteby/metacore-ui/primitives'
22
+ import { getOptionFilter } from './option-filter'
22
23
  import { useOptionsResolver } from './use-options-resolver'
23
24
  import { getFieldRef } from './dynamic-form-schema'
24
25
  import type { ActionFieldDef } from './types'
@@ -38,6 +39,7 @@ export function DynamicMultiSelectField({ field, value, onChange }: DynamicMulti
38
39
  ref,
39
40
  endpoint: !ref && field.searchEndpoint ? field.searchEndpoint : undefined,
40
41
  limit: 200,
42
+ optionFilter: getOptionFilter(field),
41
43
  })
42
44
 
43
45
  const selected = useMemo(() => (Array.isArray(value) ? value.map(String) : []), [value])
@@ -22,6 +22,7 @@
22
22
  // value). A dedicated `?ids=` lookup is a follow-up; create flows — the common
23
23
  // case — start empty and never hit this.
24
24
  import { useEffect, useRef, useState } from 'react'
25
+ import { getOptionFilter } from './option-filter'
25
26
  import { useTranslation } from 'react-i18next'
26
27
  import {
27
28
  Badge,
@@ -266,6 +267,7 @@ export function DynamicSelectField({
266
267
  const blockedByDependency = !!dependsOn && scope === ''
267
268
 
268
269
  const useStatic = Array.isArray(staticOptions)
270
+ const optionFilter = getOptionFilter(field)
269
271
 
270
272
  const { options: fetchedOptions, loading: fetchLoading } = useOptionsResolver({
271
273
  modelKey: '',
@@ -280,6 +282,8 @@ export function DynamicSelectField({
280
282
  // Cascade scope forwarded as filter_value (only when this field
281
283
  // declares a dependency). Re-fetches when the parent value changes.
282
284
  filterValue: dependsOn ? scope : undefined,
285
+ optionFilter,
286
+ keepValue: value,
283
287
  // Don't fetch until the popover opens (and keep fetching as the query
284
288
  // changes while open). A picker blocked by an unset dependency never
285
289
  // fetches. A readonly cell fetches eagerly so its value's label resolves
package/src/index.ts CHANGED
@@ -584,6 +584,14 @@ export {
584
584
  type ActivityEvent,
585
585
  type ActivityDiffProps,
586
586
  } from './activity-diff'
587
+ export {
588
+ AuditInfo,
589
+ readAuditMeta,
590
+ resolveAuditActor,
591
+ SYSTEM_ACTOR_ID,
592
+ type AuditInfoProps,
593
+ type AuditActor,
594
+ } from './audit-info'
587
595
  export {
588
596
  RecordHistory,
589
597
  type RecordHistoryProps,
@@ -661,3 +669,8 @@ export {
661
669
 
662
670
  // Página de documento (benchmark §5/§6): cabecera, badges, acciones por estado, botones inteligentes.
663
671
  export * from './document'
672
+
673
+ // Option filter — hide options of a relation / dynamic picker from the manifest
674
+ // (`option_filter` / `optionFilter` on the field).
675
+ export { getOptionFilter, applyOptionFilter, optionPassesRule } from './option-filter'
676
+ export type { OptionFilter, OptionFilterRule } from './option-filter'
@@ -0,0 +1,88 @@
1
+ // Option filter — lets a manifest HIDE options of a relation / dynamic picker
2
+ // (e.g. keep cancelled invoices out of the "abonos" invoice selector, PIT-059).
3
+ //
4
+ // The filter runs client-side over the options the resolver already fetched, so
5
+ // it needs no kernel change: it reads the extra scalar columns the options
6
+ // endpoint returns (they land in `option.meta`) plus the standard `label`,
7
+ // `name`, `description`, `color`, `icon` keys. Fully opt-in: a field without
8
+ // `option_filter` / `optionFilter` behaves exactly as before.
9
+ import type { ResolvedOption } from './use-options-resolver'
10
+
11
+ export interface OptionFilterRule {
12
+ /** Option property to test: a `meta` key (e.g. `status`) or `id`/`value`/`label`/`name`/`description`/`color`/`icon`. */
13
+ field: string
14
+ /** Keep only options whose value equals this. */
15
+ equals?: string | number | boolean
16
+ /** snake_case alias of `notEquals`. */
17
+ not_equals?: string | number | boolean
18
+ /** Hide options whose value equals this. */
19
+ notEquals?: string | number | boolean
20
+ /** Keep only options whose value is one of these. */
21
+ in?: Array<string | number | boolean>
22
+ /** Hide options whose value is one of these. */
23
+ not_in?: Array<string | number | boolean>
24
+ /** camelCase alias of `not_in`. */
25
+ notIn?: Array<string | number | boolean>
26
+ }
27
+
28
+ /** One rule, or a list of rules that must ALL pass (AND). */
29
+ export type OptionFilter = OptionFilterRule | OptionFilterRule[]
30
+
31
+ type FilterCarrier = { optionFilter?: unknown; option_filter?: unknown }
32
+
33
+ /** Reads the filter from a field (camelCase or the snake_case the kernel serves). */
34
+ export function getOptionFilter(field: unknown): OptionFilterRule[] {
35
+ if (!field || typeof field !== 'object') return []
36
+ const f = field as FilterCarrier
37
+ const raw = f.optionFilter ?? f.option_filter
38
+ const list = Array.isArray(raw) ? raw : raw && typeof raw === 'object' ? [raw] : []
39
+ return list.filter(
40
+ (r): r is OptionFilterRule =>
41
+ !!r && typeof r === 'object' && typeof (r as OptionFilterRule).field === 'string' && (r as OptionFilterRule).field !== '',
42
+ )
43
+ }
44
+
45
+ function norm(v: unknown): string {
46
+ return String(v).trim().toLowerCase()
47
+ }
48
+
49
+ function readProp(opt: ResolvedOption, key: string): unknown {
50
+ if (key === 'id' || key === 'value' || key === 'label' || key === 'name' || key === 'description' || key === 'color' || key === 'icon' || key === 'image') {
51
+ return (opt as unknown as Record<string, unknown>)[key]
52
+ }
53
+ return opt.meta?.[key]
54
+ }
55
+
56
+ /** True when the option satisfies the rule. Comparison is trimmed + case-insensitive. */
57
+ export function optionPassesRule(opt: ResolvedOption, rule: OptionFilterRule): boolean {
58
+ const raw = readProp(opt, rule.field)
59
+ const known = raw !== undefined && raw !== null
60
+ const v = known ? norm(raw) : ''
61
+ const notEquals = rule.notEquals ?? rule.not_equals
62
+ const notIn = rule.notIn ?? rule.not_in
63
+ // Positive rules need the property to exist; negative rules cannot exclude
64
+ // what they cannot see, so an option lacking the property is kept.
65
+ if (rule.equals !== undefined && !(known && v === norm(rule.equals))) return false
66
+ if (Array.isArray(rule.in) && !(known && rule.in.some((x) => norm(x) === v))) return false
67
+ if (notEquals !== undefined && known && v === norm(notEquals)) return false
68
+ if (Array.isArray(notIn) && known && notIn.some((x) => norm(x) === v)) return false
69
+ return true
70
+ }
71
+
72
+ /**
73
+ * Applies the field's filter to resolved options. Returns the SAME array when
74
+ * there is nothing to filter. `keepValue` (the current selection) is never
75
+ * hidden, so an existing record keeps showing its label after the option
76
+ * became ineligible.
77
+ */
78
+ export function applyOptionFilter(
79
+ options: ResolvedOption[],
80
+ rules: OptionFilterRule[],
81
+ keepValue?: unknown,
82
+ ): ResolvedOption[] {
83
+ if (rules.length === 0) return options
84
+ const keep = keepValue === undefined || keepValue === null || keepValue === '' ? null : String(keepValue)
85
+ return options.filter(
86
+ (o) => (keep !== null && String(o.id) === keep) || rules.every((r) => optionPassesRule(o, r)),
87
+ )
88
+ }
package/src/types.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { OptionFilter } from './option-filter'
1
2
  // Shared metadata shape consumed by every host. Some hosts add a `link`
2
3
  // action type with a `linkUrl` template — represented here as part of the
3
4
  // `type` union so the SDK can render it uniformly.
@@ -64,6 +65,21 @@ export interface DocumentFormsManifest {
64
65
  types: DocumentFormType[]
65
66
  }
66
67
 
68
+ /**
69
+ * Audit columns of a model, served by the kernel (>= v0.188) on the table
70
+ * metadata. Each value is the COLUMN NAME (the key in the row), not a value;
71
+ * a key is omitted when the model lacks that column (append-only ledgers have
72
+ * only `created_*`). Purely additive — older kernels omit `audit` entirely.
73
+ */
74
+ export interface AuditMeta {
75
+ created_at?: string
76
+ created_by?: string
77
+ updated_at?: string
78
+ updated_by?: string
79
+ deleted_at?: string
80
+ deleted_by?: string
81
+ }
82
+
67
83
  export interface TableMetadata {
68
84
  title: string
69
85
  endpoint: string
@@ -78,6 +94,11 @@ export interface TableMetadata {
78
94
  canExport?: boolean
79
95
  canImport?: boolean
80
96
  canCreate?: boolean
97
+ /**
98
+ * Audit columns (when/who) of the model. Feeds `<AuditInfo>`; absent on
99
+ * older kernels or models without audit columns.
100
+ */
101
+ audit?: AuditMeta
81
102
  /**
82
103
  * Formularios de alta guiados por tipo de documento (FAC-12). Cuando trae
83
104
  * al menos un tipo, `DynamicCRUDPage` abre `DocumentFormDialog` en lugar del
@@ -380,6 +401,16 @@ export interface ColumnDefinition {
380
401
  * widgets — `searchEndpoint` stays as the legacy escape hatch.
381
402
  */
382
403
  ref?: string
404
+ /**
405
+ * Hides options of this relation/dynamic picker (e.g. cancelled invoices in
406
+ * the payments selector). One rule or a list (AND), tested client-side
407
+ * against the option's extra columns (`meta`): `{ field: 'status', not_in:
408
+ * ['cancelada'] }`. The options endpoint must return that column. Absent →
409
+ * the picker lists everything (retrocompat). See `option-filter.ts`.
410
+ */
411
+ optionFilter?: OptionFilter
412
+ /** snake_case alias served by the kernel manifest for `optionFilter`. */
413
+ option_filter?: OptionFilter
383
414
  /**
384
415
  * Write-time rules the SDK also pre-flights. Object form `{regex,min,max,custom}`
385
416
  * or a Laravel / go-playground string (`required|min:2|email`).
@@ -546,6 +577,16 @@ export interface ActionFieldDef {
546
577
  * `useOptionsResolver` against `/api/options/<ref>?field=id`.
547
578
  */
548
579
  ref?: string
580
+ /**
581
+ * Hides options of this relation/dynamic picker (e.g. cancelled invoices in
582
+ * the payments selector). One rule or a list (AND), tested client-side
583
+ * against the option's extra columns (`meta`): `{ field: 'status', not_in:
584
+ * ['cancelada'] }`. The options endpoint must return that column. Absent →
585
+ * the picker lists everything (retrocompat). See `option-filter.ts`.
586
+ */
587
+ optionFilter?: OptionFilter
588
+ /** snake_case alias served by the kernel manifest for `optionFilter`. */
589
+ option_filter?: OptionFilter
549
590
  /**
550
591
  * snake_case aliases the kernel manifest may serve for a belongs_to FK
551
592
  * target instead of `ref`. Treated as equivalent to `ref` by the SDK so a
@@ -12,6 +12,7 @@
12
12
  // free; legacy callers that still ship `searchEndpoint` keep working.
13
13
  import { useEffect, useMemo, useRef, useState } from 'react'
14
14
  import { useApi } from './api-context'
15
+ import { applyOptionFilter, type OptionFilterRule } from './option-filter'
15
16
  import { loadQueryPart, optionsBatchToken, optionsModelFromUrl } from './query-batch'
16
17
 
17
18
  export interface ResolvedOption {
@@ -90,6 +91,13 @@ export interface UseOptionsResolverArgs {
90
91
  * `fieldKey` / `query` / `limit` exactly the same way.
91
92
  */
92
93
  endpoint?: string
94
+ /**
95
+ * Client-side rules that hide fetched options (see `option-filter.ts`).
96
+ * Empty/undefined → no filtering (retrocompat).
97
+ */
98
+ optionFilter?: OptionFilterRule[]
99
+ /** Current selection: never hidden by `optionFilter`, so its label survives. */
100
+ keepValue?: unknown
93
101
  }
94
102
 
95
103
  export interface UseOptionsResolverResult {
@@ -182,6 +190,8 @@ export function useOptionsResolver(args: UseOptionsResolverArgs): UseOptionsReso
182
190
  enabled = true,
183
191
  endpoint,
184
192
  filterValue,
193
+ optionFilter,
194
+ keepValue,
185
195
  } = args
186
196
 
187
197
  const api = useApi()
@@ -322,8 +332,16 @@ export function useOptionsResolver(args: UseOptionsResolverArgs): UseOptionsReso
322
332
  }
323
333
  }, [api, url, effectiveField, query, limit, enabled, filterValue, refreshKey])
324
334
 
335
+ // Rules arrive as a fresh array each render; key on their content.
336
+ const filterKey = optionFilter && optionFilter.length > 0 ? JSON.stringify(optionFilter) : ''
337
+ const visibleOptions = useMemo(
338
+ () => (filterKey ? applyOptionFilter(options, optionFilter ?? [], keepValue) : options),
339
+ // eslint-disable-next-line react-hooks/exhaustive-deps
340
+ [options, filterKey, keepValue],
341
+ )
342
+
325
343
  return {
326
- options,
344
+ options: visibleOptions,
327
345
  meta,
328
346
  loading,
329
347
  error,
package/vitest.config.ts CHANGED
@@ -5,5 +5,6 @@ export default defineConfig({
5
5
  environment: 'node',
6
6
  include: ['src/**/*.test.ts', 'src/**/*.test.tsx'],
7
7
  setupFiles: ['./vitest.setup.ts'],
8
+ testTimeout: 15000,
8
9
  },
9
10
  })
package/vitest.setup.ts CHANGED
@@ -27,3 +27,10 @@ function installMemoryStorage(name: 'localStorage' | 'sessionStorage') {
27
27
 
28
28
  installMemoryStorage('localStorage')
29
29
  installMemoryStorage('sessionStorage')
30
+
31
+ // `waitFor` defaults to 1s; the DynamicTable/DynamicKanban suites render large
32
+ // trees and miss it when the whole monorepo's tests run in parallel (turbo).
33
+ // A longer ceiling only affects failing waits, passing tests return immediately.
34
+ import { configure } from '@testing-library/react'
35
+
36
+ configure({ asyncUtilTimeout: 5000 })