@asteby/metacore-runtime-react 13.1.0 → 13.3.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,33 @@
1
1
  # @asteby/metacore-runtime-react
2
2
 
3
+ ## 13.3.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 99477d6: feat(runtime-react): add `dynamic_select` field widget — async searchable FK picker
8
+
9
+ Declarative answer to "I don't want to type a raw FK UUID". A field with
10
+ `type: "dynamic_select"` (or `widget: "dynamic_select"`) + `ref` renders a
11
+ typeahead combobox that queries the canonical options endpoint as the user
12
+ types (`GET /api/options/<ref>?field=id&q=<text>&limit=<n>`), reusing
13
+ `useOptionsResolver` (debounced, abortable). Works both as a flat form field
14
+ and as a line-items column cell (e.g. the account_id per debit/credit row of a
15
+ journal entry). The metacore equivalent of 7leguas' `type: search`, driven
16
+ entirely from the manifest — addons get a searchable picker with zero custom
17
+ React, keeping custom federated frontends for genuinely page-level UIs (POS).
18
+
19
+ ## 13.2.0
20
+
21
+ ### Minor Changes
22
+
23
+ - fb45ad4: Migrate federation tooling from the broken `@originjs/vite-plugin-federation` to the official `@module-federation/vite` + `@module-federation/runtime`.
24
+
25
+ **BREAKING (federation runtime swap — hosts and addons must rebuild):**
26
+ - `metacoreFederationShared()` (starter-config) now returns a `@module-federation/vite` `federation()` config instead of an `@originjs` config. Same signature/call-sites: `metacoreFederationShared({ host })` → host config (name + shared, empty remotes — remotes register dynamically at runtime); `metacoreFederationShared({ host, exposes })` → remote config (name + filename + exposes + shared). Hosts MUST switch their `vite.config.ts` to `import { federation } from '@module-federation/vite'`.
27
+ - The shared singleton list is now `{ singleton: true }` (no `requiredVersion: false`) and matches the addon + ops host contract exactly: `react`, `react-dom`, `react/jsx-runtime`, `react-i18next`, `i18next`, `@asteby/metacore-ui`, `@asteby/metacore-runtime-react`, `@asteby/metacore-sdk`, `@asteby/metacore-app-providers`, `@asteby/metacore-theme`, `@asteby/metacore-auth`. **Build-time gotcha:** `@module-federation/vite` resolves every shared bare specifier at build time, so each must be an installed (dev)dependency of the building package.
28
+ - `AddonLoader` (runtime-react) now uses `@module-federation/runtime` (`registerRemotes` + `loadRemote`) instead of the manual `init`/`get`/`window[scope]` machinery. The host's `@module-federation/vite` build auto-initialises the shared scope, so the remote consumes the HOST's React/SDK singletons — fixing the `useState`-null crash.
29
+ - `clearFederationContainer()` is now a deprecated no-op under the MF runtime (container replacement on hot-swap is handled by `registerRemotes(..., { force: true })`).
30
+
3
31
  ## 13.1.0
4
32
 
5
33
  ### Minor Changes
@@ -1,15 +1,8 @@
1
1
  import type { AddonAPI, AddonLayout } from '@asteby/metacore-sdk';
