@asteby/metacore-runtime-react 9.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 CHANGED
@@ -1,5 +1,15 @@
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
+
3
13
  ## 9.0.0
4
14
 
5
15
  ### Minor 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":"AAqCA,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,CAmXnB;AAED;;;;GAIG;AACH,eAAO,MAAM,wBAAwB,EAAE,iBACL,CAAA"}
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"}
@@ -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
- if (col.hidden)
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);
@@ -1 +1 @@
1
- {"version":3,"file":"dynamic-table.d.ts","sourceRoot":"","sources":["../src/dynamic-table.tsx"],"names":[],"mappings":"AAiBA,OAAO,EAKH,KAAK,SAAS,EAajB,MAAM,uBAAuB,CAAA;AA+B9B,OAAO,KAAK,EAAsB,iBAAiB,EAAE,MAAM,wBAAwB,CAAA;AASnF,UAAU,iBAAiB;IACvB,KAAK,EAAE,MAAM,CAAA;IACb,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,aAAa,CAAC,EAAE,OAAO,CAAA;IACvB,aAAa,CAAC,EAAE,MAAM,EAAE,CAAA;IACxB,QAAQ,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,GAAG,KAAK,IAAI,CAAA;IAC7C,cAAc,CAAC,EAAE,GAAG,CAAA;IACpB,cAAc,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;IACpC,YAAY,CAAC,EAAE,SAAS,CAAC,GAAG,CAAC,EAAE,CAAA;IAC/B;;;;;OAKG;IACH,iBAAiB,CAAC,EAAE,iBAAiB,CAAA;CACxC;AAED,wBAAgB,YAAY,CAAC,EACzB,KAAK,EACL,QAAQ,EACR,aAAoB,EACpB,aAAkB,EAClB,QAAQ,EACR,cAAc,EACd,cAAc,EACd,YAAiB,EACjB,iBAA4C,GAC/C,EAAE,iBAAiB,2CA8rBnB"}
1
+ {"version":3,"file":"dynamic-table.d.ts","sourceRoot":"","sources":["../src/dynamic-table.tsx"],"names":[],"mappings":"AAiBA,OAAO,EAKH,KAAK,SAAS,EAajB,MAAM,uBAAuB,CAAA;AA+B9B,OAAO,KAAK,EAAsB,iBAAiB,EAAE,MAAM,wBAAwB,CAAA;AAUnF,UAAU,iBAAiB;IACvB,KAAK,EAAE,MAAM,CAAA;IACb,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,aAAa,CAAC,EAAE,OAAO,CAAA;IACvB,aAAa,CAAC,EAAE,MAAM,EAAE,CAAA;IACxB,QAAQ,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,GAAG,KAAK,IAAI,CAAA;IAC7C,cAAc,CAAC,EAAE,GAAG,CAAA;IACpB,cAAc,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;IACpC,YAAY,CAAC,EAAE,SAAS,CAAC,GAAG,CAAC,EAAE,CAAA;IAC/B;;;;;OAKG;IACH,iBAAiB,CAAC,EAAE,iBAAiB,CAAA;CACxC;AAED,wBAAgB,YAAY,CAAC,EACzB,KAAK,EACL,QAAQ,EACR,aAAoB,EACpB,aAAkB,EAClB,QAAQ,EACR,cAAc,EACd,cAAc,EACd,YAAiB,EACjB,iBAA4C,GAC/C,EAAE,iBAAiB,2CA+sBnB"}
@@ -27,6 +27,7 @@ import { useApi, useCurrentBranch } from './api-context';
27
27
  import { defaultGetDynamicColumns } from './dynamic-columns';
28
28
  import { OptionsContext } from './options-context';
29
29
  import { ActionModalDispatcher } from './action-modal-dispatcher';
30
+ import { getSearchableColumnKeys } from './column-visibility';
30
31
  import { DynamicRecordDialog } from './dialogs/dynamic-record';
31
32
  import { ExportDialog } from './dialogs/export';
32
33
  import { ImportDialog } from './dialogs/import';
@@ -253,14 +254,27 @@ export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColu
253
254
  };
254
255
  initMetadataAndOptions();
255
256
  }, [model]); // eslint-disable-line react-hooks/exhaustive-deps
