@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.
- package/CHANGELOG.md +205 -0
- package/dist/actions/ActionEngine.d.ts +42 -4
- package/dist/actions/ActionEngine.js +99 -3
- package/dist/actions/ActionRunner.d.ts +106 -0
- package/dist/actions/ActionRunner.js +213 -10
- package/dist/evaluator/ExpressionEvaluator.js +26 -4
- package/dist/evaluator/fieldRules.d.ts +49 -0
- package/dist/evaluator/fieldRules.js +86 -0
- package/dist/evaluator/index.d.ts +1 -0
- package/dist/evaluator/index.js +1 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- package/dist/registry/Registry.d.ts +11 -0
- package/dist/registry/Registry.js +36 -0
- package/dist/styling/scoped-styles.d.ts +41 -0
- package/dist/styling/scoped-styles.js +68 -0
- package/dist/utils/chart-series.d.ts +52 -0
- package/dist/utils/chart-series.js +72 -0
- package/dist/utils/dataset-format.d.ts +85 -0
- package/dist/utils/dataset-format.js +117 -0
- package/dist/validation/validators/object-validation-engine.js +20 -21
- package/package.json +5 -4
- package/dist/utils/normalize-quick-filter.d.ts +0 -29
- package/dist/utils/normalize-quick-filter.js +0 -66
|
@@ -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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
281
|
-
|
|
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
|
-
|
|
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;
|
|
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
|
+
}
|
package/dist/evaluator/index.js
CHANGED
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
|