@asteby/metacore-runtime-react 13.2.0 → 13.4.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 +42 -0
- package/dist/dynamic-form-schema.d.ts +36 -0
- package/dist/dynamic-form-schema.d.ts.map +1 -1
- package/dist/dynamic-form-schema.js +80 -0
- package/dist/dynamic-form.d.ts +1 -0
- package/dist/dynamic-form.d.ts.map +1 -1
- package/dist/dynamic-form.js +33 -2
- package/dist/dynamic-line-items.d.ts.map +1 -1
- package/dist/dynamic-line-items.js +57 -3
- package/dist/dynamic-select-field.d.ts +9 -0
- package/dist/dynamic-select-field.d.ts.map +1 -0
- package/dist/dynamic-select-field.js +74 -0
- package/dist/types.d.ts +34 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/line-item-totals.test.ts +116 -0
- package/src/dynamic-form-schema.ts +94 -0
- package/src/dynamic-form.tsx +61 -18
- package/src/dynamic-line-items.tsx +127 -5
- package/src/dynamic-select-field.tsx +164 -0
- package/src/types.ts +35 -0
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// DynamicSelectField — async, searchable single-select for declarative forms.
|
|
2
|
+
//
|
|
3
|
+
// This is the declarative answer to "I don't want to type a raw FK UUID".
|
|
4
|
+
// Instead of a plain <select> that dumps every option (RefSelect) or a free
|
|
5
|
+
// text input, it renders a typeahead combobox that queries the canonical
|
|
6
|
+
// options endpoint as the user types:
|
|
7
|
+
//
|
|
8
|
+
// GET /api/options/<ref>?field=id&q=<text>&limit=<n>
|
|
9
|
+
//
|
|
10
|
+
// reusing `useOptionsResolver` (which already debounce-aborts in-flight
|
|
11
|
+
// requests). It is the metacore equivalent of 7leguas' `search.go` / dynamic
|
|
12
|
+
// `type: search` field, but driven entirely from the manifest — so an addon
|
|
13
|
+
// declares `type: "dynamic_select"` + `ref` and gets a searchable picker with
|
|
14
|
+
// zero custom React.
|
|
15
|
+
//
|
|
16
|
+
// Resolution path (highest priority first):
|
|
17
|
+
// 1. field.ref → /options/<ref>?field=id (canonical, preferred)
|
|
18
|
+
// 2. field.searchEndpoint→ used verbatim as the options endpoint (escape hatch)
|
|
19
|
+
//
|
|
20
|
+
// Edit-mode caveat: resolving an EXISTING value's label requires the id to be
|
|
21
|
+
// in a fetched page (we match by id against loaded options, else show the raw
|
|
22
|
+
// value). A dedicated `?ids=` lookup is a follow-up; create flows — the common
|
|
23
|
+
// case — start empty and never hit this.
|
|
24
|
+
import { useEffect, useState } from 'react'
|
|
25
|
+
import {
|
|
26
|
+
Button,
|
|
27
|
+
Command,
|
|
28
|
+
CommandEmpty,
|
|
29
|
+
CommandGroup,
|
|
30
|
+
CommandInput,
|
|
31
|
+
CommandItem,
|
|
32
|
+
CommandList,
|
|
33
|
+
Popover,
|
|
34
|
+
PopoverContent,
|
|
35
|
+
PopoverTrigger,
|
|
36
|
+
} from '@asteby/metacore-ui/primitives'
|
|
37
|
+
import { Check, ChevronsUpDown, Loader2 } from 'lucide-react'
|
|
38
|
+
import { useOptionsResolver, type ResolvedOption } from './use-options-resolver'
|
|
39
|
+
import type { ActionFieldDef } from './types'
|
|
40
|
+
|
|
41
|
+
function useDebounced<T>(value: T, ms: number): T {
|
|
42
|
+
const [debounced, setDebounced] = useState(value)
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
const t = setTimeout(() => setDebounced(value), ms)
|
|
45
|
+
return () => clearTimeout(t)
|
|
46
|
+
}, [value, ms])
|
|
47
|
+
return debounced
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface DynamicSelectFieldProps {
|
|
51
|
+
field: ActionFieldDef
|
|
52
|
+
value: any
|
|
53
|
+
onChange: (v: any) => void
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function DynamicSelectField({ field, value, onChange }: DynamicSelectFieldProps) {
|
|
57
|
+
const [open, setOpen] = useState(false)
|
|
58
|
+
const [search, setSearch] = useState('')
|
|
59
|
+
const debounced = useDebounced(search, 250)
|
|
60
|
+
// Remember the label of the option the user actually picked so the trigger
|
|
61
|
+
// shows a name (not a UUID) without a round-trip.
|
|
62
|
+
const [picked, setPicked] = useState<ResolvedOption | null>(null)
|
|
63
|
+
|
|
64
|
+
const { options, loading } = useOptionsResolver({
|
|
65
|
+
modelKey: '',
|
|
66
|
+
fieldKey: 'id',
|
|
67
|
+
ref: field.ref,
|
|
68
|
+
// searchEndpoint only drives the URL when there's no ref — ref is the
|
|
69
|
+
// canonical, kernel-derived path and wins.
|
|
70
|
+
endpoint: field.ref ? undefined : field.searchEndpoint,
|
|
71
|
+
query: debounced,
|
|
72
|
+
limit: 20,
|
|
73
|
+
// Don't fetch until the popover opens (and keep fetching as the query
|
|
74
|
+
// changes while open).
|
|
75
|
+
enabled: open,
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
const selectedLabel =
|
|
79
|
+
(picked && String(picked.id) === String(value) ? picked.label : null) ??
|
|
80
|
+
options.find((o) => String(o.id) === String(value))?.label ??
|
|
81
|
+
(value ? String(value) : '')
|
|
82
|
+
|
|
83
|
+
const handlePick = (opt: ResolvedOption) => {
|
|
84
|
+
setPicked(opt)
|
|
85
|
+
onChange(String(opt.id))
|
|
86
|
+
setOpen(false)
|
|
87
|
+
setSearch('')
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return (
|
|
91
|
+
<Popover open={open} onOpenChange={setOpen}>
|
|
92
|
+
<PopoverTrigger asChild>
|
|
93
|
+
<Button
|
|
94
|
+
type="button"
|
|
95
|
+
variant="outline"
|
|
96
|
+
role="combobox"
|
|
97
|
+
aria-expanded={open}
|
|
98
|
+
id={field.key}
|
|
99
|
+
className="w-full justify-between font-normal"
|
|
100
|
+
data-empty={!value}
|
|
101
|
+
>
|
|
102
|
+
<span className={'truncate ' + (selectedLabel ? '' : 'text-muted-foreground')}>
|
|
103
|
+
{selectedLabel || field.placeholder || 'Buscar…'}
|
|
104
|
+
</span>
|
|
105
|
+
<ChevronsUpDown className="ml-2 size-4 shrink-0 opacity-50" />
|
|
106
|
+
</Button>
|
|
107
|
+
</PopoverTrigger>
|
|
108
|
+
<PopoverContent
|
|
109
|
+
className="p-0"
|
|
110
|
+
align="start"
|
|
111
|
+
// Match the trigger width without an arbitrary Tailwind class
|
|
112
|
+
// (those don't always survive a consuming app's Tailwind scan).
|
|
113
|
+
style={{ width: 'var(--radix-popover-trigger-width)' }}
|
|
114
|
+
>
|
|
115
|
+
<Command shouldFilter={false}>
|
|
116
|
+
<CommandInput
|
|
117
|
+
placeholder={field.placeholder || 'Buscar…'}
|
|
118
|
+
value={search}
|
|
119
|
+
onValueChange={setSearch}
|
|
120
|
+
/>
|
|
121
|
+
<CommandList>
|
|
122
|
+
{loading && (
|
|
123
|
+
<div className="text-muted-foreground flex items-center justify-center gap-2 py-6 text-sm">
|
|
124
|
+
<Loader2 className="size-4 animate-spin" />
|
|
125
|
+
Buscando…
|
|
126
|
+
</div>
|
|
127
|
+
)}
|
|
128
|
+
{!loading && options.length === 0 && (
|
|
129
|
+
<CommandEmpty>
|
|
130
|
+
{debounced ? 'Sin resultados' : 'Escribí para buscar…'}
|
|
131
|
+
</CommandEmpty>
|
|
132
|
+
)}
|
|
133
|
+
{!loading && options.length > 0 && (
|
|
134
|
+
<CommandGroup className="max-h-64 overflow-auto">
|
|
135
|
+
{options.map((opt) => {
|
|
136
|
+
const isSel = String(opt.id) === String(value)
|
|
137
|
+
return (
|
|
138
|
+
<CommandItem
|
|
139
|
+
key={String(opt.id)}
|
|
140
|
+
value={String(opt.id)}
|
|
141
|
+
onSelect={() => handlePick(opt)}
|
|
142
|
+
>
|
|
143
|
+
<Check className={'mr-2 size-4 ' + (isSel ? 'opacity-100' : 'opacity-0')} />
|
|
144
|
+
<div className="flex min-w-0 flex-col">
|
|
145
|
+
<span className="truncate">{opt.label}</span>
|
|
146
|
+
{opt.description && (
|
|
147
|
+
<span className="text-muted-foreground truncate text-xs">
|
|
148
|
+
{opt.description}
|
|
149
|
+
</span>
|
|
150
|
+
)}
|
|
151
|
+
</div>
|
|
152
|
+
</CommandItem>
|
|
153
|
+
)
|
|
154
|
+
})}
|
|
155
|
+
</CommandGroup>
|
|
156
|
+
)}
|
|
157
|
+
</CommandList>
|
|
158
|
+
</Command>
|
|
159
|
+
</PopoverContent>
|
|
160
|
+
</Popover>
|
|
161
|
+
)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export default DynamicSelectField
|
package/src/types.ts
CHANGED
|
@@ -116,6 +116,7 @@ export type FieldWidget =
|
|
|
116
116
|
| 'number'
|
|
117
117
|
| 'date'
|
|
118
118
|
| 'select'
|
|
119
|
+
| 'dynamic_select'
|
|
119
120
|
| 'switch'
|
|
120
121
|
|
|
121
122
|
export interface ActionFieldDef {
|
|
@@ -145,6 +146,40 @@ export interface ActionFieldDef {
|
|
|
145
146
|
* keyed by these item field keys. Rendered by `DynamicLineItems`.
|
|
146
147
|
*/
|
|
147
148
|
itemFields?: ActionFieldDef[]
|
|
149
|
+
/**
|
|
150
|
+
* On an `itemFields` column: flags the column for summation in the
|
|
151
|
+
* line-items footer. The SDK renders a totals row summing every numeric
|
|
152
|
+
* column marked `total` (e.g. the debit and credit columns of a journal
|
|
153
|
+
* entry). Ignored on flat fields. Mirrors kernel v3 `ActionField.total`.
|
|
154
|
+
*/
|
|
155
|
+
total?: boolean
|
|
156
|
+
/**
|
|
157
|
+
* On a line-items (`type: "array"`) field: declares an optional, generic
|
|
158
|
+
* balance constraint between two summed columns. The SDK shows a balanced /
|
|
159
|
+
* out-of-balance indicator and blocks submit until the two sides match.
|
|
160
|
+
* Domain-agnostic — "debit"/"credit" are just the two column keys to
|
|
161
|
+
* reconcile. Mirrors kernel v3 `ActionField.balance`.
|
|
162
|
+
*/
|
|
163
|
+
balance?: FieldBalanceRule
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Declarative reconciliation constraint on a line-items field: the summed value
|
|
168
|
+
* of `debitColumn` across all rows must equal the summed value of
|
|
169
|
+
* `creditColumn`. Tolerates the snake_case shape the kernel serves
|
|
170
|
+
* (`debit_column` / `credit_column` / `require_nonzero`). Generic by design.
|
|
171
|
+
*/
|
|
172
|
+
export interface FieldBalanceRule {
|
|
173
|
+
debitColumn?: string
|
|
174
|
+
creditColumn?: string
|
|
175
|
+
/** snake_case alias served by the kernel manifest. */
|
|
176
|
+
debit_column?: string
|
|
177
|
+
/** snake_case alias served by the kernel manifest. */
|
|
178
|
+
credit_column?: string
|
|
179
|
+
message?: string
|
|
180
|
+
/** When true (default) an all-zero entry is treated as out of balance. */
|
|
181
|
+
requireNonzero?: boolean
|
|
182
|
+
require_nonzero?: boolean
|
|
148
183
|
}
|
|
149
184
|
|
|
150
185
|
export interface ActionDefinition {
|