@asteby/metacore-runtime-react 9.0.0 → 9.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/dist/column-visibility.d.ts +22 -0
  3. package/dist/column-visibility.d.ts.map +1 -0
  4. package/dist/column-visibility.js +40 -0
  5. package/dist/dynamic-columns.d.ts.map +1 -1
  6. package/dist/dynamic-columns.js +4 -1
  7. package/dist/dynamic-form-schema.d.ts +5 -0
  8. package/dist/dynamic-form-schema.d.ts.map +1 -1
  9. package/dist/dynamic-form-schema.js +34 -0
  10. package/dist/dynamic-form.d.ts.map +1 -1
  11. package/dist/dynamic-form.js +18 -2
  12. package/dist/dynamic-relation.d.ts.map +1 -1
  13. package/dist/dynamic-relation.js +59 -22
  14. package/dist/dynamic-table.d.ts.map +1 -1
  15. package/dist/dynamic-table.js +17 -3
  16. package/dist/index.d.ts +4 -0
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +4 -0
  19. package/dist/types.d.ts +44 -0
  20. package/dist/types.d.ts.map +1 -1
  21. package/dist/use-options-resolver.d.ts +87 -0
  22. package/dist/use-options-resolver.d.ts.map +1 -0
  23. package/dist/use-options-resolver.js +147 -0
  24. package/dist/use-org-config-bridge.d.ts +28 -0
  25. package/dist/use-org-config-bridge.d.ts.map +1 -0
  26. package/dist/use-org-config-bridge.js +50 -0
  27. package/package.json +3 -2
  28. package/src/__tests__/column-visibility.test.ts +116 -0
  29. package/src/__tests__/use-options-resolver.test.ts +127 -0
  30. package/src/column-visibility.ts +43 -0
  31. package/src/dynamic-columns.tsx +4 -1
  32. package/src/dynamic-form-schema.ts +36 -0
  33. package/src/dynamic-form.tsx +40 -2
  34. package/src/dynamic-relation.tsx +55 -20
  35. package/src/dynamic-table.tsx +20 -2
  36. package/src/index.ts +19 -0
  37. package/src/types.ts +49 -0
  38. package/src/use-options-resolver.ts +232 -0
  39. package/src/use-org-config-bridge.ts +60 -0
  40. package/tsconfig.json +2 -1
  41. package/dist/__tests__/dynamic-form.test.d.ts +0 -2
  42. package/dist/__tests__/dynamic-form.test.d.ts.map +0 -1
  43. package/dist/__tests__/dynamic-form.test.js +0 -93
  44. package/dist/__tests__/dynamic-relation.test.d.ts +0 -2
  45. package/dist/__tests__/dynamic-relation.test.d.ts.map +0 -1
  46. package/dist/__tests__/dynamic-relation.test.js +0 -228
