@object-ui/core 6.2.2 → 7.0.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.
@@ -14,6 +14,7 @@
14
14
  * redirect handling, action chaining, custom handler registration.
15
15
  */
16
16
  import { ExpressionEvaluator } from '../evaluator/ExpressionEvaluator';
17
+ import { globalUndoManager } from './UndoManager';
17
18
  export class ActionRunner {
18
19
  constructor(context = {}) {
19
20
  Object.defineProperty(this, "handlers", {
@@ -70,6 +71,12 @@ export class ActionRunner {
70
71
  writable: true,
71
72
  value: void 0
72
73
  });
74
+ Object.defineProperty(this, "resultDialogHandler", {
75
+ enumerable: true,
76
+ configurable: true,
77
+ writable: true,
78
+ value: void 0
79
+ });
73
80
  this.context = context;
74
81
  this.evaluator = new ExpressionEvaluator(context);
75
82
  // Default confirmation: window.confirm (can be overridden)
@@ -78,6 +85,7 @@ export class ActionRunner {
78
85
  this.modalHandler = null;
79
86
  this.navigationHandler = null;
80
87
  this.paramCollectionHandler = null;
88
+ this.resultDialogHandler = null;
81
89
  }
82
90
  /**
83
91
  * Set a custom confirmation handler (e.g., Shadcn AlertDialog).
@@ -110,6 +118,14 @@ export class ActionRunner {
110
118
  setParamCollectionHandler(handler) {
111
119
  this.paramCollectionHandler = handler;
112
120
  }
121
+ /**
122
+ * Set a result dialog handler — shows a one-shot reveal dialog after
123
+ * a successful action whose spec declares `resultDialog`. Used for
124
+ * values the user must copy now (2FA codes, OAuth client_secret).
125
+ */
126
+ setResultDialogHandler(handler) {
127
+ this.resultDialogHandler = handler;
128
+ }
113
129
  registerHandler(actionName, handler) {
114
130
  this.handlers.set(actionName, handler);
115
131
  }
@@ -139,10 +155,22 @@ export class ActionRunner {
139
155
  return { success: false, error: 'Action condition not met' };
140
156
  }
141
157
  }
142
- if (action.disabled) {
143
- const isDisabled = typeof action.disabled === 'string'
144
- ? this.evaluator.evaluateCondition(action.disabled)
145
- : action.disabled;
158
+ if (action.disabled != null && action.disabled !== false) {
159
+ // `disabled` may be a boolean, a CEL string, or the normalized envelope
160
+ // `{ dialect, source }` (what `objectstack build` emits). The previous
161
+ // code only evaluated the STRING form and treated any object as truthy,
162
+ // so an envelope-disabled action was ALWAYS "disabled" — silently
163
+ // blocking every execution (param dialog never opened, handler never
164
+ // ran). `evaluateCondition` already handles boolean/string/envelope;
165
+ // and the renderers are authoritative for the visual disabled state, so
166
+ // any eval failure here defaults to NOT-disabled (don't false-block).
167
+ let isDisabled = false;
168
+ try {
169
+ isDisabled = this.evaluator.evaluateCondition(action.disabled);
170
+ }
171
+ catch {
172
+ isDisabled = false;
173
+ }
146
174
  if (isDisabled) {
147
175
  return { success: false, error: 'Action is disabled' };
148
176
  }
@@ -208,6 +236,9 @@ export class ActionRunner {
208
236
  case 'api':
209
237
  result = await this.executeAPI(action);
210
238
  break;
239
+ case 'form':
240
+ result = await this.executeForm(action);
241
+ break;
211
242
  case 'navigation':
212
243
  result = await this.executeNavigation(action);
213
244
  break;
@@ -272,19 +303,64 @@ export class ActionRunner {
272
303
  * Post-execution: emit toast notifications, handle chaining, callbacks.
273
304
  */
274
305
  async handlePostExecution(action, result) {
306
+ // resultDialog SUPPRESSES the successMessage toast — these are one-shot
307
+ // reveals (2FA codes, fresh OAuth secrets) where a toast would let the
308
+ // user dismiss the value before copying it. The reveal dialog itself
309
+ // requires explicit acknowledgement; we await it here so refreshAfter
310
+ // / chain steps don't fire until the user closes the dialog.
311
+ const hasResultDialog = !!(action.resultDialog && result.success);
275
312
  // Toast notifications
276
313
  if (this.toastHandler) {
277
314
  const showToast = action.toast ?? { showOnSuccess: true, showOnError: true };
278
315
  const duration = action.toast?.duration;
279
- if (result.success && showToast.showOnSuccess !== false) {
280
- const message = action.successMessage || 'Action completed successfully';
281
- this.toastHandler(message, { type: 'success', duration });
316
+ if (result.success && !hasResultDialog && !result.silent && showToast.showOnSuccess !== false) {
317
+ // Prefer a DYNAMIC message the server returned (result.data.message)
318
+ // over the static action.successMessage. Server-driven actions like
319
+ // check_app_updates / publish / install compute a real outcome
320
+ // ("2 app updates available: CRM 1.0.0→1.0.1", "Published v1.2.0")
321
+ // that the static label can't express; without this the user only ever
322
+ // sees a generic "Done". Falls back to the static label, then a default.
323
+ const dyn = (result.data && typeof result.data === 'object'
324
+ && typeof result.data.message === 'string')
325
+ ? String(result.data.message).trim()
326
+ : '';
327
+ const message = dyn || action.successMessage || 'Action completed successfully';
328
+ // Undoable action: register the captured operation on the global
329
+ // UndoManager and surface an "Undo" affordance on the toast (the
330
+ // consumer's toast handler wires the button to UndoManager).
331
+ if (result.undo) {
332
+ try {
333
+ globalUndoManager.push(result.undo);
334
+ }
335
+ catch { /* non-fatal */ }
336
+ }
337
+ this.toastHandler(message, { type: 'success', duration, undo: result.undo ? {} : undefined });
282
338
  }
283
339
  if (!result.success && showToast.showOnError !== false && result.error) {
284
340
  const message = action.errorMessage || result.error;
285
341
  this.toastHandler(message, { type: 'error', duration });
286
342
  }
287
343
  }
344
+ // Open the reveal dialog. If no handler is registered we still mark the
345
+ // execution successful but log — better to lose the value visibility
346
+ // than to silently re-toast and dismiss it.
347
+ if (hasResultDialog) {
348
+ if (this.resultDialogHandler) {
349
+ try {
350
+ await this.resultDialogHandler(action.resultDialog, result.data, action);
351
+ }
352
+ catch (err) {
353
+ // Acknowledgement failure is non-fatal; the underlying action already
354
+ // succeeded. Log so consumers can wire it through their error reporter.
355
+ // eslint-disable-next-line no-console
356
+ console.warn('[ActionRunner] resultDialog handler rejected; treating as acknowledged', err);
357
+ }
358
+ }
359
+ else {
360
+ // eslint-disable-next-line no-console
361
+ console.warn('[ActionRunner] action.resultDialog set but no resultDialogHandler registered — the response value will not be shown to the user.', { action: action.name, data: result.data });
362
+ }
363
+ }
288
364
  // Apply refreshAfter from UIActionSchema
289
365
  if (action.refreshAfter && result.success) {
290
366
  result.reload = true;
@@ -352,14 +428,46 @@ export class ActionRunner {
352
428
  if (!rawUrl) {
353
429
  return { success: false, error: 'No URL provided for url action' };
354
430
  }
355
- const url = this.evaluator.evaluate(rawUrl);
431
+ // Apply target ${param.X} / ${ctx.X} interpolation FIRST — the
432
+ // ExpressionEvaluator that follows would otherwise see unresolved
433
+ // `param.provider` and either error or substitute undefined. Doing it
434
+ // here also URL-encodes values (required for query-position params
435
+ // like `?provider=foo+bar`).
436
+ const interpolated = this.interpolateTarget(rawUrl, action);
437
+ let url = this.evaluator.evaluate(interpolated);
356
438
  if (!this.isValidUrl(url)) {
357
439
  return {
358
440
  success: false,
359
441
  error: 'Invalid URL scheme. Only http://, https://, and relative URLs are allowed.',
360
442
  };
361
443
  }
444
+ // Promote same-origin-style `/api/...` paths to absolute when an
445
+ // `apiBase` is provided in context (typical of split SPA + backend
446
+ // dev setups). Without this, `window.location.href = "/api/..."`
447
+ // hits the SPA host (e.g. Vite at :5173), the router has no match,
448
+ // and the browser silently falls back to the home page instead of
449
+ // following better-auth's 302 to the IdP.
450
+ const apiBase = typeof this.context.apiBase === 'string' ? this.context.apiBase.replace(/\/+$/, '') : '';
451
+ if (apiBase && /^\/(api|_auth|_account)\//.test(url)) {
452
+ url = `${apiBase}${url}`;
453
+ }
362
454
  const isExternal = url.startsWith('http://') || url.startsWith('https://');
455
+ // Same-origin API endpoints (most commonly the auth provider's
456
+ // `/api/v1/auth/sign-in/social` redirect dance) issue server-side
457
+ // 302s that must be followed by the *browser*, not the SPA router.
458
+ // Pushing `/api/...` into React Router lands on no matching route
459
+ // and silently falls back to the default page, so the OAuth flow
460
+ // never starts. Short-circuit to a full-page navigation here.
461
+ // For absolute URLs (now also produced by the apiBase prefix above),
462
+ // we need full-page navigation for any URL that is going to issue a
463
+ // server-side redirect dance — better-auth's `/sign-in/social` is the
464
+ // canonical case. Detect "looks like a same-origin API path" both for
465
+ // bare-relative input AND for the apiBase-prefixed form.
466
+ const isApiCall = /\/(api|_auth|_account)\//.test(url) && (isExternal || url.startsWith('/'));
467
+ if (isApiCall) {
468
+ window.location.href = url;
469
+ return { success: true };
470
+ }
363
471
  const newTab = action.params?.newTab ?? isExternal;
364
472
  if (this.navigationHandler) {
365
473
  this.navigationHandler(url, { external: isExternal, newTab });
@@ -428,6 +536,28 @@ export class ActionRunner {
428
536
  /**
429
537
  * Execute navigation action
430
538
  */
539
+ /**
540
+ * `form` action — open a FormView as a routed page (`/forms/:name`, per the
541
+ * spec). `target` is the FormView name. Without this case the action fell
542
+ * through to `executeActionSchema` and silently no-opped (the "Log Time does
543
+ * nothing" report). The current record id is forwarded as `?recordId=` for
544
+ * hosts that support it; the form route ignores unknown query params.
545
+ */
546
+ async executeForm(action) {
547
+ const name = this.evaluator.evaluate(action.target);
548
+ if (!name || typeof name !== 'string') {
549
+ return { success: false, error: 'No form target (FormView name) provided for form action' };
550
+ }
551
+ const recordId = (this.context.record && this.context.record.id) ??
552
+ (this.context.data && this.context.data.id);
553
+ const base = `/forms/${name}`;
554
+ const to = recordId != null ? `${base}?recordId=${encodeURIComponent(String(recordId))}` : base;
555
+ if (this.navigationHandler) {
556
+ this.navigationHandler(to, { external: false });
557
+ return { success: true };
558
+ }
559
+ return { success: true, redirect: to };
560
+ }
431
561
  async executeNavigation(action) {
432
562
  const nav = action.navigate || action;
433
563
  const to = this.evaluator.evaluate(nav.to || nav.target);
@@ -471,14 +601,14 @@ export class ActionRunner {
471
601
  let responseType = 'json';
472
602
  if (typeof apiConfig === 'string') {
473
603
  // Simple string endpoint
474
- url = apiConfig;
604
+ url = this.interpolateTarget(apiConfig, action);
475
605
  method = action.method || 'POST';
476
606
  body = JSON.stringify(action.params || this.context.data || {});
477
607
  }
478
608
  else {
479
609
  // Complex ApiConfig
480
610
  const config = apiConfig;
481
- url = config.url;
611
+ url = this.interpolateTarget(config.url, action);
482
612
  method = config.method || action.method || 'POST';
483
613
  headers = { ...headers, ...config.headers };
484
614
  responseType = config.responseType || 'json';
@@ -531,6 +661,66 @@ export class ActionRunner {
531
661
  url.startsWith('/') ||
532
662
  url.startsWith('./'));
533
663
  }
664
+ /**
665
+ * Substitute `${param.X}` and `${ctx.X}` tokens in an action target.
666
+ *
667
+ * Used by executeUrl and executeAPI so metadata can produce dynamic
668
+ * URLs without coupling to the wider ExpressionEvaluator surface
669
+ * (which would also evaluate operators, member access, etc. — overkill
670
+ * and unsafe for URL-position values).
671
+ *
672
+ * Every substituted value is `encodeURIComponent`'d, since the only
673
+ * legitimate use case so far (better-auth `/sign-in/social?provider=…`)
674
+ * places interpolated values in URL query positions. Callers MUST
675
+ * therefore put tokens after a `?` or `&` — using `${param.x}` inside a
676
+ * path segment will produce percent-encoded slashes, which is the
677
+ * correct behaviour for opaque IDs but would break a literal path
678
+ * fragment.
679
+ *
680
+ * `ctx.origin`, `ctx.user.*`, `ctx.org.*`, `ctx.recordId` are surfaced
681
+ * implicitly from the runner's ActionContext + window. Consumers can
682
+ * extend by stuffing extra keys under `context.ctx = {...}` before
683
+ * calling `runner.execute()`.
684
+ */
685
+ interpolateTarget(target, action) {
686
+ if (typeof target !== 'string' || target.indexOf('${') === -1)
687
+ return target;
688
+ const params = (action.params && typeof action.params === 'object' && !Array.isArray(action.params))
689
+ ? action.params
690
+ : {};
691
+ const ctx = this.buildInterpolationContext();
692
+ return target.replace(/\$\{(param|ctx)\.([\w.]+)\}/g, (_match, scope, path) => {
693
+ const root = scope === 'param' ? params : ctx;
694
+ const value = readPath(root, path);
695
+ if (value == null)
696
+ return '';
697
+ return encodeURIComponent(String(value));
698
+ });
699
+ }
700
+ /**
701
+ * Assemble the `ctx` namespace exposed to target interpolation.
702
+ * Built-in keys: origin, user, org, recordId. Anything the consumer
703
+ * placed under context.ctx is merged on top.
704
+ */
705
+ buildInterpolationContext() {
706
+ const ctx = {
707
+ origin: typeof window !== 'undefined' ? window.location.origin : '',
708
+ // Backend API origin (typically `import.meta.env.VITE_SERVER_URL`
709
+ // passed in from the host app). Empty string means "same-origin",
710
+ // which collapses `${ctx.apiBase}/api/...` to a regular relative
711
+ // path — exactly the production-deployment behaviour.
712
+ apiBase: typeof this.context.apiBase === 'string'
713
+ ? this.context.apiBase.replace(/\/+$/, '')
714
+ : '',
715
+ user: this.context.user ?? {},
716
+ org: this.context.org ?? this.context.organization ?? {},
717
+ recordId: this.context.record?.id ?? this.context.recordId,
718
+ };
719
+ if (this.context.ctx && typeof this.context.ctx === 'object') {
720
+ Object.assign(ctx, this.context.ctx);
721
+ }
722
+ return ctx;
723
+ }
534
724
  updateContext(newContext) {
535
725
  this.context = { ...this.context, ...newContext };
536
726
  this.evaluator.updateContext(newContext);
@@ -552,3 +742,16 @@ export async function executeAction(action, context = {}) {
552
742
  const runner = new ActionRunner(context);
553
743
  return await runner.execute(action);
554
744
  }
745
+ /**
746
+ * Dot-path read used by target interpolation. Plain reduce — kept inline
747
+ * to avoid pulling lodash for a 3-line helper.
748
+ */
749
+ function readPath(root, path) {
750
+ if (root == null)
751
+ return undefined;
752
+ return path.split('.').reduce((acc, key) => {
753
+ if (acc == null)
754
+ return undefined;
755
+ return acc[key];
756
+ }, root);
757
+ }
@@ -181,12 +181,34 @@ export class ExpressionEvaluator {
181
181
  if (condition && typeof condition === 'object' && typeof condition.source === 'string') {
182
182
  condition = condition.source;
183
183
  }
184
+ // No condition → default to visible/enabled (undefined, null, '').
184
185
  if (!condition) {
185
- return true; // Default to visible/enabled if no condition
186
+ return true;
187
+ }
188
+ if (typeof condition !== 'string') {
189
+ return Boolean(condition);
190
+ }
191
+ const trimmed = condition.trim();
192
+ if (!trimmed) {
193
+ return true; // Whitespace-only → treat as "no condition".
194
+ }
195
+ // A condition is semantically a single boolean expression. When it's a
196
+ // `${...}` template, evaluate via the template path. Otherwise treat the
197
+ // ENTIRE string as one expression (bare CEL like `record.status == "x"`):
198
+ // `evaluate` would short-circuit a non-`${}` string and return it verbatim,
199
+ // so `Boolean('record.status == "x"')` was ALWAYS true — silently making
200
+ // every bare-expression `disabled`/`condition`/`visible` predicate truthy.
201
+ if (trimmed.includes('${')) {
202
+ return Boolean(this.evaluate(trimmed, options));
203
+ }
204
+ try {
205
+ return Boolean(this.evaluateExpression(trimmed, { sanitize: options.sanitize !== false }));
206
+ }
207
+ catch {
208
+ // Unparseable predicate — preserve the historical "default to
209
+ // visible/enabled" behaviour rather than hiding/blocking on a typo.
210
+ return true;
186
211
  }
187
- const result = this.evaluate(condition, options);
188
- // Convert result to boolean
189
- return Boolean(result);
190
212
  }
191
213
  /**
192
214
  * Update the context with new data
@@ -0,0 +1,49 @@
1
+ /**
2
+ * ObjectUI
3
+ * Copyright (c) 2024-present ObjectStack Inc.
4
+ *
5
+ * This source code is licensed under the MIT license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ */
8
+ /** A field-rule predicate as authored in metadata. */
9
+ export type FieldRulePredicate = string | {
10
+ dialect?: string;
11
+ source: string;
12
+ };
13
+ /**
14
+ * Evaluate a field-rule CEL predicate against a record.
15
+ *
16
+ * @param pred The `visibleWhen` / `readonlyWhen` / `requiredWhen` predicate.
17
+ * @param record The live form values (overlays prior persisted record).
18
+ * @param fallback Value to return when the predicate is absent or fails to
19
+ * evaluate. Pick the *safe* default for the caller:
20
+ * `false` for readonly/required (don't lock/block on error),
21
+ * `true` for visibility (don't hide on error).
22
+ * @param previous The prior persisted record, if any (for `previous.*` refs).
23
+ * @param scope Extra top-level scope variables bound alongside `record` —
24
+ * e.g. `{ parent }` so an inline line-item cell can reference
25
+ * its header (`parent.status == 'paid'`) as well as its own
26
+ * row (`record.quantity`). Bound via the engine's `extra`.
27
+ */
28
+ export declare function evalFieldPredicate(pred: FieldRulePredicate | undefined | null, record: Record<string, unknown>, fallback: boolean, previous?: Record<string, unknown>, scope?: Record<string, unknown>): boolean;
29
+ /**
30
+ * Resolve the effective `{ visible, readonly, required }` state for a field
31
+ * given its conditional rules and the live record. Each `*When` rule, when
32
+ * present, *overrides* the static flag. A static `true` is never weakened by a
33
+ * `false` predicate result for required/readonly — but `visibleWhen` is
34
+ * authoritative when present (so a field can be conditionally shown/hidden).
35
+ */
36
+ export declare function resolveFieldRuleState(rules: {
37
+ visibleWhen?: FieldRulePredicate;
38
+ readonlyWhen?: FieldRulePredicate;
39
+ requiredWhen?: FieldRulePredicate;
40
+ /** Back-compat alias of `requiredWhen` (spec @deprecated). */
41
+ conditionalRequired?: FieldRulePredicate;
42
+ }, record: Record<string, unknown>, statics: {
43
+ required?: boolean;
44
+ readonly?: boolean;
45
+ }, previous?: Record<string, unknown>, scope?: Record<string, unknown>): {
46
+ visible: boolean;
47
+ readonly: boolean;
48
+ required: boolean;
49
+ };
@@ -0,0 +1,86 @@
1
+ /**
2
+ * ObjectUI
3
+ * Copyright (c) 2024-present ObjectStack Inc.
4
+ *
5
+ * This source code is licensed under the MIT license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ */
8
+ /**
9
+ * Client-side evaluation of field-level conditional rules
10
+ * (`visibleWhen` / `readonlyWhen` / `requiredWhen`).
11
+ *
12
+ * These predicates are authored as CEL — the *same* dialect the server
13
+ * enforces in `@objectstack/objectql`'s rule-validator (`requiredWhen`) and
14
+ * `stripReadonlyWhenFields` (`readonlyWhen`). To guarantee the client UX and
15
+ * the server enforcement reach the *identical* verdict for a given record we
16
+ * delegate to the canonical engine, `@objectstack/formula`'s
17
+ * `ExpressionEngine`, rather than re-implementing a parallel evaluator. See
18
+ * ADR-0036 (field-level conditional rules) and the framework's
19
+ * `packages/formula` "No private expression DSL" note.
20
+ *
21
+ * A predicate is `string | { dialect, source }`. A bare string is treated as
22
+ * CEL (mirrors the server's `toExpression`). Evaluation is *fail-open* for
23
+ * visibility/required (a broken predicate must not hide a field or wrongly
24
+ * block submit) and *fail-open* for readonly (a broken predicate leaves the
25
+ * field editable) — matching the server, which logs and allows the change
26
+ * through.
27
+ */
28
+ import { ExpressionEngine } from '@objectstack/formula';
29
+ /** Normalize a predicate into the `Expression` shape the engine expects. */
30
+ function toExpression(pred) {
31
+ if (typeof pred === 'string')
32
+ return { dialect: 'cel', source: pred };
33
+ return { dialect: (pred.dialect ?? 'cel'), source: pred.source };
34
+ }
35
+ /**
36
+ * Evaluate a field-rule CEL predicate against a record.
37
+ *
38
+ * @param pred The `visibleWhen` / `readonlyWhen` / `requiredWhen` predicate.
39
+ * @param record The live form values (overlays prior persisted record).
40
+ * @param fallback Value to return when the predicate is absent or fails to
41
+ * evaluate. Pick the *safe* default for the caller:
42
+ * `false` for readonly/required (don't lock/block on error),
43
+ * `true` for visibility (don't hide on error).
44
+ * @param previous The prior persisted record, if any (for `previous.*` refs).
45
+ * @param scope Extra top-level scope variables bound alongside `record` —
46
+ * e.g. `{ parent }` so an inline line-item cell can reference
47
+ * its header (`parent.status == 'paid'`) as well as its own
48
+ * row (`record.quantity`). Bound via the engine's `extra`.
49
+ */
50
+ export function evalFieldPredicate(pred, record, fallback, previous, scope) {
51
+ if (pred == null || (typeof pred === 'string' && !pred.trim()))
52
+ return fallback;
53
+ try {
54
+ const res = ExpressionEngine.evaluate(toExpression(pred), {
55
+ record,
56
+ previous,
57
+ ...(scope ? { extra: scope } : {}),
58
+ });
59
+ if (!res.ok)
60
+ return fallback;
61
+ return res.value === true;
62
+ }
63
+ catch {
64
+ return fallback;
65
+ }
66
+ }
67
+ /**
68
+ * Resolve the effective `{ visible, readonly, required }` state for a field
69
+ * given its conditional rules and the live record. Each `*When` rule, when
70
+ * present, *overrides* the static flag. A static `true` is never weakened by a
71
+ * `false` predicate result for required/readonly — but `visibleWhen` is
72
+ * authoritative when present (so a field can be conditionally shown/hidden).
73
+ */
74
+ export function resolveFieldRuleState(rules, record, statics, previous, scope) {
75
+ const visible = rules.visibleWhen != null
76
+ ? evalFieldPredicate(rules.visibleWhen, record, true, previous, scope)
77
+ : true;
78
+ const readonly = statics.readonly === true ||
79
+ (rules.readonlyWhen != null
80
+ ? evalFieldPredicate(rules.readonlyWhen, record, false, previous, scope)
81
+ : false);
82
+ const requiredPred = rules.requiredWhen ?? rules.conditionalRequired;
83
+ const required = statics.required === true ||
84
+ (requiredPred != null ? evalFieldPredicate(requiredPred, record, false, previous, scope) : false);
85
+ return { visible, readonly, required };
86
+ }
@@ -7,6 +7,7 @@
7
7
  */
8
8
  export * from './ExpressionContext.js';
9
9
  export * from './ExpressionEvaluator.js';
10
+ export * from './fieldRules.js';
10
11
  export * from './ExpressionCache.js';
11
12
  export * from './FormulaFunctions.js';
12
13
  export * from './SafeExpressionParser.js';
@@ -7,6 +7,7 @@
7
7
  */
8
8
  export * from './ExpressionContext.js';
9
9
  export * from './ExpressionEvaluator.js';
10
+ export * from './fieldRules.js';
10
11
  export * from './ExpressionCache.js';
11
12
  export * from './FormulaFunctions.js';
12
13
  export * from './SafeExpressionParser.js';
package/dist/index.d.ts CHANGED
@@ -13,7 +13,6 @@ export * from './registry/WidgetRegistry.js';
13
13
  export * from './validation/index.js';
14
14
  export * from './builder/schema-builder.js';
15
15
  export * from './utils/filter-converter.js';
16
- export * from './utils/normalize-quick-filter.js';
17
16
  export * from './utils/extract-records.js';
18
17
  export * from './utils/expand-fields.js';
19
18
  export * from './evaluator/index.js';
@@ -28,6 +27,7 @@ export * from './utils/debug-collector.js';
28
27
  export * from './utils/merge-views-into-objects.js';
29
28
  export * from './utils/freeze-schema.js';
30
29
  export * from './protocols/index.js';
30
+ export * from './styling/scoped-styles.js';
31
31
  /**
32
32
  * @deprecated Import `composeStacks` from `@objectstack/spec` instead.
33
33
  *
@@ -38,3 +38,5 @@ export { composeStacks } from '@objectstack/spec';
38
38
  export * from './utils/drill-down.js';
39
39
  export * from './utils/date-macros.js';
40
40
  export * from './utils/compare-to.js';
41
+ export * from './utils/chart-series.js';
42
+ export * from './utils/dataset-format.js';
package/dist/index.js CHANGED
@@ -12,7 +12,6 @@ export * from './registry/WidgetRegistry.js';
12
12
  export * from './validation/index.js';
13
13
  export * from './builder/schema-builder.js';
14
14
  export * from './utils/filter-converter.js';
15
- export * from './utils/normalize-quick-filter.js';
16
15
  export * from './utils/extract-records.js';
17
16
  export * from './utils/expand-fields.js';
18
17
  export * from './evaluator/index.js';
@@ -27,6 +26,7 @@ export * from './utils/debug-collector.js';
27
26
  export * from './utils/merge-views-into-objects.js';
28
27
  export * from './utils/freeze-schema.js';
29
28
  export * from './protocols/index.js';
29
+ export * from './styling/scoped-styles.js';
30
30
  /**
31
31
  * @deprecated Import `composeStacks` from `@objectstack/spec` instead.
32
32
  *
@@ -37,3 +37,5 @@ export { composeStacks } from '@objectstack/spec';
37
37
  export * from './utils/drill-down.js';
38
38
  export * from './utils/date-macros.js';
39
39
  export * from './utils/compare-to.js';
40
+ export * from './utils/chart-series.js';
41
+ export * from './utils/dataset-format.js';
@@ -91,6 +91,17 @@ export declare class Registry<T = any> {
91
91
  * // Accessible as 'button'
92
92
  */
93
93
  register(type: string, component: ComponentRenderer<T>, meta?: ComponentMeta): void;
94
+ /**
95
+ * Remove a previously registered component. Mirrors {@link register} by
96
+ * clearing both the namespaced key and the bare-name fallback (when the
97
+ * fallback still points at this registration), plus any matching lazy stub.
98
+ * Notifies subscribers only when something was actually removed.
99
+ *
100
+ * Mainly used by tests that install a stub renderer and need to restore the
101
+ * prior registry state on teardown, since the registry is a process-level
102
+ * singleton shared across test files.
103
+ */
104
+ unregister(type: string, namespace?: string): boolean;
94
105
  /**
95
106
  * Register a lazy-loaded component. The `loader` is a function returning a
96
107
  * dynamic `import()` whose target module performs `register()` calls for
@@ -76,6 +76,17 @@ export class Registry {
76
76
  // the last registration wins for non-namespaced lookups
77
77
  // Skip this if skipFallback is true to avoid overwriting other components
78
78
  if (meta?.namespace && !meta?.skipFallback) {
79
+ // Collision guard: a bare-name fallback that overwrites a DIFFERENT
80
+ // component is almost always an accident (e.g. plugin 'view:grid' silently
81
+ // clobbering the layout 'grid'). Warn so it surfaces instead of 404-ing at
82
+ // render time. Pass `skipFallback: true` when a namespaced-only alias is
83
+ // intended.
84
+ const existing = this.components.get(type);
85
+ if (existing && existing.component !== component && existing.type !== fullType) {
86
+ console.warn(`Component "${type}" bare-name fallback is being overwritten by "${fullType}". ` +
87
+ `If this is intentional keep going; otherwise register "${fullType}" with ` +
88
+ `{ skipFallback: true } so it doesn't claim the bare "${type}" key.`);
89
+ }
79
90
  this.components.set(type, {
80
91
  type: fullType, // Keep reference to namespaced type
81
92
  component,
@@ -88,6 +99,31 @@ export class Registry {
88
99
  this.lazyEntries.delete(type);
89
100
  this.notify();
90
101
  }
102
+ /**
103
+ * Remove a previously registered component. Mirrors {@link register} by
104
+ * clearing both the namespaced key and the bare-name fallback (when the
105
+ * fallback still points at this registration), plus any matching lazy stub.
106
+ * Notifies subscribers only when something was actually removed.
107
+ *
108
+ * Mainly used by tests that install a stub renderer and need to restore the
109
+ * prior registry state on teardown, since the registry is a process-level
110
+ * singleton shared across test files.
111
+ */
112
+ unregister(type, namespace) {
113
+ const fullType = namespace ? `${namespace}:${type}` : type;
114
+ const removed = this.components.delete(fullType);
115
+ // Only drop the bare fallback if it still resolves to this registration.
116
+ if (namespace) {
117
+ const bare = this.components.get(type);
118
+ if (bare && bare.type === fullType)
119
+ this.components.delete(type);
120
+ }
121
+ this.lazyEntries.delete(fullType);
122
+ this.lazyEntries.delete(type);
123
+ if (removed)
124
+ this.notify();
125
+ return removed;
126
+ }
91
127
  /**
92
128
  * Register a lazy-loaded component. The `loader` is a function returning a
93
129
  * dynamic `import()` whose target module performs `register()` calls for