@asteby/metacore-runtime-react 49.4.0 → 49.6.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/customer-picker.d.ts +8 -2
- package/dist/business/customer-picker.d.ts.map +1 -1
- package/dist/business/customer-picker.js +30 -27
- 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-product-cell.d.ts +8 -10
- package/dist/business/line-product-cell.d.ts.map +1 -1
- package/dist/business/line-product-cell.js +13 -90
- package/dist/business/product-options.d.ts +41 -0
- package/dist/business/product-options.d.ts.map +1 -0
- package/dist/business/product-options.js +58 -0
- package/dist/business/product-picker.d.ts +5 -1
- package/dist/business/product-picker.d.ts.map +1 -1
- package/dist/business/product-picker.js +26 -35
- package/dist/business/relate-documents.d.ts +1 -1
- package/dist/business/relate-documents.d.ts.map +1 -1
- package/dist/business/relate-documents.js +6 -8
- package/dist/business/vehicle-picker.d.ts +8 -2
- package/dist/business/vehicle-picker.d.ts.map +1 -1
- package/dist/business/vehicle-picker.js +28 -28
- package/dist/dialogs/dynamic-record.d.ts.map +1 -1
- package/dist/dialogs/dynamic-record.js +28 -43
- package/dist/dynamic-multi-select-field.d.ts.map +1 -1
- package/dist/dynamic-multi-select-field.js +17 -5
- package/dist/dynamic-select-field.d.ts +7 -23
- package/dist/dynamic-select-field.d.ts.map +1 -1
- package/dist/dynamic-select-field.js +67 -161
- 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 +5 -2
- package/dist/entity-select.d.ts.map +1 -1
- package/dist/entity-select.js +23 -116
- package/dist/index.d.ts +7 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -1
- package/dist/query-batch.d.ts.map +1 -1
- package/dist/query-batch.js +17 -1
- package/dist/record-picker-actions.d.ts +40 -2
- package/dist/record-picker-actions.d.ts.map +1 -1
- package/dist/record-picker-actions.js +44 -4
- package/dist/record-picker-dialog.d.ts +23 -0
- package/dist/record-picker-dialog.d.ts.map +1 -0
- package/dist/record-picker-dialog.js +37 -0
- package/dist/record-picker-option.d.ts +25 -0
- package/dist/record-picker-option.d.ts.map +1 -0
- package/dist/record-picker-option.js +55 -0
- package/dist/record-picker.d.ts +149 -0
- package/dist/record-picker.d.ts.map +1 -0
- package/dist/record-picker.js +372 -0
- package/package.json +1 -1
- package/src/__tests__/api-batch-opt-out.test.tsx +83 -0
- package/src/__tests__/business-pickers.test.tsx +6 -3
- package/src/__tests__/document-editor-inline-selects.test.tsx +2 -1
- package/src/__tests__/document-form-dialog.test.tsx +4 -3
- package/src/__tests__/document-lines-grid.test.tsx +2 -1
- package/src/__tests__/dynamic-table-bulk-actions.test.tsx +140 -0
- package/src/__tests__/dynamic-table-load-error.test.tsx +97 -0
- package/src/__tests__/record-picker.test.tsx +469 -0
- package/src/api-context.tsx +19 -4
- package/src/business/customer-picker.tsx +87 -98
- package/src/business/index.ts +1 -0
- package/src/business/line-product-cell.tsx +55 -180
- package/src/business/product-options.tsx +119 -0
- package/src/business/product-picker.tsx +63 -126
- package/src/business/relate-documents.tsx +36 -45
- package/src/business/vehicle-picker.tsx +76 -99
- package/src/dialogs/dynamic-record.tsx +44 -97
- package/src/dynamic-multi-select-field.tsx +39 -13
- package/src/dynamic-select-field.tsx +143 -368
- package/src/dynamic-table.tsx +91 -4
- package/src/entity-select.tsx +53 -202
- package/src/index.ts +27 -2
- package/src/query-batch.ts +18 -1
- package/src/record-picker-actions.tsx +101 -8
- package/src/record-picker-dialog.tsx +80 -0
- package/src/record-picker-option.tsx +96 -0
- package/src/record-picker.tsx +971 -0
- package/dist/business/use-async-search.d.ts +0 -10
- package/dist/business/use-async-search.d.ts.map +0 -1
- package/dist/business/use-async-search.js +0 -38
- package/src/business/use-async-search.ts +0 -43
package/src/dynamic-table.tsx
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// * `@/components/dynamic/dynamic-columns` → host-injected via the
|
|
11
11
|
// `getDynamicColumns` prop (hosts retain ownership because the rendered
|
|
12
12
|
// column cells are tightly coupled to their design system).
|
|
13
|
-
import { useEffect, useState, useMemo, useCallback, useRef, type MouseEvent } from 'react'
|
|
13
|
+
import { useEffect, useState, useMemo, useCallback, useRef, type MouseEvent, type ReactNode } from 'react'
|
|
14
14
|
import { useTranslation } from 'react-i18next'
|
|
15
15
|
import { useTimeZone, useCurrency } from './org-runtime-context'
|
|
16
16
|
import { format } from 'date-fns'
|
|
@@ -264,6 +264,36 @@ export interface DynamicTableProps {
|
|
|
264
264
|
* render every row. A positive number overrides the threshold.
|
|
265
265
|
*/
|
|
266
266
|
virtualizeRows?: boolean | number
|
|
267
|
+
/**
|
|
268
|
+
* Host-provided actions rendered in the floating bar that appears while
|
|
269
|
+
* rows are selected, next to the built-in "Eliminar". Pass a node, or a
|
|
270
|
+
* function that receives the current selection:
|
|
271
|
+
* - `selectedRows`: the selected row objects (current page only);
|
|
272
|
+
* - `selectedIds`: their `id`s;
|
|
273
|
+
* - `clearSelection()`: unselect everything (call after a successful action);
|
|
274
|
+
* - `refresh()`: reload the list.
|
|
275
|
+
* The selection is cleared whenever the page, page size, sort, search or
|
|
276
|
+
* column filters change (classic pagination), so a bulk action can never
|
|
277
|
+
* hit rows the user no longer sees. The host owns the write call and its
|
|
278
|
+
* authorization — the table only supplies the selection.
|
|
279
|
+
*/
|
|
280
|
+
extraBulkActions?: ReactNode | ((ctx: DynamicTableBulkContext) => ReactNode)
|
|
281
|
+
/** Hide the built-in bulk "Eliminar" (e.g. a model whose rows must not be deleted in batch). */
|
|
282
|
+
hideBulkDelete?: boolean
|
|
283
|
+
/**
|
|
284
|
+
* First-use content shown instead of "No se encontraron resultados" when the
|
|
285
|
+
* list loaded fine, is empty, and no search/filter is active. Never shown
|
|
286
|
+
* after a load error (a retry is offered) or a 403.
|
|
287
|
+
*/
|
|
288
|
+
emptyState?: ReactNode
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Context handed to `DynamicTableProps.extraBulkActions` when it is a function. */
|
|
292
|
+
export interface DynamicTableBulkContext {
|
|
293
|
+
selectedRows: any[]
|
|
294
|
+
selectedIds: Array<string | number>
|
|
295
|
+
clearSelection: () => void
|
|
296
|
+
refresh: () => void
|
|
267
297
|
}
|
|
268
298
|
|
|
269
299
|
/** True when an api-client error is an HTTP 403 (axios-style or fetch-style). */
|
|
@@ -292,6 +322,9 @@ export function DynamicTable({
|
|
|
292
322
|
pagination: paginationMode,
|
|
293
323
|
infiniteScroll: infiniteScrollProp = false,
|
|
294
324
|
virtualizeRows,
|
|
325
|
+
extraBulkActions,
|
|
326
|
+
hideBulkDelete,
|
|
327
|
+
emptyState,
|
|
295
328
|
}: DynamicTableProps) {
|
|
296
329
|
// The org's timezone/currency: an explicit prop wins, else the app-wide
|
|
297
330
|
// OrgRuntimeProvider (without it, money fell back to USD).
|
|
@@ -347,6 +380,9 @@ export function DynamicTable({
|
|
|
347
380
|
// model. Rendered as "no permission" instead of masquerading as an empty
|
|
348
381
|
// list. Purely display state — never a fetch dependency (no refetch loop).
|
|
349
382
|
const [forbidden, setForbidden] = useState(false)
|
|
383
|
+
// A list request that failed for a reason other than 403 (500, network,
|
|
384
|
+
// `success: false`). Shown with a retry instead of a misleading "no results".
|
|
385
|
+
const [loadError, setLoadError] = useState(false)
|
|
350
386
|
const infPageRef = useRef(1)
|
|
351
387
|
const [optionsMap, setOptionsMap] = useState<Map<string, any[]>>(new Map())
|
|
352
388
|
|
|
@@ -897,6 +933,7 @@ export function DynamicTable({
|
|
|
897
933
|
if (relationInclude) params.include = relationInclude
|
|
898
934
|
const res = await api.get(endpoint || `/data/${model}`, { params }) as { data: ApiResponse<any[]> }
|
|
899
935
|
setForbidden(false)
|
|
936
|
+
setLoadError(!res.data.success)
|
|
900
937
|
if (res.data.success) {
|
|
901
938
|
const rows = res.data.data || []
|
|
902
939
|
setData(rows)
|
|
@@ -914,10 +951,12 @@ export function DynamicTable({
|
|
|
914
951
|
} catch (error) {
|
|
915
952
|
if (isForbiddenError(error)) {
|
|
916
953
|
setForbidden(true)
|
|
954
|
+
setLoadError(false)
|
|
917
955
|
setData([])
|
|
918
956
|
setRowCount(0)
|
|
919
957
|
} else {
|
|
920
958
|
console.error('Error al cargar los datos', error)
|
|
959
|
+
setLoadError(true)
|
|
921
960
|
}
|
|
922
961
|
} finally {
|
|
923
962
|
setLoadingData(false)
|
|
@@ -968,6 +1007,7 @@ export function DynamicTable({
|
|
|
968
1007
|
params,
|
|
969
1008
|
})) as { data: ApiResponse<any[]> }
|
|
970
1009
|
setForbidden(false)
|
|
1010
|
+
setLoadError(!res.data.success)
|
|
971
1011
|
if (res.data.success) {
|
|
972
1012
|
const rows = res.data.data || []
|
|
973
1013
|
setData((prev) => (append ? dedupeById(prev, rows) : rows))
|
|
@@ -989,6 +1029,7 @@ export function DynamicTable({
|
|
|
989
1029
|
} catch (error) {
|
|
990
1030
|
if (isForbiddenError(error)) {
|
|
991
1031
|
setForbidden(true)
|
|
1032
|
+
setLoadError(false)
|
|
992
1033
|
if (!append) {
|
|
993
1034
|
setData([])
|
|
994
1035
|
setRowCount(0)
|
|
@@ -996,6 +1037,7 @@ export function DynamicTable({
|
|
|
996
1037
|
setInfExhausted(true)
|
|
997
1038
|
} else {
|
|
998
1039
|
console.error('Error al cargar los datos', error)
|
|
1040
|
+
setLoadError(true)
|
|
999
1041
|
}
|
|
1000
1042
|
} finally {
|
|
1001
1043
|
if (append) setLoadingMore(false)
|
|
@@ -1372,6 +1414,10 @@ export function DynamicTable({
|
|
|
1372
1414
|
manualSorting: true,
|
|
1373
1415
|
manualFiltering: true,
|
|
1374
1416
|
enableRowSelection: true,
|
|
1417
|
+
// Key the selection by record id, not by position in the page: a bulk
|
|
1418
|
+
// action must act on the rows the user ticked, never on whatever row
|
|
1419
|
+
// lands at the same index after the data changes.
|
|
1420
|
+
getRowId: (row: any, index: number) => (row?.id != null ? String(row.id) : String(index)),
|
|
1375
1421
|
onRowSelectionChange: setRowSelection,
|
|
1376
1422
|
onSortingChange: setSorting,
|
|
1377
1423
|
onColumnVisibilityChange: setColumnVisibility,
|
|
@@ -1387,6 +1433,21 @@ export function DynamicTable({
|
|
|
1387
1433
|
})
|
|
1388
1434
|
|
|
1389
1435
|
const tableRows = table.getRowModel().rows
|
|
1436
|
+
const bulkSelectedRows = table.getFilteredSelectedRowModel().rows.map((r) => r.original as any)
|
|
1437
|
+
|
|
1438
|
+
// Classic pagination swaps the visible rows on every page / sort / search /
|
|
1439
|
+
// filter change; drop the selection so it never refers to rows that are no
|
|
1440
|
+
// longer on screen. Infinite mode accumulates rows (selection is id-keyed,
|
|
1441
|
+
// so it stays valid) and is left alone.
|
|
1442
|
+
const selectionScope = infiniteScroll ? '' : JSON.stringify([pagination.pageIndex, pagination.pageSize, sorting, columnFilters, globalFilter])
|
|
1443
|
+
const prevSelectionScope = useRef(selectionScope)
|
|
1444
|
+
useEffect(() => {
|
|
1445
|
+
if (prevSelectionScope.current !== selectionScope) {
|
|
1446
|
+
prevSelectionScope.current = selectionScope
|
|
1447
|
+
setRowSelection({})
|
|
1448
|
+
}
|
|
1449
|
+
}, [selectionScope])
|
|
1450
|
+
|
|
1390
1451
|
const virtualizeThreshold = resolveVirtualizeThreshold(virtualizeRows)
|
|
1391
1452
|
const shouldVirtualize =
|
|
1392
1453
|
virtualizeThreshold !== false && tableRows.length >= virtualizeThreshold
|
|
@@ -1624,6 +1685,14 @@ export function DynamicTable({
|
|
|
1624
1685
|
<h3 className="text-lg font-semibold text-foreground" role="alert">{t('dynamic.forbidden_title', { defaultValue: 'Sin permiso para ver este módulo' })}</h3>
|
|
1625
1686
|
<p className="text-sm text-muted-foreground">{t('dynamic.forbidden_hint', { defaultValue: 'Tu rol no tiene acceso de lectura a estos datos. Pide acceso a un administrador.' })}</p>
|
|
1626
1687
|
</>
|
|
1688
|
+
) : loadError ? (
|
|
1689
|
+
<>
|
|
1690
|
+
<h3 className="text-lg font-semibold text-foreground" role="alert">{t('dynamic.load_error_title', { defaultValue: 'No se pudieron cargar los datos' })}</h3>
|
|
1691
|
+
<p className="text-sm text-muted-foreground">{t('dynamic.load_error_hint', { defaultValue: 'Ocurrió un error al consultar la lista. Inténtalo de nuevo.' })}</p>
|
|
1692
|
+
<Button variant="outline" size="sm" className="mt-2" onClick={handleRefresh}>{t('dynamic.retry', { defaultValue: 'Reintentar' })}</Button>
|
|
1693
|
+
</>
|
|
1694
|
+
) : emptyState && !hasActiveFilters ? (
|
|
1695
|
+
emptyState
|
|
1627
1696
|
) : (
|
|
1628
1697
|
<>
|
|
1629
1698
|
<h3 className="text-lg font-semibold text-foreground">No se encontraron resultados</h3>
|
|
@@ -1823,6 +1892,14 @@ export function DynamicTable({
|
|
|
1823
1892
|
<h3 className='text-base font-semibold text-foreground' role='alert'>{t('dynamic.forbidden_title', { defaultValue: 'Sin permiso para ver este módulo' })}</h3>
|
|
1824
1893
|
<p className='text-sm text-muted-foreground'>{t('dynamic.forbidden_hint', { defaultValue: 'Tu rol no tiene acceso de lectura a estos datos. Pide acceso a un administrador.' })}</p>
|
|
1825
1894
|
</>
|
|
1895
|
+
) : loadError ? (
|
|
1896
|
+
<>
|
|
1897
|
+
<h3 className='text-base font-semibold text-foreground' role='alert'>{t('dynamic.load_error_title', { defaultValue: 'No se pudieron cargar los datos' })}</h3>
|
|
1898
|
+
<p className='text-sm text-muted-foreground'>{t('dynamic.load_error_hint', { defaultValue: 'Ocurrió un error al consultar la lista. Inténtalo de nuevo.' })}</p>
|
|
1899
|
+
<Button variant='outline' size='sm' onClick={handleRefresh}>{t('dynamic.retry', { defaultValue: 'Reintentar' })}</Button>
|
|
1900
|
+
</>
|
|
1901
|
+
) : emptyState && !hasActiveFilters ? (
|
|
1902
|
+
emptyState
|
|
1826
1903
|
) : (
|
|
1827
1904
|
<>
|
|
1828
1905
|
<h3 className='text-base font-semibold text-foreground'>No se encontraron resultados</h3>
|
|
@@ -1896,9 +1973,19 @@ export function DynamicTable({
|
|
|
1896
1973
|
<ImportDialog open={importOpen} onOpenChange={setImportOpen} model={model} metadata={metadata} onImported={handleRefresh} />
|
|
1897
1974
|
)}
|
|
1898
1975
|
<DataTableBulkActions table={table} entityName="registro">
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1976
|
+
{typeof extraBulkActions === 'function'
|
|
1977
|
+
? extraBulkActions({
|
|
1978
|
+
selectedRows: bulkSelectedRows,
|
|
1979
|
+
selectedIds: bulkSelectedRows.map((r) => r.id).filter((id) => id != null),
|
|
1980
|
+
clearSelection: () => table.resetRowSelection(),
|
|
1981
|
+
refresh: handleRefresh,
|
|
1982
|
+
})
|
|
1983
|
+
: extraBulkActions}
|
|
1984
|
+
{!hideBulkDelete && (
|
|
1985
|
+
<Button variant="destructive" size="sm" className="h-8" onClick={() => setShowBulkDeleteConfirm(true)}>
|
|
1986
|
+
<Trash2 className="h-4 w-4 mr-1.5" /> Eliminar
|
|
1987
|
+
</Button>
|
|
1988
|
+
)}
|
|
1902
1989
|
</DataTableBulkActions>
|
|
1903
1990
|
</OptionsContext.Provider>
|
|
1904
1991
|
</RowActionsModelContext.Provider>
|
package/src/entity-select.tsx
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
// EntitySelect — the shared, permission-aware single-select for a related model.
|
|
2
2
|
//
|
|
3
|
+
// Now a thin configuration of <RecordPicker> (record-picker.tsx); prefer it for
|
|
4
|
+
// new screens. Kept with its exact props for consumers (POS, collections and
|
|
5
|
+
// other federated addons).
|
|
6
|
+
//
|
|
3
7
|
// A searchable async combobox over a kernel model's records, plus the two
|
|
4
8
|
// affordances every "pick a related record" control should have, exactly like
|
|
5
9
|
// the dynamic create modal's relation fields (Categoría/Marca) do:
|
|
@@ -14,23 +18,11 @@
|
|
|
14
18
|
// `/metadata/modal/:model` schema via <CreateRecordDialog>, so no per-model form
|
|
15
19
|
// code is needed. This lives in the SDK so POS, purchases and any future addon
|
|
16
20
|
// share ONE implementation instead of each re-porting a bespoke picker.
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
20
|
-
|
|
21
|
-
Command,
|
|
22
|
-
CommandGroup,
|
|
23
|
-
CommandInput,
|
|
24
|
-
CommandItem,
|
|
25
|
-
CommandList,
|
|
26
|
-
Popover,
|
|
27
|
-
PopoverContent,
|
|
28
|
-
PopoverTrigger,
|
|
29
|
-
} from '@asteby/metacore-ui'
|
|
30
|
-
import { CreateRecordDialog } from './dialogs/create-record-dialog'
|
|
31
|
-
import { JOINED_TRIGGER_CLASS, PickerCreateItem, RecordPickerAction, hasRecordPickerAction } from './record-picker-actions'
|
|
21
|
+
import type { LucideIcon } from 'lucide-react'
|
|
22
|
+
import { RecordPicker, useLatestSearch } from './record-picker'
|
|
23
|
+
import { recordLabel } from './record-picker-actions'
|
|
24
|
+
import { useRecordPickerDialog } from './record-picker-dialog'
|
|
32
25
|
import { useCan } from './permissions-context'
|
|
33
|
-
import { useApi } from './api-context'
|
|
34
26
|
|
|
35
27
|
/** One searchable option: `value` is the id, `label` the display text. */
|
|
36
28
|
export interface EntitySelectOption {
|
|
@@ -96,13 +88,16 @@ function capabilityNamespace(model: string): string {
|
|
|
96
88
|
return model.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase()
|
|
97
89
|
}
|
|
98
90
|
|
|
91
|
+
/**
|
|
92
|
+
* @deprecated Thin wrapper over {@link RecordPicker}; prefer it for new code.
|
|
93
|
+
*/
|
|
99
94
|
export function EntitySelect({
|
|
100
95
|
model,
|
|
101
96
|
value,
|
|
102
97
|
label,
|
|
103
98
|
onSelect,
|
|
104
99
|
fetcher,
|
|
105
|
-
icon
|
|
100
|
+
icon,
|
|
106
101
|
placeholder = 'Seleccionar…',
|
|
107
102
|
searchPlaceholder = 'Buscar…',
|
|
108
103
|
emptyText = 'Sin resultados',
|
|
@@ -116,197 +111,53 @@ export function EntitySelect({
|
|
|
116
111
|
lockedCreateFields,
|
|
117
112
|
}: EntitySelectProps) {
|
|
118
113
|
const can = useCan()
|
|
119
|
-
const api = useApi()
|
|
120
|
-
|
|
121
114
|
const ns = capabilityNamespace(model)
|
|
122
115
|
const mayCreate = canCreate ?? can(`${ns}.create`)
|
|
123
116
|
const mayEdit = canEdit ?? can(`${ns}.update`)
|
|
124
|
-
const base = endpoint ?? `/data/${model}/me`
|
|
125
|
-
|
|
126
|
-
const [open, setOpen] = useState(false)
|
|
127
|
-
const [dialogOpen, setDialogOpen] = useState(false)
|
|
128
|
-
const [dialogRecordId, setDialogRecordId] = useState<string | undefined>(undefined)
|
|
129
|
-
const [searchTerm, setSearchTerm] = useState('')
|
|
130
|
-
const [results, setResults] = useState<EntitySelectOption[]>([])
|
|
131
|
-
const [isLoading, setIsLoading] = useState(false)
|
|
132
|
-
|
|
133
|
-
const minChars = preload ? 0 : 2
|
|
134
117
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
if (!signal.aborted) setResults([])
|
|
147
|
-
} finally {
|
|
148
|
-
if (!signal.aborted) setIsLoading(false)
|
|
149
|
-
}
|
|
118
|
+
// Read the {id,label} off a saved record and select it (create) or
|
|
119
|
+
// refresh its label (edit).
|
|
120
|
+
const { openCreate, openEdit, dialog } = useRecordPickerDialog({
|
|
121
|
+
model,
|
|
122
|
+
endpoint,
|
|
123
|
+
prefillField: labelField,
|
|
124
|
+
createDefaults,
|
|
125
|
+
lockedCreateFields,
|
|
126
|
+
onSaved: (rec) => {
|
|
127
|
+
const id = rec.id != null ? String(rec.id) : value ?? ''
|
|
128
|
+
onSelect(id, recordLabel(rec, labelField) ?? label ?? id)
|
|
150
129
|
},
|
|
151
|
-
|
|
152
|
-
)
|
|
153
|
-
|
|
154
|
-
useEffect(() => {
|
|
155
|
-
if (!open) return
|
|
156
|
-
const controller = new AbortController()
|
|
157
|
-
const timeout = setTimeout(() => run(searchTerm, controller.signal), 250)
|
|
158
|
-
return () => {
|
|
159
|
-
clearTimeout(timeout)
|
|
160
|
-
controller.abort()
|
|
161
|
-
}
|
|
162
|
-
}, [searchTerm, run, open])
|
|
163
|
-
|
|
164
|
-
const pick = (row: EntitySelectOption) => {
|
|
165
|
-
onSelect(row.value, row.label)
|
|
166
|
-
setOpen(false)
|
|
167
|
-
setSearchTerm('')
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
const clear = (e: React.MouseEvent) => {
|
|
171
|
-
e.stopPropagation()
|
|
172
|
-
onSelect(null, null)
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
const openCreate = () => {
|
|
176
|
-
setOpen(false)
|
|
177
|
-
setDialogRecordId(undefined)
|
|
178
|
-
setDialogOpen(true)
|
|
179
|
-
}
|
|
180
|
-
const openEdit = () => {
|
|
181
|
-
if (!value) return
|
|
182
|
-
setOpen(false)
|
|
183
|
-
setDialogRecordId(value)
|
|
184
|
-
setDialogOpen(true)
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
// Read the {id,label} off a saved record and select it. The transport
|
|
188
|
-
// matches the standard org-scoped CRUD the dynamic modal uses, so create/edit
|
|
189
|
-
// stay consistent with the rest of the app.
|
|
190
|
-
const selectSaved = (rec: Record<string, unknown> | undefined | null) => {
|
|
191
|
-
if (!rec) return
|
|
192
|
-
const id = rec.id != null ? String(rec.id) : value ?? ''
|
|
193
|
-
const lbl =
|
|
194
|
-
(rec[labelField] != null && String(rec[labelField])) ||
|
|
195
|
-
(rec.name != null && String(rec.name)) ||
|
|
196
|
-
label ||
|
|
197
|
-
id
|
|
198
|
-
onSelect(id, lbl)
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
const onCreate = mayCreate ? openCreate : undefined
|
|
202
|
-
const onEdit = mayEdit ? openEdit : undefined
|
|
203
|
-
const joined = hasRecordPickerAction(!!value, onCreate, onEdit)
|
|
130
|
+
})
|
|
131
|
+
const search = useLatestSearch(fetcher)
|
|
204
132
|
|
|
205
133
|
return (
|
|
206
|
-
|
|
207
|
-
<
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
align="start"
|
|
235
|
-
collisionPadding={8}
|
|
236
|
-
style={{ width: 'max(var(--radix-popover-trigger-width), 14rem)', maxWidth: 'calc(100vw - 1rem)' }}
|
|
237
|
-
>
|
|
238
|
-
<Command shouldFilter={false}>
|
|
239
|
-
<CommandInput
|
|
240
|
-
placeholder={searchPlaceholder}
|
|
241
|
-
value={searchTerm}
|
|
242
|
-
onValueChange={setSearchTerm}
|
|
243
|
-
/>
|
|
244
|
-
<CommandList>
|
|
245
|
-
{isLoading && (
|
|
246
|
-
<div className="text-muted-foreground py-4 text-center text-sm">
|
|
247
|
-
Buscando…
|
|
248
|
-
</div>
|
|
249
|
-
)}
|
|
250
|
-
{!isLoading &&
|
|
251
|
-
searchTerm.length >= minChars &&
|
|
252
|
-
results.length === 0 && <div className="text-muted-foreground py-6 text-center text-sm">{emptyText}</div>}
|
|
253
|
-
{!isLoading && results.length > 0 && (
|
|
254
|
-
<CommandGroup className="max-h-64 overflow-auto">
|
|
255
|
-
{results.map((row) => (
|
|
256
|
-
<CommandItem
|
|
257
|
-
key={row.value}
|
|
258
|
-
value={row.value}
|
|
259
|
-
onSelect={() => pick(row)}
|
|
260
|
-
className="flex flex-col items-start gap-0.5"
|
|
261
|
-
>
|
|
262
|
-
<span className="text-sm font-medium">{row.label}</span>
|
|
263
|
-
{row.description && (
|
|
264
|
-
<span className="text-muted-foreground text-xs">
|
|
265
|
-
{row.description}
|
|
266
|
-
</span>
|
|
267
|
-
)}
|
|
268
|
-
</CommandItem>
|
|
269
|
-
))}
|
|
270
|
-
</CommandGroup>
|
|
271
|
-
)}
|
|
272
|
-
{!isLoading && !preload && searchTerm.length < minChars && (
|
|
273
|
-
<div className="text-muted-foreground flex flex-col items-center gap-1 py-6">
|
|
274
|
-
<Search className="size-5" />
|
|
275
|
-
<span className="text-xs">Escribe al menos 2 caracteres</span>
|
|
276
|
-
</div>
|
|
277
|
-
)}
|
|
278
|
-
{onCreate && !isLoading && <PickerCreateItem label={model} onSelect={openCreate} />}
|
|
279
|
-
</CommandList>
|
|
280
|
-
</Command>
|
|
281
|
-
</PopoverContent>
|
|
282
|
-
</Popover>
|
|
283
|
-
|
|
284
|
-
{/* Selected → edit (pencil); empty → create (+). Each gated by perms. */}
|
|
285
|
-
<RecordPickerAction hasValue={!!value} label={model} onCreate={onCreate} onEdit={onEdit} disabled={disabled} />
|
|
286
|
-
|
|
287
|
-
{dialogOpen && (
|
|
288
|
-
<CreateRecordDialog
|
|
289
|
-
modelKey={model}
|
|
290
|
-
open={dialogOpen}
|
|
291
|
-
onOpenChange={setDialogOpen}
|
|
292
|
-
recordId={dialogRecordId}
|
|
293
|
-
endpoint={base}
|
|
294
|
-
defaults={dialogRecordId ? undefined : createDefaults}
|
|
295
|
-
lockedFields={dialogRecordId ? undefined : lockedCreateFields}
|
|
296
|
-
onCreate={async (data) => {
|
|
297
|
-
const res = await api.post(base, data)
|
|
298
|
-
const rec = (res.data?.data ?? res.data) as Record<string, unknown>
|
|
299
|
-
selectSaved(rec)
|
|
300
|
-
return rec.id != null ? { id: String(rec.id) } : undefined
|
|
301
|
-
}}
|
|
302
|
-
onUpdate={async (id, data) => {
|
|
303
|
-
const res = await api.put(`${base}/${id}`, data)
|
|
304
|
-
const rec = (res.data?.data ?? res.data) as Record<string, unknown>
|
|
305
|
-
selectSaved({ id, ...rec })
|
|
306
|
-
return { id: String(id) }
|
|
307
|
-
}}
|
|
308
|
-
/>
|
|
309
|
-
)}
|
|
310
|
-
</div>
|
|
134
|
+
<>
|
|
135
|
+
<RecordPicker<EntitySelectOption>
|
|
136
|
+
slot="entity-select"
|
|
137
|
+
search={search}
|
|
138
|
+
minChars={preload ? 0 : 2}
|
|
139
|
+
minCharsText="Escribe al menos 2 caracteres"
|
|
140
|
+
getKey={(o) => o.value}
|
|
141
|
+
getLabel={(o) => o.label}
|
|
142
|
+
getDescription={(o) => o.description}
|
|
143
|
+
showCheck={false}
|
|
144
|
+
value={value}
|
|
145
|
+
selected={value ? { value, label: label ?? value } : null}
|
|
146
|
+
onSelect={(o) => onSelect(o.value, o.label)}
|
|
147
|
+
onClear={() => onSelect(null, null)}
|
|
148
|
+
icon={icon}
|
|
149
|
+
placeholder={placeholder}
|
|
150
|
+
renderValue={(sel) => (
|
|
151
|
+
<span className={'min-w-0 flex-1 truncate' + (sel ? '' : ' text-muted-foreground')}>{label ?? placeholder}</span>
|
|
152
|
+
)}
|
|
153
|
+
searchPlaceholder={searchPlaceholder}
|
|
154
|
+
emptyText={emptyText}
|
|
155
|
+
entityLabel={model}
|
|
156
|
+
onCreate={mayCreate ? (q) => openCreate(q) : undefined}
|
|
157
|
+
onEdit={mayEdit && value ? () => openEdit(value) : undefined}
|
|
158
|
+
disabled={disabled}
|
|
159
|
+
/>
|
|
160
|
+
{dialog}
|
|
161
|
+
</>
|
|
311
162
|
)
|
|
312
163
|
}
|
package/src/index.ts
CHANGED
|
@@ -475,8 +475,33 @@ 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
|
-
|
|
479
|
-
|
|
478
|
+
// RecordPicker — the ONE "pick a related record" control (search, single /
|
|
479
|
+
// multiple, create / edit, rich options, field / cell, portaled list). Every
|
|
480
|
+
// picker above (DynamicSelectField, EntitySelect, Customer/Product/Vehicle
|
|
481
|
+
// pickers, LineProductCell) is a thin configuration of it.
|
|
482
|
+
export { RecordPicker, useRecordSearch, useLatestSearch, RECORD_PICKER_Z_INDEX } from './record-picker'
|
|
483
|
+
export type {
|
|
484
|
+
RecordPickerProps,
|
|
485
|
+
RecordPickerItemState,
|
|
486
|
+
RecordPickerVariant,
|
|
487
|
+
RecordPickerTrigger,
|
|
488
|
+
UseRecordSearchOptions,
|
|
489
|
+
UseRecordSearchResult,
|
|
490
|
+
} from './record-picker'
|
|
491
|
+
export {
|
|
492
|
+
RecordPickerAction,
|
|
493
|
+
PickerCreateItem,
|
|
494
|
+
JOINED_TRIGGER_CLASS,
|
|
495
|
+
hasRecordPickerAction,
|
|
496
|
+
requestRecordCreate,
|
|
497
|
+
requestRecordEdit,
|
|
498
|
+
recordLabel,
|
|
499
|
+
withSearchPrefill,
|
|
500
|
+
} from './record-picker-actions'
|
|
501
|
+
export type { RecordPickerActionProps, RecordCreateRequest, RecordEditRequest } from './record-picker-actions'
|
|
502
|
+
export { useRecordPickerDialog } from './record-picker-dialog'
|
|
503
|
+
export type { UseRecordPickerDialogOptions, UseRecordPickerDialogResult } from './record-picker-dialog'
|
|
504
|
+
export { OptionLead, OptionThumb } from './record-picker-option'
|
|
480
505
|
export { ViewRecordDialog } from './dialogs/view-record-dialog'
|
|
481
506
|
export type {
|
|
482
507
|
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) {
|