@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.
@@ -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 {