257
+ // Derived from `metadata.columns[].searchable`. `null` means the kernel
258
+ // didn't emit the flag for any column → preserve legacy "search every
259
+ // column" behaviour by not narrowing the request. An empty array means
260
+ // every column was explicitly opted out → skip sending `search` at all.
261
+ const searchableKeys = useMemo(() => (metadata ? getSearchableColumnKeys(metadata) : null), [metadata]);
256
262
  const buildFilterParams = useCallback(() => {
257
263
  const params = {};
258
264
  if (sorting.length > 0) {
259
265
  params.sortBy = sorting[0].id;
260
266
  params.order = sorting[0].desc ? 'desc' : 'asc';
261
267
  }
262
- if (globalFilter)
263
- params.search = globalFilter;
268
+ if (globalFilter) {
269
+ if (searchableKeys === null) {
270
+ params.search = globalFilter;
271
+ }
272
+ else if (searchableKeys.length > 0) {
273
+ params.search = globalFilter;
274
+ params.search_columns = searchableKeys.join(',');
275
+ }
276
+ // searchableKeys === [] → drop the search request entirely
277
+ }
264
278
  columnFilters.forEach((filter) => { params[`f_${filter.id}`] = filter.value; });
265
279
  if (defaultFilters)
266
280
  Object.entries(defaultFilters).forEach(([key, value]) => { params[`f_${key}`] = value; });
@@ -286,7 +300,7 @@ export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColu
286
300
  params['f_created_at'] = `${startDate}_${endDate}`;
287
301
  }
288
302
  return params;
289
- }, [sorting, globalFilter, columnFilters, defaultFilters, dynamicFilters, dateRange]);
303
+ }, [sorting, globalFilter, columnFilters, defaultFilters, dynamicFilters, dateRange, searchableKeys]);
290
304
  const hasActiveFilters = useMemo(() => {
291
305
  if (globalFilter)
292
306
  return true;
package/dist/index.d.ts CHANGED
@@ -19,4 +19,5 @@ export { ImportDialog } from './dialogs/import';
19
19
  export { DynamicCRUDPage, type DynamicCRUDPageProps, type DynamicCRUDPageStrings, type DynamicCRUDPageClasses, } from './dynamic-crud-page';
20
20
  export { DynamicRelation, type DynamicRelationProps, type DynamicRelationStrings, type DynamicRelationKind, buildRelationFilterParams, buildCreatePayload, deriveRelationFormFields, relationRowKey, } from './dynamic-relation';
21
21
  export { registerModelExtension, getModelExtension, clearModelExtensions, type ModelExtension, type ModelExtensionProps, } from './model-extension-registry';
22
+ export { isColumnVisibleInTable, getSearchableColumnKeys, } from './column-visibility';
22
23
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAKA,cAAc,SAAS,CAAA;AACvB,cAAc,mBAAmB,CAAA;AACjC,cAAc,iBAAiB,CAAA;AAC/B,cAAc,gBAAgB,CAAA;AAC9B,OAAO,EACH,qBAAqB,EACrB,KAAK,gBAAgB,GACxB,MAAM,2BAA2B,CAAA;AAClC,cAAc,gBAAgB,CAAA;AAC9B,cAAc,QAAQ,CAAA;AACtB,cAAc,mBAAmB,CAAA;AACjC,cAAc,sBAAsB,CAAA;AACpC,cAAc,iBAAiB,CAAA;AAC/B,cAAc,eAAe,CAAA;AAC7B,cAAc,kBAAkB,CAAA;AAChC,cAAc,gBAAgB,CAAA;AAC9B,YAAY,EACR,kBAAkB,EAClB,YAAY,IAAI,yBAAyB,EACzC,iBAAiB,EACjB,oBAAoB,GACvB,MAAM,wBAAwB,CAAA;AAC/B,OAAO,EACH,wBAAwB,EACxB,4BAA4B,EAC5B,KAAK,qBAAqB,GAC7B,MAAM,mBAAmB,CAAA;AAC1B,OAAO,EAAE,mBAAmB,EAAE,MAAM,0BAA0B,CAAA;AAC9D,OAAO,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAA;AAC/C,OAAO,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAA;AAC/C,OAAO,EACH,eAAe,EACf,KAAK,oBAAoB,EACzB,KAAK,sBAAsB,EAC3B,KAAK,sBAAsB,GAC9B,MAAM,qBAAqB,CAAA;AAC5B,OAAO,EACH,eAAe,EACf,KAAK,oBAAoB,EACzB,KAAK,sBAAsB,EAC3B,KAAK,mBAAmB,EACxB,yBAAyB,EACzB,kBAAkB,EAClB,wBAAwB,EACxB,cAAc,GACjB,MAAM,oBAAoB,CAAA;AAC3B,OAAO,EACH,sBAAsB,EACtB,iBAAiB,EACjB,oBAAoB,EACpB,KAAK,cAAc,EACnB,KAAK,mBAAmB,GAC3B,MAAM,4BAA4B,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAKA,cAAc,SAAS,CAAA;AACvB,cAAc,mBAAmB,CAAA;AACjC,cAAc,iBAAiB,CAAA;AAC/B,cAAc,gBAAgB,CAAA;AAC9B,OAAO,EACH,qBAAqB,EACrB,KAAK,gBAAgB,GACxB,MAAM,2BAA2B,CAAA;AAClC,cAAc,gBAAgB,CAAA;AAC9B,cAAc,QAAQ,CAAA;AACtB,cAAc,mBAAmB,CAAA;AACjC,cAAc,sBAAsB,CAAA;AACpC,cAAc,iBAAiB,CAAA;AAC/B,cAAc,eAAe,CAAA;AAC7B,cAAc,kBAAkB,CAAA;AAChC,cAAc,gBAAgB,CAAA;AAC9B,YAAY,EACR,kBAAkB,EAClB,YAAY,IAAI,yBAAyB,EACzC,iBAAiB,EACjB,oBAAoB,GACvB,MAAM,wBAAwB,CAAA;AAC/B,OAAO,EACH,wBAAwB,EACxB,4BAA4B,EAC5B,KAAK,qBAAqB,GAC7B,MAAM,mBAAmB,CAAA;AAC1B,OAAO,EAAE,mBAAmB,EAAE,MAAM,0BAA0B,CAAA;AAC9D,OAAO,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAA;AAC/C,OAAO,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAA;AAC/C,OAAO,EACH,eAAe,EACf,KAAK,oBAAoB,EACzB,KAAK,sBAAsB,EAC3B,KAAK,sBAAsB,GAC9B,MAAM,qBAAqB,CAAA;AAC5B,OAAO,EACH,eAAe,EACf,KAAK,oBAAoB,EACzB,KAAK,sBAAsB,EAC3B,KAAK,mBAAmB,EACxB,yBAAyB,EACzB,kBAAkB,EAClB,wBAAwB,EACxB,cAAc,GACjB,MAAM,oBAAoB,CAAA;AAC3B,OAAO,EACH,sBAAsB,EACtB,iBAAiB,EACjB,oBAAoB,EACpB,KAAK,cAAc,EACnB,KAAK,mBAAmB,GAC3B,MAAM,4BAA4B,CAAA;AACnC,OAAO,EACH,sBAAsB,EACtB,uBAAuB,GAC1B,MAAM,qBAAqB,CAAA"}
package/dist/index.js CHANGED
@@ -23,3 +23,4 @@ export { ImportDialog } from './dialogs/import';
23
23
  export { DynamicCRUDPage, } from './dynamic-crud-page';
24
24
  export { DynamicRelation, buildRelationFilterParams, buildCreatePayload, deriveRelationFormFields, relationRowKey, } from './dynamic-relation';
25
25
  export { registerModelExtension, getModelExtension, clearModelExtensions, } from './model-extension-registry';
26
+ export { isColumnVisibleInTable, getSearchableColumnKeys, } from './column-visibility';
package/dist/types.d.ts CHANGED
@@ -26,6 +26,17 @@ export interface FilterDefinition {
26
26
  }[];
27
27
  searchEndpoint?: string;
28
28
  }
29
+ /**
30
+ * Where a column is rendered. Mirrors `manifest.ColumnDef.Visibility` in the
31
+ * kernel:
32
+ * - `''` / `'all'` — visible everywhere (default).
33
+ * - `'table'` — only the list/index page.
34
+ * - `'modal'` — only the create/edit modal.
35
+ * - `'list'` — only API list payloads (omitted from UI).
36
+ * Hosts may extend the union with their own scopes; the SDK only acts on the
37
+ * canonical values above.
38
+ */
39
+ export type ColumnVisibility = 'all' | 'table' | 'modal' | 'list' | (string & {});
29
40
  export interface ColumnDefinition {
30
41
  key: string;
31
42
  label: string;
@@ -33,6 +44,19 @@ export interface ColumnDefinition {
33
44
  sortable: boolean;
34
45
  filterable: boolean;
35
46
  hidden?: boolean;
47
+ /**
48
+ * Scopes where this column is rendered. When `'modal'` (or `'list'`) the
49
+ * column is hidden from the table even if `hidden` is unset. Empty/`'all'`/
50
+ * `'table'` keep the column visible. See `column-visibility.ts`.
51
+ */
52
+ visibility?: ColumnVisibility;
53
+ /**
54
+ * Opts the column into the model's full-text/contains search. Independent
55
+ * of `filterable` (which drives column-level filter chips). When at least
56
+ * one column declares `searchable`, the SDK narrows the global search to
57
+ * those columns; otherwise legacy "search every column" behaviour applies.
58
+ */
59
+ searchable?: boolean;
36
60
  styleConfig?: Record<string, any>;
37
61
  tooltip?: string;
38
62
  description?: string;
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAGA,MAAM,WAAW,aAAa;IAC1B,KAAK,EAAE,MAAM,CAAA;IACb,QAAQ,EAAE,MAAM,CAAA;IAChB,OAAO,EAAE,gBAAgB,EAAE,CAAA;IAC3B,OAAO,EAAE,gBAAgB,EAAE,CAAA;IAC3B,OAAO,CAAC,EAAE,gBAAgB,EAAE,CAAA;IAC5B,cAAc,EAAE,MAAM,EAAE,CAAA;IACxB,cAAc,EAAE,MAAM,CAAA;IACtB,iBAAiB,EAAE,MAAM,CAAA;IACzB,iBAAiB,EAAE,OAAO,CAAA;IAC1B,UAAU,EAAE,OAAO,CAAA;IACnB,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB,SAAS,CAAC,EAAE,OAAO,CAAA;CACtB;AAED,MAAM,WAAW,gBAAgB;IAC7B,GAAG,EAAE,MAAM,CAAA;IACX,KAAK,EAAE,MAAM,CAAA;IACb,IAAI,EAAE,QAAQ,GAAG,SAAS,GAAG,YAAY,GAAG,cAAc,GAAG,MAAM,CAAA;IACnE,MAAM,EAAE,MAAM,CAAA;IACd,OAAO,CAAC,EAAE;QAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE,EAAE,CAAA;IACrF,cAAc,CAAC,EAAE,MAAM,CAAA;CAC1B;AAED;;;;;;;;;GASG;AACH,MAAM,MAAM,gBAAgB,GAAG,KAAK,GAAG,OAAO,GAAG,OAAO,GAAG,MAAM,GAAG,CAAC,MAAM,GAAG,EAAE,CAAC,CAAA;AAEjF,MAAM,WAAW,gBAAgB;IAC7B,GAAG,EAAE,MAAM,CAAA;IACX,KAAK,EAAE,MAAM,CAAA;IACb,IAAI,EAAE,MAAM,GAAG,QAAQ,GAAG,MAAM,GAAG,QAAQ,GAAG,QAAQ,GAAG,qBAAqB,GAAG,QAAQ,GAAG,SAAS,GAAG,OAAO,GAAG,eAAe,GAAG,OAAO,CAAA;IAC3I,QAAQ,EAAE,OAAO,CAAA;IACjB,UAAU,EAAE,OAAO,CAAA;IACnB,MAAM,CAAC,EAAE,OAAO,CAAA;IAChB;;;;OAIG;IACH,UAAU,CAAC,EAAE,gBAAgB,CAAA;IAC7B;;;;;OAKG;IACH,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;IACjC,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB,OAAO,CAAC,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE,EAAE,CAAA;CAC9E;AAED,MAAM,WAAW,eAAe;IAC5B,KAAK,EAAE,MAAM,CAAA;IACb,QAAQ,EAAE,IAAI,GAAG,KAAK,GAAG,IAAI,GAAG,QAAQ,CAAA;IACxC,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,CAAA;CAC3B;AAKD,MAAM,WAAW,eAAe;IAC5B,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,MAAM,CAAC,EAAE,MAAM,CAAA;CAClB;AAID,MAAM,MAAM,WAAW,GACjB,MAAM,GACN,UAAU,GACV,UAAU,GACV,OAAO,GACP,QAAQ,GACR,MAAM,GACN,QAAQ,GACR,QAAQ,CAAA;AAEd,MAAM,WAAW,cAAc;IAC3B,GAAG,EAAE,MAAM,CAAA;IACX,KAAK,EAAE,MAAM,CAAA;IACb,IAAI,EAAE,MAAM,CAAA;IACZ,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,OAAO,CAAC,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,EAAE,CAAA;IAC5C,YAAY,CAAC,EAAE,GAAG,CAAA;IAClB,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB,UAAU,CAAC,EAAE,eAAe,CAAA;IAC5B,MAAM,CAAC,EAAE,WAAW,GAAG,MAAM,CAAA;CAChC;AAED,MAAM,WAAW,gBAAgB;IAC7B,GAAG,EAAE,MAAM,CAAA;IACX,IAAI,EAAE,MAAM,CAAA;IACZ,KAAK,EAAE,MAAM,CAAA;IACb,IAAI,EAAE,MAAM,CAAA;IACZ,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,QAAQ,GAAG,QAAQ,GAAG,MAAM,CAAA;IACpD,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,SAAS,CAAC,EAAE,eAAe,CAAA;IAC3B,OAAO,CAAC,EAAE,OAAO,CAAA;IACjB,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB,MAAM,CAAC,EAAE,cAAc,EAAE,CAAA;IACzB,aAAa,CAAC,EAAE,MAAM,EAAE,CAAA;IACxB,UAAU,CAAC,EAAE,OAAO,CAAA;CACvB;AAED,MAAM,WAAW,WAAW,CAAC,CAAC;IAC1B,OAAO,EAAE,OAAO,CAAA;IAChB,IAAI,EAAE,CAAC,CAAA;IACP,IAAI,CAAC,EAAE,cAAc,CAAA;IACrB,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;IAC7B,OAAO,CAAC,EAAE,MAAM,CAAA;CACnB;AAED,MAAM,WAAW,cAAc;IAC3B,YAAY,EAAE,MAAM,CAAA;IACpB,IAAI,EAAE,MAAM,CAAA;IACZ,SAAS,EAAE,MAAM,CAAA;IACjB,QAAQ,EAAE,MAAM,CAAA;IAChB,EAAE,EAAE,MAAM,CAAA;IACV,KAAK,EAAE,MAAM,CAAA;CAChB;AAKD,MAAM,WAAW,cAAc;IAC3B,GAAG,EAAE,MAAM,CAAA;IACX,KAAK,EAAE,MAAM,CAAA;IACb,IAAI,EAAE,MAAM,CAAA;IACZ,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,OAAO,CAAC,EAAE,OAAO,CAAA;IACjB,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB,MAAM,CAAC,EAAE,cAAc,EAAE,CAAA;IACzB,aAAa,CAAC,EAAE,MAAM,EAAE,CAAA;IACxB,UAAU,CAAC,EAAE,OAAO,CAAA;CACvB"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@asteby/metacore-runtime-react",
3
- "version": "9.0.0",
3
+ "version": "9.1.0",
4
4
  "description": "React runtime for metacore hosts — renders addon contributions dynamically",
5
5
  "repository": {
6
6
  "type": "git",
@@ -56,6 +56,7 @@
56
56
  "react-dom": "^19.2.4",
57
57
  "react-i18next": "^17.0.0",
58
58
  "sonner": "^2.0.0",
59
+ "tsx": "^4.21.0",
59
60
  "typescript": "^5.6.0",
60
61
  "vitest": "^4.0.0",
61
62
  "zustand": "^5.0.0",
@@ -0,0 +1,116 @@
1
+ import { describe, it, expect } from 'vitest'
2
+
3
+ import {
4
+ isColumnVisibleInTable,
5
+ getSearchableColumnKeys,
6
+ } from '../column-visibility'
7
+ import type { ColumnDefinition, TableMetadata } from '../types'
8
+
9
+ const baseCol = (overrides: Partial<ColumnDefinition> = {}): ColumnDefinition => ({
10
+ key: 'name',
11
+ label: 'Name',
12
+ type: 'text',
13
+ sortable: true,
14
+ filterable: false,
15
+ ...overrides,
16
+ })
17
+
18
+ const baseMeta = (columns: ColumnDefinition[]): TableMetadata => ({
19
+ title: 'Mock',
20
+ endpoint: '/data/mock',
21
+ columns,
22
+ actions: [],
23
+ perPageOptions: [10],
24
+ defaultPerPage: 10,
25
+ searchPlaceholder: 'Search…',
26
+ enableCRUDActions: true,
27
+ hasActions: false,
28
+ })
29
+
30
+ describe('isColumnVisibleInTable', () => {
31
+ it('keeps columns with no visibility flag (legacy zero-value)', () => {
32
+ expect(isColumnVisibleInTable(baseCol())).toBe(true)
33
+ })
34
+
35
+ it('keeps columns with visibility="all"', () => {
36
+ expect(isColumnVisibleInTable(baseCol({ visibility: 'all' }))).toBe(true)
37
+ })
38
+
39
+ it('keeps columns with visibility="table"', () => {
40
+ expect(isColumnVisibleInTable(baseCol({ visibility: 'table' }))).toBe(true)
41
+ })
42
+
43
+ it('hides columns with visibility="modal"', () => {
44
+ expect(isColumnVisibleInTable(baseCol({ visibility: 'modal' }))).toBe(false)
45
+ })
46
+
47
+ it('hides columns with visibility="list"', () => {
48
+ expect(isColumnVisibleInTable(baseCol({ visibility: 'list' }))).toBe(false)
49
+ })
50
+
51
+ it('hides columns with the legacy hidden boolean even if visibility="all"', () => {
52
+ expect(isColumnVisibleInTable(baseCol({ hidden: true, visibility: 'all' }))).toBe(false)
53
+ })
54
+
55
+ it('hides columns with an unknown visibility value (fail-closed)', () => {
56
+ expect(isColumnVisibleInTable(baseCol({ visibility: 'detail' as any }))).toBe(false)
57
+ })
58
+ })
59
+
60
+ describe('getSearchableColumnKeys', () => {
61
+ it('returns null when no column declares searchable (legacy metadata)', () => {
62
+ const meta = baseMeta([
63
+ baseCol({ key: 'name' }),
64
+ baseCol({ key: 'email' }),
65
+ ])
66
+ expect(getSearchableColumnKeys(meta)).toBe(null)
67
+ })
68
+
69
+ it('returns only the searchable keys when at least one column declares it', () => {
70
+ const meta = baseMeta([
71
+ baseCol({ key: 'name', searchable: true }),
72
+ baseCol({ key: 'email', searchable: true }),
73
+ baseCol({ key: 'phone', searchable: false }),
74
+ baseCol({ key: 'created_at' }), // undefined → not searchable
75
+ ])
76
+ expect(getSearchableColumnKeys(meta)).toEqual(['name', 'email'])
77
+ })
78
+
79
+ it('returns an empty array when every column is explicitly opted out', () => {
80
+ const meta = baseMeta([
81
+ baseCol({ key: 'name', searchable: false }),
82
+ baseCol({ key: 'email', searchable: false }),
83
+ ])
84
+ expect(getSearchableColumnKeys(meta)).toEqual([])
85
+ })
86
+
87
+ it('treats searchable=true on a single column as the explicit allowlist', () => {
88
+ const meta = baseMeta([
89
+ baseCol({ key: 'name', searchable: true }),
90
+ baseCol({ key: 'internal_notes' }),
91
+ ])
92
+ expect(getSearchableColumnKeys(meta)).toEqual(['name'])
93
+ })
94
+
95
+ it('handles missing columns array defensively', () => {
96
+ const meta = { columns: undefined as unknown as ColumnDefinition[] }
97
+ expect(getSearchableColumnKeys(meta as any)).toBe(null)
98
+ })
99
+ })
100
+
101
+ describe('column-visibility integration with mock metadata', () => {
102
+ it('filtering and search keys can be derived from a single mock', () => {
103
+ const meta = baseMeta([
104
+ baseCol({ key: 'id', visibility: 'list', searchable: false }),
105
+ baseCol({ key: 'name', visibility: 'all', searchable: true }),
106
+ baseCol({ key: 'email', visibility: 'table', searchable: true }),
107
+ baseCol({ key: 'password_hash', visibility: 'modal', searchable: false }),
108
+ baseCol({ key: 'profile', visibility: 'modal', searchable: false }),
109
+ ])
110
+
111
+ const tableColumns = meta.columns.filter(isColumnVisibleInTable).map(c => c.key)
112
+ expect(tableColumns).toEqual(['name', 'email'])
113
+
114
+ expect(getSearchableColumnKeys(meta)).toEqual(['name', 'email'])
115
+ })
116
+ })
@@ -0,0 +1,43 @@
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
+ import type { ColumnDefinition, TableMetadata } from './types'
10
+
11
+ /**
12
+ * Whether a column should render in a list/index table view.
13
+ *
14
+ * A column is hidden when its `visibility` is scoped away from the table
15
+ * (`'modal'`: only the create/edit dialog; `'list'`: only API payloads) or
16
+ * when the legacy `hidden` boolean is set. Empty / `'all'` / `'table'` keep
17
+ * the column visible — preserving zero-value behaviour for metadata emitted
18
+ * by older kernels that don't set `visibility` at all.
19
+ */
20
+ export function isColumnVisibleInTable(col: ColumnDefinition): boolean {
21
+ if (col.hidden) return false
22
+ const v = col.visibility
23
+ if (!v) return true
24
+ return v === 'all' || v === 'table'
25
+ }
26
+
27
+ /**
28
+ * Returns the keys of columns that opt into the model's full-text search,
29
+ * or `null` when no column declares `searchable` at all.
30
+ *
31
+ * `null` is the legacy signal: the host should NOT narrow the search request
32
+ * (every column participates, matching pre-Searchable kernels). An empty
33
+ * array is meaningful — it means every column has been explicitly opted out
34
+ * and the host should disable the global search input.
35
+ */
36
+ export function getSearchableColumnKeys(
37
+ metadata: Pick<TableMetadata, 'columns'>,
38
+ ): string[] | null {
39
+ const cols = metadata.columns ?? []
40
+ const declared = cols.some(c => typeof c.searchable === 'boolean')
41
+ if (!declared) return null
42
+ return cols.filter(c => c.searchable === true).map(c => c.key)
43
+ }
@@ -35,6 +35,7 @@ import { generateBadgeStyles, getInitials } from '@asteby/metacore-ui/lib'
35
35
  import { OptionsContext } from './options-context'
36
36
  import { DynamicIcon } from './dynamic-icon'
37
37
  import type { TableMetadata, ColumnDefinition } from './types'
38
+ import { isColumnVisibleInTable } from './column-visibility'
38
39
  import type {
39
40
  ColumnFilterConfig,
40
41
  GetDynamicColumns,
@@ -221,7 +222,9 @@ export function makeDefaultGetDynamicColumns(
221
222
  ]
222
223
 
223
224
  metadata.columns.forEach((col) => {
224
- if (col.hidden) return
225
+ // Honors both the legacy `hidden` boolean and the kernel's
226
+ // `visibility` scope (skips `'modal'` and `'list'`).
227
+ if (!isColumnVisibleInTable(col)) return
225
228
 
226
229
  const translatedLabel = col.label
227
230
  const filterConfig = filterConfigs?.get(col.key)
@@ -69,6 +69,7 @@ import { defaultGetDynamicColumns } from './dynamic-columns'
69
69
  import { OptionsContext } from './options-context'
70
70
  import { ActionModalDispatcher } from './action-modal-dispatcher'
71
71
  import type { TableMetadata, ApiResponse, ActionMetadata } from './types'
72
+ import { getSearchableColumnKeys } from './column-visibility'
72
73
  import { DynamicRecordDialog } from './dialogs/dynamic-record'
73
74
  import { ExportDialog } from './dialogs/export'
74
75
  import { ImportDialog } from './dialogs/import'
@@ -324,13 +325,30 @@ export function DynamicTable({
324
325
  initMetadataAndOptions()
325
326
  }, [model]) // eslint-disable-line react-hooks/exhaustive-deps
326
327
 
328
+ // Derived from `metadata.columns[].searchable`. `null` means the kernel
329
+ // didn't emit the flag for any column → preserve legacy "search every
330
+ // column" behaviour by not narrowing the request. An empty array means
331
+ // every column was explicitly opted out → skip sending `search` at all.
332
+ const searchableKeys = useMemo(
333
+ () => (metadata ? getSearchableColumnKeys(metadata) : null),
334
+ [metadata],
335
+ )
336
+
327
337
  const buildFilterParams = useCallback(() => {
328
338
  const params: Record<string, any> = {}
329
339
  if (sorting.length > 0) {
330
340
  params.sortBy = sorting[0].id
331
341
  params.order = sorting[0].desc ? 'desc' : 'asc'
332
342
  }
333
- if (globalFilter) params.search = globalFilter
343
+ if (globalFilter) {
344
+ if (searchableKeys === null) {
345
+ params.search = globalFilter
346
+ } else if (searchableKeys.length > 0) {
347
+ params.search = globalFilter
348
+ params.search_columns = searchableKeys.join(',')
349
+ }
350
+ // searchableKeys === [] → drop the search request entirely
351
+ }
334
352
  columnFilters.forEach((filter: { id: string; value: unknown }) => { params[`f_${filter.id}`] = filter.value })
335
353
  if (defaultFilters) Object.entries(defaultFilters).forEach(([key, value]) => { params[`f_${key}`] = value })
336
354
  Object.entries(dynamicFilters).forEach(([key, values]) => {
@@ -352,7 +370,7 @@ export function DynamicTable({
352
370
  params['f_created_at'] = `${startDate}_${endDate}`
353
371
  }
354
372
  return params
355
- }, [sorting, globalFilter, columnFilters, defaultFilters, dynamicFilters, dateRange])
373
+ }, [sorting, globalFilter, columnFilters, defaultFilters, dynamicFilters, dateRange, searchableKeys])
356
374
 
357
375
  const hasActiveFilters = useMemo(() => {
358
376
  if (globalFilter) return true
package/src/index.ts CHANGED
@@ -56,3 +56,7 @@ export {
56
56
  type ModelExtension,
57
57
  type ModelExtensionProps,
58
58
  } from './model-extension-registry'
59
+ export {
60
+ isColumnVisibleInTable,
61
+ getSearchableColumnKeys,
62
+ } from './column-visibility'
package/src/types.ts CHANGED
@@ -26,6 +26,18 @@ export interface FilterDefinition {
26
26
  searchEndpoint?: string
27
27
  }
28
28
 
29
+ /**
30
+ * Where a column is rendered. Mirrors `manifest.ColumnDef.Visibility` in the
31
+ * kernel:
32
+ * - `''` / `'all'` — visible everywhere (default).
33
+ * - `'table'` — only the list/index page.
34
+ * - `'modal'` — only the create/edit modal.
35
+ * - `'list'` — only API list payloads (omitted from UI).
36
+ * Hosts may extend the union with their own scopes; the SDK only acts on the
37
+ * canonical values above.
38
+ */
39
+ export type ColumnVisibility = 'all' | 'table' | 'modal' | 'list' | (string & {})
40
+
29
41
  export interface ColumnDefinition {
30
42
  key: string
31
43
  label: string
@@ -33,6 +45,19 @@ export interface ColumnDefinition {
33
45
  sortable: boolean
34
46
  filterable: boolean
35
47
  hidden?: boolean
48
+ /**
49
+ * Scopes where this column is rendered. When `'modal'` (or `'list'`) the
50
+ * column is hidden from the table even if `hidden` is unset. Empty/`'all'`/
51
+ * `'table'` keep the column visible. See `column-visibility.ts`.
52
+ */
53
+ visibility?: ColumnVisibility
54
+ /**
55
+ * Opts the column into the model's full-text/contains search. Independent
56
+ * of `filterable` (which drives column-level filter chips). When at least
57
+ * one column declares `searchable`, the SDK narrows the global search to
58
+ * those columns; otherwise legacy "search every column" behaviour applies.
59
+ */
60
+ searchable?: boolean
36
61
  styleConfig?: Record<string, any>
37
62
  tooltip?: string
38
63
  description?: string
package/tsconfig.json CHANGED
@@ -12,5 +12,6 @@
12
12
  "outDir": "./dist",
13
13
  "rootDir": "./src"
14
14
  },
15
- "include": ["src/**/*"]
15
+ "include": ["src/**/*"],
16
+ "exclude": ["src/**/*.test.ts", "src/**/__tests__/**"]
16
17
  }
@@ -1,2 +0,0 @@
1
- export {};
2
- //# sourceMappingURL=dynamic-form.test.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"dynamic-form.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/dynamic-form.test.ts"],"names":[],"mappings":""}
@@ -1,93 +0,0 @@
1
- import { describe, it, expect } from 'vitest';
2
- import { buildZodSchema, resolveWidget } from '../dynamic-form-schema';
3
- describe('buildZodSchema', () => {
4
- it('aplica regex de Validation a strings', () => {
5
- const fields = [
6
- { key: 'sku', label: 'SKU', type: 'string', required: true, validation: { regex: '^[A-Z]{3}-\\d{3}$' } },
7
- ];
8
- const schema = buildZodSchema(fields);
9
- expect(schema.safeParse({ sku: 'ABC-123' }).success).toBe(true);
10
- expect(schema.safeParse({ sku: 'abc-123' }).success).toBe(false);
11
- expect(schema.safeParse({ sku: 'ABCD-123' }).success).toBe(false);
12
- });
13
- it('aplica min/max como longitud sobre strings', () => {
14
- const fields = [
15
- { key: 'name', label: 'Nombre', type: 'string', required: true, validation: { min: 3, max: 8 } },
16
- ];
17
- const schema = buildZodSchema(fields);
18
- expect(schema.safeParse({ name: 'ab' }).success).toBe(false);
19
- expect(schema.safeParse({ name: 'abc' }).success).toBe(true);
20
- expect(schema.safeParse({ name: 'abcdefgh' }).success).toBe(true);
21
- expect(schema.safeParse({ name: 'abcdefghi' }).success).toBe(false);
22
- });
23
- it('aplica min/max como bounds sobre números', () => {
24
- const fields = [
25
- { key: 'age', label: 'Edad', type: 'number', required: true, validation: { min: 18, max: 99 } },
26
- ];
27
- const schema = buildZodSchema(fields);
28
- expect(schema.safeParse({ age: 17 }).success).toBe(false);
29
- expect(schema.safeParse({ age: 18 }).success).toBe(true);
30
- expect(schema.safeParse({ age: 99 }).success).toBe(true);
31
- expect(schema.safeParse({ age: 100 }).success).toBe(false);
32
- });
33
- it('marca campos requeridos vacíos como inválidos', () => {
34
- const fields = [
35
- { key: 'title', label: 'Título', type: 'string', required: true },
36
- ];
37
- const schema = buildZodSchema(fields);
38
- expect(schema.safeParse({ title: '' }).success).toBe(false);
39
- expect(schema.safeParse({ title: 'ok' }).success).toBe(true);
40
- });
41
- it('campos opcionales aceptan vacío o ausente', () => {
42
- const fields = [
43
- { key: 'note', label: 'Nota', type: 'string' },
44
- { key: 'qty', label: 'Cantidad', type: 'number' },
45
- ];
46
- const schema = buildZodSchema(fields);
47
- expect(schema.safeParse({ note: '', qty: '' }).success).toBe(true);
48
- expect(schema.safeParse({}).success).toBe(true);
49
- });
50
- it('valida email y url por type', () => {
51
- const fields = [
52
- { key: 'mail', label: 'Email', type: 'email', required: true },
53
- { key: 'site', label: 'URL', type: 'url', required: true },
54
- ];
55
- const schema = buildZodSchema(fields);
56
- expect(schema.safeParse({ mail: 'a@b.co', site: 'https://x.test' }).success).toBe(true);
57
- expect(schema.safeParse({ mail: 'no-email', site: 'https://x.test' }).success).toBe(false);
58
- expect(schema.safeParse({ mail: 'a@b.co', site: 'no-url' }).success).toBe(false);
59
- });
60
- it('regex inválida no rompe el build (silently skipped)', () => {
61
- const fields = [
62
- { key: 'x', label: 'X', type: 'string', required: true, validation: { regex: '[invalid(' } },
63
- ];
64
- expect(() => buildZodSchema(fields)).not.toThrow();
65
- const schema = buildZodSchema(fields);
66
- expect(schema.safeParse({ x: 'anything' }).success).toBe(true);
67
- });
68
- it('booleans requeridos exigen un valor explícito', () => {
69
- const fields = [
70
- { key: 'agree', label: 'Acepto', type: 'boolean', required: true },
71
- ];
72
- const schema = buildZodSchema(fields);
73
- expect(schema.safeParse({ agree: true }).success).toBe(true);
74
- expect(schema.safeParse({ agree: false }).success).toBe(true);
75
- expect(schema.safeParse({}).success).toBe(false);
76
- });
77
- });
78
- describe('resolveWidget', () => {
79
- it('respeta widget explícito', () => {
80
- expect(resolveWidget({ key: 'k', label: 'L', type: 'string', widget: 'textarea' })).toBe('textarea');
81
- expect(resolveWidget({ key: 'k', label: 'L', type: 'string', widget: 'richtext' })).toBe('richtext');
82
- expect(resolveWidget({ key: 'k', label: 'L', type: 'string', widget: 'color' })).toBe('color');
83
- });
84
- it('cae al inferido por type cuando widget no está', () => {
85
- expect(resolveWidget({ key: 'k', label: 'L', type: 'textarea' })).toBe('textarea');
86
- expect(resolveWidget({ key: 'k', label: 'L', type: 'select' })).toBe('select');
87
- expect(resolveWidget({ key: 'k', label: 'L', type: 'boolean' })).toBe('switch');
88
- expect(resolveWidget({ key: 'k', label: 'L', type: 'number' })).toBe('number');
89
- expect(resolveWidget({ key: 'k', label: 'L', type: 'date' })).toBe('date');
90
- expect(resolveWidget({ key: 'k', label: 'L', type: 'string' })).toBe('text');
91
- expect(resolveWidget({ key: 'k', label: 'L', type: 'email' })).toBe('text');
92
- });
93
- });
@@ -1,2 +0,0 @@
1
- export {};
2
- //# sourceMappingURL=dynamic-relation.test.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"dynamic-relation.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/dynamic-relation.test.ts"],"names":[],"mappings":""}
@@ -1,228 +0,0 @@
1
- import { describe, it, expect } from 'vitest';
2
- import { buildCreatePayload, buildPivotAttachPayload, buildPivotRowIndex, buildRelationFilterParams, deriveRelationFormFields, diffSelection, extractSelectedTargetIds, pickOptionLabel, relationRowKey, } from '../dynamic-relation-helpers';
3
- describe('buildRelationFilterParams', () => {
4
- it('produce el filtro f_<fk>=eq:<id> con string parentId', () => {
5
- expect(buildRelationFilterParams('invoice_id', 'inv_42')).toEqual({
6
- f_invoice_id: 'eq:inv_42',
7
- });
8
- });
9
- it('coerce parentId numérico a string en el query', () => {
10
- expect(buildRelationFilterParams('invoice_id', 42)).toEqual({
11
- f_invoice_id: 'eq:42',
12
- });
13
- });
14
- it('rechaza foreignKey vacío', () => {
15
- expect(() => buildRelationFilterParams('', 'x')).toThrow(/foreignKey/);
16
- });
17
- it('rechaza parentId vacío / null / undefined', () => {
18
- expect(() => buildRelationFilterParams('invoice_id', '')).toThrow(/parentId/);
19
- // @ts-expect-error testing runtime guard
20
- expect(() => buildRelationFilterParams('invoice_id', null)).toThrow(/parentId/);
21
- // @ts-expect-error testing runtime guard
22
- expect(() => buildRelationFilterParams('invoice_id', undefined)).toThrow(/parentId/);
23
- });
24
- });
25
- describe('buildCreatePayload', () => {
26
- it('inyecta el foreign key sobre los valores del form', () => {
27
- const payload = buildCreatePayload('invoice_id', 'inv_42', { qty: 3, sku: 'A' });
28
- expect(payload).toEqual({ qty: 3, sku: 'A', invoice_id: 'inv_42' });
29
- });
30
- it('el foreign key sobreescribe lo que venga del form', () => {
31
- const payload = buildCreatePayload('invoice_id', 'inv_42', { invoice_id: 'inv_OTHER', qty: 1 });
32
- expect(payload.invoice_id).toBe('inv_42');
33
- });
34
- it('preserva el parentId numérico tal cual', () => {
35
- const payload = buildCreatePayload('invoice_id', 7, { qty: 1 });
36
- expect(payload.invoice_id).toBe(7);
37
- });
38
- it('rechaza foreignKey vacío', () => {
39
- expect(() => buildCreatePayload('', 'x', {})).toThrow(/foreignKey/);
40
- });
41
- });
42
- describe('deriveRelationFormFields', () => {
43
- const baseMeta = {
44
- columns: [
45
- { key: 'id', label: 'ID', type: 'text', sortable: true, filterable: false, hidden: true },
46
- { key: 'invoice_id', label: 'Factura', type: 'text', sortable: false, filterable: false },
47
- { key: 'sku', label: 'SKU', type: 'text', sortable: true, filterable: true },
48
- { key: 'qty', label: 'Cantidad', type: 'number', sortable: true, filterable: false },
49
- { key: 'taxable', label: 'Aplica IVA', type: 'boolean', sortable: false, filterable: false },
50
- { key: 'category', label: 'Categoría', type: 'select', sortable: false, filterable: true, options: [
51
- { value: 'a', label: 'A' }, { value: 'b', label: 'B' },
52
- ] },
53
- ],
54
- };
55
- it('omite la foreign key porque está fija al parentId', () => {
56
- const fields = deriveRelationFormFields(baseMeta, 'invoice_id');
57
- expect(fields.find(f => f.key === 'invoice_id')).toBeUndefined();
58
- });
59
- it('omite columnas marcadas hidden', () => {
60
- const fields = deriveRelationFormFields(baseMeta, 'invoice_id');
61
- expect(fields.find(f => f.key === 'id')).toBeUndefined();
62
- });
63
- it('mapea types de ColumnDefinition al ActionFieldDef.type', () => {
64
- const fields = deriveRelationFormFields(baseMeta, 'invoice_id');
65
- const byKey = Object.fromEntries(fields.map(f => [f.key, f]));
66
- expect(byKey['sku']?.type).toBe('string');
67
- expect(byKey['qty']?.type).toBe('number');
68
- expect(byKey['taxable']?.type).toBe('boolean');
69
- expect(byKey['category']?.type).toBe('select');
70
- });
71
- it('propaga options con value coerced a string', () => {
72
- const fields = deriveRelationFormFields(baseMeta, 'invoice_id');
73
- const cat = fields.find(f => f.key === 'category');
74
- expect(cat?.options).toEqual([
75
- { value: 'a', label: 'A' },
76
- { value: 'b', label: 'B' },
77
- ]);
78
- });
79
- it('devuelve [] cuando no hay metadata', () => {
80
- expect(deriveRelationFormFields(null, 'invoice_id')).toEqual([]);
81
- expect(deriveRelationFormFields(undefined, 'invoice_id')).toEqual([]);
82
- expect(deriveRelationFormFields({ columns: [] }, 'invoice_id')).toEqual([]);
83
- });
84
- });
85
- describe('relationRowKey', () => {
86
- it('usa row.id como key cuando existe', () => {
87
- expect(relationRowKey({ id: 'abc' }, 0, 'invoice_id')).toBe('abc');
88
- expect(relationRowKey({ id: 7 }, 5, 'invoice_id')).toBe('7');
89
- });
90
- it('cae a synthetic key cuando id falta o es vacío', () => {
91
- expect(relationRowKey({}, 2, 'invoice_id')).toBe('__rel-invoice_id-2');
92
- expect(relationRowKey({ id: '' }, 3, 'invoice_id')).toBe('__rel-invoice_id-3');
93
- expect(relationRowKey({ id: null }, 1, 'invoice_id')).toBe('__rel-invoice_id-1');
94
- expect(relationRowKey(undefined, 0, 'invoice_id')).toBe('__rel-invoice_id-0');
95
- });
96
- });
97
- // ---------------------------------------------------------------------------
98
- // many_to_many helpers
99
- // ---------------------------------------------------------------------------
100
- describe('buildPivotAttachPayload', () => {
101
- it('produce el body con los dos FKs fijos', () => {
102
- const body = buildPivotAttachPayload('org_id', 'org_1', 'user_id', 'user_42');
103
- expect(body).toEqual({ org_id: 'org_1', user_id: 'user_42' });
104
- });
105
- it('mezcla campos extra del pivot sin pisar los FKs', () => {
106
- const body = buildPivotAttachPayload('org_id', 1, 'user_id', 2, {
107
- role: 'owner',
108
- org_id: 'evil',
109
- user_id: 'evil',
110
- });
111
- expect(body.org_id).toBe(1);
112
- expect(body.user_id).toBe(2);
113
- expect(body.role).toBe('owner');
114
- });
115
- it('rechaza foreignKey / referencesKey vacíos', () => {
116
- expect(() => buildPivotAttachPayload('', 'p', 'r', 't')).toThrow(/foreignKey/);
117
- expect(() => buildPivotAttachPayload('f', 'p', '', 't')).toThrow(/referencesKey/);
118
- });
119
- it('rechaza parentId / targetId vacíos', () => {
120
- expect(() => buildPivotAttachPayload('f', '', 'r', 't')).toThrow(/parentId/);
121
- expect(() => buildPivotAttachPayload('f', 'p', 'r', '')).toThrow(/targetId/);
122
- // @ts-expect-error testing runtime guard
123
- expect(() => buildPivotAttachPayload('f', 'p', 'r', null)).toThrow(/targetId/);
124
- });
125
- });
126
- describe('extractSelectedTargetIds', () => {
127
- it('mapea pivot rows al set de target ids como strings', () => {
128
- const ids = extractSelectedTargetIds([
129
- { id: 1, org_id: 'org_1', user_id: 'u_1' },
130
- { id: 2, org_id: 'org_1', user_id: 7 },
131
- ], 'user_id');
132
- expect(ids).toEqual(['u_1', '7']);
133
- });
134
- it('omite filas sin valor en el referencesKey', () => {
135
- const ids = extractSelectedTargetIds([
136
- { id: 1, user_id: 'u_1' },
137
- { id: 2, user_id: null },
138
- { id: 3, user_id: '' },
139
- { id: 4 },
140
- ], 'user_id');
141
- expect(ids).toEqual(['u_1']);
142
- });
143
- it('devuelve [] cuando no hay rows o referencesKey', () => {
144
- expect(extractSelectedTargetIds(null, 'user_id')).toEqual([]);
145
- expect(extractSelectedTargetIds(undefined, 'user_id')).toEqual([]);
146
- expect(extractSelectedTargetIds([{ user_id: 'x' }], '')).toEqual([]);
147
- });
148
- });
149
- describe('buildPivotRowIndex', () => {
150
- it('mapea targetId -> pivotRowId', () => {
151
- const idx = buildPivotRowIndex([
152
- { id: 'p1', user_id: 'u_1' },
153
- { id: 'p2', user_id: 7 },
154
- ], 'user_id');
155
- expect(idx.get('u_1')).toBe('p1');
156
- expect(idx.get('7')).toBe('p2');
157
- });
158
- it('omite filas sin id pivot o sin target', () => {
159
- const idx = buildPivotRowIndex([
160
- { id: 'p1', user_id: 'u_1' },
161
- { user_id: 'u_2' },
162
- { id: 'p3' },
163
- { id: 'p4', user_id: null },
164
- ], 'user_id');
165
- expect(Array.from(idx.keys())).toEqual(['u_1']);
166
- });
167
- it('última fila gana cuando hay duplicados', () => {
168
- const idx = buildPivotRowIndex([
169
- { id: 'p1', user_id: 'u_1' },
170
- { id: 'p2', user_id: 'u_1' },
171
- ], 'user_id');
172
- expect(idx.get('u_1')).toBe('p2');
173
- });
174
- });
175
- describe('diffSelection', () => {
176
- it('detecta toAdd y toRemove respecto al estado previo', () => {
177
- const { toAdd, toRemove } = diffSelection(['a', 'b', 'c'], ['b', 'c', 'd']);
178
- expect(toAdd).toEqual(['d']);
179
- expect(toRemove).toEqual(['a']);
180
- });
181
- it('preserva el orden de aparición en next/prev', () => {
182
- const { toAdd, toRemove } = diffSelection(['a', 'b'], ['c', 'a', 'd']);
183
- expect(toAdd).toEqual(['c', 'd']);
184
- expect(toRemove).toEqual(['b']);
185
- });
186
- it('devuelve arrays vacíos cuando no cambia nada', () => {
187
- const { toAdd, toRemove } = diffSelection(['a', 'b'], ['b', 'a']);
188
- expect(toAdd).toEqual([]);
189
- expect(toRemove).toEqual([]);
190
- });
191
- it('caso vacío -> next agrega todo', () => {
192
- const { toAdd, toRemove } = diffSelection([], ['a', 'b']);
193
- expect(toAdd).toEqual(['a', 'b']);
194
- expect(toRemove).toEqual([]);
195
- });
196
- it('caso prev -> [] remueve todo', () => {
197
- const { toAdd, toRemove } = diffSelection(['a', 'b'], []);
198
- expect(toAdd).toEqual([]);
199
- expect(toRemove).toEqual(['a', 'b']);
200
- });
201
- });
202
- describe('pickOptionLabel', () => {
203
- const cols = [
204
- { key: 'id', label: 'ID', type: 'text', sortable: false, filterable: false, hidden: true },
205
- { key: 'name', label: 'Nombre', type: 'text', sortable: true, filterable: true },
206
- { key: 'email', label: 'Email', type: 'text', sortable: false, filterable: true },
207
- ];
208
- it('respeta displayKey cuando existe en la fila', () => {
209
- expect(pickOptionLabel({ id: 1, name: 'Alice', email: 'a@x' }, 'email', cols)).toBe('a@x');
210
- });
211
- it('cae al primer column no-id no-hidden cuando displayKey falta', () => {
212
- expect(pickOptionLabel({ id: 1, name: 'Alice', email: 'a@x' }, undefined, cols)).toBe('Alice');
213
- });
214
- it('salta valores nulos / vacíos al inferir', () => {
215
- expect(pickOptionLabel({ id: 1, name: '', email: 'a@x' }, undefined, cols)).toBe('a@x');
216
- });
217
- it('cae a row.id cuando no hay match en columns', () => {
218
- expect(pickOptionLabel({ id: 7 }, undefined, cols)).toBe('7');
219
- expect(pickOptionLabel({ id: 7 }, undefined, undefined)).toBe('7');
220
- });
221
- it('devuelve "—" cuando no hay nada usable', () => {
222
- expect(pickOptionLabel(null, undefined, cols)).toBe('—');
223
- expect(pickOptionLabel({}, undefined, cols)).toBe('—');
224
- });
225
- it('ignora valores object al inferir', () => {
226
- expect(pickOptionLabel({ id: 1, name: { nested: true }, email: 'a@x' }, undefined, cols)).toBe('a@x');
227
- });
228
- });