2
- declare global {
3
- interface Window {
4
- [key: string]: any;
5
- __webpack_init_sharing__?: (scope: string) => Promise<void>;
6
- __webpack_share_scopes__?: Record<string, unknown>;
7
- }
8
- }
9
2
  export interface AddonLoaderProps {
10
3
  /** Unique key of the addon — maps to the federation container name. */
11
4
  scope: string;
12
- /** URL of the addon's remoteEntry.js bundle. */
5
+ /** URL of the addon's remoteEntry.js bundle (may carry a `?v=` cache-bust). */
13
6
  url: string;
14
7
  /** Exposed module to import from the remote (e.g. './register'). */
15
8
  module?: string;
@@ -1 +1 @@
1
- {"version":3,"file":"addon-loader.d.ts","sourceRoot":"","sources":["../src/addon-loader.tsx"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,QAAQ,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAA;AAGjE,OAAO,CAAC,MAAM,CAAC;IACX,UAAU,MAAM;QACZ,CAAC,GAAG,EAAE,MAAM,GAAG,GAAG,CAAA;QAClB,wBAAwB,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAA;QAC3D,wBAAwB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;KACrD;CACJ;AAED,MAAM,WAAW,gBAAgB;IAC7B,uEAAuE;IACvE,KAAK,EAAE,MAAM,CAAA;IACb,gDAAgD;IAChD,GAAG,EAAE,MAAM,CAAA;IACX,oEAAoE;IACpE,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,+DAA+D;IAC/D,GAAG,EAAE,QAAQ,CAAA;IACb,wCAAwC;IACxC,QAAQ,CAAC,EAAE,KAAK,CAAC,SAAS,CAAA;IAC1B,yDAAyD;IACzD,OAAO,CAAC,EAAE,MAAM,IAAI,CAAA;IACpB,+BAA+B;IAC/B,OAAO,CAAC,EAAE,CAAC,GAAG,EAAE,KAAK,KAAK,IAAI,CAAA;IAC9B;;;;;;;;;;OAUG;IACH,MAAM,CAAC,EAAE,WAAW,CAAA;IACpB,QAAQ,CAAC,EAAE,KAAK,CAAC,SAAS,CAAA;CAC7B;AAqFD,wBAAgB,WAAW,CAAC,EACxB,KAAK,EACL,GAAG,EACH,MAAqB,EACrB,GAAG,EACH,QAAe,EACf,OAAO,EACP,OAAO,EACP,MAAM,EACN,QAAQ,GACX,EAAE,gBAAgB,2CAoClB"}
1
+ {"version":3,"file":"addon-loader.d.ts","sourceRoot":"","sources":["../src/addon-loader.tsx"],"names":[],"mappings":"AAcA,OAAO,KAAK,EAAE,QAAQ,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAA;AAGjE,MAAM,WAAW,gBAAgB;IAC7B,uEAAuE;IACvE,KAAK,EAAE,MAAM,CAAA;IACb,+EAA+E;IAC/E,GAAG,EAAE,MAAM,CAAA;IACX,oEAAoE;IACpE,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,+DAA+D;IAC/D,GAAG,EAAE,QAAQ,CAAA;IACb,wCAAwC;IACxC,QAAQ,CAAC,EAAE,KAAK,CAAC,SAAS,CAAA;IAC1B,yDAAyD;IACzD,OAAO,CAAC,EAAE,MAAM,IAAI,CAAA;IACpB,+BAA+B;IAC/B,OAAO,CAAC,EAAE,CAAC,GAAG,EAAE,KAAK,KAAK,IAAI,CAAA;IAC9B;;;;;;;;;;OAUG;IACH,MAAM,CAAC,EAAE,WAAW,CAAA;IACpB,QAAQ,CAAC,EAAE,KAAK,CAAC,SAAS,CAAA;CAC7B;AA6CD,wBAAgB,WAAW,CAAC,EACxB,KAAK,EACL,GAAG,EACH,MAAqB,EACrB,GAAG,EACH,QAAe,EACf,OAAO,EACP,OAAO,EACP,MAAM,EACN,QAAQ,GACX,EAAE,gBAAgB,2CA8ClB"}
@@ -1,81 +1,46 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- // Minimal federated-module addon loader. Injects a remoteEntry.js <script>,
3
- // waits for the `window[scope]` container to initialize, then calls the
4
- // addon's `register(api)` export with the AddonAPI injected by the host.
2
+ // Federated-module addon loader, built on the official Module Federation
3
+ // runtime (`@module-federation/runtime`). Per addon it registers the remote's
4
+ // `remoteEntry.js` as an ESM container, loads the exposed `./register` module,
5
+ // and calls `register(api)` with the AddonAPI injected by the host.
6
+ //
7
+ // Why @module-federation/runtime (not the old manual init/get machinery):
8
+ // the host's Vite build uses `@module-federation/vite`'s `federation()` plugin,
9
+ // which auto-initialises the shared scope at host boot. `registerRemotes` +
10
+ // `loadRemote` then transparently wire the remote into that already-initialised
11
+ // share scope — so the remote consumes the HOST's React/SDK singletons instead
12
+ // of bundling its own. That's the whole point: it fixes the `useState`-null
13
+ // crash WITHOUT this loader ever touching a share scope manually.
5
14
  import { useEffect, useRef, useState } from 'react';
15
+ import { registerRemotes, loadRemote } from '@module-federation/runtime';
6
16
  import { useDeclareAddonLayout } from './addon-layout-context';
7
- // Runtime dynamic import of an external URL. Wrapped in `new Function` so no
8
- // build tool (tsc here, Vite in the consuming host) tries to statically
9
- // analyse or rewrite the import it stays a genuine runtime ESM fetch.
10
- const importModule = new Function('u', 'return import(u)');
11
- const loadedScripts = new Map();
12
- function loadScript(url, scope) {
13
- const key = `${scope}::${url}`;
14
- const existing = loadedScripts.get(key);
15
- if (existing)
16
- return existing;
17
- const promise = new Promise((resolve, reject) => {
18
- const el = document.createElement('script');
19
- el.src = url;
20
- el.type = 'text/javascript';
21
- el.async = true;
22
- el.onload = () => resolve();
23
- el.onerror = () => reject(new Error(`Failed to load addon script: ${url}`));
24
- document.head.appendChild(el);
25
- });
26
- loadedScripts.set(key, promise);
27
- return promise;
17
+ // `registerRemotes` is additive + idempotent across re-mounts; we still track
18
+ // which scopes we've registered to avoid redundant `force` churn (each `force`
19
+ // re-register wipes that remote's module cache and logs a runtime warning).
20
+ const registered = new Set();
21
+ // Derive the `loadRemote` id from the scope + exposed module name. MF resolves
22
+ // `"<remoteName>/<expose>"` e.g. `metacore_tickets/register` for the
23
+ // `"./register"` expose. We strip the leading `./` of the expose path.
24
+ function remoteId(scope, module) {
25
+ const expose = module.replace(/^\.\//, '');
26
+ return `${scope}/${expose}`;
28
27
  }
29
- const esmContainers = new Map();
30
- // Resolve a federation container for the remote. Vite/@originjs remotes built
31
- // with `format:"esm"` (our standard) are ES modules that top-level `import`
32
- // their preload helper and export `{ init, get }` they MUST be loaded as a
33
- // module (a classic <script> throws "Cannot use import statement outside a
34
- // module"), so we dynamic-import them and use the module namespace as the
35
- // container. Legacy "var"/window remotes (which assign `window[scope]`) are
36
- // still supported via the classic <script> fallback.
37
- async function resolveContainer(scope, url) {
38
- const key = `${scope}::${url}`;
39
- const cached = esmContainers.get(key);
40
- if (cached)
41
- return cached;
42
- const p = (async () => {
43
- try {
44
- const mod = await importModule(url);
45
- if (mod && typeof mod.init === 'function' && typeof mod.get === 'function') {
46
- return mod;
47
- }
48
- }
49
- catch {
50
- // Not an importable module (legacy var-format remote) — fall back.
51
- }
52
- await loadScript(url, scope);
53
- return window[scope];
54
- })();
55
- esmContainers.set(key, p);
56
- p.catch(() => esmContainers.delete(key));
57
- return p;
58
- }
59
- async function loadRemote(scope, url, module) {
60
- if (typeof window.__webpack_init_sharing__ === 'function') {
61
- await window.__webpack_init_sharing__('default');
62
- }
63
- const container = await resolveContainer(scope, url);
64
- if (!container) {
65
- throw new Error(`Addon container "${scope}" not found (neither ESM export nor window[scope])`);
28
+ async function loadAddon(scope, url, module) {
29
+ // Register the remote container as an ES module. `type: 'module'` matches
30
+ // the `@module-federation/vite` remote (remoteEntry.js is an ESM bundle).
31
+ // The `url` already carries the `?v=` cache-bust the host computed, so the
32
+ // browser refetches a fresh remoteEntry when the addon version changes.
33
+ if (!registered.has(scope)) {
34
+ registerRemotes([{ name: scope, entry: url, type: 'module' }],
35
+ // `force: true` so a re-registration with a new `?v=` URL (addon
36
+ // hot-swap / version bump) overwrites the stale entry + cache.
37
+ { force: true });
38
+ registered.add(scope);
66
39
  }
67
- if (typeof container.init === 'function') {
68
- const shareScope = window.__webpack_share_scopes__?.default ??
69
- (window.__METACORE_SHARE_SCOPE__ ??= {});
70
- try {
71
- await container.init(shareScope);
72
- }
73
- catch {
74
- // Container already initialized (re-entrant load) — safe to ignore.
75
- }
76
- }
77
- const factory = await container.get(module);
78
- return factory();
40
+ // loadRemote("<scope>/<expose>") returns the exposed module namespace (or
41
+ // null if it can't be resolved). No manual share-scope init — the host's
42
+ // federation runtime already initialised it.
43
+ return loadRemote(remoteId(scope, module));
79
44
  }
80
45
  export function AddonLoader({ scope, url, module = './register', api, fallback = null, onReady, onError, layout, children, }) {
81
46
  const [status, setStatus] = useState('loading');
@@ -90,12 +55,16 @@ export function AddonLoader({ scope, url, module = './register', api, fallback =
90
55
  let cancelled = false;
91
56
  (async () => {
92
57
  try {
93
- const mod = await loadRemote(scope, url, module);
58
+ const mod = await loadAddon(scope, url, module);
94
59
  if (cancelled)
95
60
  return;
96
- if (!didRegister.current && typeof mod?.register === 'function') {
61
+ const register = mod?.register ?? mod?.default;
62
+ if (typeof register !== 'function') {
63
+ throw new Error(`Addon "${scope}" module "${module}" has no register() export`);
64
+ }
65
+ if (!didRegister.current) {
97
66
  didRegister.current = true;
98
- await Promise.resolve(mod.register(api));
67
+ await Promise.resolve(register(api));
99
68
  }
100
69
  setStatus('ready');
101
70
  onReady?.();
@@ -103,12 +72,15 @@ export function AddonLoader({ scope, url, module = './register', api, fallback =
103
72
  catch (e) {
104
73
  if (cancelled)
105
74
  return;
106
- setError(e);
75
+ const err = e instanceof Error ? e : new Error(String(e));
76
+ setError(err);
107
77
  setStatus('error');
108
- onError?.(e);
78
+ onError?.(err);
109
79
  }
110
80
  })();
111
- return () => { cancelled = true; };
81
+ return () => {
82
+ cancelled = true;
83
+ };
112
84
  }, [scope, url, module]);
113
85
  if (status === 'loading')
114
86
  return _jsx(_Fragment, { children: fallback });
@@ -1 +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;AAiB9D;;;GAGG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,CAAC,CAAC,EAAE,CAAC,CAAC,SAAS,KAAK,CAAC,CAAC,SAAS,GAAG,IAAI,CAEzF;AAcD,wBAAgB,cAAc,CAAC,MAAM,EAAE,cAAc,EAAE;;kBAMtD;AAED;;;;;GAKG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAAE,cAAc,GAAG,cAAc,EAAE,CAGrE;AAED,8EAA8E;AAC9E,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,cAAc,GAAG,OAAO,CAE/D;AAqDD,wBAAgB,aAAa,CAAC,KAAK,EAAE,cAAc,GAAG,MAAM,CAU3D"}
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;AAiB9D;;;GAGG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,CAAC,CAAC,EAAE,CAAC,CAAC,SAAS,KAAK,CAAC,CAAC,SAAS,GAAG,IAAI,CAEzF;AAcD,wBAAgB,cAAc,CAAC,MAAM,EAAE,cAAc,EAAE;;kBAMtD;AAED;;;;;GAKG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAAE,cAAc,GAAG,cAAc,EAAE,CAGrE;AAED,8EAA8E;AAC9E,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,cAAc,GAAG,OAAO,CAE/D;AAqDD,wBAAgB,aAAa,CAAC,KAAK,EAAE,cAAc,GAAG,MAAM,CAa3D"}
@@ -116,6 +116,9 @@ export function resolveWidget(field) {
116
116
  switch (field.type) {
117
117
  case 'textarea': return 'textarea';
118
118
  case 'select': return 'select';
119
+ // Async searchable single-select against /api/options/<ref>. The
120
+ // declarative replacement for typing a raw FK UUID.
121
+ case 'dynamic_select': return 'dynamic_select';
119
122
  case 'boolean': return 'switch';
120
123
  case 'number': return 'number';
121
124
  case 'date': return 'date';
@@ -2,6 +2,7 @@ import type { ActionFieldDef } from './types';
2
2
  import { buildZodSchema, resolveWidget } from './dynamic-form-schema';
3
3
  export { buildZodSchema, resolveWidget };
4
4
  export { DynamicLineItems } from './dynamic-line-items';
5
+ export { DynamicSelectField } from './dynamic-select-field';
5
6
  export interface DynamicFormProps {
6
7
  fields: ActionFieldDef[];
7
8
  initialValues?: Record<string, any>;
@@ -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;AAC7C,OAAO,EAAE,cAAc,EAAE,aAAa,EAAoB,MAAM,uBAAuB,CAAA;AAIvF,OAAO,EAAE,cAAc,EAAE,aAAa,EAAE,CAAA;AACxC,OAAO,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAA;AAEvD,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,2CAoElB"}
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,EAAoB,MAAM,uBAAuB,CAAA;AAKvF,OAAO,EAAE,cAAc,EAAE,aAAa,EAAE,CAAA;AACxC,OAAO,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAA;AACvD,OAAO,EAAE,kBAAkB,EAAE,MAAM,wBAAwB,CAAA;AAE3D,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,2CAoElB"}
@@ -7,8 +7,10 @@ import { Input, Textarea, Label, Switch, Button, Select, SelectContent, SelectIt
7
7
  import { buildZodSchema, resolveWidget, isLineItemsField } from './dynamic-form-schema';
8
8
  import { useOptionsResolver } from './use-options-resolver';
9
9
  import { DynamicLineItems } from './dynamic-line-items';
10
+ import { DynamicSelectField } from './dynamic-select-field';
10
11
  export { buildZodSchema, resolveWidget };
11
12
  export { DynamicLineItems } from './dynamic-line-items';
13
+ export { DynamicSelectField } from './dynamic-select-field';
12
14
  export function DynamicForm({ fields, initialValues, onSubmit, onCancel, submitLabel = 'Guardar', cancelLabel = 'Cancelar', disabled = false, }) {
13
15
  const [values, setValues] = useState({});
14
16
  const [errors, setErrors] = useState({});
@@ -58,6 +60,12 @@ function FieldRenderer({ field, value, onChange }) {
58
60
  return _jsx(DynamicLineItems, { field: field, value: value, onChange: onChange });
59
61
  }
60
62
  const widget = resolveWidget(field);
63
+ // Async searchable picker (typeahead against /api/options/<ref>?q=…).
64
+ // Preferred for FK fields with large option sets — no UUID typing, no
65
+ // dumping every row into a plain <select>.
66
+ if (widget === 'dynamic_select') {
67
+ return _jsx(DynamicSelectField, { field: field, value: value, onChange: onChange });
68
+ }
61
69
  // Ref-driven select: hook into useOptionsResolver so the canonical
62
70
  // /api/options/<ref>?field=id endpoint feeds the dropdown. This is
63
71
  // the path the kernel auto-derives for FK columns; legacy callers
@@ -1 +1 @@
1
- {"version":3,"file":"dynamic-line-items.d.ts","sourceRoot":"","sources":["../src/dynamic-line-items.tsx"],"names":[],"mappings":"AAqBA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,SAAS,CAAA;AAI7C,MAAM,WAAW,qBAAqB;IAClC,KAAK,EAAE,cAAc,CAAA;IACrB,KAAK,EAAE,GAAG,EAAE,GAAG,SAAS,CAAA;IACxB,QAAQ,EAAE,CAAC,IAAI,EAAE,GAAG,EAAE,KAAK,IAAI,CAAA;IAC/B,QAAQ,CAAC,EAAE,OAAO,CAAA;CACrB;AAUD,wBAAgB,gBAAgB,CAAC,EAAE,KAAK,EAAE,KAAK,EAAE,QAAQ,EAAE,QAAgB,EAAE,EAAE,qBAAqB,2CAwEnG"}
1
+ {"version":3,"file":"dynamic-line-items.d.ts","sourceRoot":"","sources":["../src/dynamic-line-items.tsx"],"names":[],"mappings":"AAqBA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,SAAS,CAAA;AAK7C,MAAM,WAAW,qBAAqB;IAClC,KAAK,EAAE,cAAc,CAAA;IACrB,KAAK,EAAE,GAAG,EAAE,GAAG,SAAS,CAAA;IACxB,QAAQ,EAAE,CAAC,IAAI,EAAE,GAAG,EAAE,KAAK,IAAI,CAAA;IAC/B,QAAQ,CAAC,EAAE,OAAO,CAAA;CACrB;AAUD,wBAAgB,gBAAgB,CAAC,EAAE,KAAK,EAAE,KAAK,EAAE,QAAQ,EAAE,QAAgB,EAAE,EAAE,qBAAqB,2CAwEnG"}
@@ -11,6 +11,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
11
11
  import { Input, Textarea, Switch, Button, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@asteby/metacore-ui/primitives';
12
12
  import { Plus, Trash2 } from 'lucide-react';
13
13
  import { resolveWidget, getItemFields } from './dynamic-form-schema';
14
+ import { DynamicSelectField } from './dynamic-select-field';
14
15
  import { useOptionsResolver } from './use-options-resolver';
15
16
  function emptyRow(itemFields) {
16
17
  const row = {};
@@ -33,6 +34,11 @@ export function DynamicLineItems({ field, value, onChange, disabled = false }) {
33
34
  // a scalar widget).
34
35
  function CellRenderer({ field, value, onChange, disabled }) {
35
36
  const widget = resolveWidget(field);
37
+ // Async searchable picker per row cell — e.g. the account_id column of a
38
+ // journal entry's debit/credit lines. Same widget as the flat form.
39
+ if (widget === 'dynamic_select') {
40
+ return _jsx(DynamicSelectField, { field: field, value: value, onChange: onChange });
41
+ }
36
42
  if (widget === 'select' && field.ref) {
37
43
  return _jsx(RefCell, { field: field, value: value, onChange: onChange, disabled: disabled });
38
44
  }
@@ -0,0 +1,9 @@
1
+ import type { ActionFieldDef } from './types';
2
+ export interface DynamicSelectFieldProps {
3
+ field: ActionFieldDef;
4
+ value: any;
5
+ onChange: (v: any) => void;
6
+ }
7
+ export declare function DynamicSelectField({ field, value, onChange }: DynamicSelectFieldProps): import("react/jsx-runtime").JSX.Element;
8
+ export default DynamicSelectField;
9
+ //# sourceMappingURL=dynamic-select-field.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"dynamic-select-field.d.ts","sourceRoot":"","sources":["../src/dynamic-select-field.tsx"],"names":[],"mappings":"AAsCA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,SAAS,CAAA;AAW7C,MAAM,WAAW,uBAAuB;IACpC,KAAK,EAAE,cAAc,CAAA;IACrB,KAAK,EAAE,GAAG,CAAA;IACV,QAAQ,EAAE,CAAC,CAAC,EAAE,GAAG,KAAK,IAAI,CAAA;CAC7B;AAED,wBAAgB,kBAAkB,CAAC,EAAE,KAAK,EAAE,KAAK,EAAE,QAAQ,EAAE,EAAE,uBAAuB,2CA0GrF;AAED,eAAe,kBAAkB,CAAA"}
@@ -0,0 +1,74 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // DynamicSelectField — async, searchable single-select for declarative forms.
3
+ //
4
+ // This is the declarative answer to "I don't want to type a raw FK UUID".
5
+ // Instead of a plain <select> that dumps every option (RefSelect) or a free
6
+ // text input, it renders a typeahead combobox that queries the canonical
7
+ // options endpoint as the user types:
8
+ //
9
+ // GET /api/options/<ref>?field=id&q=<text>&limit=<n>
10
+ //
11
+ // reusing `useOptionsResolver` (which already debounce-aborts in-flight
12
+ // requests). It is the metacore equivalent of 7leguas' `search.go` / dynamic
13
+ // `type: search` field, but driven entirely from the manifest — so an addon
14
+ // declares `type: "dynamic_select"` + `ref` and gets a searchable picker with
15
+ // zero custom React.
16
+ //
17
+ // Resolution path (highest priority first):
18
+ // 1. field.ref → /options/<ref>?field=id (canonical, preferred)
19
+ // 2. field.searchEndpoint→ used verbatim as the options endpoint (escape hatch)
20
+ //
21
+ // Edit-mode caveat: resolving an EXISTING value's label requires the id to be
22
+ // in a fetched page (we match by id against loaded options, else show the raw
23
+ // value). A dedicated `?ids=` lookup is a follow-up; create flows — the common
24
+ // case — start empty and never hit this.
25
+ import { useEffect, useState } from 'react';
26
+ import { Button, Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, Popover, PopoverContent, PopoverTrigger, } from '@asteby/metacore-ui/primitives';
27
+ import { Check, ChevronsUpDown, Loader2 } from 'lucide-react';
28
+ import { useOptionsResolver } from './use-options-resolver';
29
+ function useDebounced(value, ms) {
30
+ const [debounced, setDebounced] = useState(value);
31
+ useEffect(() => {
32
+ const t = setTimeout(() => setDebounced(value), ms);
33
+ return () => clearTimeout(t);
34
+ }, [value, ms]);
35
+ return debounced;
36
+ }
37
+ export function DynamicSelectField({ field, value, onChange }) {
38
+ const [open, setOpen] = useState(false);
39
+ const [search, setSearch] = useState('');
40
+ const debounced = useDebounced(search, 250);
41
+ // Remember the label of the option the user actually picked so the trigger
42
+ // shows a name (not a UUID) without a round-trip.
43
+ const [picked, setPicked] = useState(null);
44
+ const { options, loading } = useOptionsResolver({
45
+ modelKey: '',
46
+ fieldKey: 'id',
47
+ ref: field.ref,
48
+ // searchEndpoint only drives the URL when there's no ref — ref is the
49
+ // canonical, kernel-derived path and wins.
50
+ endpoint: field.ref ? undefined : field.searchEndpoint,
51
+ query: debounced,
52
+ limit: 20,
53
+ // Don't fetch until the popover opens (and keep fetching as the query
54
+ // changes while open).
55
+ enabled: open,
56
+ });
57
+ const selectedLabel = (picked && String(picked.id) === String(value) ? picked.label : null) ??
58
+ options.find((o) => String(o.id) === String(value))?.label ??
59
+ (value ? String(value) : '');
60
+ const handlePick = (opt) => {
61
+ setPicked(opt);
62
+ onChange(String(opt.id));
63
+ setOpen(false);
64
+ setSearch('');
65
+ };
66
+ return (_jsxs(Popover, { open: open, onOpenChange: setOpen, children: [_jsx(PopoverTrigger, { asChild: true, children: _jsxs(Button, { type: "button", variant: "outline", role: "combobox", "aria-expanded": open, id: field.key, className: "w-full justify-between font-normal", "data-empty": !value, children: [_jsx("span", { className: 'truncate ' + (selectedLabel ? '' : 'text-muted-foreground'), children: selectedLabel || field.placeholder || 'Buscar…' }), _jsx(ChevronsUpDown, { className: "ml-2 size-4 shrink-0 opacity-50" })] }) }), _jsx(PopoverContent, { className: "p-0", align: "start",
67
+ // Match the trigger width without an arbitrary Tailwind class
68
+ // (those don't always survive a consuming app's Tailwind scan).
69
+ style: { width: 'var(--radix-popover-trigger-width)' }, children: _jsxs(Command, { shouldFilter: false, children: [_jsx(CommandInput, { placeholder: field.placeholder || 'Buscar…', value: search, onValueChange: setSearch }), _jsxs(CommandList, { children: [loading && (_jsxs("div", { className: "text-muted-foreground flex items-center justify-center gap-2 py-6 text-sm", children: [_jsx(Loader2, { className: "size-4 animate-spin" }), "Buscando\u2026"] })), !loading && options.length === 0 && (_jsx(CommandEmpty, { children: debounced ? 'Sin resultados' : 'Escribí para buscar…' })), !loading && options.length > 0 && (_jsx(CommandGroup, { className: "max-h-64 overflow-auto", children: options.map((opt) => {
70
+ const isSel = String(opt.id) === String(value);
71
+ return (_jsxs(CommandItem, { value: String(opt.id), onSelect: () => handlePick(opt), children: [_jsx(Check, { className: 'mr-2 size-4 ' + (isSel ? 'opacity-100' : 'opacity-0') }), _jsxs("div", { className: "flex min-w-0 flex-col", children: [_jsx("span", { className: "truncate", children: opt.label }), opt.description && (_jsx("span", { className: "text-muted-foreground truncate text-xs", children: opt.description }))] })] }, String(opt.id)));
72
+ }) }))] })] }) })] }));
73
+ }
74
+ export default DynamicSelectField;
@@ -126,19 +126,15 @@ export declare function useHotSwapReload(client: ManifestHotSwapClient | undefin
126
126
  */
127
127
  export declare function withVersionParam(url: string, hash: string | undefined): string;
128
128
  /**
129
- * Remove the federation container previously registered on `window[scope]`.
130
- * Hosts call this from `onSwap` before letting the addon route re-mount
131
- * so the next `remoteEntry.js` injection creates a fresh container instead
132
- * of short-circuiting on the cached one.
129
+ * @deprecated Legacy `@originjs/vite-plugin-federation` helper. Under the
130
+ * current `@module-federation/runtime` loader ({@link AddonLoader}), container
131
+ * replacement on hot-swap is handled by `registerRemotes(..., { force: true })`
132
+ * with the new `?v=` URL — there is no `window[scope]` container to delete.
133
133
  *
134
- * Best-effort: if `window` is undefined (SSR) or the scope was never
135
- * registered, this is a no-op. Returns `true` if a container was actually
136
- * removed, `false` otherwise useful for telemetry.
137
- *
138
- * **Caveat:** some federation runtimes wrap the container in a Proxy
139
- * whose internal state survives `delete`. If you hit `Container already
140
- * registered` after calling this, the federation runtime is holding the
141
- * reference internally and the only reliable swap is `"page-reload"`.
134
+ * Kept for backward compatibility so existing host `onSwap` wiring keeps
135
+ * compiling. Best-effort: removes a stale `window[scope]` if a legacy
136
+ * `@originjs` remote left one behind, otherwise a no-op. Returns `true` if a
137
+ * value was removed, `false` otherwise.
142
138
  */
143
139
  export declare function clearFederationContainer(scope: string): boolean;
144
140
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"hotswap-reload-policy.d.ts","sourceRoot":"","sources":["../src/hotswap-reload-policy.ts"],"names":[],"mappings":"AA+DA,OAAO,EAEH,KAAK,2BAA2B,EAChC,KAAK,qBAAqB,EAC1B,KAAK,8BAA8B,EACtC,MAAM,+BAA+B,CAAA;AAEtC;;;;;;GAMG;AACH,MAAM,MAAM,qBAAqB,GAAG,OAAO,GAAG,aAAa,GAAG,QAAQ,CAAA;AAEtE;;;;GAIG;AACH,MAAM,WAAW,mBAAmB;IAChC,wDAAwD;IACxD,QAAQ,CAAC,EAAE,qBAAqB,CAAA;IAChC;;;;;;;;;;;;OAYG;IACH,cAAc,CAAC,EAAE,CACb,KAAK,EAAE,2BAA2B,KACjC,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAA;IAC/B;;;;;OAKG;IACH,MAAM,CAAC,EAAE,CACL,KAAK,EAAE,2BAA2B,EAClC,MAAM,EAAE,OAAO,GAAG,aAAa,GAAG,WAAW,GAAG,QAAQ,KACvD,IAAI,CAAA;IACT;;;OAGG;IACH,OAAO,CAAC,EAAE,8BAA8B,CAAC,SAAS,CAAC,CAAA;CACtD;AAED,MAAM,WAAW,sBAAsB;IACnC;;;;;;;;OAQG;IACH,eAAe,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CAC1C;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH;;;;GAIG;AACH,MAAM,MAAM,mBAAmB,GACzB,OAAO,GACP,aAAa,GACb,WAAW,GACX,QAAQ,GACR,MAAM,CAAA;AAEZ,MAAM,WAAW,iBAAiB;IAC9B,0EAA0E;IAC1E,aAAa,EAAE,CACX,OAAO,EAAE,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,KAAK,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,KAChE,IAAI,CAAA;IACT,yEAAyE;IACzE,MAAM,CAAC,EAAE,MAAM,IAAI,CAAA;CACtB;AAED;;;;;;;;GAQG;AACH,wBAAsB,kBAAkB,CACpC,OAAO,EAAE,2BAA2B,EACpC,MAAM,EAAE,mBAAmB,EAC3B,IAAI,EAAE,iBAAiB,GACxB,OAAO,CAAC,mBAAmB,CAAC,CA6C9B;AAED,wBAAgB,gBAAgB,CAC5B,MAAM,EAAE,qBAAqB,GAAG,SAAS,GAAG,IAAI,EAChD,MAAM,GAAE,mBAAwB,GACjC,sBAAsB,CA2BxB;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,CAiB9E;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,wBAAwB,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAa/D;AAED;;;;;;;;;GASG;AACH,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CAOxE"}
1
+ {"version":3,"file":"hotswap-reload-policy.d.ts","sourceRoot":"","sources":["../src/hotswap-reload-policy.ts"],"names":[],"mappings":"AAyDA,OAAO,EAEH,KAAK,2BAA2B,EAChC,KAAK,qBAAqB,EAC1B,KAAK,8BAA8B,EACtC,MAAM,+BAA+B,CAAA;AAEtC;;;;;;GAMG;AACH,MAAM,MAAM,qBAAqB,GAAG,OAAO,GAAG,aAAa,GAAG,QAAQ,CAAA;AAEtE;;;;GAIG;AACH,MAAM,WAAW,mBAAmB;IAChC,wDAAwD;IACxD,QAAQ,CAAC,EAAE,qBAAqB,CAAA;IAChC;;;;;;;;;;;;OAYG;IACH,cAAc,CAAC,EAAE,CACb,KAAK,EAAE,2BAA2B,KACjC,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAA;IAC/B;;;;;OAKG;IACH,MAAM,CAAC,EAAE,CACL,KAAK,EAAE,2BAA2B,EAClC,MAAM,EAAE,OAAO,GAAG,aAAa,GAAG,WAAW,GAAG,QAAQ,KACvD,IAAI,CAAA;IACT;;;OAGG;IACH,OAAO,CAAC,EAAE,8BAA8B,CAAC,SAAS,CAAC,CAAA;CACtD;AAED,MAAM,WAAW,sBAAsB;IACnC;;;;;;;;OAQG;IACH,eAAe,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CAC1C;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH;;;;GAIG;AACH,MAAM,MAAM,mBAAmB,GACzB,OAAO,GACP,aAAa,GACb,WAAW,GACX,QAAQ,GACR,MAAM,CAAA;AAEZ,MAAM,WAAW,iBAAiB;IAC9B,0EAA0E;IAC1E,aAAa,EAAE,CACX,OAAO,EAAE,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,KAAK,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,KAChE,IAAI,CAAA;IACT,yEAAyE;IACzE,MAAM,CAAC,EAAE,MAAM,IAAI,CAAA;CACtB;AAED;;;;;;;;GAQG;AACH,wBAAsB,kBAAkB,CACpC,OAAO,EAAE,2BAA2B,EACpC,MAAM,EAAE,mBAAmB,EAC3B,IAAI,EAAE,iBAAiB,GACxB,OAAO,CAAC,mBAAmB,CAAC,CA6C9B;AAED,wBAAgB,gBAAgB,CAC5B,MAAM,EAAE,qBAAqB,GAAG,SAAS,GAAG,IAAI,EAChD,MAAM,GAAE,mBAAwB,GACjC,sBAAsB,CA2BxB;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,CAiB9E;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,wBAAwB,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAU/D;AAED;;;;;;;;;GASG;AACH,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CAOxE"}
@@ -44,21 +44,15 @@
44
44
  // ## Federation runtime caveat
45
45
  //
46
46
  // The `"rekey"` strategy re-fetches `remoteEntry.js` with a `?v=<hash8>` query
47
- // suffix (see {@link withVersionParam}). For the new container to replace the
48
- // old one, the federation loader **must** `delete window[Container]` before
49
- // loading the new script otherwise the cached container object short-
50
- // circuits the loader and you get `Container already registered` style errors
51
- // or, worse, the old code silently keeps running. This module exports
52
- // {@link clearFederationContainer} for that purpose; the host's federation
53
- // loader should call it from its `onSwap` hook.
54
- //
55
- // We deliberately keep the `delete window[Container]` side-effect OUT of this
56
- // module's default behaviour. Some federation runtimes (vite-plugin-federation
57
- // in dev, webpack 5 with `runtime: false`) wrap the container in a `Proxy`
58
- // that mutates internal state on every access; blindly deleting it from
59
- // here would race against any unmounting consumer that still holds a
60
- // reference. Hosts that hit `Container already registered` should call
61
- // `clearFederationContainer(scope)` from `onSwap` as documented below.
47
+ // suffix (see {@link withVersionParam}). With `@module-federation/runtime`, the
48
+ // {@link AddonLoader} re-registers the remote on every rekey remount via
49
+ // `registerRemotes([{ name, entry: <new ?v= url> }], { force: true })`. The
50
+ // `force` flag overwrites the previously registered container AND wipes that
51
+ // remote's loaded-module cache, so the new code takes effect without any manual
52
+ // `window[scope]` deletion the old `@originjs` requirement to `delete
53
+ // window[Container]` no longer applies. {@link clearFederationContainer} is kept
54
+ // for backward compatibility (legacy hosts may still call it from `onSwap`) but
55
+ // is a no-op under the MF runtime.
62
56
  import { useMemo, useRef, useState } from 'react';
63
57
  import { useManifestHotSwapSubscriber, } from './manifest-hotswap-subscriber';
64
58
  /**
@@ -173,19 +167,15 @@ export function withVersionParam(url, hash) {
173
167
  return `${head}?${parts.join('&')}${fragment}`;
174
168
  }
175
169
  /**
176
- * Remove the federation container previously registered on `window[scope]`.
177
- * Hosts call this from `onSwap` before letting the addon route re-mount
178
- * so the next `remoteEntry.js` injection creates a fresh container instead
179
- * of short-circuiting on the cached one.
180
- *
181
- * Best-effort: if `window` is undefined (SSR) or the scope was never
182
- * registered, this is a no-op. Returns `true` if a container was actually
183
- * removed, `false` otherwise — useful for telemetry.
170
+ * @deprecated Legacy `@originjs/vite-plugin-federation` helper. Under the
171
+ * current `@module-federation/runtime` loader ({@link AddonLoader}), container
172
+ * replacement on hot-swap is handled by `registerRemotes(..., { force: true })`
173
+ * with the new `?v=` URL — there is no `window[scope]` container to delete.
184
174
  *
185
- * **Caveat:** some federation runtimes wrap the container in a Proxy
186
- * whose internal state survives `delete`. If you hit `Container already
187
- * registered` after calling this, the federation runtime is holding the
188
- * reference internally and the only reliable swap is `"page-reload"`.
175
+ * Kept for backward compatibility so existing host `onSwap` wiring keeps
176
+ * compiling. Best-effort: removes a stale `window[scope]` if a legacy
177
+ * `@originjs` remote left one behind, otherwise a no-op. Returns `true` if a
178
+ * value was removed, `false` otherwise.
189
179
  */
190
180
  export function clearFederationContainer(scope) {
191
181
  if (typeof window === 'undefined')
@@ -197,9 +187,6 @@ export function clearFederationContainer(scope) {
197
187
  return true;
198
188
  }
199
189
  catch {
200
- // Some browsers refuse to delete non-configurable globals. Set
201
- // to undefined as a fallback so the loader's `if (!window[scope])`
202
- // check still triggers a re-inject.
203
190
  ;
204
191
  window[scope] = undefined;
205
192
  return true;
package/dist/types.d.ts CHANGED
@@ -100,7 +100,7 @@ export interface FieldValidation {
100
100
  max?: number;
101
101
  custom?: string;
102
102
  }
103
- export type FieldWidget = 'text' | 'textarea' | 'richtext' | 'color' | 'number' | 'date' | 'select' | 'switch';
103
+ export type FieldWidget = 'text' | 'textarea' | 'richtext' | 'color' | 'number' | 'date' | 'select' | 'dynamic_select' | 'switch';
104
104
  export interface ActionFieldDef {
105
105
  key: string;
106
106
  label: string;
@@ -1 +1 @@
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;IAC3E;;;;;;OAMG;IACH,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ;;;;OAIG;IACH,UAAU,CAAC,EAAE,eAAe,CAAA;CAC/B;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;AASD,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;IAC7B;;;;OAIG;IACH,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ;;;;;;;;OAQG;IACH,UAAU,CAAC,EAAE,cAAc,EAAE,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;IACpB;;;;;OAKG;IACH,SAAS,CAAC,EAAE,KAAK,GAAG,OAAO,GAAG,QAAQ,CAAA;CACzC;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;IACpB,SAAS,CAAC,EAAE,KAAK,GAAG,OAAO,GAAG,QAAQ,CAAA;CACzC"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@asteby/metacore-runtime-react",
3
- "version": "13.1.0",
3
+ "version": "13.3.0",
4
4
  "description": "React runtime for metacore hosts — renders addon contributions dynamically",
5
5
  "repository": {
6
6
  "type": "git",
@@ -17,6 +17,7 @@
17
17
  }
18
18
  },
19
19
  "dependencies": {
20
+ "@module-federation/runtime": "^2.5.0",
20
21
  "zod": "^4.3.0"
21
22
  },
22
23
  "peerDependencies": {
@@ -1,22 +1,24 @@
1
- // Minimal federated-module addon loader. Injects a remoteEntry.js <script>,
2
- // waits for the `window[scope]` container to initialize, then calls the
3
- // addon's `register(api)` export with the AddonAPI injected by the host.
1
+ // Federated-module addon loader, built on the official Module Federation
2
+ // runtime (`@module-federation/runtime`). Per addon it registers the remote's
3
+ // `remoteEntry.js` as an ESM container, loads the exposed `./register` module,
4
+ // and calls `register(api)` with the AddonAPI injected by the host.
5
+ //
6
+ // Why @module-federation/runtime (not the old manual init/get machinery):
7
+ // the host's Vite build uses `@module-federation/vite`'s `federation()` plugin,
8
+ // which auto-initialises the shared scope at host boot. `registerRemotes` +
9
+ // `loadRemote` then transparently wire the remote into that already-initialised
10
+ // share scope — so the remote consumes the HOST's React/SDK singletons instead
11
+ // of bundling its own. That's the whole point: it fixes the `useState`-null
12
+ // crash WITHOUT this loader ever touching a share scope manually.
4
13
  import { useEffect, useRef, useState } from 'react'
14
+ import { registerRemotes, loadRemote } from '@module-federation/runtime'
5
15
  import type { AddonAPI, AddonLayout } from '@asteby/metacore-sdk'
6
16
  import { useDeclareAddonLayout } from './addon-layout-context'
7
17
 
8
- declare global {
9
- interface Window {
10
- [key: string]: any
11
- __webpack_init_sharing__?: (scope: string) => Promise<void>
12
- __webpack_share_scopes__?: Record<string, unknown>
13
- }
14
- }
15
-
16
18
  export interface AddonLoaderProps {
17
19
  /** Unique key of the addon — maps to the federation container name. */
18
20
  scope: string
19
- /** URL of the addon's remoteEntry.js bundle. */
21
+ /** URL of the addon's remoteEntry.js bundle (may carry a `?v=` cache-bust). */
20
22
  url: string
21
23
  /** Exposed module to import from the remote (e.g. './register'). */
22
24
  module?: string
@@ -43,87 +45,47 @@ export interface AddonLoaderProps {
43
45
  children?: React.ReactNode
44
46
  }
45
47
 
46
- interface FederationContainer {
47
- init: (shareScope: unknown) => Promise<void>
48
- get: (module: string) => Promise<() => any>
49
- }
50
-
51
- // Runtime dynamic import of an external URL. Wrapped in `new Function` so no
52
- // build tool (tsc here, Vite in the consuming host) tries to statically
53
- // analyse or rewrite the import — it stays a genuine runtime ESM fetch.
54
- const importModule = new Function('u', 'return import(u)') as (
55
- u: string,
56
- ) => Promise<Record<string, unknown>>
57
-
58
- const loadedScripts = new Map<string, Promise<void>>()
59
-
60
- function loadScript(url: string, scope: string): Promise<void> {
61
- const key = `${scope}::${url}`
62
- const existing = loadedScripts.get(key)
63
- if (existing) return existing
64
- const promise = new Promise<void>((resolve, reject) => {
65
- const el = document.createElement('script')
66
- el.src = url
67
- el.type = 'text/javascript'
68
- el.async = true
69
- el.onload = () => resolve()
70
- el.onerror = () => reject(new Error(`Failed to load addon script: ${url}`))
71
- document.head.appendChild(el)
72
- })
73
- loadedScripts.set(key, promise)
74
- return promise
48
+ /** Shape of the exposed `./register` module. */
49
+ interface AddonRegisterModule {
50
+ register?: (api: AddonAPI) => void | Promise<void>
51
+ default?: (api: AddonAPI) => void | Promise<void>
75
52
  }
76
53
 
77
- const esmContainers = new Map<string, Promise<FederationContainer | undefined>>()
54
+ // `registerRemotes` is additive + idempotent across re-mounts; we still track
55
+ // which scopes we've registered to avoid redundant `force` churn (each `force`
56
+ // re-register wipes that remote's module cache and logs a runtime warning).
57
+ const registered = new Set<string>()
78
58
 
79
- // Resolve a federation container for the remote. Vite/@originjs remotes built
80
- // with `format:"esm"` (our standard) are ES modules that top-level `import`
81
- // their preload helper and export `{ init, get }` they MUST be loaded as a
82
- // module (a classic <script> throws "Cannot use import statement outside a
83
- // module"), so we dynamic-import them and use the module namespace as the
84
- // container. Legacy "var"/window remotes (which assign `window[scope]`) are
85
- // still supported via the classic <script> fallback.
86
- async function resolveContainer(scope: string, url: string): Promise<FederationContainer | undefined> {
87
- const key = `${scope}::${url}`
88
- const cached = esmContainers.get(key)
89
- if (cached) return cached
90
- const p = (async () => {
91
- try {
92
- const mod = await importModule(url)
93
- if (mod && typeof mod.init === 'function' && typeof mod.get === 'function') {
94
- return mod as unknown as FederationContainer
95
- }
96
- } catch {
97
- // Not an importable module (legacy var-format remote) — fall back.
98
- }
99
- await loadScript(url, scope)
100
- return (window as any)[scope] as FederationContainer | undefined
101
- })()
102
- esmContainers.set(key, p)
103
- p.catch(() => esmContainers.delete(key))
104
- return p
59
+ // Derive the `loadRemote` id from the scope + exposed module name. MF resolves
60
+ // `"<remoteName>/<expose>"` e.g. `metacore_tickets/register` for the
61
+ // `"./register"` expose. We strip the leading `./` of the expose path.
62
+ function remoteId(scope: string, module: string): string {
63
+ const expose = module.replace(/^\.\//, '')
64
+ return `${scope}/${expose}`
105
65
  }
106
66
 
107
- async function loadRemote(scope: string, url: string, module: string) {
108
- if (typeof window.__webpack_init_sharing__ === 'function') {
109
- await window.__webpack_init_sharing__('default')
67
+ async function loadAddon(
68
+ scope: string,
69
+ url: string,
70
+ module: string,
71
+ ): Promise<AddonRegisterModule | null> {
72
+ // Register the remote container as an ES module. `type: 'module'` matches
73
+ // the `@module-federation/vite` remote (remoteEntry.js is an ESM bundle).
74
+ // The `url` already carries the `?v=` cache-bust the host computed, so the
75
+ // browser refetches a fresh remoteEntry when the addon version changes.
76
+ if (!registered.has(scope)) {
77
+ registerRemotes(
78
+ [{ name: scope, entry: url, type: 'module' }],
79
+ // `force: true` so a re-registration with a new `?v=` URL (addon
80
+ // hot-swap / version bump) overwrites the stale entry + cache.
81
+ { force: true },
82
+ )
83
+ registered.add(scope)
110
84
  }
111
- const container = await resolveContainer(scope, url)
112
- if (!container) {
113
- throw new Error(`Addon container "${scope}" not found (neither ESM export nor window[scope])`)
114
- }
115
- if (typeof container.init === 'function') {
116
- const shareScope =
117
- window.__webpack_share_scopes__?.default ??
118
- ((window as any).__METACORE_SHARE_SCOPE__ ??= {})
119
- try {
120
- await container.init(shareScope)
121
- } catch {
122
- // Container already initialized (re-entrant load) — safe to ignore.
123
- }
124
- }
125
- const factory = await container.get(module)
126
- return factory()
85
+ // loadRemote("<scope>/<expose>") returns the exposed module namespace (or
86
+ // null if it can't be resolved). No manual share-scope init — the host's
87
+ // federation runtime already initialised it.
88
+ return loadRemote<AddonRegisterModule>(remoteId(scope, module))
127
89
  }
128
90
 
129
91
  export function AddonLoader({
@@ -151,25 +113,35 @@ export function AddonLoader({
151
113
  let cancelled = false
152
114
  ;(async () => {
153
115
  try {
154
- const mod = await loadRemote(scope, url, module)
116
+ const mod = await loadAddon(scope, url, module)
155
117
  if (cancelled) return
156
- if (!didRegister.current && typeof mod?.register === 'function') {
118
+ const register = mod?.register ?? mod?.default
119
+ if (typeof register !== 'function') {
120
+ throw new Error(
121
+ `Addon "${scope}" module "${module}" has no register() export`,
122
+ )
123
+ }
124
+ if (!didRegister.current) {
157
125
  didRegister.current = true
158
- await Promise.resolve(mod.register(api))
126
+ await Promise.resolve(register(api))
159
127
  }
160
128
  setStatus('ready')
161
129
  onReady?.()
162
- } catch (e: any) {
130
+ } catch (e: unknown) {
163
131
  if (cancelled) return
164
- setError(e)
132
+ const err = e instanceof Error ? e : new Error(String(e))
133
+ setError(err)
165
134
  setStatus('error')
166
- onError?.(e)
135
+ onError?.(err)
167
136
  }
168
137
  })()
169
- return () => { cancelled = true }
138
+ return () => {
139
+ cancelled = true
140
+ }
170
141
  }, [scope, url, module])
171
142
 
172
143
  if (status === 'loading') return <>{fallback}</>
173
- if (status === 'error') return <div className="text-sm text-red-500">Addon load error: {error?.message}</div>
144
+ if (status === 'error')
145
+ return <div className="text-sm text-red-500">Addon load error: {error?.message}</div>
174
146
  return <>{children}</>
175
147
  }
@@ -119,6 +119,9 @@ export function resolveWidget(field: ActionFieldDef): string {
119
119
  switch (field.type) {
120
120
  case 'textarea': return 'textarea'
121
121
  case 'select': return 'select'
122
+ // Async searchable single-select against /api/options/<ref>. The
123
+ // declarative replacement for typing a raw FK UUID.
124
+ case 'dynamic_select': return 'dynamic_select'
122
125
  case 'boolean': return 'switch'
123
126
  case 'number': return 'number'
124
127
  case 'date': return 'date'
@@ -18,9 +18,11 @@ import type { ActionFieldDef } from './types'
18
18
  import { buildZodSchema, resolveWidget, isLineItemsField } from './dynamic-form-schema'
19
19
  import { useOptionsResolver, type ResolvedOption } from './use-options-resolver'
20
20
  import { DynamicLineItems } from './dynamic-line-items'
21
+ import { DynamicSelectField } from './dynamic-select-field'
21
22
 
22
23
  export { buildZodSchema, resolveWidget }
23
24
  export { DynamicLineItems } from './dynamic-line-items'
25
+ export { DynamicSelectField } from './dynamic-select-field'
24
26
 
25
27
  export interface DynamicFormProps {
26
28
  fields: ActionFieldDef[]
@@ -123,6 +125,12 @@ function FieldRenderer({ field, value, onChange }: FieldRendererProps) {
123
125
  return <DynamicLineItems field={field} value={value} onChange={onChange} />
124
126
  }
125
127
  const widget = resolveWidget(field)
128
+ // Async searchable picker (typeahead against /api/options/<ref>?q=…).
129
+ // Preferred for FK fields with large option sets — no UUID typing, no
130
+ // dumping every row into a plain <select>.
131
+ if (widget === 'dynamic_select') {
132
+ return <DynamicSelectField field={field} value={value} onChange={onChange} />
133
+ }
126
134
  // Ref-driven select: hook into useOptionsResolver so the canonical
127
135
  // /api/options/<ref>?field=id endpoint feeds the dropdown. This is
128
136
  // the path the kernel auto-derives for FK columns; legacy callers
@@ -21,6 +21,7 @@ import {
21
21
  import { Plus, Trash2 } from 'lucide-react'
22
22
  import type { ActionFieldDef } from './types'
23
23
  import { resolveWidget, getItemFields } from './dynamic-form-schema'
24
+ import { DynamicSelectField } from './dynamic-select-field'
24
25
  import { useOptionsResolver, type ResolvedOption } from './use-options-resolver'
25
26
 
26
27
  export interface DynamicLineItemsProps {
@@ -125,6 +126,11 @@ interface CellRendererProps {
125
126
  // a scalar widget).
126
127
  function CellRenderer({ field, value, onChange, disabled }: CellRendererProps) {
127
128
  const widget = resolveWidget(field)
129
+ // Async searchable picker per row cell — e.g. the account_id column of a
130
+ // journal entry's debit/credit lines. Same widget as the flat form.
131
+ if (widget === 'dynamic_select') {
132
+ return <DynamicSelectField field={field} value={value} onChange={onChange} />
133
+ }
128
134
  if (widget === 'select' && field.ref) {
129
135
  return <RefCell field={field} value={value} onChange={onChange} disabled={disabled} />
130
136
  }
@@ -0,0 +1,164 @@
1
+ // DynamicSelectField — async, searchable single-select for declarative forms.
2
+ //
3
+ // This is the declarative answer to "I don't want to type a raw FK UUID".
4
+ // Instead of a plain <select> that dumps every option (RefSelect) or a free
5
+ // text input, it renders a typeahead combobox that queries the canonical
6
+ // options endpoint as the user types:
7
+ //
8
+ // GET /api/options/<ref>?field=id&q=<text>&limit=<n>
9
+ //
10
+ // reusing `useOptionsResolver` (which already debounce-aborts in-flight
11
+ // requests). It is the metacore equivalent of 7leguas' `search.go` / dynamic
12
+ // `type: search` field, but driven entirely from the manifest — so an addon
13
+ // declares `type: "dynamic_select"` + `ref` and gets a searchable picker with
14
+ // zero custom React.
15
+ //
16
+ // Resolution path (highest priority first):
17
+ // 1. field.ref → /options/<ref>?field=id (canonical, preferred)
18
+ // 2. field.searchEndpoint→ used verbatim as the options endpoint (escape hatch)
19
+ //
20
+ // Edit-mode caveat: resolving an EXISTING value's label requires the id to be
21
+ // in a fetched page (we match by id against loaded options, else show the raw
22
+ // value). A dedicated `?ids=` lookup is a follow-up; create flows — the common
23
+ // case — start empty and never hit this.
24
+ import { useEffect, useState } from 'react'
25
+ import {
26
+ Button,
27
+ Command,
28
+ CommandEmpty,
29
+ CommandGroup,
30
+ CommandInput,
31
+ CommandItem,
32
+ CommandList,
33
+ Popover,
34
+ PopoverContent,
35
+ PopoverTrigger,
36
+ } from '@asteby/metacore-ui/primitives'
37
+ import { Check, ChevronsUpDown, Loader2 } from 'lucide-react'
38
+ import { useOptionsResolver, type ResolvedOption } from './use-options-resolver'
39
+ import type { ActionFieldDef } from './types'
40
+
41
+ function useDebounced<T>(value: T, ms: number): T {
42
+ const [debounced, setDebounced] = useState(value)
43
+ useEffect(() => {
44
+ const t = setTimeout(() => setDebounced(value), ms)
45
+ return () => clearTimeout(t)
46
+ }, [value, ms])
47
+ return debounced
48
+ }
49
+
50
+ export interface DynamicSelectFieldProps {
51
+ field: ActionFieldDef
52
+ value: any
53
+ onChange: (v: any) => void
54
+ }
55
+
56
+ export function DynamicSelectField({ field, value, onChange }: DynamicSelectFieldProps) {
57
+ const [open, setOpen] = useState(false)
58
+ const [search, setSearch] = useState('')
59
+ const debounced = useDebounced(search, 250)
60
+ // Remember the label of the option the user actually picked so the trigger
61
+ // shows a name (not a UUID) without a round-trip.
62
+ const [picked, setPicked] = useState<ResolvedOption | null>(null)
63
+
64
+ const { options, loading } = useOptionsResolver({
65
+ modelKey: '',
66
+ fieldKey: 'id',
67
+ ref: field.ref,
68
+ // searchEndpoint only drives the URL when there's no ref — ref is the
69
+ // canonical, kernel-derived path and wins.
70
+ endpoint: field.ref ? undefined : field.searchEndpoint,
71
+ query: debounced,
72
+ limit: 20,
73
+ // Don't fetch until the popover opens (and keep fetching as the query
74
+ // changes while open).
75
+ enabled: open,
76
+ })
77
+
78
+ const selectedLabel =
79
+ (picked && String(picked.id) === String(value) ? picked.label : null) ??
80
+ options.find((o) => String(o.id) === String(value))?.label ??
81
+ (value ? String(value) : '')
82
+
83
+ const handlePick = (opt: ResolvedOption) => {
84
+ setPicked(opt)
85
+ onChange(String(opt.id))
86
+ setOpen(false)
87
+ setSearch('')
88
+ }
89
+
90
+ return (
91
+ <Popover open={open} onOpenChange={setOpen}>
92
+ <PopoverTrigger asChild>
93
+ <Button
94
+ type="button"
95
+ variant="outline"
96
+ role="combobox"
97
+ aria-expanded={open}
98
+ id={field.key}
99
+ className="w-full justify-between font-normal"
100
+ data-empty={!value}
101
+ >
102
+ <span className={'truncate ' + (selectedLabel ? '' : 'text-muted-foreground')}>
103
+ {selectedLabel || field.placeholder || 'Buscar…'}
104
+ </span>
105
+ <ChevronsUpDown className="ml-2 size-4 shrink-0 opacity-50" />
106
+ </Button>
107
+ </PopoverTrigger>
108
+ <PopoverContent
109
+ className="p-0"
110
+ align="start"
111
+ // Match the trigger width without an arbitrary Tailwind class
112
+ // (those don't always survive a consuming app's Tailwind scan).
113
+ style={{ width: 'var(--radix-popover-trigger-width)' }}
114
+ >
115
+ <Command shouldFilter={false}>
116
+ <CommandInput
117
+ placeholder={field.placeholder || 'Buscar…'}
118
+ value={search}
119
+ onValueChange={setSearch}
120
+ />
121
+ <CommandList>
122
+ {loading && (
123
+ <div className="text-muted-foreground flex items-center justify-center gap-2 py-6 text-sm">
124
+ <Loader2 className="size-4 animate-spin" />
125
+ Buscando…
126
+ </div>
127
+ )}
128
+ {!loading && options.length === 0 && (
129
+ <CommandEmpty>
130
+ {debounced ? 'Sin resultados' : 'Escribí para buscar…'}
131
+ </CommandEmpty>
132
+ )}
133
+ {!loading && options.length > 0 && (
134
+ <CommandGroup className="max-h-64 overflow-auto">
135
+ {options.map((opt) => {
136
+ const isSel = String(opt.id) === String(value)
137
+ return (
138
+ <CommandItem
139
+ key={String(opt.id)}
140
+ value={String(opt.id)}
141
+ onSelect={() => handlePick(opt)}
142
+ >
143
+ <Check className={'mr-2 size-4 ' + (isSel ? 'opacity-100' : 'opacity-0')} />
144
+ <div className="flex min-w-0 flex-col">
145
+ <span className="truncate">{opt.label}</span>
146
+ {opt.description && (
147
+ <span className="text-muted-foreground truncate text-xs">
148
+ {opt.description}
149
+ </span>
150
+ )}
151
+ </div>
152
+ </CommandItem>
153
+ )
154
+ })}
155
+ </CommandGroup>
156
+ )}
157
+ </CommandList>
158
+ </Command>
159
+ </PopoverContent>
160
+ </Popover>
161
+ )
162
+ }
163
+
164
+ export default DynamicSelectField
@@ -44,21 +44,15 @@
44
44
  // ## Federation runtime caveat
45
45
  //
46
46
  // The `"rekey"` strategy re-fetches `remoteEntry.js` with a `?v=<hash8>` query
47
- // suffix (see {@link withVersionParam}). For the new container to replace the
48
- // old one, the federation loader **must** `delete window[Container]` before
49
- // loading the new script otherwise the cached container object short-
50
- // circuits the loader and you get `Container already registered` style errors
51
- // or, worse, the old code silently keeps running. This module exports
52
- // {@link clearFederationContainer} for that purpose; the host's federation
53
- // loader should call it from its `onSwap` hook.
54
- //
55
- // We deliberately keep the `delete window[Container]` side-effect OUT of this
56
- // module's default behaviour. Some federation runtimes (vite-plugin-federation
57
- // in dev, webpack 5 with `runtime: false`) wrap the container in a `Proxy`
58
- // that mutates internal state on every access; blindly deleting it from
59
- // here would race against any unmounting consumer that still holds a
60
- // reference. Hosts that hit `Container already registered` should call
61
- // `clearFederationContainer(scope)` from `onSwap` as documented below.
47
+ // suffix (see {@link withVersionParam}). With `@module-federation/runtime`, the
48
+ // {@link AddonLoader} re-registers the remote on every rekey remount via
49
+ // `registerRemotes([{ name, entry: <new ?v= url> }], { force: true })`. The
50
+ // `force` flag overwrites the previously registered container AND wipes that
51
+ // remote's loaded-module cache, so the new code takes effect without any manual
52
+ // `window[scope]` deletion the old `@originjs` requirement to `delete
53
+ // window[Container]` no longer applies. {@link clearFederationContainer} is kept
54
+ // for backward compatibility (legacy hosts may still call it from `onSwap`) but
55
+ // is a no-op under the MF runtime.
62
56
 
63
57
  import { useMemo, useRef, useState } from 'react'
64
58
  import {
@@ -311,30 +305,23 @@ export function withVersionParam(url: string, hash: string | undefined): string
311
305
  }
312
306
 
313
307
  /**
314
- * Remove the federation container previously registered on `window[scope]`.
315
- * Hosts call this from `onSwap` before letting the addon route re-mount
316
- * so the next `remoteEntry.js` injection creates a fresh container instead
317
- * of short-circuiting on the cached one.
318
- *
319
- * Best-effort: if `window` is undefined (SSR) or the scope was never
320
- * registered, this is a no-op. Returns `true` if a container was actually
321
- * removed, `false` otherwise — useful for telemetry.
308
+ * @deprecated Legacy `@originjs/vite-plugin-federation` helper. Under the
309
+ * current `@module-federation/runtime` loader ({@link AddonLoader}), container
310
+ * replacement on hot-swap is handled by `registerRemotes(..., { force: true })`
311
+ * with the new `?v=` URL — there is no `window[scope]` container to delete.
322
312
  *
323
- * **Caveat:** some federation runtimes wrap the container in a Proxy
324
- * whose internal state survives `delete`. If you hit `Container already
325
- * registered` after calling this, the federation runtime is holding the
326
- * reference internally and the only reliable swap is `"page-reload"`.
313
+ * Kept for backward compatibility so existing host `onSwap` wiring keeps
314
+ * compiling. Best-effort: removes a stale `window[scope]` if a legacy
315
+ * `@originjs` remote left one behind, otherwise a no-op. Returns `true` if a
316
+ * value was removed, `false` otherwise.
327
317
  */
328
318
  export function clearFederationContainer(scope: string): boolean {
329
319
  if (typeof window === 'undefined') return false
330
- if (!(scope in window)) return false
320
+ if (!(scope in (window as Record<string, unknown>))) return false
331
321
  try {
332
322
  delete (window as Record<string, unknown>)[scope]
333
323
  return true
334
324
  } catch {
335
- // Some browsers refuse to delete non-configurable globals. Set
336
- // to undefined as a fallback so the loader's `if (!window[scope])`
337
- // check still triggers a re-inject.
338
325
  ;(window as Record<string, unknown>)[scope] = undefined
339
326
  return true
340
327
  }
package/src/types.ts CHANGED
@@ -116,6 +116,7 @@ export type FieldWidget =
116
116
  | 'number'
117
117
  | 'date'
118
118
  | 'select'
119
+ | 'dynamic_select'
119
120
  | 'switch'
120
121
 
121
122
  export interface ActionFieldDef {