@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.
- package/CHANGELOG.md +13 -0
- package/dist/audit-info.d.ts +54 -0
- package/dist/audit-info.d.ts.map +1 -0
- package/dist/audit-info.js +142 -0
- package/dist/dialogs/dynamic-record.d.ts.map +1 -1
- package/dist/dialogs/dynamic-record.js +5 -1
- package/dist/dynamic-form.d.ts.map +1 -1
- package/dist/dynamic-form.js +3 -0
- package/dist/dynamic-kanban.d.ts.map +1 -1
- package/dist/dynamic-kanban.js +8 -1
- package/dist/dynamic-line-items.d.ts.map +1 -1
- package/dist/dynamic-line-items.js +3 -0
- package/dist/dynamic-multi-select-field.d.ts.map +1 -1
- package/dist/dynamic-multi-select-field.js +2 -0
- package/dist/dynamic-select-field.d.ts.map +1 -1
- package/dist/dynamic-select-field.js +4 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/option-filter.d.ts +31 -0
- package/dist/option-filter.d.ts.map +1 -0
- package/dist/option-filter.js +49 -0
- package/dist/types.d.ts +40 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/use-options-resolver.d.ts +8 -0
- package/dist/use-options-resolver.d.ts.map +1 -1
- package/dist/use-options-resolver.js +8 -2
- package/package.json +3 -3
- package/src/__tests__/action-modal-grid-layout.test.tsx +1 -1
- package/src/__tests__/apply-sidebar-layout.test.ts +1 -1
- package/src/__tests__/audit-info.test.tsx +103 -0
- package/src/__tests__/dependent-options.test.tsx +12 -5
- package/src/__tests__/dynamic-table-infinite-scroll.test.tsx +4 -0
- package/src/__tests__/line-items-lock-rows.test.tsx +2 -1
- package/src/__tests__/option-filter.test.tsx +115 -0
- package/src/__tests__/structured-view-value.test.tsx +5 -1
- package/src/__tests__/wizard-action-modal.test.tsx +1 -1
- package/src/audit-info.tsx +219 -0
- package/src/dialogs/dynamic-record.tsx +13 -0
- package/src/dropdown-menu-entry.test.ts +23 -0
- package/src/dynamic-form.tsx +3 -0
- package/src/dynamic-kanban.tsx +7 -3
- package/src/dynamic-line-items.tsx +3 -0
- package/src/dynamic-multi-select-field.tsx +2 -0
- package/src/dynamic-select-field.tsx +4 -0
- package/src/index.ts +13 -0
- package/src/option-filter.ts +88 -0
- package/src/types.ts +41 -0
- package/src/use-options-resolver.ts +19 -1
- package/vitest.config.ts +1 -0
- 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
|
+
})
|
package/src/dynamic-form.tsx
CHANGED
|
@@ -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}>
|
package/src/dynamic-kanban.tsx
CHANGED
|
@@ -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
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 })
|