@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.
Files changed (55) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/api-context.d.ts +9 -1
  3. package/dist/api-context.d.ts.map +1 -1
  4. package/dist/api-context.js +6 -4
  5. package/dist/business/document-editor-panels.d.ts +2 -1
  6. package/dist/business/document-editor-panels.d.ts.map +1 -1
  7. package/dist/business/document-editor-panels.js +6 -6
  8. package/dist/business/document-editor.d.ts.map +1 -1
  9. package/dist/business/document-editor.js +23 -9
  10. package/dist/business/index.d.ts +1 -0
  11. package/dist/business/index.d.ts.map +1 -1
  12. package/dist/business/index.js +1 -0
  13. package/dist/business/line-items-editor.d.ts +6 -1
  14. package/dist/business/line-items-editor.d.ts.map +1 -1
  15. package/dist/business/line-items-editor.js +109 -83
  16. package/dist/business/line-product-cell.d.ts +32 -0
  17. package/dist/business/line-product-cell.d.ts.map +1 -0
  18. package/dist/business/line-product-cell.js +100 -0
  19. package/dist/dynamic-select-field.d.ts +2 -1
  20. package/dist/dynamic-select-field.d.ts.map +1 -1
  21. package/dist/dynamic-select-field.js +49 -19
  22. package/dist/dynamic-table.d.ts +31 -1
  23. package/dist/dynamic-table.d.ts.map +1 -1
  24. package/dist/dynamic-table.js +37 -4
  25. package/dist/entity-select.d.ts.map +1 -1
  26. package/dist/entity-select.js +13 -11
  27. package/dist/field-grid.d.ts +7 -1
  28. package/dist/field-grid.d.ts.map +1 -1
  29. package/dist/field-grid.js +4 -2
  30. package/dist/index.d.ts +2 -0
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +1 -0
  33. package/dist/query-batch.d.ts.map +1 -1
  34. package/dist/query-batch.js +17 -1
  35. package/dist/record-picker-actions.d.ts +27 -0
  36. package/dist/record-picker-actions.d.ts.map +1 -0
  37. package/dist/record-picker-actions.js +32 -0
  38. package/package.json +1 -1
  39. package/src/__tests__/api-batch-opt-out.test.tsx +83 -0
  40. package/src/__tests__/document-editor-inline-selects.test.tsx +202 -0
  41. package/src/__tests__/dynamic-table-bulk-actions.test.tsx +140 -0
  42. package/src/__tests__/dynamic-table-load-error.test.tsx +97 -0
  43. package/src/api-context.tsx +19 -4
  44. package/src/business/document-editor-panels.tsx +13 -10
  45. package/src/business/document-editor.tsx +72 -20
  46. package/src/business/index.ts +1 -0
  47. package/src/business/line-items-editor.tsx +229 -174
  48. package/src/business/line-product-cell.tsx +219 -0
  49. package/src/dynamic-select-field.tsx +51 -22
  50. package/src/dynamic-table.tsx +91 -4
  51. package/src/entity-select.tsx +17 -39
  52. package/src/field-grid.tsx +15 -1
  53. package/src/index.ts +2 -0
  54. package/src/query-batch.ts +18 -1
  55. package/src/record-picker-actions.tsx +81 -0
@@ -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, Plus, Pencil, type LucideIcon } from 'lucide-react'
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 = (e: React.MouseEvent) => {
176
- e.stopPropagation()
175
+ const openCreate = () => {
176
+ setOpen(false)
177
177
  setDialogRecordId(undefined)
178
178
  setDialogOpen(true)
179
179
  }
180
- const openEdit = (e: React.MouseEvent) => {
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 gap-1.5">
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="w-full flex-1 justify-start gap-2 font-normal"
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
- style={{ width: 'var(--radix-popover-trigger-width)' }}
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 && <CommandEmpty>{emptyText}</CommandEmpty>}
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
@@ -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 htmlFor={htmlFor} className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
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,
@@ -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
+ }