@asteby/metacore-runtime-react 8.0.0 → 9.1.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 +73 -0
- package/dist/column-visibility.d.ts +22 -0
- package/dist/column-visibility.d.ts.map +1 -0
- package/dist/column-visibility.js +40 -0
- package/dist/dynamic-columns.d.ts.map +1 -1
- package/dist/dynamic-columns.js +4 -1
- package/dist/dynamic-form-schema.d.ts +7 -0
- package/dist/dynamic-form-schema.d.ts.map +1 -0
- package/dist/dynamic-form-schema.js +68 -0
- package/dist/dynamic-form.d.ts +2 -0
- package/dist/dynamic-form.d.ts.map +1 -1
- package/dist/dynamic-form.js +28 -9
- package/dist/dynamic-relation-helpers.d.ts +77 -0
- package/dist/dynamic-relation-helpers.d.ts.map +1 -0
- package/dist/dynamic-relation-helpers.js +186 -0
- package/dist/dynamic-relation.d.ts +64 -0
- package/dist/dynamic-relation.d.ts.map +1 -0
- package/dist/dynamic-relation.js +226 -0
- package/dist/dynamic-table.d.ts.map +1 -1
- package/dist/dynamic-table.js +17 -3
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/types.d.ts +33 -0
- package/dist/types.d.ts.map +1 -1
- package/docs/relations.md +290 -0
- package/package.json +9 -3
- package/src/__tests__/column-visibility.test.ts +116 -0
- package/src/__tests__/dynamic-form.test.ts +104 -0
- package/src/__tests__/dynamic-relation.test.ts +293 -0
- package/src/column-visibility.ts +43 -0
- package/src/dynamic-columns.tsx +4 -1
- package/src/dynamic-form-schema.ts +66 -0
- package/src/dynamic-form.tsx +34 -9
- package/src/dynamic-relation-helpers.ts +226 -0
- package/src/dynamic-relation.tsx +497 -0
- package/src/dynamic-table.tsx +20 -2
- package/src/index.ts +14 -0
- package/src/types.ts +49 -0
- package/tsconfig.json +2 -1
- package/vitest.config.ts +8 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,78 @@
|
|
|
1
1
|
# @asteby/metacore-runtime-react
|
|
2
2
|
|
|
3
|
+
## 9.1.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 2e50839: feat(runtime-react): leer `visibility` y `searchable` en metadata de columnas.
|
|
8
|
+
- `ColumnDefinition` tipa los nuevos campos `visibility?` (`"all" | "table" | "modal" | "list"`) y `searchable?` que el kernel ya emite (`manifest.ColumnDef`). Backwards compat: zero-value preserva el comportamiento previo.
|
|
9
|
+
- `<DynamicTable>` ahora oculta del listado las columnas con `visibility === "modal"` (y `"list"`) además del legacy `hidden`. Las columnas sin `visibility` o con `"all" | "table"` siguen visibles.
|
|
10
|
+
- Cuando al menos una columna declara `searchable` el SDK acota el global search a esas columnas vía el nuevo query param `search_columns=<keys>`. Si todas las columnas se opt-out (`searchable: false`), el SDK deja de mandar `search` al backend. Si ninguna columna trae el flag (kernel anterior a v0.8.x), no se cambia nada.
|
|
11
|
+
- Nuevos helpers públicos `isColumnVisibleInTable(col)` y `getSearchableColumnKeys(metadata)` exportados desde el barrel; tests con metadata mock cubren los pasos legacy + opt-in + opt-out total.
|
|
12
|
+
|
|
13
|
+
## 9.0.0
|
|
14
|
+
|
|
15
|
+
### Minor Changes
|
|
16
|
+
|
|
17
|
+
- d51ef45: feat(runtime-react): `DynamicForm` aplica `Validation` (regex/min/max) al schema zod generado y soporta widgets `textarea`/`richtext`/`color`.
|
|
18
|
+
- `ActionFieldDef` extendido con `validation?: FieldValidation` (regex/min/max/custom — espejo del `ValidationRule` del manifest del kernel) y `widget?: FieldWidget | string`.
|
|
19
|
+
- `DynamicForm` ahora deriva un schema zod por field y valida en el submit, mostrando errores inline en lugar del `alert()` previo. Min/max aplica como longitud para strings y como bound para numéricos (mismo dual semantics que el kernel). Regex malformada del manifest se ignora silenciosamente para no tirar el render.
|
|
20
|
+
- Nuevo export `buildZodSchema(fields)` para que callers reutilicen el mismo schema fuera del form.
|
|
21
|
+
- Renderer mapea widgets explícitos a primitivos de `@asteby/metacore-ui`:
|
|
22
|
+
- `textarea` → `Textarea`
|
|
23
|
+
- `richtext` → `Textarea` con `data-widget="richtext"` (puente hasta que aterrice un primitivo MDX/rich; mantiene el contrato sin romper consumers).
|
|
24
|
+
- `color` → `Input type="color"`.
|
|
25
|
+
- Backwards compat: zero-value (sin `validation`/`widget`) preserva el comportamiento previo (widget inferido por `type`, sin reglas de validación más allá de `required`).
|
|
26
|
+
|
|
27
|
+
- 88b176c: feat(runtime-react): `<DynamicRelation kind="many_to_many">` — multi-select sobre la tabla destino, sync transparente contra la tabla pivote (`through`).
|
|
28
|
+
|
|
29
|
+
API mínima:
|
|
30
|
+
|
|
31
|
+
```tsx
|
|
32
|
+
<DynamicRelation
|
|
33
|
+
kind="many_to_many"
|
|
34
|
+
through="org_members" // tabla pivote
|
|
35
|
+
references="users" // tabla destino sobre la que se hace multi-select
|
|
36
|
+
foreignKey="organization_id" // FK del pivot al padre
|
|
37
|
+
parentId={org.id}
|
|
38
|
+
/>
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
- `referencesKey` por default es `${references}_id` (override opcional). Endpoints `/data/${through}` y `/data/${references}` con override por prop si la app expone rutas custom.
|
|
42
|
+
- Lectura: lista pivot rows filtradas por `f_<foreignKey>=eq:<parentId>` (mismo envelope kernel `{success, data, meta}` que `<DynamicTable>`); lista target rows del modelo `references`.
|
|
43
|
+
- Escritura: el `<MultiSelect>` dispara un diff entre la selección previa y la nueva. Cada nuevo target → `POST /data/${through}` con `{[foreignKey]: parentId, [referencesKey]: targetId}`. Cada target removido → `DELETE /data/${through}/<pivotRowId>`.
|
|
44
|
+
- Permisos por prop (`canCreate` controla attach, `canDelete` controla detach — default `true`).
|
|
45
|
+
- Label de cada opción: `displayKey` prop si está; si no se infiere de la metadata (primer column no-id no-hidden); fallback al `id`.
|
|
46
|
+
- Nuevos helpers puros exportados: `buildPivotAttachPayload`, `extractSelectedTargetIds`, `buildPivotRowIndex`, `diffSelection`, `pickOptionLabel`.
|
|
47
|
+
|
|
48
|
+
`kind="one_to_many"` no cambia.
|
|
49
|
+
|
|
50
|
+
- 88b176c: feat(runtime-react): nuevo `<DynamicRelation kind="one_to_many">` — lista inline editable que cuelga del registro padre.
|
|
51
|
+
|
|
52
|
+
API mínima:
|
|
53
|
+
|
|
54
|
+
```tsx
|
|
55
|
+
<DynamicRelation
|
|
56
|
+
kind="one_to_many"
|
|
57
|
+
model="line_items"
|
|
58
|
+
foreignKey="invoice_id"
|
|
59
|
+
parentId={id}
|
|
60
|
+
/>
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
- Lista filas del modelo hijo filtradas por `f_<foreignKey>=eq:<parentId>` (envelope kernel `{success, data, meta}`).
|
|
64
|
+
- Crear/Editar via `<DynamicForm>` derivado del `TableMetadata.columns` del modelo; la FK queda fija al `parentId` y se oculta automáticamente del form y de la lista.
|
|
65
|
+
- Quitar via `DELETE /data/<model>/<id>` con confirm dialog.
|
|
66
|
+
- Permisos por prop (`canCreate` / `canEdit` / `canDelete` — default `true`) y strings traducibles via prop `strings`.
|
|
67
|
+
- Helpers puros exportados (`buildRelationFilterParams`, `buildCreatePayload`, `deriveRelationFormFields`, `relationRowKey`) para que callers reutilicen las convenciones fuera del componente.
|
|
68
|
+
- `kind="many_to_many"` queda stubbed (renderiza `not-implemented`) — sigue como follow-up; la RFC completa vive en `packages/runtime-react/docs/relations.md`.
|
|
69
|
+
- Ejemplo end-to-end en `examples/dynamic-relation-one-to-many/`.
|
|
70
|
+
|
|
71
|
+
### Patch Changes
|
|
72
|
+
|
|
73
|
+
- Updated dependencies [ec9ad56]
|
|
74
|
+
- @asteby/metacore-sdk@2.4.0
|
|
75
|
+
|
|
3
76
|
## 8.0.0
|
|
4
77
|
|
|
5
78
|
### Patch Changes
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { ColumnDefinition, TableMetadata } from './types';
|
|
2
|
+
/**
|
|
3
|
+
* Whether a column should render in a list/index table view.
|
|
4
|
+
*
|
|
5
|
+
* A column is hidden when its `visibility` is scoped away from the table
|
|
6
|
+
* (`'modal'`: only the create/edit dialog; `'list'`: only API payloads) or
|
|
7
|
+
* when the legacy `hidden` boolean is set. Empty / `'all'` / `'table'` keep
|
|
8
|
+
* the column visible — preserving zero-value behaviour for metadata emitted
|
|
9
|
+
* by older kernels that don't set `visibility` at all.
|
|
10
|
+
*/
|
|
11
|
+
export declare function isColumnVisibleInTable(col: ColumnDefinition): boolean;
|
|
12
|
+
/**
|
|
13
|
+
* Returns the keys of columns that opt into the model's full-text search,
|
|
14
|
+
* or `null` when no column declares `searchable` at all.
|
|
15
|
+
*
|
|
16
|
+
* `null` is the legacy signal: the host should NOT narrow the search request
|
|
17
|
+
* (every column participates, matching pre-Searchable kernels). An empty
|
|
18
|
+
* array is meaningful — it means every column has been explicitly opted out
|
|
19
|
+
* and the host should disable the global search input.
|
|
20
|
+
*/
|
|
21
|
+
export declare function getSearchableColumnKeys(metadata: Pick<TableMetadata, 'columns'>): string[] | null;
|
|
22
|
+
//# sourceMappingURL=column-visibility.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"column-visibility.d.ts","sourceRoot":"","sources":["../src/column-visibility.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAAE,gBAAgB,EAAE,aAAa,EAAE,MAAM,SAAS,CAAA;AAE9D;;;;;;;;GAQG;AACH,wBAAgB,sBAAsB,CAAC,GAAG,EAAE,gBAAgB,GAAG,OAAO,CAKrE;AAED;;;;;;;;GAQG;AACH,wBAAgB,uBAAuB,CACnC,QAAQ,EAAE,IAAI,CAAC,aAAa,EAAE,SAAS,CAAC,GACzC,MAAM,EAAE,GAAG,IAAI,CAKjB"}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Pure helpers that map kernel `manifest.ColumnDef` metadata flags
|
|
2
|
+
// (Visibility, Searchable) into client-side decisions:
|
|
3
|
+
// - which columns the dynamic table should render
|
|
4
|
+
// - which column keys are in scope for the global search
|
|
5
|
+
//
|
|
6
|
+
// Kept side-effect free and free of React/UI imports so the same logic can
|
|
7
|
+
// be tested with plain unit tests against mock metadata.
|
|
8
|
+
/**
|
|
9
|
+
* Whether a column should render in a list/index table view.
|
|
10
|
+
*
|
|
11
|
+
* A column is hidden when its `visibility` is scoped away from the table
|
|
12
|
+
* (`'modal'`: only the create/edit dialog; `'list'`: only API payloads) or
|
|
13
|
+
* when the legacy `hidden` boolean is set. Empty / `'all'` / `'table'` keep
|
|
14
|
+
* the column visible — preserving zero-value behaviour for metadata emitted
|
|
15
|
+
* by older kernels that don't set `visibility` at all.
|
|
16
|
+
*/
|
|
17
|
+
export function isColumnVisibleInTable(col) {
|
|
18
|
+
if (col.hidden)
|
|
19
|
+
return false;
|
|
20
|
+
const v = col.visibility;
|
|
21
|
+
if (!v)
|
|
22
|
+
return true;
|
|
23
|
+
return v === 'all' || v === 'table';
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Returns the keys of columns that opt into the model's full-text search,
|
|
27
|
+
* or `null` when no column declares `searchable` at all.
|
|
28
|
+
*
|
|
29
|
+
* `null` is the legacy signal: the host should NOT narrow the search request
|
|
30
|
+
* (every column participates, matching pre-Searchable kernels). An empty
|
|
31
|
+
* array is meaningful — it means every column has been explicitly opted out
|
|
32
|
+
* and the host should disable the global search input.
|
|
33
|
+
*/
|
|
34
|
+
export function getSearchableColumnKeys(metadata) {
|
|
35
|
+
const cols = metadata.columns ?? [];
|
|
36
|
+
const declared = cols.some(c => typeof c.searchable === 'boolean');
|
|
37
|
+
if (!declared)
|
|
38
|
+
return null;
|
|
39
|
+
return cols.filter(c => c.searchable === true).map(c => c.key);
|
|
40
|
+
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"dynamic-columns.d.ts","sourceRoot":"","sources":["../src/dynamic-columns.tsx"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"dynamic-columns.d.ts","sourceRoot":"","sources":["../src/dynamic-columns.tsx"],"names":[],"mappings":"AAsCA,OAAO,KAAK,EAER,iBAAiB,EACpB,MAAM,wBAAwB,CAAA;AAE/B,qEAAqE;AACrE,MAAM,WAAW,qBAAqB;IAClC;;;;OAIG;IACH,WAAW,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,CAAA;IACtC;;;;OAIG;IACH,UAAU,CAAC,EAAE,MAAM,CAAA;CACtB;AAwHD;;;;GAIG;AACH,wBAAgB,4BAA4B,CACxC,OAAO,GAAE,qBAA0B,GACpC,iBAAiB,CAqXnB;AAED;;;;GAIG;AACH,eAAO,MAAM,wBAAwB,EAAE,iBACL,CAAA"}
|
package/dist/dynamic-columns.js
CHANGED
|
@@ -18,6 +18,7 @@ import { DataTableColumnHeader, FilterableColumnHeader, } from '@asteby/metacore
|
|
|
18
18
|
import { generateBadgeStyles, getInitials } from '@asteby/metacore-ui/lib';
|
|
19
19
|
import { OptionsContext } from './options-context';
|
|
20
20
|
import { DynamicIcon } from './dynamic-icon';
|
|
21
|
+
import { isColumnVisibleInTable } from './column-visibility';
|
|
21
22
|
const defaultGetImageUrl = (path) => path;
|
|
22
23
|
const getNestedValue = (obj, path) => path.split('.').reduce((acc, part) => acc && acc[part], obj);
|
|
23
24
|
const lowerFirst = (value) => {
|
|
@@ -123,7 +124,9 @@ export function makeDefaultGetDynamicColumns(helpers = {}) {
|
|
|
123
124
|
},
|
|
124
125
|
];
|
|
125
126
|
metadata.columns.forEach((col) => {
|
|
126
|
-
|
|
127
|
+
// Honors both the legacy `hidden` boolean and the kernel's
|
|
128
|
+
// `visibility` scope (skips `'modal'` and `'list'`).
|
|
129
|
+
if (!isColumnVisibleInTable(col))
|
|
127
130
|
return;
|
|
128
131
|
const translatedLabel = col.label;
|
|
129
132
|
const filterConfig = filterConfigs?.get(col.key);
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import type { ActionFieldDef } from './types';
|
|
3
|
+
export declare function buildZodSchema(fields: ActionFieldDef[]): z.ZodObject<{
|
|
4
|
+
[x: string]: z.ZodType<unknown, unknown, z.core.$ZodTypeInternals<unknown, unknown>>;
|
|
5
|
+
}, z.core.$strip>;
|
|
6
|
+
export declare function resolveWidget(field: ActionFieldDef): string;
|
|
7
|
+
//# sourceMappingURL=dynamic-form-schema.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"dynamic-form-schema.d.ts","sourceRoot":"","sources":["../src/dynamic-form-schema.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,CAAC,EAAmB,MAAM,KAAK,CAAA;AACxC,OAAO,KAAK,EAAE,cAAc,EAAmB,MAAM,SAAS,CAAA;AAM9D,wBAAgB,cAAc,CAAC,MAAM,EAAE,cAAc,EAAE;;kBAMtD;AAuCD,wBAAgB,aAAa,CAAC,KAAK,EAAE,cAAc,GAAG,MAAM,CAU3D"}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Pure schema-building helpers for DynamicForm. Lives in its own module so
|
|
2
|
+
// callers (and unit tests) can use the zod schema without pulling in React or
|
|
3
|
+
// metacore-ui primitives.
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
// Builds a zod object schema from an ActionFieldDef[]. Required fields stay
|
|
6
|
+
// non-empty; optional fields accept undefined / "". Validation rules
|
|
7
|
+
// (regex/min/max) layer on top: for numeric columns they bound the value, for
|
|
8
|
+
// strings they bound length — same dual semantics the kernel uses.
|
|
9
|
+
export function buildZodSchema(fields) {
|
|
10
|
+
const shape = {};
|
|
11
|
+
for (const field of fields) {
|
|
12
|
+
shape[field.key] = fieldToZod(field);
|
|
13
|
+
}
|
|
14
|
+
return z.object(shape);
|
|
15
|
+
}
|
|
16
|
+
function fieldToZod(field) {
|
|
17
|
+
const v = field.validation ?? {};
|
|
18
|
+
const isNumeric = field.type === 'number';
|
|
19
|
+
const isBool = field.type === 'boolean';
|
|
20
|
+
if (isBool) {
|
|
21
|
+
const base = z.boolean();
|
|
22
|
+
return field.required ? base : base.optional();
|
|
23
|
+
}
|
|
24
|
+
if (isNumeric) {
|
|
25
|
+
let s = z.coerce.number();
|
|
26
|
+
if (typeof v.min === 'number')
|
|
27
|
+
s = s.min(v.min, `Debe ser ≥ ${v.min}`);
|
|
28
|
+
if (typeof v.max === 'number')
|
|
29
|
+
s = s.max(v.max, `Debe ser ≤ ${v.max}`);
|
|
30
|
+
if (field.required)
|
|
31
|
+
return s;
|
|
32
|
+
return z.preprocess((val) => (val === '' || val == null ? undefined : val), s.optional());
|
|
33
|
+
}
|
|
34
|
+
let s = z.string();
|
|
35
|
+
if (typeof v.min === 'number')
|
|
36
|
+
s = s.min(v.min, `Mínimo ${v.min} caracteres`);
|
|
37
|
+
if (typeof v.max === 'number')
|
|
38
|
+
s = s.max(v.max, `Máximo ${v.max} caracteres`);
|
|
39
|
+
if (v.regex) {
|
|
40
|
+
try {
|
|
41
|
+
s = s.regex(new RegExp(v.regex), `Formato inválido`);
|
|
42
|
+
}
|
|
43
|
+
catch { /* malformed regex from manifest — skip rather than throw at render time */ }
|
|
44
|
+
}
|
|
45
|
+
if (field.type === 'email')
|
|
46
|
+
s = s.email('Email inválido');
|
|
47
|
+
if (field.type === 'url')
|
|
48
|
+
s = s.url('URL inválida');
|
|
49
|
+
if (field.required) {
|
|
50
|
+
return s.min(Math.max(typeof v.min === 'number' ? v.min : 1, 1), `${field.label} es requerido`);
|
|
51
|
+
}
|
|
52
|
+
return s.optional().or(z.literal(''));
|
|
53
|
+
}
|
|
54
|
+
// Resolves the renderer widget for a field. Explicit `widget` wins; otherwise
|
|
55
|
+
// it is inferred from `type` to preserve the legacy behaviour (zero-value =
|
|
56
|
+
// same render as before).
|
|
57
|
+
export function resolveWidget(field) {
|
|
58
|
+
if (field.widget)
|
|
59
|
+
return field.widget;
|
|
60
|
+
switch (field.type) {
|
|
61
|
+
case 'textarea': return 'textarea';
|
|
62
|
+
case 'select': return 'select';
|
|
63
|
+
case 'boolean': return 'switch';
|
|
64
|
+
case 'number': return 'number';
|
|
65
|
+
case 'date': return 'date';
|
|
66
|
+
default: return 'text';
|
|
67
|
+
}
|
|
68
|
+
}
|
package/dist/dynamic-form.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"dynamic-form.d.ts","sourceRoot":"","sources":["../src/dynamic-form.tsx"],"names":[],"mappings":"AAgBA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,SAAS,CAAA;
|
|
1
|
+
{"version":3,"file":"dynamic-form.d.ts","sourceRoot":"","sources":["../src/dynamic-form.tsx"],"names":[],"mappings":"AAgBA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,SAAS,CAAA;AAC7C,OAAO,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAA;AAErE,OAAO,EAAE,cAAc,EAAE,aAAa,EAAE,CAAA;AAExC,MAAM,WAAW,gBAAgB;IAC7B,MAAM,EAAE,cAAc,EAAE,CAAA;IACxB,aAAa,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;IACnC,QAAQ,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IAC/D,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAA;IACrB,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,QAAQ,CAAC,EAAE,OAAO,CAAA;CACrB;AAED,wBAAgB,WAAW,CAAC,EACxB,MAAM,EACN,aAAa,EACb,QAAQ,EACR,QAAQ,EACR,WAAuB,EACvB,WAAwB,EACxB,QAAgB,GACnB,EAAE,gBAAgB,2CA4DlB"}
|
package/dist/dynamic-form.js
CHANGED
|
@@ -2,44 +2,63 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
// Minimal standalone DynamicForm. Factored from the dynamic-record-dialog
|
|
3
3
|
// pattern + ActionFieldDef renderer so callers can reuse the form layout
|
|
4
4
|
// outside the full record-edit modal.
|
|
5
|
-
import { useEffect, useState } from 'react';
|
|
5
|
+
import { useEffect, useMemo, useState } from 'react';
|
|
6
6
|
import { Input, Textarea, Label, Switch, Button, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@asteby/metacore-ui/primitives';
|
|
7
|
+
import { buildZodSchema, resolveWidget } from './dynamic-form-schema';
|
|
8
|
+
export { buildZodSchema, resolveWidget };
|
|
7
9
|
export function DynamicForm({ fields, initialValues, onSubmit, onCancel, submitLabel = 'Guardar', cancelLabel = 'Cancelar', disabled = false, }) {
|
|
8
10
|
const [values, setValues] = useState({});
|
|
11
|
+
const [errors, setErrors] = useState({});
|
|
9
12
|
const [submitting, setSubmitting] = useState(false);
|
|
13
|
+
const schema = useMemo(() => buildZodSchema(fields), [fields]);
|
|
10
14
|
useEffect(() => {
|
|
11
15
|
const defaults = {};
|
|
12
16
|
for (const f of fields) {
|
|
13
17
|
defaults[f.key] = initialValues?.[f.key] ?? f.defaultValue ?? (f.type === 'boolean' ? false : '');
|
|
14
18
|
}
|
|
15
19
|
setValues(defaults);
|
|
20
|
+
setErrors({});
|
|
16
21
|
}, [fields, initialValues]);
|
|
17
22
|
const update = (k, v) => setValues((prev) => ({ ...prev, [k]: v }));
|
|
18
23
|
const handleSubmit = async (e) => {
|
|
19
24
|
e.preventDefault();
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
25
|
+
const result = schema.safeParse(values);
|
|
26
|
+
if (!result.success) {
|
|
27
|
+
const next = {};
|
|
28
|
+
for (const issue of result.error.issues) {
|
|
29
|
+
const key = issue.path[0];
|
|
30
|
+
if (typeof key === 'string' && !next[key])
|
|
31
|
+
next[key] = issue.message;
|
|
24
32
|
}
|
|
33
|
+
setErrors(next);
|
|
34
|
+
return;
|
|
25
35
|
}
|
|
36
|
+
setErrors({});
|
|
26
37
|
setSubmitting(true);
|
|
27
38
|
try {
|
|
28
|
-
await onSubmit(
|
|
39
|
+
await onSubmit(result.data);
|
|
29
40
|
}
|
|
30
41
|
finally {
|
|
31
42
|
setSubmitting(false);
|
|
32
43
|
}
|
|
33
44
|
};
|
|
34
|
-
return (_jsxs("form", { onSubmit: handleSubmit, className: "grid gap-4", children: [fields.map((field) => (_jsxs("div", { className: "grid gap-2", children: [_jsxs(Label, { htmlFor: field.key, children: [field.label, field.required && _jsx("span", { className: "text-red-500 ml-1", children: "*" })] }), renderField(field, values[field.key], (v) => update(field.key, v))] }, field.key))), _jsxs("div", { className: "flex justify-end gap-2 pt-2", children: [onCancel && (_jsx(Button, { type: "button", variant: "outline", onClick: onCancel, disabled: submitting || disabled, children: cancelLabel })), _jsx(Button, { type: "submit", disabled: submitting || disabled, children: submitLabel })] })] }));
|
|
45
|
+
return (_jsxs("form", { onSubmit: handleSubmit, className: "grid gap-4", children: [fields.map((field) => (_jsxs("div", { className: "grid gap-2", children: [_jsxs(Label, { htmlFor: field.key, children: [field.label, field.required && _jsx("span", { className: "text-red-500 ml-1", children: "*" })] }), renderField(field, values[field.key], (v) => update(field.key, v)), errors[field.key] && (_jsx("span", { className: "text-red-500 text-sm", role: "alert", children: errors[field.key] }))] }, field.key))), _jsxs("div", { className: "flex justify-end gap-2 pt-2", children: [onCancel && (_jsx(Button, { type: "button", variant: "outline", onClick: onCancel, disabled: submitting || disabled, children: cancelLabel })), _jsx(Button, { type: "submit", disabled: submitting || disabled, children: submitLabel })] })] }));
|
|
35
46
|
}
|
|
36
47
|
function renderField(field, value, onChange) {
|
|
37
|
-
|
|
48
|
+
const widget = resolveWidget(field);
|
|
49
|
+
switch (widget) {
|
|
38
50
|
case 'textarea':
|
|
39
51
|
return _jsx(Textarea, { id: field.key, value: value || '', onChange: (e) => onChange(e.target.value), placeholder: field.placeholder });
|
|
52
|
+
case 'richtext':
|
|
53
|
+
// Until a real rich-text primitive lands in metacore-ui this maps
|
|
54
|
+
// to a tagged Textarea. The data attribute lets app-level theming
|
|
55
|
+
// / future MDX editor pick it up without breaking the contract.
|
|
56
|
+
return _jsx(Textarea, { id: field.key, "data-widget": "richtext", value: value || '', onChange: (e) => onChange(e.target.value), placeholder: field.placeholder });
|
|
57
|
+
case 'color':
|
|
58
|
+
return _jsx(Input, { id: field.key, type: "color", value: value || '#000000', onChange: (e) => onChange(e.target.value) });
|
|
40
59
|
case 'select':
|
|
41
60
|
return (_jsxs(Select, { value: value || '', onValueChange: onChange, children: [_jsx(SelectTrigger, { children: _jsx(SelectValue, { placeholder: field.placeholder || 'Seleccionar...' }) }), _jsx(SelectContent, { children: field.options?.map((opt) => _jsx(SelectItem, { value: opt.value, children: opt.label }, opt.value)) })] }));
|
|
42
|
-
case '
|
|
61
|
+
case 'switch':
|
|
43
62
|
return _jsx(Switch, { id: field.key, checked: !!value, onCheckedChange: onChange });
|
|
44
63
|
case 'number':
|
|
45
64
|
return _jsx(Input, { id: field.key, type: "number", value: value ?? '', onChange: (e) => onChange(e.target.valueAsNumber || ''), placeholder: field.placeholder });
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { ActionFieldDef, ColumnDefinition, TableMetadata } from './types';
|
|
2
|
+
export type DynamicRelationKind = 'one_to_many' | 'many_to_many';
|
|
3
|
+
export interface PivotRowLike {
|
|
4
|
+
id?: string | number | null;
|
|
5
|
+
[k: string]: unknown;
|
|
6
|
+
}
|
|
7
|
+
export interface TargetRowLike {
|
|
8
|
+
id?: string | number | null;
|
|
9
|
+
[k: string]: unknown;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Builds the query params used by `<DynamicRelation kind="one_to_many">` to
|
|
13
|
+
* scope a child list to a single parent record. Mirrors the
|
|
14
|
+
* `f_<column>=eq:<value>` convention enforced by `query/params.go` in the
|
|
15
|
+
* kernel.
|
|
16
|
+
*/
|
|
17
|
+
export declare function buildRelationFilterParams(foreignKey: string, parentId: string | number): Record<string, string>;
|
|
18
|
+
/**
|
|
19
|
+
* Builds the POST body for creating a child row. The foreign key is forced to
|
|
20
|
+
* `parentId` regardless of what the form returned — the inline form hides the
|
|
21
|
+
* FK input but a misbehaving caller (or a manual override) should not be able
|
|
22
|
+
* to redirect the row to a different parent.
|
|
23
|
+
*/
|
|
24
|
+
export declare function buildCreatePayload(foreignKey: string, parentId: string | number, formValues: Record<string, any>): Record<string, any>;
|
|
25
|
+
/**
|
|
26
|
+
* Maps a `TableMetadata.columns` shape into `ActionFieldDef[]` so the inline
|
|
27
|
+
* form can be rendered with `<DynamicForm>`. Excludes the foreign-key column
|
|
28
|
+
* (already fixed to parentId) and any column flagged `hidden`. Falls back to
|
|
29
|
+
* sensible widget hints derived from `ColumnDefinition.type`.
|
|
30
|
+
*/
|
|
31
|
+
export declare function deriveRelationFormFields(metadata: Pick<TableMetadata, 'columns'> | null | undefined, foreignKey: string): ActionFieldDef[];
|
|
32
|
+
/**
|
|
33
|
+
* Stable id for relation rows. Falls back to a synthetic
|
|
34
|
+
* `__rel-<foreignKey>-<index>` when the row has no id — keeps React keys
|
|
35
|
+
* stable and lets optimistic creates render before the backend assigns an id.
|
|
36
|
+
*/
|
|
37
|
+
export declare function relationRowKey(row: {
|
|
38
|
+
id?: string | number | null;
|
|
39
|
+
} | undefined, index: number, foreignKey: string): string;
|
|
40
|
+
/**
|
|
41
|
+
* Builds the POST body for attaching a target row to the parent through the
|
|
42
|
+
* pivot table. Both FKs are required: `foreignKey -> parentId` (pivot side)
|
|
43
|
+
* and `referencesKey -> targetId` (target side). Extra pivot fields can be
|
|
44
|
+
* passed via `extra` (e.g. `role`, `position`); never override the two FKs.
|
|
45
|
+
*/
|
|
46
|
+
export declare function buildPivotAttachPayload(foreignKey: string, parentId: string | number, referencesKey: string, targetId: string | number, extra?: Record<string, any>): Record<string, any>;
|
|
47
|
+
/**
|
|
48
|
+
* From a list of pivot rows, returns the set of currently-attached target ids
|
|
49
|
+
* coerced to string (the form expected by `<MultiSelect>` `selected`).
|
|
50
|
+
* Skips rows missing the FK (defensive — the kernel should never return them).
|
|
51
|
+
*/
|
|
52
|
+
export declare function extractSelectedTargetIds(pivotRows: ReadonlyArray<PivotRowLike> | null | undefined, referencesKey: string): string[];
|
|
53
|
+
/**
|
|
54
|
+
* Builds a `targetId -> pivotRowId` lookup so detach can DELETE the pivot row
|
|
55
|
+
* by its own id without re-fetching. When several pivot rows point at the
|
|
56
|
+
* same target (shouldn't happen with a unique constraint but the kernel does
|
|
57
|
+
* not guarantee it), the *last* one wins — detach will only drop one at a
|
|
58
|
+
* time, the next render will surface the leftover.
|
|
59
|
+
*/
|
|
60
|
+
export declare function buildPivotRowIndex(pivotRows: ReadonlyArray<PivotRowLike> | null | undefined, referencesKey: string): Map<string, string | number>;
|
|
61
|
+
/**
|
|
62
|
+
* Diffs the previous selection against the next one and returns the work to
|
|
63
|
+
* apply: target ids that need a POST (toAdd) and ids that need a DELETE
|
|
64
|
+
* (toRemove). Both arrays are deduped and order-stable to make tests
|
|
65
|
+
* deterministic.
|
|
66
|
+
*/
|
|
67
|
+
export declare function diffSelection(prev: ReadonlyArray<string>, next: ReadonlyArray<string>): {
|
|
68
|
+
toAdd: string[];
|
|
69
|
+
toRemove: string[];
|
|
70
|
+
};
|
|
71
|
+
/**
|
|
72
|
+
* Picks a human-readable label for a target row. Tries `displayKey` first,
|
|
73
|
+
* then walks the metadata columns in order looking for the first non-id /
|
|
74
|
+
* non-FK string-ish value. Falls back to the row id, then to '—'.
|
|
75
|
+
*/
|
|
76
|
+
export declare function pickOptionLabel(row: TargetRowLike | null | undefined, displayKey: string | undefined, columns: ReadonlyArray<ColumnDefinition> | null | undefined): string;
|
|
77
|
+
//# sourceMappingURL=dynamic-relation-helpers.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"dynamic-relation-helpers.d.ts","sourceRoot":"","sources":["../src/dynamic-relation-helpers.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,cAAc,EAAE,gBAAgB,EAAE,aAAa,EAAE,MAAM,SAAS,CAAA;AAE9E,MAAM,MAAM,mBAAmB,GAAG,aAAa,GAAG,cAAc,CAAA;AAEhE,MAAM,WAAW,YAAY;IACzB,EAAE,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAAA;IAC3B,CAAC,CAAC,EAAE,MAAM,GAAG,OAAO,CAAA;CACvB;AAED,MAAM,WAAW,aAAa;IAC1B,EAAE,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAAA;IAC3B,CAAC,CAAC,EAAE,MAAM,GAAG,OAAO,CAAA;CACvB;AAED;;;;;GAKG;AACH,wBAAgB,yBAAyB,CACrC,UAAU,EAAE,MAAM,EAClB,QAAQ,EAAE,MAAM,GAAG,MAAM,GAC1B,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAMxB;AAED;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAC9B,UAAU,EAAE,MAAM,EAClB,QAAQ,EAAE,MAAM,GAAG,MAAM,EACzB,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAChC,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAGrB;AAED;;;;;GAKG;AACH,wBAAgB,wBAAwB,CACpC,QAAQ,EAAE,IAAI,CAAC,aAAa,EAAE,SAAS,CAAC,GAAG,IAAI,GAAG,SAAS,EAC3D,UAAU,EAAE,MAAM,GACnB,cAAc,EAAE,CAelB;AAcD;;;;GAIG;AACH,wBAAgB,cAAc,CAC1B,GAAG,EAAE;IAAE,EAAE,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAAA;CAAE,GAAG,SAAS,EAChD,KAAK,EAAE,MAAM,EACb,UAAU,EAAE,MAAM,GACnB,MAAM,CAKR;AAMD;;;;;GAKG;AACH,wBAAgB,uBAAuB,CACnC,UAAU,EAAE,MAAM,EAClB,QAAQ,EAAE,MAAM,GAAG,MAAM,EACzB,aAAa,EAAE,MAAM,EACrB,QAAQ,EAAE,MAAM,GAAG,MAAM,EACzB,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAC5B,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAcrB;AAED;;;;GAIG;AACH,wBAAgB,wBAAwB,CACpC,SAAS,EAAE,aAAa,CAAC,YAAY,CAAC,GAAG,IAAI,GAAG,SAAS,EACzD,aAAa,EAAE,MAAM,GACtB,MAAM,EAAE,CASV;AAED;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAC9B,SAAS,EAAE,aAAa,CAAC,YAAY,CAAC,GAAG,IAAI,GAAG,SAAS,EACzD,aAAa,EAAE,MAAM,GACtB,GAAG,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAAC,CAU9B;AAED;;;;;GAKG;AACH,wBAAgB,aAAa,CACzB,IAAI,EAAE,aAAa,CAAC,MAAM,CAAC,EAC3B,IAAI,EAAE,aAAa,CAAC,MAAM,CAAC,GAC5B;IAAE,KAAK,EAAE,MAAM,EAAE,CAAC;IAAC,QAAQ,EAAE,MAAM,EAAE,CAAA;CAAE,CAYzC;AAED;;;;GAIG;AACH,wBAAgB,eAAe,CAC3B,GAAG,EAAE,aAAa,GAAG,IAAI,GAAG,SAAS,EACrC,UAAU,EAAE,MAAM,GAAG,SAAS,EAC9B,OAAO,EAAE,aAAa,CAAC,gBAAgB,CAAC,GAAG,IAAI,GAAG,SAAS,GAC5D,MAAM,CAiBR"}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builds the query params used by `<DynamicRelation kind="one_to_many">` to
|
|
3
|
+
* scope a child list to a single parent record. Mirrors the
|
|
4
|
+
* `f_<column>=eq:<value>` convention enforced by `query/params.go` in the
|
|
5
|
+
* kernel.
|
|
6
|
+
*/
|
|
7
|
+
export function buildRelationFilterParams(foreignKey, parentId) {
|
|
8
|
+
if (!foreignKey)
|
|
9
|
+
throw new Error('foreignKey requerido');
|
|
10
|
+
if (parentId === undefined || parentId === null || parentId === '') {
|
|
11
|
+
throw new Error('parentId requerido');
|
|
12
|
+
}
|
|
13
|
+
return { [`f_${foreignKey}`]: `eq:${String(parentId)}` };
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Builds the POST body for creating a child row. The foreign key is forced to
|
|
17
|
+
* `parentId` regardless of what the form returned — the inline form hides the
|
|
18
|
+
* FK input but a misbehaving caller (or a manual override) should not be able
|
|
19
|
+
* to redirect the row to a different parent.
|
|
20
|
+
*/
|
|
21
|
+
export function buildCreatePayload(foreignKey, parentId, formValues) {
|
|
22
|
+
if (!foreignKey)
|
|
23
|
+
throw new Error('foreignKey requerido');
|
|
24
|
+
return { ...formValues, [foreignKey]: parentId };
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Maps a `TableMetadata.columns` shape into `ActionFieldDef[]` so the inline
|
|
28
|
+
* form can be rendered with `<DynamicForm>`. Excludes the foreign-key column
|
|
29
|
+
* (already fixed to parentId) and any column flagged `hidden`. Falls back to
|
|
30
|
+
* sensible widget hints derived from `ColumnDefinition.type`.
|
|
31
|
+
*/
|
|
32
|
+
export function deriveRelationFormFields(metadata, foreignKey) {
|
|
33
|
+
if (!metadata?.columns)
|
|
34
|
+
return [];
|
|
35
|
+
const out = [];
|
|
36
|
+
for (const col of metadata.columns) {
|
|
37
|
+
if (col.key === foreignKey)
|
|
38
|
+
continue;
|
|
39
|
+
if (col.hidden)
|
|
40
|
+
continue;
|
|
41
|
+
out.push({
|
|
42
|
+
key: col.key,
|
|
43
|
+
label: col.label,
|
|
44
|
+
type: columnTypeToFieldType(col),
|
|
45
|
+
required: false,
|
|
46
|
+
options: col.options?.map(o => ({ value: String(o.value), label: o.label })),
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
function columnTypeToFieldType(col) {
|
|
52
|
+
switch (col.type) {
|
|
53
|
+
case 'number': return 'number';
|
|
54
|
+
case 'boolean': return 'boolean';
|
|
55
|
+
case 'date': return 'date';
|
|
56
|
+
case 'select': return 'select';
|
|
57
|
+
case 'text':
|
|
58
|
+
default:
|
|
59
|
+
return 'string';
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Stable id for relation rows. Falls back to a synthetic
|
|
64
|
+
* `__rel-<foreignKey>-<index>` when the row has no id — keeps React keys
|
|
65
|
+
* stable and lets optimistic creates render before the backend assigns an id.
|
|
66
|
+
*/
|
|
67
|
+
export function relationRowKey(row, index, foreignKey) {
|
|
68
|
+
if (row && row.id !== undefined && row.id !== null && row.id !== '') {
|
|
69
|
+
return String(row.id);
|
|
70
|
+
}
|
|
71
|
+
return `__rel-${foreignKey}-${index}`;
|
|
72
|
+
}
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
// many_to_many helpers
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
/**
|
|
77
|
+
* Builds the POST body for attaching a target row to the parent through the
|
|
78
|
+
* pivot table. Both FKs are required: `foreignKey -> parentId` (pivot side)
|
|
79
|
+
* and `referencesKey -> targetId` (target side). Extra pivot fields can be
|
|
80
|
+
* passed via `extra` (e.g. `role`, `position`); never override the two FKs.
|
|
81
|
+
*/
|
|
82
|
+
export function buildPivotAttachPayload(foreignKey, parentId, referencesKey, targetId, extra) {
|
|
83
|
+
if (!foreignKey)
|
|
84
|
+
throw new Error('foreignKey requerido');
|
|
85
|
+
if (!referencesKey)
|
|
86
|
+
throw new Error('referencesKey requerido');
|
|
87
|
+
if (parentId === undefined || parentId === null || parentId === '') {
|
|
88
|
+
throw new Error('parentId requerido');
|
|
89
|
+
}
|
|
90
|
+
if (targetId === undefined || targetId === null || targetId === '') {
|
|
91
|
+
throw new Error('targetId requerido');
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
...(extra || {}),
|
|
95
|
+
[foreignKey]: parentId,
|
|
96
|
+
[referencesKey]: targetId,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* From a list of pivot rows, returns the set of currently-attached target ids
|
|
101
|
+
* coerced to string (the form expected by `<MultiSelect>` `selected`).
|
|
102
|
+
* Skips rows missing the FK (defensive — the kernel should never return them).
|
|
103
|
+
*/
|
|
104
|
+
export function extractSelectedTargetIds(pivotRows, referencesKey) {
|
|
105
|
+
if (!pivotRows || !referencesKey)
|
|
106
|
+
return [];
|
|
107
|
+
const out = [];
|
|
108
|
+
for (const row of pivotRows) {
|
|
109
|
+
const v = row[referencesKey];
|
|
110
|
+
if (v === undefined || v === null || v === '')
|
|
111
|
+
continue;
|
|
112
|
+
out.push(String(v));
|
|
113
|
+
}
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Builds a `targetId -> pivotRowId` lookup so detach can DELETE the pivot row
|
|
118
|
+
* by its own id without re-fetching. When several pivot rows point at the
|
|
119
|
+
* same target (shouldn't happen with a unique constraint but the kernel does
|
|
120
|
+
* not guarantee it), the *last* one wins — detach will only drop one at a
|
|
121
|
+
* time, the next render will surface the leftover.
|
|
122
|
+
*/
|
|
123
|
+
export function buildPivotRowIndex(pivotRows, referencesKey) {
|
|
124
|
+
const map = new Map();
|
|
125
|
+
if (!pivotRows || !referencesKey)
|
|
126
|
+
return map;
|
|
127
|
+
for (const row of pivotRows) {
|
|
128
|
+
const target = row[referencesKey];
|
|
129
|
+
if (target === undefined || target === null || target === '')
|
|
130
|
+
continue;
|
|
131
|
+
if (row.id === undefined || row.id === null || row.id === '')
|
|
132
|
+
continue;
|
|
133
|
+
map.set(String(target), row.id);
|
|
134
|
+
}
|
|
135
|
+
return map;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Diffs the previous selection against the next one and returns the work to
|
|
139
|
+
* apply: target ids that need a POST (toAdd) and ids that need a DELETE
|
|
140
|
+
* (toRemove). Both arrays are deduped and order-stable to make tests
|
|
141
|
+
* deterministic.
|
|
142
|
+
*/
|
|
143
|
+
export function diffSelection(prev, next) {
|
|
144
|
+
const prevSet = new Set(prev);
|
|
145
|
+
const nextSet = new Set(next);
|
|
146
|
+
const toAdd = [];
|
|
147
|
+
for (const id of next) {
|
|
148
|
+
if (!prevSet.has(id) && !toAdd.includes(id))
|
|
149
|
+
toAdd.push(id);
|
|
150
|
+
}
|
|
151
|
+
const toRemove = [];
|
|
152
|
+
for (const id of prev) {
|
|
153
|
+
if (!nextSet.has(id) && !toRemove.includes(id))
|
|
154
|
+
toRemove.push(id);
|
|
155
|
+
}
|
|
156
|
+
return { toAdd, toRemove };
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Picks a human-readable label for a target row. Tries `displayKey` first,
|
|
160
|
+
* then walks the metadata columns in order looking for the first non-id /
|
|
161
|
+
* non-FK string-ish value. Falls back to the row id, then to '—'.
|
|
162
|
+
*/
|
|
163
|
+
export function pickOptionLabel(row, displayKey, columns) {
|
|
164
|
+
if (!row)
|
|
165
|
+
return '—';
|
|
166
|
+
if (displayKey) {
|
|
167
|
+
const v = row[displayKey];
|
|
168
|
+
if (v !== undefined && v !== null && v !== '')
|
|
169
|
+
return String(v);
|
|
170
|
+
}
|
|
171
|
+
if (columns) {
|
|
172
|
+
for (const col of columns) {
|
|
173
|
+
if (col.key === 'id' || col.hidden)
|
|
174
|
+
continue;
|
|
175
|
+
const v = row[col.key];
|
|
176
|
+
if (v === undefined || v === null || v === '')
|
|
177
|
+
continue;
|
|
178
|
+
if (typeof v === 'object')
|
|
179
|
+
continue;
|
|
180
|
+
return String(v);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
if (row.id !== undefined && row.id !== null && row.id !== '')
|
|
184
|
+
return String(row.id);
|
|
185
|
+
return '—';
|
|
186
|
+
}
|