@asteby/metacore-runtime-react 49.3.1 → 49.5.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 +14 -0
- package/dist/api-context.d.ts +9 -1
- package/dist/api-context.d.ts.map +1 -1
- package/dist/api-context.js +6 -4
- package/dist/business/document-editor-panels.d.ts +2 -1
- package/dist/business/document-editor-panels.d.ts.map +1 -1
- package/dist/business/document-editor-panels.js +6 -6
- package/dist/business/document-editor.d.ts.map +1 -1
- package/dist/business/document-editor.js +23 -9
- package/dist/business/index.d.ts +1 -0
- package/dist/business/index.d.ts.map +1 -1
- package/dist/business/index.js +1 -0
- package/dist/business/line-items-editor.d.ts +6 -1
- package/dist/business/line-items-editor.d.ts.map +1 -1
- package/dist/business/line-items-editor.js +109 -83
- package/dist/business/line-product-cell.d.ts +32 -0
- package/dist/business/line-product-cell.d.ts.map +1 -0
- package/dist/business/line-product-cell.js +100 -0
- package/dist/dynamic-select-field.d.ts +2 -1
- package/dist/dynamic-select-field.d.ts.map +1 -1
- package/dist/dynamic-select-field.js +49 -19
- package/dist/dynamic-table.d.ts +31 -1
- package/dist/dynamic-table.d.ts.map +1 -1
- package/dist/dynamic-table.js +37 -4
- package/dist/entity-select.d.ts.map +1 -1
- package/dist/entity-select.js +13 -11
- package/dist/field-grid.d.ts +7 -1
- package/dist/field-grid.d.ts.map +1 -1
- package/dist/field-grid.js +4 -2
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/query-batch.d.ts.map +1 -1
- package/dist/query-batch.js +17 -1
- package/dist/record-picker-actions.d.ts +27 -0
- package/dist/record-picker-actions.d.ts.map +1 -0
- package/dist/record-picker-actions.js +32 -0
- package/package.json +1 -1
- package/src/__tests__/api-batch-opt-out.test.tsx +83 -0
- package/src/__tests__/document-editor-inline-selects.test.tsx +202 -0
- package/src/__tests__/dynamic-table-bulk-actions.test.tsx +140 -0
- package/src/__tests__/dynamic-table-load-error.test.tsx +97 -0
- package/src/api-context.tsx +19 -4
- package/src/business/document-editor-panels.tsx +13 -10
- package/src/business/document-editor.tsx +72 -20
- package/src/business/index.ts +1 -0
- package/src/business/line-items-editor.tsx +229 -174
- package/src/business/line-product-cell.tsx +219 -0
- package/src/dynamic-select-field.tsx +51 -22
- package/src/dynamic-table.tsx +91 -4
- package/src/entity-select.tsx +17 -39
- package/src/field-grid.tsx +15 -1
- package/src/index.ts +2 -0
- package/src/query-batch.ts +18 -1
- package/src/record-picker-actions.tsx +81 -0
package/src/entity-select.tsx
CHANGED
|
@@ -15,11 +15,10 @@
|
|
|
15
15
|
// code is needed. This lives in the SDK so POS, purchases and any future addon
|
|
16
16
|
// share ONE implementation instead of each re-porting a bespoke picker.
|
|
17
17
|
import { useCallback, useEffect, useState } from 'react'
|
|
18
|
-
import { Search, X,
|
|
18
|
+
import { Search, X, type LucideIcon } from 'lucide-react'
|
|
19
19
|
import {
|
|
20
20
|
Button,
|
|
21
21
|
Command,
|
|
22
|
-
CommandEmpty,
|
|
23
22
|
CommandGroup,
|
|
24
23
|
CommandInput,
|
|
25
24
|
CommandItem,
|
|
@@ -29,6 +28,7 @@ import {
|
|
|
29
28
|
PopoverTrigger,
|
|
30
29
|
} from '@asteby/metacore-ui'
|
|
31
30
|
import { CreateRecordDialog } from './dialogs/create-record-dialog'
|
|
31
|
+
import { JOINED_TRIGGER_CLASS, PickerCreateItem, RecordPickerAction, hasRecordPickerAction } from './record-picker-actions'
|
|
32
32
|
import { useCan } from './permissions-context'
|
|
33
33
|
import { useApi } from './api-context'
|
|
34
34
|
|
|
@@ -172,14 +172,14 @@ export function EntitySelect({
|
|
|
172
172
|
onSelect(null, null)
|
|
173
173
|
}
|
|
174
174
|
|
|
175
|
-
const openCreate = (
|
|
176
|
-
|
|
175
|
+
const openCreate = () => {
|
|
176
|
+
setOpen(false)
|
|
177
177
|
setDialogRecordId(undefined)
|
|
178
178
|
setDialogOpen(true)
|
|
179
179
|
}
|
|
180
|
-
const openEdit = (
|
|
181
|
-
e.stopPropagation()
|
|
180
|
+
const openEdit = () => {
|
|
182
181
|
if (!value) return
|
|
182
|
+
setOpen(false)
|
|
183
183
|
setDialogRecordId(value)
|
|
184
184
|
setDialogOpen(true)
|
|
185
185
|
}
|
|
@@ -198,14 +198,18 @@ export function EntitySelect({
|
|
|
198
198
|
onSelect(id, lbl)
|
|
199
199
|
}
|
|
200
200
|
|
|
201
|
+
const onCreate = mayCreate ? openCreate : undefined
|
|
202
|
+
const onEdit = mayEdit ? openEdit : undefined
|
|
203
|
+
const joined = hasRecordPickerAction(!!value, onCreate, onEdit)
|
|
204
|
+
|
|
201
205
|
return (
|
|
202
|
-
<div className="flex items-center
|
|
206
|
+
<div className="flex items-center" data-slot="entity-select">
|
|
203
207
|
<Popover open={open} onOpenChange={disabled ? undefined : setOpen}>
|
|
204
208
|
<PopoverTrigger asChild>
|
|
205
209
|
<Button
|
|
206
210
|
variant="outline"
|
|
207
211
|
disabled={disabled}
|
|
208
|
-
className=
|
|
212
|
+
className={'w-full min-w-0 flex-1 justify-start gap-2 font-normal' + (joined ? ` ${JOINED_TRIGGER_CLASS}` : '')}
|
|
209
213
|
>
|
|
210
214
|
{Icon && <Icon className="text-muted-foreground size-4 shrink-0" />}
|
|
211
215
|
<span className="flex-1 truncate text-left">{label ?? placeholder}</span>
|
|
@@ -228,7 +232,8 @@ export function EntitySelect({
|
|
|
228
232
|
<PopoverContent
|
|
229
233
|
className="p-0"
|
|
230
234
|
align="start"
|
|
231
|
-
|
|
235
|
+
collisionPadding={8}
|
|
236
|
+
style={{ width: 'max(var(--radix-popover-trigger-width), 14rem)', maxWidth: 'calc(100vw - 1rem)' }}
|
|
232
237
|
>
|
|
233
238
|
<Command shouldFilter={false}>
|
|
234
239
|
<CommandInput
|
|
@@ -244,7 +249,7 @@ export function EntitySelect({
|
|
|
244
249
|
)}
|
|
245
250
|
{!isLoading &&
|
|
246
251
|
searchTerm.length >= minChars &&
|
|
247
|
-
results.length === 0 && <
|
|
252
|
+
results.length === 0 && <div className="text-muted-foreground py-6 text-center text-sm">{emptyText}</div>}
|
|
248
253
|
{!isLoading && results.length > 0 && (
|
|
249
254
|
<CommandGroup className="max-h-64 overflow-auto">
|
|
250
255
|
{results.map((row) => (
|
|
@@ -270,41 +275,14 @@ export function EntitySelect({
|
|
|
270
275
|
<span className="text-xs">Escribe al menos 2 caracteres</span>
|
|
271
276
|
</div>
|
|
272
277
|
)}
|
|
278
|
+
{onCreate && !isLoading && <PickerCreateItem label={model} onSelect={openCreate} />}
|
|
273
279
|
</CommandList>
|
|
274
280
|
</Command>
|
|
275
281
|
</PopoverContent>
|
|
276
282
|
</Popover>
|
|
277
283
|
|
|
278
284
|
{/* Selected → edit (pencil); empty → create (+). Each gated by perms. */}
|
|
279
|
-
{value
|
|
280
|
-
? mayEdit && (
|
|
281
|
-
<Button
|
|
282
|
-
type="button"
|
|
283
|
-
variant="outline"
|
|
284
|
-
size="icon"
|
|
285
|
-
disabled={disabled}
|
|
286
|
-
onClick={openEdit}
|
|
287
|
-
aria-label="Editar"
|
|
288
|
-
title="Editar"
|
|
289
|
-
className="shrink-0"
|
|
290
|
-
>
|
|
291
|
-
<Pencil className="size-4" />
|
|
292
|
-
</Button>
|
|
293
|
-
)
|
|
294
|
-
: mayCreate && (
|
|
295
|
-
<Button
|
|
296
|
-
type="button"
|
|
297
|
-
variant="outline"
|
|
298
|
-
size="icon"
|
|
299
|
-
disabled={disabled}
|
|
300
|
-
onClick={openCreate}
|
|
301
|
-
aria-label="Crear"
|
|
302
|
-
title="Crear"
|
|
303
|
-
className="shrink-0"
|
|
304
|
-
>
|
|
305
|
-
<Plus className="size-4" />
|
|
306
|
-
</Button>
|
|
307
|
-
)}
|
|
285
|
+
<RecordPickerAction hasValue={!!value} label={model} onCreate={onCreate} onEdit={onEdit} disabled={disabled} />
|
|
308
286
|
|
|
309
287
|
{dialogOpen && (
|
|
310
288
|
<CreateRecordDialog
|
package/src/field-grid.tsx
CHANGED
|
@@ -42,14 +42,28 @@ export function FieldCell({
|
|
|
42
42
|
export function FieldLabel({
|
|
43
43
|
htmlFor,
|
|
44
44
|
required,
|
|
45
|
+
tone = 'caps',
|
|
45
46
|
children,
|
|
46
47
|
}: {
|
|
47
48
|
htmlFor?: string
|
|
48
49
|
required?: boolean
|
|
50
|
+
/**
|
|
51
|
+
* `caps` (default): small uppercase eyebrow of the CRUD modals. `sentence`:
|
|
52
|
+
* sentence-case label for dense editors (DocumentEditor) where a column of
|
|
53
|
+
* uppercase labels reads as shouting.
|
|
54
|
+
*/
|
|
55
|
+
tone?: 'caps' | 'sentence'
|
|
49
56
|
children: ReactNode
|
|
50
57
|
}) {
|
|
51
58
|
return (
|
|
52
|
-
<Label
|
|
59
|
+
<Label
|
|
60
|
+
htmlFor={htmlFor}
|
|
61
|
+
className={
|
|
62
|
+
tone === 'sentence'
|
|
63
|
+
? 'text-sm font-medium text-foreground/80'
|
|
64
|
+
: 'text-xs font-medium uppercase tracking-wide text-muted-foreground'
|
|
65
|
+
}
|
|
66
|
+
>
|
|
53
67
|
{children}
|
|
54
68
|
{required && <span className="ml-0.5 text-destructive">*</span>}
|
|
55
69
|
</Label>
|
package/src/index.ts
CHANGED
|
@@ -475,6 +475,8 @@ export type { DynamicRecordDialogProps, FieldDef, FieldOption, GetImageUrl } fro
|
|
|
475
475
|
export { CreateRecordDialog } from './dialogs/create-record-dialog'
|
|
476
476
|
export { EntitySelect } from './entity-select'
|
|
477
477
|
export type { EntitySelectProps, EntitySelectOption } from './entity-select'
|
|
478
|
+
export { RecordPickerAction, PickerCreateItem, JOINED_TRIGGER_CLASS, hasRecordPickerAction } from './record-picker-actions'
|
|
479
|
+
export type { RecordPickerActionProps } from './record-picker-actions'
|
|
478
480
|
export { ViewRecordDialog } from './dialogs/view-record-dialog'
|
|
479
481
|
export type {
|
|
480
482
|
ModelKey,
|
package/src/query-batch.ts
CHANGED
|
@@ -170,7 +170,19 @@ export function rememberInRows(
|
|
|
170
170
|
}
|
|
171
171
|
}
|
|
172
172
|
|
|
173
|
+
/**
|
|
174
|
+
* Clients whose backend has no `/q` (it answered 404/405). They get plain GETs
|
|
175
|
+
* for the rest of the session instead of one failed POST per read.
|
|
176
|
+
*/
|
|
177
|
+
let unsupportedClients = new WeakSet<object>()
|
|
178
|
+
|
|
179
|
+
function errorStatus(err: unknown): number | undefined {
|
|
180
|
+
const e = err as { status?: number; response?: { status?: number } } | null
|
|
181
|
+
return e?.status ?? e?.response?.status
|
|
182
|
+
}
|
|
183
|
+
|
|
173
184
|
export function resetQueryBatchCache(): void {
|
|
185
|
+
unsupportedClients = new WeakSet<object>()
|
|
174
186
|
partCache.clear()
|
|
175
187
|
entityCache.clear()
|
|
176
188
|
waiters = []
|
|
@@ -320,6 +332,11 @@ async function flushQueryBatch() {
|
|
|
320
332
|
waiter.resolve(merged)
|
|
321
333
|
}
|
|
322
334
|
} catch (err) {
|
|
335
|
+
// The host has no /q route: remember it so later reads skip the POST.
|
|
336
|
+
// Every waiter of this window still rejects and batchGet answers it
|
|
337
|
+
// with the plain GET, so no read is lost.
|
|
338
|
+
const status = errorStatus(err)
|
|
339
|
+
if (status === 404 || status === 405) unsupportedClients.add(api)
|
|
323
340
|
const error = err instanceof Error ? err : new Error(String(err))
|
|
324
341
|
for (const waiter of batch) waiter.reject(error)
|
|
325
342
|
}
|
|
@@ -381,7 +398,7 @@ export async function batchGet(api: ApiClient, url: string, config?: { params?:
|
|
|
381
398
|
// trailing `undefined` config reaching the host client.
|
|
382
399
|
const plainGet = () => (config === undefined ? api.get(url) : api.get(url, config))
|
|
383
400
|
const token = tokenForGet(url, config?.params)
|
|
384
|
-
if (!token) return plainGet()
|
|
401
|
+
if (!token || unsupportedClients.has(api)) return plainGet()
|
|
385
402
|
try {
|
|
386
403
|
const part = await loadQueryPart(api, token)
|
|
387
404
|
if (!part.success) {
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// RecordPickerActions — create/edit affordances shared by every "pick a related
|
|
2
|
+
// record" control (DynamicSelectField for declarative forms, EntitySelect for
|
|
3
|
+
// hand-written screens). ONE implementation of the same contract:
|
|
4
|
+
//
|
|
5
|
+
// - nothing selected → "+" joined to the trigger: opens the model's CREATE
|
|
6
|
+
// modal and the new record ends up selected;
|
|
7
|
+
// - a record selected → pencil joined to the trigger: opens THAT record's
|
|
8
|
+
// EDIT modal and the label refreshes on save;
|
|
9
|
+
// - "Crear …" at the foot of the result list, so creating is one step away
|
|
10
|
+
// from a search with no hits.
|
|
11
|
+
//
|
|
12
|
+
// The action renders as a segment of the trigger (shared border, no gap) so the
|
|
13
|
+
// control reads as a single field instead of a select plus a loose button.
|
|
14
|
+
import type { ReactNode } from 'react'
|
|
15
|
+
import { Pencil, Plus } from 'lucide-react'
|
|
16
|
+
import { Button, CommandGroup, CommandItem, CommandSeparator } from '@asteby/metacore-ui/primitives'
|
|
17
|
+
|
|
18
|
+
/** Classes for a trigger that has a joined action segment on its right. */
|
|
19
|
+
export const JOINED_TRIGGER_CLASS = 'rounded-r-none focus-visible:z-10'
|
|
20
|
+
|
|
21
|
+
export interface RecordPickerActionProps {
|
|
22
|
+
/** Current value: drives create (+) vs edit (pencil). */
|
|
23
|
+
hasValue: boolean
|
|
24
|
+
/** Human label of the referenced model / field, for the accessible name. */
|
|
25
|
+
label: string
|
|
26
|
+
onCreate?: () => void
|
|
27
|
+
onEdit?: () => void
|
|
28
|
+
disabled?: boolean
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Joined trailing segment: pencil when there is a value (and `onEdit`), "+"
|
|
33
|
+
* otherwise (and `onCreate`). Renders nothing when neither applies — callers
|
|
34
|
+
* should then drop {@link JOINED_TRIGGER_CLASS} from the trigger.
|
|
35
|
+
*/
|
|
36
|
+
export function RecordPickerAction({ hasValue, label, onCreate, onEdit, disabled }: RecordPickerActionProps) {
|
|
37
|
+
const edit = hasValue && !!onEdit
|
|
38
|
+
const create = !hasValue && !!onCreate
|
|
39
|
+
if (!edit && !create) return null
|
|
40
|
+
const name = edit ? `Editar ${label}` : `Crear ${label}`
|
|
41
|
+
return (
|
|
42
|
+
<Button
|
|
43
|
+
type="button"
|
|
44
|
+
variant="outline"
|
|
45
|
+
size="icon"
|
|
46
|
+
disabled={disabled}
|
|
47
|
+
onClick={(e) => {
|
|
48
|
+
e.stopPropagation()
|
|
49
|
+
if (edit) onEdit?.()
|
|
50
|
+
else onCreate?.()
|
|
51
|
+
}}
|
|
52
|
+
aria-label={name}
|
|
53
|
+
title={name}
|
|
54
|
+
data-slot="record-picker-action"
|
|
55
|
+
data-action={edit ? 'edit' : 'create'}
|
|
56
|
+
className="-ml-px size-9 shrink-0 rounded-l-none text-muted-foreground hover:text-foreground focus-visible:z-10"
|
|
57
|
+
>
|
|
58
|
+
{edit ? <Pencil className="size-4" /> : <Plus className="size-4" />}
|
|
59
|
+
</Button>
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** True when a picker will render a joined action for the given state. */
|
|
64
|
+
export function hasRecordPickerAction(hasValue: boolean, onCreate?: unknown, onEdit?: unknown): boolean {
|
|
65
|
+
return hasValue ? !!onEdit : !!onCreate
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** "Crear …" row at the foot of a Command list. */
|
|
69
|
+
export function PickerCreateItem({ label, onSelect, children }: { label: string; onSelect: () => void; children?: ReactNode }) {
|
|
70
|
+
return (
|
|
71
|
+
<>
|
|
72
|
+
<CommandSeparator />
|
|
73
|
+
<CommandGroup>
|
|
74
|
+
<CommandItem value="__create__" onSelect={onSelect} data-slot="picker-create" className="text-primary">
|
|
75
|
+
<Plus className="mr-2 size-4 shrink-0" />
|
|
76
|
+
<span className="truncate">{children ?? `Crear ${label}`}</span>
|
|
77
|
+
</CommandItem>
|
|
78
|
+
</CommandGroup>
|
|
79
|
+
</>
|
|
80
|
+
)
|
|
81
|
+
}
|