@@ -0,0 +1,87 @@
1
+ export interface ResolvedOption {
2
+ /** Canonical id (server-side primary key). */
3
+ id: string | number;
4
+ /** Same as `id` — preserved for legacy frontend parity. */
5
+ value: string | number;
6
+ /** Display string. */
7
+ label: string;
8
+ /** Same as `label` — preserved for legacy frontend parity. */
9
+ name: string;
10
+ description?: string | null;
11
+ image?: string | null;
12
+ color?: string | null;
13
+ icon?: string | null;
14
+ }
15
+ export interface OptionsMeta {
16
+ /** 'static' for inline options, 'dynamic' for FK-resolved lists. */
17
+ type: 'static' | 'dynamic' | string;
18
+ /** Number of options the server returned in this batch. */
19
+ count: number;
20
+ }
21
+ export interface UseOptionsResolverArgs {
22
+ /**
23
+ * The owning model whose options endpoint is queried. Pass the model
24
+ * key (e.g. 'sales_orders'). Required — passing an empty string puts
25
+ * the hook in idle mode and no fetch fires.
26
+ */
27
+ modelKey: string;
28
+ /**
29
+ * Field on `modelKey` to resolve. Maps to `?field=<fieldKey>`.
30
+ */
31
+ fieldKey: string;
32
+ /**
33
+ * Optional FK target. When set the hook resolves against
34
+ * `/api/options/<ref>?field=id` instead of `/api/options/<modelKey>`.
35
+ * This is the canonical path the kernel auto-derives from
36
+ * `ColumnDef.Ref`. Prefer this over `endpoint`.
37
+ */
38
+ ref?: string;
39
+ /**
40
+ * Free-text query forwarded as `?q=`. Empty values are skipped so the
41
+ * server returns the first page unfiltered.
42
+ */
43
+ query?: string;
44
+ /**
45
+ * Server-side pagination cap. Defaults to 50 (kernel
46
+ * DefaultOptionsLimit) if omitted.
47
+ */
48
+ limit?: number;
49
+ /**
50
+ * Toggle to disable fetching entirely (e.g. while a parent row is
51
+ * still loading). Defaults to true.
52
+ */
53
+ enabled?: boolean;
54
+ /**
55
+ * Escape hatch for callers that need a non-canonical URL — e.g.
56
+ * legacy `/options/<custom>?...`. When set it overrides `ref` and
57
+ * `modelKey` for the fetch path. The query string is built from
58
+ * `fieldKey` / `query` / `limit` exactly the same way.
59
+ */
60
+ endpoint?: string;
61
+ }
62
+ export interface UseOptionsResolverResult {
63
+ options: ResolvedOption[];
64
+ meta: OptionsMeta | null;
65
+ loading: boolean;
66
+ error: Error | null;
67
+ /** Forces a refetch. Useful after a parent record updates. */
68
+ refetch: () => void;
69
+ }
70
+ /**
71
+ * Resolves select options for a field via the canonical
72
+ * `/api/options/:model?field=…` endpoint. Returns the v0.9.0 envelope
73
+ * `{ data, meta: { type, count } }` projected into a stable shape.
74
+ *
75
+ * The hook is intentionally minimal: it does NOT debounce `query`
76
+ * (callers should hold the controlled value and pass it post-debounce)
77
+ * and does NOT cache across hook instances (apps that need shared state
78
+ * compose this with TanStack Query in their own layer).
79
+ */
80
+ export declare function useOptionsResolver(args: UseOptionsResolverArgs): UseOptionsResolverResult;
81
+ /**
82
+ * Normalizes the wire shape into ResolvedOption. The kernel returns dual
83
+ * id/value and label/name fields for legacy parity — we accept either
84
+ * and surface a stable shape downstream.
85
+ */
86
+ export declare function projectOption(raw: any): ResolvedOption;
87
+ //# sourceMappingURL=use-options-resolver.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"use-options-resolver.d.ts","sourceRoot":"","sources":["../src/use-options-resolver.ts"],"names":[],"mappings":"AAeA,MAAM,WAAW,cAAc;IAC3B,8CAA8C;IAC9C,EAAE,EAAE,MAAM,GAAG,MAAM,CAAA;IACnB,2DAA2D;IAC3D,KAAK,EAAE,MAAM,GAAG,MAAM,CAAA;IACtB,sBAAsB;IACtB,KAAK,EAAE,MAAM,CAAA;IACb,8DAA8D;IAC9D,IAAI,EAAE,MAAM,CAAA;IACZ,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IAC3B,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACrB,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACrB,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CACvB;AAED,MAAM,WAAW,WAAW;IACxB,oEAAoE;IACpE,IAAI,EAAE,QAAQ,GAAG,SAAS,GAAG,MAAM,CAAA;IACnC,2DAA2D;IAC3D,KAAK,EAAE,MAAM,CAAA;CAChB;AAED,MAAM,WAAW,sBAAsB;IACnC;;;;OAIG;IACH,QAAQ,EAAE,MAAM,CAAA;IAChB;;OAEG;IACH,QAAQ,EAAE,MAAM,CAAA;IAChB;;;;;OAKG;IACH,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ;;;OAGG;IACH,KAAK,CAAC,EAAE,MAAM,CAAA;IACd;;;OAGG;IACH,KAAK,CAAC,EAAE,MAAM,CAAA;IACd;;;OAGG;IACH,OAAO,CAAC,EAAE,OAAO,CAAA;IACjB;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;CACpB;AAED,MAAM,WAAW,wBAAwB;IACrC,OAAO,EAAE,cAAc,EAAE,CAAA;IACzB,IAAI,EAAE,WAAW,GAAG,IAAI,CAAA;IACxB,OAAO,EAAE,OAAO,CAAA;IAChB,KAAK,EAAE,KAAK,GAAG,IAAI,CAAA;IACnB,8DAA8D;IAC9D,OAAO,EAAE,MAAM,IAAI,CAAA;CACtB;AAED;;;;;;;;;GASG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,sBAAsB,GAAG,wBAAwB,CAiHzF;AAED;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,GAAG,EAAE,GAAG,GAAG,cAAc,CAatD"}
@@ -0,0 +1,147 @@
1
+ // useOptionsResolver — single hook the SDK uses to fetch select options
2
+ // for a metadata-driven field. Replaces the ad-hoc `/data/<model>` reads
3
+ // that DynamicForm and DynamicRelation used to do.
4
+ //
5
+ // Contract (matches kernel ≥ v0.9.0):
6
+ // GET /api/options/:model?field=<key>&q=<text>&limit=<n>
7
+ // → { success: true, data: Option[], meta: { type: 'static'|'dynamic', count } }
8
+ //
9
+ // The hook prefers `ColumnDef.Ref` (auto-derived by the kernel from
10
+ // belongs_to relations) over a hand-wired `searchEndpoint`. Apps that
11
+ // adopt Ref via the kernel auto-derivation get the right behaviour for
12
+ // free; legacy callers that still ship `searchEndpoint` keep working.
13
+ import { useEffect, useMemo, useRef, useState } from 'react';
14
+ import { useApi } from './api-context';
15
+ /**
16
+ * Resolves select options for a field via the canonical
17
+ * `/api/options/:model?field=…` endpoint. Returns the v0.9.0 envelope
18
+ * `{ data, meta: { type, count } }` projected into a stable shape.
19
+ *
20
+ * The hook is intentionally minimal: it does NOT debounce `query`
21
+ * (callers should hold the controlled value and pass it post-debounce)
22
+ * and does NOT cache across hook instances (apps that need shared state
23
+ * compose this with TanStack Query in their own layer).
24
+ */
25
+ export function useOptionsResolver(args) {
26
+ const { modelKey, fieldKey, ref, query, limit, enabled = true, endpoint, } = args;
27
+ const api = useApi();
28
+ const [options, setOptions] = useState([]);
29
+ const [meta, setMeta] = useState(null);
30
+ const [loading, setLoading] = useState(false);
31
+ const [error, setError] = useState(null);
32
+ // refreshKey is bumped by `refetch` to force the effect to re-run
33
+ // even when none of the input args changed.
34
+ const [refreshKey, setRefreshKey] = useState(0);
35
+ // The URL the hook hits. Ref wins over modelKey because the kernel's
36
+ // auto-derivation makes ref the canonical pointer; a manual endpoint
37
+ // wins over both as the explicit override.
38
+ const url = useMemo(() => {
39
+ if (endpoint)
40
+ return endpoint;
41
+ if (ref)
42
+ return `/options/${ref}`;
43
+ if (!modelKey)
44
+ return '';
45
+ return `/options/${modelKey}`;
46
+ }, [endpoint, ref, modelKey]);
47
+ // The field to query. When using `ref` the canonical lookup field is
48
+ // `id` (FK targets the target model's PK), unless the caller wants
49
+ // to override that explicitly via `fieldKey`. We only inject the `id`
50
+ // default when `ref` is set AND `fieldKey` is empty.
51
+ const effectiveField = useMemo(() => {
52
+ if (fieldKey)
53
+ return fieldKey;
54
+ if (ref)
55
+ return 'id';
56
+ return '';
57
+ }, [fieldKey, ref]);
58
+ // Track the in-flight controller so a new fetch can abort the
59
+ // previous one — matters for typeahead callers passing changing `query`.
60
+ const abortRef = useRef(null);
61
+ useEffect(() => {
62
+ if (!enabled || !url || !effectiveField) {
63
+ setOptions([]);
64
+ setMeta(null);
65
+ setLoading(false);
66
+ setError(null);
67
+ return;
68
+ }
69
+ // Cancel any pending request before issuing a new one.
70
+ abortRef.current?.abort();
71
+ const controller = new AbortController();
72
+ abortRef.current = controller;
73
+ setLoading(true);
74
+ setError(null);
75
+ const params = { field: effectiveField };
76
+ if (query)
77
+ params.q = query;
78
+ if (typeof limit === 'number' && limit > 0)
79
+ params.limit = limit;
80
+ api.get(url, { params, signal: controller.signal })
81
+ .then((res) => {
82
+ if (controller.signal.aborted)
83
+ return;
84
+ const body = res.data;
85
+ if (!body || body.success !== true) {
86
+ throw new Error(body?.message || 'options resolver: unsuccessful response');
87
+ }
88
+ const rawOptions = Array.isArray(body.data) ? body.data : [];
89
+ const projected = rawOptions.map(projectOption);
90
+ setOptions(projected);
91
+ // v0.9.0 envelope: meta.type / meta.count. We tolerate
92
+ // older deployments that still emit a root-level `type`
93
+ // by reading either spot — the projection prefers the
94
+ // canonical location so the SDK guides apps to the new
95
+ // shape without breaking grace-period upgrades.
96
+ const metaPayload = body.meta && typeof body.meta === 'object'
97
+ ? body.meta
98
+ : { type: body.type, count: rawOptions.length };
99
+ setMeta({
100
+ type: metaPayload?.type ?? 'dynamic',
101
+ count: typeof metaPayload?.count === 'number'
102
+ ? metaPayload.count
103
+ : rawOptions.length,
104
+ });
105
+ })
106
+ .catch((err) => {
107
+ if (controller.signal.aborted)
108
+ return;
109
+ setError(err instanceof Error ? err : new Error(String(err)));
110
+ setOptions([]);
111
+ setMeta(null);
112
+ })
113
+ .finally(() => {
114
+ if (!controller.signal.aborted)
115
+ setLoading(false);
116
+ });
117
+ return () => {
118
+ controller.abort();
119
+ };
120
+ }, [api, url, effectiveField, query, limit, enabled, refreshKey]);
121
+ return {
122
+ options,
123
+ meta,
124
+ loading,
125
+ error,
126
+ refetch: () => setRefreshKey((k) => k + 1),
127
+ };
128
+ }
129
+ /**
130
+ * Normalizes the wire shape into ResolvedOption. The kernel returns dual
131
+ * id/value and label/name fields for legacy parity — we accept either
132
+ * and surface a stable shape downstream.
133
+ */
134
+ export function projectOption(raw) {
135
+ const id = raw?.id ?? raw?.value ?? '';
136
+ const label = String(raw?.label ?? raw?.name ?? id ?? '');
137
+ return {
138
+ id,
139
+ value: raw?.value ?? id,
140
+ label,
141
+ name: String(raw?.name ?? label),
142
+ description: raw?.description ?? null,
143
+ image: raw?.image ?? null,
144
+ color: raw?.color ?? null,
145
+ icon: raw?.icon ?? null,
146
+ };
147
+ }
@@ -0,0 +1,28 @@
1
+ export interface OrgConfigBridge {
2
+ /** Resolves a `$org.<key>` reference (or plain key) to a literal id. */
3
+ resolveValidator: (refOrKey: string) => string | null;
4
+ /** When true the app actually has a provider mounted. */
5
+ available: boolean;
6
+ }
7
+ /**
8
+ * Apps that consume `runtime-react` AND `@asteby/metacore-app-providers`
9
+ * call this once near the root (typically inside the OrgConfigProvider
10
+ * children) so the SDK reads the same resolver. Hosts without an org
11
+ * provider can ignore this entirely; the SDK's null bridge keeps every
12
+ * call returning `null` so $org.<key> tokens stay verbatim in the form
13
+ * — same fallback the kernel uses for unresolved references.
14
+ */
15
+ export declare function setOrgConfigBridge(bridge: OrgConfigBridge | null): void;
16
+ /**
17
+ * Returns the active bridge. Pure read — no React hook so it can be
18
+ * called from non-component code (zod schema builders, helpers).
19
+ */
20
+ export declare function getOrgConfigBridge(): OrgConfigBridge;
21
+ /**
22
+ * Resolves a Validation token into the validator identifier the SDK
23
+ * should apply. Returns the resolved literal when the org config knows
24
+ * the key, or the original token when it doesn't (so apps can decide).
25
+ * Plain literals (no `$org.` prefix) pass through.
26
+ */
27
+ export declare function resolveValidatorToken(token: string | undefined | null): string | null;
28
+ //# sourceMappingURL=use-org-config-bridge.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"use-org-config-bridge.d.ts","sourceRoot":"","sources":["../src/use-org-config-bridge.ts"],"names":[],"mappings":"AAcA,MAAM,WAAW,eAAe;IAC5B,wEAAwE;IACxE,gBAAgB,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,MAAM,GAAG,IAAI,CAAA;IACrD,yDAAyD;IACzD,SAAS,EAAE,OAAO,CAAA;CACrB;AASD;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,eAAe,GAAG,IAAI,QAEhE;AAED;;;GAGG;AACH,wBAAgB,kBAAkB,IAAI,eAAe,CAEpD;AAED;;;;;GAKG;AACH,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,GAAG,MAAM,GAAG,IAAI,CAKrF"}
@@ -0,0 +1,50 @@
1
+ // Bridge to `useOrgConfig` from `@asteby/metacore-app-providers` without
2
+ // adding it as a hard dependency of `runtime-react`. The provider package
3
+ // is a peer; in apps that mount it the hook returns the live config, in
4
+ // apps that don't the SDK falls through to a no-op shim that resolves
5
+ // every reference to null. Forms then leave $org.<key> tokens in place
6
+ // rather than crashing — the operator notices the missing config when
7
+ // the validator fails to fire, not at app boot.
8
+ //
9
+ // Why a bridge: runtime-react cannot import `@asteby/metacore-app-providers`
10
+ // directly without inverting the dependency graph (app-providers depends
11
+ // on runtime-react today via peerDependenciesMeta). The shim shape
12
+ // matches `OrgConfigContextValue` so DynamicForm code reads through one
13
+ // stable interface regardless of provider mount.
14
+ const NULL_BRIDGE = {
15
+ resolveValidator: () => null,
16
+ available: false,
17
+ };
18
+ let activeBridge = NULL_BRIDGE;
19
+ /**
20
+ * Apps that consume `runtime-react` AND `@asteby/metacore-app-providers`
21
+ * call this once near the root (typically inside the OrgConfigProvider
22
+ * children) so the SDK reads the same resolver. Hosts without an org
23
+ * provider can ignore this entirely; the SDK's null bridge keeps every
24
+ * call returning `null` so $org.<key> tokens stay verbatim in the form
25
+ * — same fallback the kernel uses for unresolved references.
26
+ */
27
+ export function setOrgConfigBridge(bridge) {
28
+ activeBridge = bridge ?? NULL_BRIDGE;
29
+ }
30
+ /**
31
+ * Returns the active bridge. Pure read — no React hook so it can be
32
+ * called from non-component code (zod schema builders, helpers).
33
+ */
34
+ export function getOrgConfigBridge() {
35
+ return activeBridge;
36
+ }
37
+ /**
38
+ * Resolves a Validation token into the validator identifier the SDK
39
+ * should apply. Returns the resolved literal when the org config knows
40
+ * the key, or the original token when it doesn't (so apps can decide).
41
+ * Plain literals (no `$org.` prefix) pass through.
42
+ */
43
+ export function resolveValidatorToken(token) {
44
+ if (!token)
45
+ return null;
46
+ if (!token.startsWith('$org.'))
47
+ return token;
48
+ const resolved = activeBridge.resolveValidator(token);
49
+ return resolved ?? token;
50
+ }
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.2.0",
4
4
  "description": "React runtime for metacore hosts — renders addon contributions dynamically",
5
5
  "repository": {
6
6
  "type": "git",
@@ -56,7 +56,8 @@
56
56
  "react-dom": "^19.2.4",
57
57
  "react-i18next": "^17.0.0",
58
58
  "sonner": "^2.0.0",
59
- "typescript": "^5.6.0",
59
+ "tsx": "^4.21.0",
60
+ "typescript": "^6.0.0",
60
61
  "vitest": "^4.0.0",
61
62
  "zustand": "^5.0.0",
62
63
  "@asteby/metacore-sdk": "2.4.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,127 @@
1
+ import { afterEach, describe, it, expect, vi } from 'vitest'
2
+ import { projectOption } from '../use-options-resolver'
3
+ import {
4
+ resolveValidatorToken,
5
+ setOrgConfigBridge,
6
+ } from '../use-org-config-bridge'
7
+
8
+ // `useOptionsResolver` itself is a React hook and would need jsdom +
9
+ // react-test-renderer to exercise end-to-end. The bridge tests here
10
+ // pin down the projection layer (the only impure shape conversion the
11
+ // hook performs) so consumers can rely on the v0.9.0 envelope reading
12
+ // without spinning up a renderer.
13
+
14
+ describe('projectOption', () => {
15
+ it('mirrors id into value and label into name when missing', () => {
16
+ const out = projectOption({ id: 'abc', label: 'Hello' })
17
+ expect(out.id).toBe('abc')
18
+ expect(out.value).toBe('abc')
19
+ expect(out.label).toBe('Hello')
20
+ expect(out.name).toBe('Hello')
21
+ })
22
+
23
+ it('preserves explicit value and name when provided', () => {
24
+ const out = projectOption({ id: 1, value: 'one', label: 'L', name: 'N' })
25
+ expect(out.value).toBe('one')
26
+ expect(out.name).toBe('N')
27
+ })
28
+
29
+ it('coerces label to string from numeric id when none provided', () => {
30
+ const out = projectOption({ id: 42 })
31
+ expect(out.label).toBe('42')
32
+ expect(out.name).toBe('42')
33
+ })
34
+
35
+ it('preserves optional decoration fields', () => {
36
+ const out = projectOption({
37
+ id: 'x', label: 'X',
38
+ description: 'desc', image: '/a.png',
39
+ color: '#fff', icon: 'IconStar',
40
+ })
41
+ expect(out.description).toBe('desc')
42
+ expect(out.image).toBe('/a.png')
43
+ expect(out.color).toBe('#fff')
44
+ expect(out.icon).toBe('IconStar')
45
+ })
46
+
47
+ it('null-safes missing optionals to null', () => {
48
+ const out = projectOption({ id: 'x', label: 'X' })
49
+ expect(out.description).toBeNull()
50
+ expect(out.image).toBeNull()
51
+ expect(out.color).toBeNull()
52
+ expect(out.icon).toBeNull()
53
+ })
54
+
55
+ it('survives empty payload (defensive)', () => {
56
+ const out = projectOption({})
57
+ expect(out.id).toBe('')
58
+ expect(out.value).toBe('')
59
+ expect(out.label).toBe('')
60
+ expect(out.name).toBe('')
61
+ })
62
+ })
63
+
64
+ // The envelope shape the hook expects from the kernel is exercised here
65
+ // with a mocked transport so apps can document the wire contract in a
66
+ // single place. `useOptionsResolver` reads `body.data` for options and
67
+ // `body.meta.{type, count}` for the discriminator — the legacy
68
+ // root-level `body.type` is also accepted for grace-period upgrades.
69
+ describe('options envelope contract', () => {
70
+ it('v0.9.0 shape carries meta.type and meta.count', () => {
71
+ const wire = {
72
+ success: true,
73
+ data: [
74
+ { id: '1', label: 'One' },
75
+ { id: '2', label: 'Two' },
76
+ ],
77
+ meta: { type: 'dynamic', count: 2 },
78
+ }
79
+ // Smoke-check the projection a real call would do.
80
+ expect(wire.data.map(projectOption)).toHaveLength(2)
81
+ expect(wire.meta.type).toBe('dynamic')
82
+ expect(wire.meta.count).toBe(2)
83
+ })
84
+
85
+ it('legacy shape is identifiable but consumers should migrate', () => {
86
+ const legacy = {
87
+ success: true,
88
+ data: [{ id: '1', label: 'One' }],
89
+ // root-level type, not under meta — the SDK reads it as a
90
+ // fallback but logs no warning (kernel ≥ v0.9.0 emits the
91
+ // canonical shape; older deployments are an interop case).
92
+ type: 'static',
93
+ } as any
94
+ expect(legacy.type).toBe('static')
95
+ expect(legacy.meta).toBeUndefined()
96
+ })
97
+ })
98
+
99
+ // Sanity-check the resolver bridge: when no provider is mounted
100
+ // `resolveValidatorToken` returns the original token. Apps that mount
101
+ // `OrgConfigProvider` swap that for the resolved literal.
102
+ describe('OrgConfigBridge integration', () => {
103
+ afterEach(() => {
104
+ // Reset to the null bridge so independent tests do not leak state.
105
+ setOrgConfigBridge(null)
106
+ })
107
+
108
+ it('resolveValidatorToken passes through plain literals', () => {
109
+ expect(resolveValidatorToken('mx.rfc')).toBe('mx.rfc')
110
+ expect(resolveValidatorToken(null)).toBeNull()
111
+ expect(resolveValidatorToken('')).toBeNull()
112
+ })
113
+
114
+ it('resolveValidatorToken returns the $org reference verbatim when no bridge mounted', () => {
115
+ // Default null bridge: ref keys resolve to null → token preserved.
116
+ expect(resolveValidatorToken('$org.tax_id')).toBe('$org.tax_id')
117
+ })
118
+
119
+ it('setOrgConfigBridge swaps the active resolver and survives clearing', () => {
120
+ const spy = vi.fn((key: string) => (key === '$org.tax_id' ? 'mx.rfc' : null))
121
+ setOrgConfigBridge({ resolveValidator: spy, available: true })
122
+ expect(resolveValidatorToken('$org.tax_id')).toBe('mx.rfc')
123
+ expect(spy).toHaveBeenCalledWith('$org.tax_id')
124
+ setOrgConfigBridge(null)
125
+ expect(resolveValidatorToken('$org.tax_id')).toBe('$org.tax_id')
126
+ })
127
+ })
@@ -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)