ngx-json-render 0.2.1 → 0.4.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/README.md +448 -30
- package/fesm2022/ngx-json-render-testing.mjs +389 -0
- package/fesm2022/ngx-json-render-testing.mjs.map +1 -0
- package/fesm2022/ngx-json-render.mjs +969 -97
- package/fesm2022/ngx-json-render.mjs.map +1 -1
- package/package.json +5 -1
- package/types/ngx-json-render-testing.d.ts +197 -0
- package/types/ngx-json-render.d.ts +447 -10
|
@@ -1,9 +1,9 @@
|
|
|
1
|
+
import { NgComponentOutlet } from '@angular/common';
|
|
1
2
|
import * as i0 from '@angular/core';
|
|
2
|
-
import { signal, Injectable, inject, computed, untracked, effect, isDevMode, input, output,
|
|
3
|
-
import { createStateStore, getByPath, runValidation, resolveAction, nextActionDispatchId, notifyActionDispatch, notifyActionSettle, executeAction, isDevtoolsActive, subscribeDevtoolsActive, evaluateVisibility, resolveElementProps, resolveBindings, resolveActionParam, createDirectiveRegistry, resolveRepeatStatePath, resolveRepeatItemStatePath, removeByPath, addByPath, setByPath, SPEC_DATA_PART_TYPE, nestedToFlat, createMixedStreamParser, defineSchema } from '@json-render/core';
|
|
4
|
-
export { createStateStore, nestedToFlat } from '@json-render/core';
|
|
3
|
+
import { signal, Injectable, inject, computed, untracked, effect, isDevMode, DestroyRef, InjectionToken, input, output, viewChild, afterNextRender, ChangeDetectionStrategy, Component, Injector, forwardRef } from '@angular/core';
|
|
4
|
+
import { createStateStore, getByPath, runValidation, resolveAction, nextActionDispatchId, notifyActionDispatch, notifyActionSettle, executeAction, isDevtoolsActive, subscribeDevtoolsActive, evaluateVisibility, resolveElementProps, resolveBindings, resolveActionParam, autoFixSpec, validateSpec, createDirectiveRegistry, resolveRepeatStatePath, resolveRepeatItemStatePath, removeByPath, addByPath, setByPath, SPEC_DATA_PART_TYPE, nestedToFlat, createMixedStreamParser, defineSchema } from '@json-render/core';
|
|
5
|
+
export { autoFixSpec, createStateStore, formatSpecIssues, nestedToFlat, validateSpec } from '@json-render/core';
|
|
5
6
|
import { flattenToPointers } from '@json-render/core/store-utils';
|
|
6
|
-
import { NgComponentOutlet } from '@angular/common';
|
|
7
7
|
|
|
8
8
|
const EMPTY = signal(undefined, ...(ngDevMode ? [{ debugName: "EMPTY" }] : /* istanbul ignore next */ []));
|
|
9
9
|
/**
|
|
@@ -18,6 +18,11 @@ class JsonRenderRootContext {
|
|
|
18
18
|
registry = EMPTY;
|
|
19
19
|
loading = signal(false, ...(ngDevMode ? [{ debugName: "loading" }] : /* istanbul ignore next */ []));
|
|
20
20
|
fallback = EMPTY;
|
|
21
|
+
/**
|
|
22
|
+
* Caps on what the spec may cost the browser. Read as the tree renders, so
|
|
23
|
+
* they apply whatever `validate` is set to.
|
|
24
|
+
*/
|
|
25
|
+
limits = EMPTY;
|
|
21
26
|
/** External store (controlled mode). */
|
|
22
27
|
store = EMPTY;
|
|
23
28
|
/** Initial state (uncontrolled mode); falls back to `spec.state`. */
|
|
@@ -273,29 +278,73 @@ function validationConfigEqual(a, b) {
|
|
|
273
278
|
}
|
|
274
279
|
return true;
|
|
275
280
|
}
|
|
281
|
+
/**
|
|
282
|
+
* Owner of a registration made through the service's own API rather than by
|
|
283
|
+
* a field component. Manual registrations release together, which is the
|
|
284
|
+
* behaviour a caller of `registerField(path, config)` would expect.
|
|
285
|
+
*/
|
|
286
|
+
const MANUAL_OWNER = { manual: true };
|
|
276
287
|
/**
|
|
277
288
|
* Form validation state of a `<json-render>` subtree. Fields register their
|
|
278
289
|
* {@link ValidationConfig}; the built-in `validateForm` action validates all
|
|
279
290
|
* registered fields and writes the result to state.
|
|
291
|
+
*
|
|
292
|
+
* Registrations are owner-scoped: a path stays registered while at least one
|
|
293
|
+
* owner holds it, and its config and state are dropped when the last one
|
|
294
|
+
* releases. A spec that swaps screens or hides fields therefore stops
|
|
295
|
+
* validating what is no longer on screen.
|
|
280
296
|
*/
|
|
281
297
|
class JsonRenderValidationService {
|
|
282
298
|
root = inject(JsonRenderRootContext);
|
|
283
299
|
state = inject(JsonRenderStateService);
|
|
284
300
|
_fieldStates = signal({}, ...(ngDevMode ? [{ debugName: "_fieldStates" }] : /* istanbul ignore next */ []));
|
|
285
301
|
_fieldConfigs = signal({}, ...(ngDevMode ? [{ debugName: "_fieldConfigs" }] : /* istanbul ignore next */ []));
|
|
302
|
+
/** Live owners per path — the reference count behind a registration. */
|
|
303
|
+
fieldOwners = new Map();
|
|
286
304
|
/** Validation state per registered field path. */
|
|
287
305
|
fieldStates = this._fieldStates.asReadonly();
|
|
288
306
|
get customFunctions() {
|
|
289
307
|
return untracked(this.root.validationFunctions) ?? {};
|
|
290
308
|
}
|
|
291
|
-
/**
|
|
292
|
-
|
|
309
|
+
/**
|
|
310
|
+
* Register (or update) a field's validation config.
|
|
311
|
+
*
|
|
312
|
+
* @param owner Identity holding the registration — pass the same value to
|
|
313
|
+
* {@link unregisterField} to release it. Field components pass themselves,
|
|
314
|
+
* so two components bound to one path each hold their own claim.
|
|
315
|
+
*/
|
|
316
|
+
registerField(path, config, owner = MANUAL_OWNER) {
|
|
317
|
+
const owners = this.fieldOwners.get(path);
|
|
318
|
+
if (owners)
|
|
319
|
+
owners.add(owner);
|
|
320
|
+
else
|
|
321
|
+
this.fieldOwners.set(path, new Set([owner]));
|
|
293
322
|
const prev = untracked(this._fieldConfigs);
|
|
294
323
|
const existing = prev[path];
|
|
295
324
|
if (existing && validationConfigEqual(existing, config))
|
|
296
325
|
return;
|
|
297
326
|
this._fieldConfigs.set({ ...prev, [path]: config });
|
|
298
327
|
}
|
|
328
|
+
/**
|
|
329
|
+
* Release one owner's registration of a field. The config and validation
|
|
330
|
+
* state survive while another owner still holds the path; once the last
|
|
331
|
+
* one lets go, `validateAll` stops seeing the field.
|
|
332
|
+
*/
|
|
333
|
+
unregisterField(path, owner = MANUAL_OWNER) {
|
|
334
|
+
const owners = this.fieldOwners.get(path);
|
|
335
|
+
if (!owners)
|
|
336
|
+
return;
|
|
337
|
+
owners.delete(owner);
|
|
338
|
+
if (owners.size > 0)
|
|
339
|
+
return;
|
|
340
|
+
this.fieldOwners.delete(path);
|
|
341
|
+
const prev = untracked(this._fieldConfigs);
|
|
342
|
+
if (path in prev) {
|
|
343
|
+
const { [path]: _removed, ...rest } = prev;
|
|
344
|
+
this._fieldConfigs.set(rest);
|
|
345
|
+
}
|
|
346
|
+
this.clear(path);
|
|
347
|
+
}
|
|
299
348
|
/** Validate a single field and record the result. */
|
|
300
349
|
validate(path, config) {
|
|
301
350
|
const currentState = this.state.getSnapshot();
|
|
@@ -368,18 +417,39 @@ function injectValidation() {
|
|
|
368
417
|
/**
|
|
369
418
|
* Field-level validation helper for catalog input components: registers the
|
|
370
419
|
* config and exposes the field's validation state as signals.
|
|
420
|
+
*
|
|
421
|
+
* The registration follows the component: it moves when the bound path
|
|
422
|
+
* changes and is released when the component is destroyed, so a field that
|
|
423
|
+
* has left the screen no longer takes part in `validateForm`.
|
|
371
424
|
*/
|
|
372
425
|
function injectFieldValidation(path, config) {
|
|
373
426
|
const validation = injectValidation();
|
|
374
427
|
const getPath = typeof path === 'function' ? path : () => path;
|
|
375
428
|
const getConfig = typeof config === 'function' ? config : () => config;
|
|
429
|
+
// Identity of this field's claim on a path. Two components bound to the
|
|
430
|
+
// same path hold separate claims, so one being destroyed does not
|
|
431
|
+
// deregister the other.
|
|
432
|
+
const owner = {};
|
|
433
|
+
let registeredPath = null;
|
|
376
434
|
effect(() => {
|
|
377
435
|
const p = getPath();
|
|
378
436
|
const c = getConfig();
|
|
379
|
-
|
|
380
|
-
|
|
437
|
+
const next = p && c ? p : null;
|
|
438
|
+
if (registeredPath !== null && registeredPath !== next) {
|
|
439
|
+
validation.unregisterField(registeredPath, owner);
|
|
440
|
+
registeredPath = null;
|
|
441
|
+
}
|
|
442
|
+
if (next && c) {
|
|
443
|
+
validation.registerField(next, c, owner);
|
|
444
|
+
registeredPath = next;
|
|
381
445
|
}
|
|
382
446
|
});
|
|
447
|
+
inject(DestroyRef).onDestroy(() => {
|
|
448
|
+
if (registeredPath === null)
|
|
449
|
+
return;
|
|
450
|
+
validation.unregisterField(registeredPath, owner);
|
|
451
|
+
registeredPath = null;
|
|
452
|
+
});
|
|
383
453
|
const state = computed(() => {
|
|
384
454
|
const current = validation.fieldStates()[getPath()];
|
|
385
455
|
return current ?? { touched: false, validated: false, result: null };
|
|
@@ -432,7 +502,7 @@ function deepResolveValue(value, get) {
|
|
|
432
502
|
* Carries a stable `name` so callers can tell a cancellation apart from a
|
|
433
503
|
* handler failure without matching on the message.
|
|
434
504
|
*
|
|
435
|
-
* @internal Not part of the public API;
|
|
505
|
+
* @internal Not part of the public API; use {@link isActionCancelled}.
|
|
436
506
|
*/
|
|
437
507
|
class ActionCancelledError extends Error {
|
|
438
508
|
constructor() {
|
|
@@ -448,9 +518,9 @@ function isActionCancelled(error) {
|
|
|
448
518
|
* Action dispatcher of a `<json-render>` subtree.
|
|
449
519
|
*
|
|
450
520
|
* Executes {@link ActionBinding}s: built-in actions (`setState`, `pushState`,
|
|
451
|
-
* `removeState`, `push`, `pop`, `validateForm`) are handled
|
|
452
|
-
* everything else is routed to the host-provided `handlers` (or
|
|
453
|
-
* `onAction` catch-all), honoring `confirm`, `onSuccess`, and `onError`.
|
|
521
|
+
* `removeState`, `push`, `pop`, `validateForm`, `submitForm`) are handled
|
|
522
|
+
* internally; everything else is routed to the host-provided `handlers` (or
|
|
523
|
+
* the `onAction` catch-all), honoring `confirm`, `onSuccess`, and `onError`.
|
|
454
524
|
*/
|
|
455
525
|
class JsonRenderActionsService {
|
|
456
526
|
root = inject(JsonRenderRootContext);
|
|
@@ -557,19 +627,36 @@ class JsonRenderActionsService {
|
|
|
557
627
|
return;
|
|
558
628
|
}
|
|
559
629
|
if (resolved.action === 'validateForm') {
|
|
560
|
-
if (
|
|
630
|
+
if (this.checkForm(resolved) === null) {
|
|
561
631
|
console.warn('validateForm action was dispatched but no JsonRenderValidationService is available.');
|
|
632
|
+
}
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
if (resolved.action === 'submitForm') {
|
|
636
|
+
const valid = this.checkForm(resolved);
|
|
637
|
+
if (valid === null) {
|
|
638
|
+
console.warn('submitForm action was dispatched but no JsonRenderValidationService is available; nothing was submitted.');
|
|
562
639
|
return;
|
|
563
640
|
}
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
641
|
+
// An invalid form stops here, with the errors already written to
|
|
642
|
+
// state and every field marked validated, so they are on screen.
|
|
643
|
+
if (!valid)
|
|
644
|
+
return;
|
|
645
|
+
const target = resolved.params?.['action'];
|
|
646
|
+
if (typeof target !== 'string' || !target) {
|
|
647
|
+
console.warn('submitForm needs the action to submit to: { "action": "submitForm", "params": { "action": "saveUser" } }.');
|
|
648
|
+
return;
|
|
570
649
|
}
|
|
571
|
-
|
|
572
|
-
|
|
650
|
+
// Dispatched, not called directly, so the submitted action goes
|
|
651
|
+
// through the same lookup, confirmation, loading state and observers
|
|
652
|
+
// as any other — submitForm gates it, it does not replace it.
|
|
653
|
+
await this.execute({
|
|
654
|
+
action: target,
|
|
655
|
+
params: deepResolveValue(resolved.params?.['params'], get),
|
|
656
|
+
confirm: resolved.confirm,
|
|
657
|
+
onSuccess: resolved.onSuccess,
|
|
658
|
+
onError: resolved.onError,
|
|
659
|
+
});
|
|
573
660
|
return;
|
|
574
661
|
}
|
|
575
662
|
const handler = this.lookupHandler(resolved.action);
|
|
@@ -622,6 +709,28 @@ class JsonRenderActionsService {
|
|
|
622
709
|
cancel() {
|
|
623
710
|
untracked(this._pendingConfirmation)?.reject();
|
|
624
711
|
}
|
|
712
|
+
/**
|
|
713
|
+
* Validate every registered field and write `{ valid, errors }` to the
|
|
714
|
+
* action's `statePath` (default `/formValidation`). Returns null when there
|
|
715
|
+
* is no validation service to ask.
|
|
716
|
+
*
|
|
717
|
+
* Shared by `validateForm` and `submitForm`: the two must report the same
|
|
718
|
+
* thing about the same form, and one implementation is how that stays true.
|
|
719
|
+
*/
|
|
720
|
+
checkForm(resolved) {
|
|
721
|
+
if (!this.validation)
|
|
722
|
+
return null;
|
|
723
|
+
const valid = this.validation.validateAll();
|
|
724
|
+
const errors = {};
|
|
725
|
+
for (const [path, fieldState] of Object.entries(untracked(this.validation.fieldStates))) {
|
|
726
|
+
if (fieldState.result && !fieldState.result.valid) {
|
|
727
|
+
errors[path] = fieldState.result.errors;
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
const statePath = resolved.params?.['statePath'] || '/formValidation';
|
|
731
|
+
this.state.set(statePath, { valid, errors });
|
|
732
|
+
return valid;
|
|
733
|
+
}
|
|
625
734
|
lookupHandler(name) {
|
|
626
735
|
const direct = this.handlers[name];
|
|
627
736
|
if (direct)
|
|
@@ -674,91 +783,178 @@ function injectAction(binding) {
|
|
|
674
783
|
};
|
|
675
784
|
}
|
|
676
785
|
|
|
786
|
+
/**
|
|
787
|
+
* Labels for the default confirmation dialog's buttons, when the spec does not
|
|
788
|
+
* supply `confirmLabel` / `cancelLabel` of its own.
|
|
789
|
+
*
|
|
790
|
+
* They are the only strings this package puts on screen, and they were English
|
|
791
|
+
* with no way to change them. Override once, in an app's providers:
|
|
792
|
+
*
|
|
793
|
+
* ```ts
|
|
794
|
+
* { provide: JR_CONFIRM_LABELS, useValue: { confirm: 'Подтвердить', cancel: 'Отмена' } }
|
|
795
|
+
* ```
|
|
796
|
+
*/
|
|
797
|
+
const JR_CONFIRM_LABELS = new InjectionToken('ngx-json-render JR_CONFIRM_LABELS', {
|
|
798
|
+
providedIn: 'root',
|
|
799
|
+
factory: () => ({ confirm: 'Confirm', cancel: 'Cancel' }),
|
|
800
|
+
});
|
|
801
|
+
/** The confirmation a replacement dialog component is rendered for. */
|
|
802
|
+
const CONFIRM_CONTEXT = new InjectionToken('ngx-json-render CONFIRM_CONTEXT');
|
|
803
|
+
/**
|
|
804
|
+
* The component `<json-render>` should render for a `confirm` on an action.
|
|
805
|
+
* Unset means the packaged {@link JrConfirmDialog}.
|
|
806
|
+
*
|
|
807
|
+
* The component is rendered with a {@link CONFIRM_CONTEXT} in its injector, so
|
|
808
|
+
* it takes no inputs and emits no outputs:
|
|
809
|
+
*
|
|
810
|
+
* ```ts
|
|
811
|
+
* @Component({ template: `<my-modal (ok)="ctx.confirm()" (dismiss)="ctx.cancel()">…` })
|
|
812
|
+
* export class AppConfirm {
|
|
813
|
+
* readonly ctx = injectConfirmContext();
|
|
814
|
+
* }
|
|
815
|
+
* // providers: [{ provide: JR_CONFIRM_DIALOG, useValue: AppConfirm }]
|
|
816
|
+
* ```
|
|
817
|
+
*/
|
|
818
|
+
const JR_CONFIRM_DIALOG = new InjectionToken('ngx-json-render JR_CONFIRM_DIALOG');
|
|
819
|
+
/** Inject the confirmation a replacement dialog component is rendering for. */
|
|
820
|
+
function injectConfirmContext() {
|
|
821
|
+
const ctx = inject(CONFIRM_CONTEXT, { optional: true });
|
|
822
|
+
if (!ctx) {
|
|
823
|
+
throw new Error('injectConfirmContext() must be used inside a component provided through JR_CONFIRM_DIALOG');
|
|
824
|
+
}
|
|
825
|
+
return ctx;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
let dialogIdCounter = 0;
|
|
677
829
|
/**
|
|
678
830
|
* Default confirmation dialog shown for action bindings with a `confirm`
|
|
679
831
|
* field. Rendered automatically by `<json-render>`; can also be used
|
|
680
832
|
* standalone with a custom action flow.
|
|
833
|
+
*
|
|
834
|
+
* It is a real dialog: `role="dialog"` with `aria-modal`, labelled by its own
|
|
835
|
+
* title and described by its message, focus moved in on open and returned to
|
|
836
|
+
* wherever it came from on close, Tab kept inside it and Escape cancelling.
|
|
837
|
+
* A generated UI can ask for confirmation of something destructive, so a
|
|
838
|
+
* keyboard or screen-reader user has to be able to answer it.
|
|
839
|
+
*
|
|
840
|
+
* Colours come from CSS custom properties with light and dark defaults, so an
|
|
841
|
+
* app can theme it — `--jr-confirm-surface`, `--jr-confirm-ink`,
|
|
842
|
+
* `--jr-confirm-muted`, `--jr-confirm-border`, `--jr-confirm-scrim`,
|
|
843
|
+
* `--jr-confirm-accent`, `--jr-confirm-danger`, `--jr-confirm-on-accent`,
|
|
844
|
+
* `--jr-confirm-radius` — and replace it outright through
|
|
845
|
+
* {@link JR_CONFIRM_DIALOG} when that is not enough.
|
|
681
846
|
*/
|
|
682
847
|
class JrConfirmDialog {
|
|
683
848
|
config = input.required(...(ngDevMode ? [{ debugName: "config" }] : /* istanbul ignore next */ []));
|
|
684
849
|
confirmed = output();
|
|
685
850
|
cancelled = output();
|
|
851
|
+
labels = inject(JR_CONFIRM_LABELS);
|
|
852
|
+
id = dialogIdCounter++;
|
|
853
|
+
titleId = `jr-confirm-title-${this.id}`;
|
|
854
|
+
messageId = `jr-confirm-message-${this.id}`;
|
|
855
|
+
cancelButton = viewChild('cancel', ...(ngDevMode ? [{ debugName: "cancelButton" }] : /* istanbul ignore next */ []));
|
|
856
|
+
confirmButton = viewChild('confirm', ...(ngDevMode ? [{ debugName: "confirmButton" }] : /* istanbul ignore next */ []));
|
|
686
857
|
isDanger = computed(() => this.config().variant === 'danger', ...(ngDevMode ? [{ debugName: "isDanger" }] : /* istanbul ignore next */ []));
|
|
858
|
+
constructor() {
|
|
859
|
+
// Whatever was focused when the action fired — usually the control that
|
|
860
|
+
// dispatched it, which is still in the document behind the dialog.
|
|
861
|
+
const previous = typeof document !== 'undefined'
|
|
862
|
+
? document.activeElement
|
|
863
|
+
: null;
|
|
864
|
+
// Cancel takes focus rather than confirm: a dialog exists to interrupt,
|
|
865
|
+
// and landing on the destructive answer makes a stray Enter destructive.
|
|
866
|
+
afterNextRender(() => this.cancelButton()?.nativeElement.focus());
|
|
867
|
+
inject(DestroyRef).onDestroy(() => previous?.focus?.());
|
|
868
|
+
}
|
|
869
|
+
/**
|
|
870
|
+
* Keep Tab inside the dialog. With exactly two buttons the whole trap is
|
|
871
|
+
* wrapping the ends; a modal that lets focus wander behind it is a modal
|
|
872
|
+
* only for people using a mouse.
|
|
873
|
+
*/
|
|
874
|
+
onKeydown(event) {
|
|
875
|
+
if (event.key !== 'Tab')
|
|
876
|
+
return;
|
|
877
|
+
const cancel = this.cancelButton()?.nativeElement;
|
|
878
|
+
const confirm = this.confirmButton()?.nativeElement;
|
|
879
|
+
if (!cancel || !confirm)
|
|
880
|
+
return;
|
|
881
|
+
const active = event.target;
|
|
882
|
+
if (event.shiftKey && active === cancel) {
|
|
883
|
+
event.preventDefault();
|
|
884
|
+
confirm.focus();
|
|
885
|
+
}
|
|
886
|
+
else if (!event.shiftKey && active === confirm) {
|
|
887
|
+
event.preventDefault();
|
|
888
|
+
cancel.focus();
|
|
889
|
+
}
|
|
890
|
+
}
|
|
687
891
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: JrConfirmDialog, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
688
|
-
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.
|
|
689
|
-
<div
|
|
690
|
-
style="position: fixed; inset: 0; background-color: rgba(0, 0, 0, 0.5); display: flex; align-items: center; justify-content: center; z-index: 50"
|
|
691
|
-
(click)="cancelled.emit()"
|
|
692
|
-
>
|
|
892
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.2.0", version: "21.2.22", type: JrConfirmDialog, isStandalone: true, selector: "jr-confirm-dialog", inputs: { config: { classPropertyName: "config", publicName: "config", isSignal: true, isRequired: true, transformFunction: null } }, outputs: { confirmed: "confirmed", cancelled: "cancelled" }, host: { listeners: { "keydown.escape": "cancelled.emit()" } }, viewQueries: [{ propertyName: "cancelButton", first: true, predicate: ["cancel"], descendants: true, isSignal: true }, { propertyName: "confirmButton", first: true, predicate: ["confirm"], descendants: true, isSignal: true }], ngImport: i0, template: `
|
|
893
|
+
<div class="jr-confirm-scrim" (click)="cancelled.emit()">
|
|
693
894
|
<div
|
|
694
|
-
|
|
895
|
+
class="jr-confirm-panel"
|
|
896
|
+
role="dialog"
|
|
897
|
+
aria-modal="true"
|
|
898
|
+
[attr.aria-labelledby]="titleId"
|
|
899
|
+
[attr.aria-describedby]="messageId"
|
|
695
900
|
(click)="$event.stopPropagation()"
|
|
901
|
+
(keydown)="onKeydown($event)"
|
|
696
902
|
>
|
|
697
|
-
<h3
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
<button
|
|
703
|
-
type="button"
|
|
704
|
-
(click)="cancelled.emit()"
|
|
705
|
-
style="padding: 8px 16px; border-radius: 6px; border: 1px solid #d1d5db; background-color: white; cursor: pointer"
|
|
706
|
-
>
|
|
707
|
-
{{ config().cancelLabel ?? 'Cancel' }}
|
|
903
|
+
<h3 class="jr-confirm-title" [id]="titleId">{{ config().title }}</h3>
|
|
904
|
+
<p class="jr-confirm-message" [id]="messageId">{{ config().message }}</p>
|
|
905
|
+
<div class="jr-confirm-actions">
|
|
906
|
+
<button #cancel type="button" class="jr-confirm-cancel" (click)="cancelled.emit()">
|
|
907
|
+
{{ config().cancelLabel ?? labels.cancel }}
|
|
708
908
|
</button>
|
|
709
909
|
<button
|
|
910
|
+
#confirm
|
|
710
911
|
type="button"
|
|
912
|
+
class="jr-confirm-confirm"
|
|
913
|
+
[class.jr-confirm-danger]="isDanger()"
|
|
711
914
|
(click)="confirmed.emit()"
|
|
712
|
-
[style.background-color]="isDanger() ? '#dc2626' : '#3b82f6'"
|
|
713
|
-
style="padding: 8px 16px; border-radius: 6px; border: none; color: white; cursor: pointer"
|
|
714
915
|
>
|
|
715
|
-
{{ config().confirmLabel ??
|
|
916
|
+
{{ config().confirmLabel ?? labels.confirm }}
|
|
716
917
|
</button>
|
|
717
918
|
</div>
|
|
718
919
|
</div>
|
|
719
920
|
</div>
|
|
720
|
-
`, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
921
|
+
`, isInline: true, styles: [":host{--jr-confirm-scrim-default: rgba(0, 0, 0, .5);--jr-confirm-surface-default: #ffffff;--jr-confirm-ink-default: #111827;--jr-confirm-muted-default: #6b7280;--jr-confirm-border-default: #d1d5db;--jr-confirm-accent-default: #3b82f6;--jr-confirm-danger-default: #dc2626;--jr-confirm-on-accent-default: #ffffff}@media(prefers-color-scheme:dark){:host{--jr-confirm-scrim-default: rgba(0, 0, 0, .65);--jr-confirm-surface-default: #1f2937;--jr-confirm-ink-default: #f3f4f6;--jr-confirm-muted-default: #9ca3af;--jr-confirm-border-default: #4b5563;--jr-confirm-accent-default: #60a5fa;--jr-confirm-danger-default: #f87171;--jr-confirm-on-accent-default: #111827}}.jr-confirm-scrim{position:fixed;inset:0;z-index:50;display:flex;align-items:center;justify-content:center;background-color:var(--jr-confirm-scrim, var(--jr-confirm-scrim-default))}.jr-confirm-panel{width:100%;max-width:400px;padding:24px;border-radius:var(--jr-confirm-radius, 8px);background-color:var(--jr-confirm-surface, var(--jr-confirm-surface-default));color:var(--jr-confirm-ink, var(--jr-confirm-ink-default));box-shadow:0 20px 25px -5px #00000040}.jr-confirm-title{margin:0 0 8px;font-size:18px;font-weight:600}.jr-confirm-message{margin:0 0 24px;color:var(--jr-confirm-muted, var(--jr-confirm-muted-default))}.jr-confirm-actions{display:flex;gap:12px;justify-content:flex-end}.jr-confirm-actions button{padding:8px 16px;border-radius:6px;font:inherit;cursor:pointer}.jr-confirm-cancel{border:1px solid var(--jr-confirm-border, var(--jr-confirm-border-default));background-color:transparent;color:inherit}.jr-confirm-confirm{border:none;background-color:var(--jr-confirm-accent, var(--jr-confirm-accent-default));color:var(--jr-confirm-on-accent, var(--jr-confirm-on-accent-default))}.jr-confirm-confirm.jr-confirm-danger{background-color:var(--jr-confirm-danger, var(--jr-confirm-danger-default))}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
721
922
|
}
|
|
722
923
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: JrConfirmDialog, decorators: [{
|
|
723
924
|
type: Component,
|
|
724
|
-
args: [{
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
<div
|
|
729
|
-
style="position: fixed; inset: 0; background-color: rgba(0, 0, 0, 0.5); display: flex; align-items: center; justify-content: center; z-index: 50"
|
|
730
|
-
(click)="cancelled.emit()"
|
|
731
|
-
>
|
|
925
|
+
args: [{ selector: 'jr-confirm-dialog', changeDetection: ChangeDetectionStrategy.OnPush, host: {
|
|
926
|
+
'(keydown.escape)': 'cancelled.emit()',
|
|
927
|
+
}, template: `
|
|
928
|
+
<div class="jr-confirm-scrim" (click)="cancelled.emit()">
|
|
732
929
|
<div
|
|
733
|
-
|
|
930
|
+
class="jr-confirm-panel"
|
|
931
|
+
role="dialog"
|
|
932
|
+
aria-modal="true"
|
|
933
|
+
[attr.aria-labelledby]="titleId"
|
|
934
|
+
[attr.aria-describedby]="messageId"
|
|
734
935
|
(click)="$event.stopPropagation()"
|
|
936
|
+
(keydown)="onKeydown($event)"
|
|
735
937
|
>
|
|
736
|
-
<h3
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
<button
|
|
742
|
-
type="button"
|
|
743
|
-
(click)="cancelled.emit()"
|
|
744
|
-
style="padding: 8px 16px; border-radius: 6px; border: 1px solid #d1d5db; background-color: white; cursor: pointer"
|
|
745
|
-
>
|
|
746
|
-
{{ config().cancelLabel ?? 'Cancel' }}
|
|
938
|
+
<h3 class="jr-confirm-title" [id]="titleId">{{ config().title }}</h3>
|
|
939
|
+
<p class="jr-confirm-message" [id]="messageId">{{ config().message }}</p>
|
|
940
|
+
<div class="jr-confirm-actions">
|
|
941
|
+
<button #cancel type="button" class="jr-confirm-cancel" (click)="cancelled.emit()">
|
|
942
|
+
{{ config().cancelLabel ?? labels.cancel }}
|
|
747
943
|
</button>
|
|
748
944
|
<button
|
|
945
|
+
#confirm
|
|
749
946
|
type="button"
|
|
947
|
+
class="jr-confirm-confirm"
|
|
948
|
+
[class.jr-confirm-danger]="isDanger()"
|
|
750
949
|
(click)="confirmed.emit()"
|
|
751
|
-
[style.background-color]="isDanger() ? '#dc2626' : '#3b82f6'"
|
|
752
|
-
style="padding: 8px 16px; border-radius: 6px; border: none; color: white; cursor: pointer"
|
|
753
950
|
>
|
|
754
|
-
{{ config().confirmLabel ??
|
|
951
|
+
{{ config().confirmLabel ?? labels.confirm }}
|
|
755
952
|
</button>
|
|
756
953
|
</div>
|
|
757
954
|
</div>
|
|
758
955
|
</div>
|
|
759
|
-
`,
|
|
760
|
-
|
|
761
|
-
}], propDecorators: { config: [{ type: i0.Input, args: [{ isSignal: true, alias: "config", required: true }] }], confirmed: [{ type: i0.Output, args: ["confirmed"] }], cancelled: [{ type: i0.Output, args: ["cancelled"] }] } });
|
|
956
|
+
`, styles: [":host{--jr-confirm-scrim-default: rgba(0, 0, 0, .5);--jr-confirm-surface-default: #ffffff;--jr-confirm-ink-default: #111827;--jr-confirm-muted-default: #6b7280;--jr-confirm-border-default: #d1d5db;--jr-confirm-accent-default: #3b82f6;--jr-confirm-danger-default: #dc2626;--jr-confirm-on-accent-default: #ffffff}@media(prefers-color-scheme:dark){:host{--jr-confirm-scrim-default: rgba(0, 0, 0, .65);--jr-confirm-surface-default: #1f2937;--jr-confirm-ink-default: #f3f4f6;--jr-confirm-muted-default: #9ca3af;--jr-confirm-border-default: #4b5563;--jr-confirm-accent-default: #60a5fa;--jr-confirm-danger-default: #f87171;--jr-confirm-on-accent-default: #111827}}.jr-confirm-scrim{position:fixed;inset:0;z-index:50;display:flex;align-items:center;justify-content:center;background-color:var(--jr-confirm-scrim, var(--jr-confirm-scrim-default))}.jr-confirm-panel{width:100%;max-width:400px;padding:24px;border-radius:var(--jr-confirm-radius, 8px);background-color:var(--jr-confirm-surface, var(--jr-confirm-surface-default));color:var(--jr-confirm-ink, var(--jr-confirm-ink-default));box-shadow:0 20px 25px -5px #00000040}.jr-confirm-title{margin:0 0 8px;font-size:18px;font-weight:600}.jr-confirm-message{margin:0 0 24px;color:var(--jr-confirm-muted, var(--jr-confirm-muted-default))}.jr-confirm-actions{display:flex;gap:12px;justify-content:flex-end}.jr-confirm-actions button{padding:8px 16px;border-radius:6px;font:inherit;cursor:pointer}.jr-confirm-cancel{border:1px solid var(--jr-confirm-border, var(--jr-confirm-border-default));background-color:transparent;color:inherit}.jr-confirm-confirm{border:none;background-color:var(--jr-confirm-accent, var(--jr-confirm-accent-default));color:var(--jr-confirm-on-accent, var(--jr-confirm-on-accent-default))}.jr-confirm-confirm.jr-confirm-danger{background-color:var(--jr-confirm-danger, var(--jr-confirm-danger-default))}\n"] }]
|
|
957
|
+
}], ctorParameters: () => [], propDecorators: { config: [{ type: i0.Input, args: [{ isSignal: true, alias: "config", required: true }] }], confirmed: [{ type: i0.Output, args: ["confirmed"] }], cancelled: [{ type: i0.Output, args: ["cancelled"] }], cancelButton: [{ type: i0.ViewChild, args: ['cancel', { isSignal: true }] }], confirmButton: [{ type: i0.ViewChild, args: ['confirm', { isSignal: true }] }] } });
|
|
762
958
|
|
|
763
959
|
/**
|
|
764
960
|
* Reactive mirror of the json-render devtools-active flag. When active, the
|
|
@@ -776,11 +972,26 @@ function injectDevtoolsActive() {
|
|
|
776
972
|
* Provided by the renderer for every catalog component instance.
|
|
777
973
|
*/
|
|
778
974
|
const RENDER_CONTEXT = new InjectionToken('ngx-json-render RENDER_CONTEXT');
|
|
975
|
+
/**
|
|
976
|
+
* The spec key of the element currently being rendered. Provided alongside
|
|
977
|
+
* {@link RENDER_CONTEXT} for every catalog component instance.
|
|
978
|
+
*/
|
|
979
|
+
const ELEMENT_KEY = new InjectionToken('ngx-json-render ELEMENT_KEY');
|
|
779
980
|
/**
|
|
780
981
|
* The current repeat scope. Present only for elements rendered inside a
|
|
781
982
|
* `repeat` block.
|
|
782
983
|
*/
|
|
783
984
|
const REPEAT_SCOPE = new InjectionToken('ngx-json-render REPEAT_SCOPE');
|
|
985
|
+
/**
|
|
986
|
+
* This element's position in the render tree. Provided by `<jr-element>` for
|
|
987
|
+
* everything it renders below itself, so each element can see its own depth
|
|
988
|
+
* and its own ancestry.
|
|
989
|
+
*
|
|
990
|
+
* @internal Not part of the public API: it exists so the renderer can refuse
|
|
991
|
+
* to draw an element that is its own ancestor, and to enforce
|
|
992
|
+
* `renderLimits.maxDepth`.
|
|
993
|
+
*/
|
|
994
|
+
const RENDER_PATH = new InjectionToken('ngx-json-render RENDER_PATH');
|
|
784
995
|
/**
|
|
785
996
|
* Inject the render context inside a catalog component.
|
|
786
997
|
*
|
|
@@ -802,6 +1013,27 @@ function injectRenderContext() {
|
|
|
802
1013
|
}
|
|
803
1014
|
return ctx;
|
|
804
1015
|
}
|
|
1016
|
+
/**
|
|
1017
|
+
* Inject the spec key of the element this component renders.
|
|
1018
|
+
*
|
|
1019
|
+
* Catalog components that collect their children — a tab group discovering
|
|
1020
|
+
* its tabs, for instance — need it to order registrations by the spec's
|
|
1021
|
+
* `children` array rather than by the order the children happened to
|
|
1022
|
+
* announce themselves in.
|
|
1023
|
+
*
|
|
1024
|
+
* @example
|
|
1025
|
+
* ```ts
|
|
1026
|
+
* const key = injectElementKey();
|
|
1027
|
+
* const index = computed(() => parent.element().children?.indexOf(key()) ?? -1);
|
|
1028
|
+
* ```
|
|
1029
|
+
*/
|
|
1030
|
+
function injectElementKey() {
|
|
1031
|
+
const key = inject(ELEMENT_KEY, { optional: true });
|
|
1032
|
+
if (!key) {
|
|
1033
|
+
throw new Error('injectElementKey() must be used inside a component rendered by <json-render>');
|
|
1034
|
+
}
|
|
1035
|
+
return key;
|
|
1036
|
+
}
|
|
805
1037
|
/**
|
|
806
1038
|
* Inject the current repeat scope, or `null` when the component is not
|
|
807
1039
|
* rendered inside a `repeat` block.
|
|
@@ -820,6 +1052,53 @@ function reportActionError(error) {
|
|
|
820
1052
|
return;
|
|
821
1053
|
console.error(error);
|
|
822
1054
|
}
|
|
1055
|
+
/**
|
|
1056
|
+
* Whether `inner` reads strictly deeper into state than `outer` — the state
|
|
1057
|
+
* path of one repeat item nested inside another's.
|
|
1058
|
+
*
|
|
1059
|
+
* `null` is the world outside any repeat, so stepping into one is always
|
|
1060
|
+
* progress. Below that it is a prefix test on the resolved path, and the
|
|
1061
|
+
* trailing separator is what keeps `/tree/10` from counting as a step inside
|
|
1062
|
+
* `/tree/1`.
|
|
1063
|
+
*/
|
|
1064
|
+
function descendsInto(outer, inner) {
|
|
1065
|
+
if (inner === null)
|
|
1066
|
+
return false;
|
|
1067
|
+
if (outer === null)
|
|
1068
|
+
return true;
|
|
1069
|
+
return inner.startsWith(`${outer}/`);
|
|
1070
|
+
}
|
|
1071
|
+
/**
|
|
1072
|
+
* Whether this element renders itself again without getting anywhere — the
|
|
1073
|
+
* one arrangement that cannot terminate.
|
|
1074
|
+
*
|
|
1075
|
+
* An element reaching itself is not by itself a defect. It is how a tree gets
|
|
1076
|
+
* drawn — a comment thread, a file browser, a nested menu — where each pass
|
|
1077
|
+
* repeats over a path relative to the item it is already inside and so reads
|
|
1078
|
+
* one level further into the data. That ends when the data does.
|
|
1079
|
+
*
|
|
1080
|
+
* What separates the two is whether the repeat descended. Compare against the
|
|
1081
|
+
* nearest occurrence above: `/tree/0` then `/tree/0/children/1` is a tree
|
|
1082
|
+
* walking down, while `/items/0` then `/items/1` is a spec repeating over a
|
|
1083
|
+
* fixed array inside itself, and `/items/0` twice is one with no repeat
|
|
1084
|
+
* between the passes at all. Only the first can run out.
|
|
1085
|
+
*/
|
|
1086
|
+
function repeatsItself(path) {
|
|
1087
|
+
for (let step = path.parent; step !== null; step = step.parent) {
|
|
1088
|
+
if (step.key !== path.key)
|
|
1089
|
+
continue;
|
|
1090
|
+
return !descendsInto(step.scopePath, path.scopePath);
|
|
1091
|
+
}
|
|
1092
|
+
return false;
|
|
1093
|
+
}
|
|
1094
|
+
/** The chain from the root down to `path`, as a line a human can follow. */
|
|
1095
|
+
function describePath(path) {
|
|
1096
|
+
const keys = [];
|
|
1097
|
+
for (let step = path; step !== null; step = step.parent) {
|
|
1098
|
+
keys.push(step.key);
|
|
1099
|
+
}
|
|
1100
|
+
return keys.reverse().join(' \u2192 ');
|
|
1101
|
+
}
|
|
823
1102
|
/**
|
|
824
1103
|
* Renders a single spec element: evaluates visibility, resolves prop
|
|
825
1104
|
* expressions against the state model, wires event/watch bindings, and
|
|
@@ -833,6 +1112,7 @@ class JrElement {
|
|
|
833
1112
|
state = inject(JsonRenderStateService);
|
|
834
1113
|
actions = inject(JsonRenderActionsService);
|
|
835
1114
|
repeatScope = inject(REPEAT_SCOPE, { optional: true });
|
|
1115
|
+
parentPath = inject(RENDER_PATH, { optional: true });
|
|
836
1116
|
devtoolsActive = injectDevtoolsActive();
|
|
837
1117
|
/** The raw (unresolved) element from the spec. */
|
|
838
1118
|
rawElement = computed(() => this.root.spec()?.elements?.[this.elementKey()], ...(ngDevMode ? [{ debugName: "rawElement" }] : /* istanbul ignore next */ []));
|
|
@@ -881,6 +1161,39 @@ class JrElement {
|
|
|
881
1161
|
}, ...(ngDevMode ? [{ debugName: "entry" }] : /* istanbul ignore next */ []));
|
|
882
1162
|
component = computed(() => this.entry()?.component ?? null, ...(ngDevMode ? [{ debugName: "component" }] : /* istanbul ignore next */ []));
|
|
883
1163
|
devtoolsKey = computed(() => this.devtoolsActive() ? this.elementKey() : null, ...(ngDevMode ? [{ debugName: "devtoolsKey" }] : /* istanbul ignore next */ []));
|
|
1164
|
+
/**
|
|
1165
|
+
* Where this element sits in the tree. Passed down to everything it renders
|
|
1166
|
+
* below itself, which is what lets each element see its own ancestry
|
|
1167
|
+
* without the renderer holding a registry of who is drawing what.
|
|
1168
|
+
*/
|
|
1169
|
+
path = computed(() => {
|
|
1170
|
+
const parent = this.parentPath?.() ?? null;
|
|
1171
|
+
return {
|
|
1172
|
+
key: this.elementKey(),
|
|
1173
|
+
depth: (parent?.depth ?? 0) + 1,
|
|
1174
|
+
scopePath: this.repeatScope?.basePath() ?? null,
|
|
1175
|
+
parent,
|
|
1176
|
+
};
|
|
1177
|
+
}, ...(ngDevMode ? [{ debugName: "path" }] : /* istanbul ignore next */ []));
|
|
1178
|
+
/**
|
|
1179
|
+
* Why this element is not drawn, or null when it is.
|
|
1180
|
+
*
|
|
1181
|
+
* The cycle half is unconditional — not something `validate` turns on. An
|
|
1182
|
+
* element that renders itself without reading any deeper renders forever,
|
|
1183
|
+
* and a spec that never stops rendering takes the tab with it; refusing to
|
|
1184
|
+
* draw the repetition is the only outcome that leaves anything on screen.
|
|
1185
|
+
* It costs one walk up a chain of parents, bounded by `maxDepth` where one
|
|
1186
|
+
* is set and by the spec's own nesting where it is not.
|
|
1187
|
+
*/
|
|
1188
|
+
refusal = computed(() => {
|
|
1189
|
+
const path = this.path();
|
|
1190
|
+
if (repeatsItself(path))
|
|
1191
|
+
return 'cycle';
|
|
1192
|
+
const maxDepth = this.root.limits()?.maxDepth;
|
|
1193
|
+
if (maxDepth !== undefined && path.depth > maxDepth)
|
|
1194
|
+
return 'depth';
|
|
1195
|
+
return null;
|
|
1196
|
+
}, ...(ngDevMode ? [{ debugName: "refusal" }] : /* istanbul ignore next */ []));
|
|
884
1197
|
renderCtx = {
|
|
885
1198
|
element: this.resolvedElement,
|
|
886
1199
|
props: computed(() => this.resolvedElement()?.props ?? {}),
|
|
@@ -899,10 +1212,31 @@ class JrElement {
|
|
|
899
1212
|
},
|
|
900
1213
|
};
|
|
901
1214
|
outletInjector = Injector.create({
|
|
902
|
-
providers: [
|
|
1215
|
+
providers: [
|
|
1216
|
+
{ provide: RENDER_CONTEXT, useValue: this.renderCtx },
|
|
1217
|
+
{ provide: ELEMENT_KEY, useValue: this.elementKey },
|
|
1218
|
+
{ provide: RENDER_PATH, useValue: this.path },
|
|
1219
|
+
],
|
|
903
1220
|
parent: inject(Injector),
|
|
904
1221
|
});
|
|
905
1222
|
constructor() {
|
|
1223
|
+
// Warn (once) when this element is refused. Both reasons are silent
|
|
1224
|
+
// failures on screen — something the spec asked for is missing — so
|
|
1225
|
+
// neither should be silent in the console.
|
|
1226
|
+
let warnedRefusal = false;
|
|
1227
|
+
effect(() => {
|
|
1228
|
+
const reason = this.refusal();
|
|
1229
|
+
if (!reason || warnedRefusal)
|
|
1230
|
+
return;
|
|
1231
|
+
warnedRefusal = true;
|
|
1232
|
+
const path = untracked(this.path);
|
|
1233
|
+
if (reason === 'cycle') {
|
|
1234
|
+
console.warn(`[ngx-json-render] Cycle in the spec: ${describePath(path)}. "${path.key}" renders itself again without reading any deeper into state${path.scopePath === null ? '' : ` (still at "${path.scopePath}")`}, so it never ends. It is not rendered again; everything above it still is. A tree that recurses needs a repeat with a relative statePath, like {"$item": "children"}.`);
|
|
1235
|
+
}
|
|
1236
|
+
else {
|
|
1237
|
+
console.warn(`[ngx-json-render] renderLimits.maxDepth (${untracked(this.root.limits)?.maxDepth}) reached at ${describePath(path)}. "${path.key}" and anything below it do not render.`);
|
|
1238
|
+
}
|
|
1239
|
+
});
|
|
906
1240
|
// Warn (once per type) about unknown component types.
|
|
907
1241
|
const warnedTypes = new Set();
|
|
908
1242
|
effect(() => {
|
|
@@ -935,6 +1269,14 @@ class JrElement {
|
|
|
935
1269
|
});
|
|
936
1270
|
// Watch effect: fire actions when watched state paths change.
|
|
937
1271
|
effect((onCleanup) => {
|
|
1272
|
+
// A refused element does not act, not just draw nothing. `watch` is the
|
|
1273
|
+
// one thing an element does without being on screen, so leaving it wired
|
|
1274
|
+
// would let the element past `maxDepth` — or the one that closes a cycle
|
|
1275
|
+
// — keep dispatching actions from behind a cap that was supposed to have
|
|
1276
|
+
// stopped it. Reading the signal here also unwires it if the refusal
|
|
1277
|
+
// arrives later, when the limits change.
|
|
1278
|
+
if (this.refusal())
|
|
1279
|
+
return;
|
|
938
1280
|
const watchConfig = this.rawElement()?.watch;
|
|
939
1281
|
if (!watchConfig)
|
|
940
1282
|
return;
|
|
@@ -1021,7 +1363,7 @@ class JrElement {
|
|
|
1021
1363
|
}
|
|
1022
1364
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: JrElement, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
1023
1365
|
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.22", type: JrElement, isStandalone: true, selector: "jr-element", inputs: { elementKey: { classPropertyName: "elementKey", publicName: "elementKey", isSignal: true, isRequired: true, transformFunction: null } }, ngImport: i0, template: `
|
|
1024
|
-
@if (rawElement() && visible() && component()) {
|
|
1366
|
+
@if (!refusal() && rawElement() && visible() && component()) {
|
|
1025
1367
|
@if (devtoolsKey(); as dk) {
|
|
1026
1368
|
<span [attr.data-jr-key]="dk" style="display: contents">
|
|
1027
1369
|
<ng-container *ngComponentOutlet="component(); injector: outletInjector" />
|
|
@@ -1035,7 +1377,7 @@ class JrElement {
|
|
|
1035
1377
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: JrElement, decorators: [{
|
|
1036
1378
|
type: Component,
|
|
1037
1379
|
args: [{ selector: 'jr-element', changeDetection: ChangeDetectionStrategy.OnPush, imports: [NgComponentOutlet], template: `
|
|
1038
|
-
@if (rawElement() && visible() && component()) {
|
|
1380
|
+
@if (!refusal() && rawElement() && visible() && component()) {
|
|
1039
1381
|
@if (devtoolsKey(); as dk) {
|
|
1040
1382
|
<span [attr.data-jr-key]="dk" style="display: contents">
|
|
1041
1383
|
<ng-container *ngComponentOutlet="component(); injector: outletInjector" />
|
|
@@ -1047,6 +1389,332 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
|
|
|
1047
1389
|
`, styles: [":host{display:contents}\n"] }]
|
|
1048
1390
|
}], ctorParameters: () => [], propDecorators: { elementKey: [{ type: i0.Input, args: [{ isSignal: true, alias: "elementKey", required: true }] }] } });
|
|
1049
1391
|
|
|
1392
|
+
/**
|
|
1393
|
+
* A hostile spec can fail the catalog's schema in as many places as it has
|
|
1394
|
+
* properties. Report enough to act on and stop; the spec is refused either
|
|
1395
|
+
* way, and a console full of them helps nobody.
|
|
1396
|
+
*/
|
|
1397
|
+
const MAX_REPORTED_PROP_ISSUES = 20;
|
|
1398
|
+
/**
|
|
1399
|
+
* Whether this element's `repeat` descends into the item it is already inside,
|
|
1400
|
+
* rather than re-reading a fixed path in state.
|
|
1401
|
+
*
|
|
1402
|
+
* A relative statePath — `{"$item": "children"}` — resolves against the
|
|
1403
|
+
* enclosing item, so each pass reads one level further into the data and the
|
|
1404
|
+
* recursion ends where the data does. A plain string is absolute: every pass
|
|
1405
|
+
* reads the same array, and a spec that re-enters itself through one never
|
|
1406
|
+
* terminates.
|
|
1407
|
+
*/
|
|
1408
|
+
function descendsIntoItems(element) {
|
|
1409
|
+
const repeat = element.repeat;
|
|
1410
|
+
return repeat !== undefined && typeof repeat.statePath !== 'string';
|
|
1411
|
+
}
|
|
1412
|
+
/** Every key an element renders below itself: `children` plus every slot. */
|
|
1413
|
+
function childKeysOf(element) {
|
|
1414
|
+
const slots = element.slots;
|
|
1415
|
+
if (!slots)
|
|
1416
|
+
return element.children ?? [];
|
|
1417
|
+
const fromSlots = Object.values(slots).flat();
|
|
1418
|
+
return element.children ? [...element.children, ...fromSlots] : fromSlots;
|
|
1419
|
+
}
|
|
1420
|
+
/**
|
|
1421
|
+
* Walk the element graph once for depth and cycles.
|
|
1422
|
+
*
|
|
1423
|
+
* Iterative on purpose. A recursive walk would blow the stack on exactly the
|
|
1424
|
+
* specs this exists to catch, which would make the guard a second way to
|
|
1425
|
+
* crash rather than the fix for the first.
|
|
1426
|
+
*
|
|
1427
|
+
* Depth is measured from `spec.root`, because that is what renders. Cycles
|
|
1428
|
+
* are swept for across every element, including the ones no parent reaches:
|
|
1429
|
+
* an unreachable cycle is still a defect, and one edit away from a reachable
|
|
1430
|
+
* one.
|
|
1431
|
+
*
|
|
1432
|
+
* Not every element that reaches itself is a defect, though. Drawing a tree —
|
|
1433
|
+
* a comment thread, a file browser, a nested menu — means an element that
|
|
1434
|
+
* repeats over a relative path and renders itself for each item, and that ends
|
|
1435
|
+
* when the data does. A back edge is only reported when nothing along it
|
|
1436
|
+
* descends into the data, which is the case that cannot end.
|
|
1437
|
+
*
|
|
1438
|
+
* `depthBelow` is memoised per element, so a shared subtree costs one visit
|
|
1439
|
+
* instead of one per path into it — without that, a spec whose elements each
|
|
1440
|
+
* name the next two is exponential to check. The memo makes the reported
|
|
1441
|
+
* depth a lower bound once a cycle has been cut, since which edge closes the
|
|
1442
|
+
* cycle depends on the order the walk reached it. The cycle is reported as an
|
|
1443
|
+
* error of its own, which is the finding worth acting on.
|
|
1444
|
+
*/
|
|
1445
|
+
function analyseSpecGraph(spec) {
|
|
1446
|
+
const elements = spec.elements ?? {};
|
|
1447
|
+
const cycles = new Set();
|
|
1448
|
+
const depthBelow = new Map();
|
|
1449
|
+
const onPath = new Set();
|
|
1450
|
+
const walkFrom = (start) => {
|
|
1451
|
+
const first = elements[start];
|
|
1452
|
+
if (!first || depthBelow.has(start))
|
|
1453
|
+
return;
|
|
1454
|
+
const stack = [
|
|
1455
|
+
{ key: start, kids: childKeysOf(first), next: 0, deepest: 0 },
|
|
1456
|
+
];
|
|
1457
|
+
onPath.add(start);
|
|
1458
|
+
while (stack.length > 0) {
|
|
1459
|
+
const frame = stack[stack.length - 1];
|
|
1460
|
+
if (frame.next < frame.kids.length) {
|
|
1461
|
+
const childKey = frame.kids[frame.next++];
|
|
1462
|
+
const memo = depthBelow.get(childKey);
|
|
1463
|
+
if (memo !== undefined) {
|
|
1464
|
+
frame.deepest = Math.max(frame.deepest, memo);
|
|
1465
|
+
continue;
|
|
1466
|
+
}
|
|
1467
|
+
// A child that closes the cycle is not followed — that is what keeps
|
|
1468
|
+
// this walk finite — and not counted towards depth either.
|
|
1469
|
+
if (onPath.has(childKey)) {
|
|
1470
|
+
const from = stack.findIndex((frame) => frame.key === childKey);
|
|
1471
|
+
const descends = stack
|
|
1472
|
+
.slice(from)
|
|
1473
|
+
.some((frame) => descendsIntoItems(elements[frame.key]));
|
|
1474
|
+
if (!descends)
|
|
1475
|
+
cycles.add(childKey);
|
|
1476
|
+
continue;
|
|
1477
|
+
}
|
|
1478
|
+
const child = elements[childKey];
|
|
1479
|
+
// A child the spec never defined is validateSpec's finding, not ours.
|
|
1480
|
+
if (!child)
|
|
1481
|
+
continue;
|
|
1482
|
+
onPath.add(childKey);
|
|
1483
|
+
stack.push({
|
|
1484
|
+
key: childKey,
|
|
1485
|
+
kids: childKeysOf(child),
|
|
1486
|
+
next: 0,
|
|
1487
|
+
deepest: 0,
|
|
1488
|
+
});
|
|
1489
|
+
continue;
|
|
1490
|
+
}
|
|
1491
|
+
stack.pop();
|
|
1492
|
+
onPath.delete(frame.key);
|
|
1493
|
+
const depth = frame.deepest + 1;
|
|
1494
|
+
depthBelow.set(frame.key, depth);
|
|
1495
|
+
const parent = stack[stack.length - 1];
|
|
1496
|
+
if (parent)
|
|
1497
|
+
parent.deepest = Math.max(parent.deepest, depth);
|
|
1498
|
+
}
|
|
1499
|
+
};
|
|
1500
|
+
if (spec.root)
|
|
1501
|
+
walkFrom(spec.root);
|
|
1502
|
+
const depth = (spec.root ? depthBelow.get(spec.root) : undefined) ?? 0;
|
|
1503
|
+
for (const key of Object.keys(elements))
|
|
1504
|
+
walkFrom(key);
|
|
1505
|
+
return { depth, cycles: [...cycles] };
|
|
1506
|
+
}
|
|
1507
|
+
/** Report a cycle, naming the element that is its own descendant. */
|
|
1508
|
+
function cycleIssue(key) {
|
|
1509
|
+
return {
|
|
1510
|
+
severity: 'error',
|
|
1511
|
+
code: 'cycle',
|
|
1512
|
+
elementKey: key,
|
|
1513
|
+
message: `Element "${key}" renders itself against the same data, which never terminates. The cycle is broken where it closes — the repeated element does not render a second time — but the spec cannot be drawn as written. A tree that recurses needs a repeat with a relative statePath, like {"$item": "children"}.`,
|
|
1514
|
+
};
|
|
1515
|
+
}
|
|
1516
|
+
/** Report a breached element-count cap. */
|
|
1517
|
+
function elementCountIssue(count, limit) {
|
|
1518
|
+
return {
|
|
1519
|
+
severity: 'error',
|
|
1520
|
+
code: 'too_many_elements',
|
|
1521
|
+
message: `Spec has ${count} elements, over the limit of ${limit}. Nothing is rendered: renderLimits.maxElements is a cap on the whole spec.`,
|
|
1522
|
+
};
|
|
1523
|
+
}
|
|
1524
|
+
/** Report a breached depth cap. */
|
|
1525
|
+
function depthIssue(depth, limit) {
|
|
1526
|
+
return {
|
|
1527
|
+
severity: 'error',
|
|
1528
|
+
code: 'too_deep',
|
|
1529
|
+
message: `Spec nests ${depth} levels deep, over the limit of ${limit}. Elements below level ${limit} do not render.`,
|
|
1530
|
+
};
|
|
1531
|
+
}
|
|
1532
|
+
/**
|
|
1533
|
+
* Check a spec against a catalog: the component types it names, and then the
|
|
1534
|
+
* catalog's own schema.
|
|
1535
|
+
*
|
|
1536
|
+
* The schema pass is skipped while a type is unknown. A catalog schema keys
|
|
1537
|
+
* its element shapes off `type`, so one name it does not recognise turns into
|
|
1538
|
+
* a pile of prop errors describing a component that was never the point.
|
|
1539
|
+
*/
|
|
1540
|
+
function catalogIssues(spec, catalog) {
|
|
1541
|
+
const issues = [];
|
|
1542
|
+
const known = new Set(catalog.componentNames);
|
|
1543
|
+
for (const [key, element] of Object.entries(spec.elements ?? {})) {
|
|
1544
|
+
if (known.has(element.type))
|
|
1545
|
+
continue;
|
|
1546
|
+
issues.push({
|
|
1547
|
+
severity: 'error',
|
|
1548
|
+
code: 'unknown_component',
|
|
1549
|
+
elementKey: key,
|
|
1550
|
+
message: `Element "${key}" has type "${element.type}", which this catalog does not define. Nothing renders for it.`,
|
|
1551
|
+
});
|
|
1552
|
+
}
|
|
1553
|
+
if (issues.length > 0)
|
|
1554
|
+
return issues;
|
|
1555
|
+
const result = catalog.validate(spec);
|
|
1556
|
+
if (result.success)
|
|
1557
|
+
return issues;
|
|
1558
|
+
return propIssues(result.error);
|
|
1559
|
+
}
|
|
1560
|
+
/**
|
|
1561
|
+
* Turn whatever the catalog's schema reported into issues.
|
|
1562
|
+
*
|
|
1563
|
+
* Read defensively rather than typed against the schema library: the shape
|
|
1564
|
+
* below (`issues`, each with `path` and `message`) is what every version of
|
|
1565
|
+
* it has produced, and a renderer that threw while explaining a bad spec
|
|
1566
|
+
* would be the worse failure.
|
|
1567
|
+
*/
|
|
1568
|
+
function propIssues(error) {
|
|
1569
|
+
const raw = error?.issues;
|
|
1570
|
+
if (!Array.isArray(raw))
|
|
1571
|
+
return [];
|
|
1572
|
+
return raw.slice(0, MAX_REPORTED_PROP_ISSUES).map((entry) => {
|
|
1573
|
+
const item = entry;
|
|
1574
|
+
const path = Array.isArray(item.path) ? item.path : [];
|
|
1575
|
+
const message = typeof item.message === 'string' ? item.message : 'is not valid';
|
|
1576
|
+
// Paths are rooted at the spec, so `elements.<key>.props.<prop>` names the
|
|
1577
|
+
// element in its second segment.
|
|
1578
|
+
const elementKey = path[0] === 'elements' && typeof path[1] === 'string'
|
|
1579
|
+
? path[1]
|
|
1580
|
+
: undefined;
|
|
1581
|
+
const where = path.length > 0 ? path.join('.') : 'spec';
|
|
1582
|
+
return {
|
|
1583
|
+
severity: 'error',
|
|
1584
|
+
code: 'invalid_props',
|
|
1585
|
+
...(elementKey === undefined ? {} : { elementKey }),
|
|
1586
|
+
message: `${where}: ${message}`,
|
|
1587
|
+
};
|
|
1588
|
+
});
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
const CLEAN = {
|
|
1592
|
+
spec: null,
|
|
1593
|
+
issues: [],
|
|
1594
|
+
fixes: [],
|
|
1595
|
+
hasErrors: false,
|
|
1596
|
+
blocked: false,
|
|
1597
|
+
};
|
|
1598
|
+
/**
|
|
1599
|
+
* Check a spec the way `validate` and `renderLimits` ask for.
|
|
1600
|
+
*
|
|
1601
|
+
* Only lossless fixes are applied — `visible`, `on` and `repeat` that a model
|
|
1602
|
+
* put inside `props`, which the renderer would otherwise ignore, moved back
|
|
1603
|
+
* where they belong. The lossy ones prune content, and core's own guidance is
|
|
1604
|
+
* to re-prompt rather than accept that; a renderer that silently deleted
|
|
1605
|
+
* elements would be the worse failure.
|
|
1606
|
+
*
|
|
1607
|
+
* Note what is *not* here: cycles are broken as the tree renders, whatever
|
|
1608
|
+
* this returns, because a spec that never stops rendering is a crash rather
|
|
1609
|
+
* than an opinion about quality. This reports them so an app can see them.
|
|
1610
|
+
*/
|
|
1611
|
+
function checkSpec(spec, mode, options = {}) {
|
|
1612
|
+
if (!spec)
|
|
1613
|
+
return CLEAN;
|
|
1614
|
+
const limits = options.limits ?? null;
|
|
1615
|
+
const wantsStructure = mode !== 'off';
|
|
1616
|
+
// `maxRepeatItems` caps a live state array, which no amount of reading the
|
|
1617
|
+
// spec can predict — it is enforced as the repeat expands, not here.
|
|
1618
|
+
const maxElements = limits?.maxElements;
|
|
1619
|
+
const maxDepth = limits?.maxDepth;
|
|
1620
|
+
if (!wantsStructure && maxElements === undefined && maxDepth === undefined) {
|
|
1621
|
+
return { spec, issues: [], fixes: [], hasErrors: false, blocked: false };
|
|
1622
|
+
}
|
|
1623
|
+
// The order below is the point of this function, not an accident of it.
|
|
1624
|
+
//
|
|
1625
|
+
// Core's `validateSpec` walks the element tree by recursion, so the specs
|
|
1626
|
+
// that most need a limit are the ones that would overflow the stack proving
|
|
1627
|
+
// they exceed it. Each cheap, non-recursive cap therefore runs first and can
|
|
1628
|
+
// stop the expensive check from being attempted at all: count, then depth —
|
|
1629
|
+
// measured by a walk of our own that uses an explicit stack — and only then
|
|
1630
|
+
// the structural pass.
|
|
1631
|
+
//
|
|
1632
|
+
// `autoFixSpec` only relocates fields inside `props` and never touches
|
|
1633
|
+
// `children` or `slots`, so measuring the graph before fixing it measures
|
|
1634
|
+
// the same graph.
|
|
1635
|
+
if (maxElements !== undefined) {
|
|
1636
|
+
const count = Object.keys(spec.elements ?? {}).length;
|
|
1637
|
+
if (count > maxElements) {
|
|
1638
|
+
const issue = elementCountIssue(count, maxElements);
|
|
1639
|
+
return {
|
|
1640
|
+
spec,
|
|
1641
|
+
issues: [issue],
|
|
1642
|
+
fixes: [],
|
|
1643
|
+
hasErrors: true,
|
|
1644
|
+
blocked: true,
|
|
1645
|
+
};
|
|
1646
|
+
}
|
|
1647
|
+
}
|
|
1648
|
+
const issues = [];
|
|
1649
|
+
let tooDeep = false;
|
|
1650
|
+
if (wantsStructure || maxDepth !== undefined) {
|
|
1651
|
+
const graph = analyseSpecGraph(spec);
|
|
1652
|
+
if (maxDepth !== undefined && graph.depth > maxDepth) {
|
|
1653
|
+
tooDeep = true;
|
|
1654
|
+
issues.push(depthIssue(graph.depth, maxDepth));
|
|
1655
|
+
}
|
|
1656
|
+
if (wantsStructure) {
|
|
1657
|
+
for (const key of graph.cycles)
|
|
1658
|
+
issues.push(cycleIssue(key));
|
|
1659
|
+
}
|
|
1660
|
+
}
|
|
1661
|
+
// A spec past its depth cap is not handed to the recursive checks. The cap
|
|
1662
|
+
// is the finding worth having, and it renders truncated either way.
|
|
1663
|
+
const fixed = wantsStructure && !tooDeep
|
|
1664
|
+
? autoFixSpec(spec, { lossy: false })
|
|
1665
|
+
: { spec, fixes: [] };
|
|
1666
|
+
if (wantsStructure && !tooDeep) {
|
|
1667
|
+
issues.push(...validateSpec(fixed.spec).issues);
|
|
1668
|
+
if (options.catalog) {
|
|
1669
|
+
issues.push(...catalogIssues(fixed.spec, options.catalog));
|
|
1670
|
+
}
|
|
1671
|
+
}
|
|
1672
|
+
return {
|
|
1673
|
+
spec: fixed.spec,
|
|
1674
|
+
issues,
|
|
1675
|
+
fixes: fixed.fixes,
|
|
1676
|
+
hasErrors: issues.some((issue) => issue.severity === 'error'),
|
|
1677
|
+
blocked: false,
|
|
1678
|
+
};
|
|
1679
|
+
}
|
|
1680
|
+
/**
|
|
1681
|
+
* Render issues as one indented block, errors and warnings alike.
|
|
1682
|
+
*
|
|
1683
|
+
* Core's `formatSpecIssues` drops warnings and only accepts core's own issue
|
|
1684
|
+
* codes, so it cannot describe what this package reports. Use this instead
|
|
1685
|
+
* when showing `issues()` to a developer.
|
|
1686
|
+
*/
|
|
1687
|
+
function formatSpecCheckIssues(issues) {
|
|
1688
|
+
return issues
|
|
1689
|
+
.map((issue) => ` ${issue.severity === 'error' ? 'error ' : 'warning'} ${issue.elementKey ? `[${issue.elementKey}] ` : ''}${issue.message}`)
|
|
1690
|
+
.join('\n');
|
|
1691
|
+
}
|
|
1692
|
+
/**
|
|
1693
|
+
* Report a check to the console: fixes as information, issues at the severity
|
|
1694
|
+
* the outcome gives them. A spec that will not render — refused by a limit,
|
|
1695
|
+
* or by `strict` — is reported as an error, because something the developer
|
|
1696
|
+
* asked for is now missing from the screen; one that renders anyway is a
|
|
1697
|
+
* warning.
|
|
1698
|
+
*/
|
|
1699
|
+
function reportSpecCheck(check, mode) {
|
|
1700
|
+
if (check.fixes.length > 0) {
|
|
1701
|
+
console.info(`[ngx-json-render] Fixed ${check.fixes.length} spec issue(s) before rendering:\n` +
|
|
1702
|
+
check.fixes.map((fix) => ` - ${fix}`).join('\n'));
|
|
1703
|
+
}
|
|
1704
|
+
if (check.issues.length === 0)
|
|
1705
|
+
return;
|
|
1706
|
+
const report = `[ngx-json-render] Spec validation:\n${formatSpecCheckIssues(check.issues)}`;
|
|
1707
|
+
if (check.blocked) {
|
|
1708
|
+
console.error(`${report}\nNothing is rendered: the spec is over a renderLimits cap.`);
|
|
1709
|
+
}
|
|
1710
|
+
else if (mode === 'strict' && check.hasErrors) {
|
|
1711
|
+
console.error(`${report}\nNothing is rendered: validate="strict".`);
|
|
1712
|
+
}
|
|
1713
|
+
else {
|
|
1714
|
+
console.warn(report);
|
|
1715
|
+
}
|
|
1716
|
+
}
|
|
1717
|
+
|
|
1050
1718
|
/**
|
|
1051
1719
|
* Renders a json-render {@link Spec} using a registry of Angular components.
|
|
1052
1720
|
*
|
|
@@ -1075,6 +1743,40 @@ class JsonRenderer {
|
|
|
1075
1743
|
loading = input(false, ...(ngDevMode ? [{ debugName: "loading" }] : /* istanbul ignore next */ []));
|
|
1076
1744
|
/** Fallback component for unknown types. */
|
|
1077
1745
|
fallback = input(null, ...(ngDevMode ? [{ debugName: "fallback" }] : /* istanbul ignore next */ []));
|
|
1746
|
+
/**
|
|
1747
|
+
* Whether to check the spec's structure before rendering it, and what to do
|
|
1748
|
+
* about what turns up. Off by default.
|
|
1749
|
+
*
|
|
1750
|
+
* `'warn'` reports the issues and renders anyway; `'strict'` renders nothing
|
|
1751
|
+
* when the spec has errors. Both first apply the lossless fixes — `visible`,
|
|
1752
|
+
* `on` and `repeat` misplaced inside `props` are moved back to the element,
|
|
1753
|
+
* where they take effect instead of being ignored.
|
|
1754
|
+
*
|
|
1755
|
+
* The check is skipped while `loading` is true: a spec that is still
|
|
1756
|
+
* arriving is expected to reference children that have not streamed in yet.
|
|
1757
|
+
*/
|
|
1758
|
+
validate = input('off', ...(ngDevMode ? [{ debugName: "validate" }] : /* istanbul ignore next */ []));
|
|
1759
|
+
/**
|
|
1760
|
+
* Caps on what the spec may cost the browser: `maxElements`, `maxDepth` and
|
|
1761
|
+
* `maxRepeatItems`. Unset by default, and each field is independently
|
|
1762
|
+
* optional.
|
|
1763
|
+
*
|
|
1764
|
+
* Unlike `validate`, these are enforced in every mode — a number the app
|
|
1765
|
+
* chose is a control, not a report. A spec over `maxElements` renders
|
|
1766
|
+
* nothing; `maxDepth` and `maxRepeatItems` truncate where they are hit.
|
|
1767
|
+
* They apply while `loading` too: a partial spec is a subset of the
|
|
1768
|
+
* finished one, so a cap can only ever fire early, never falsely.
|
|
1769
|
+
*/
|
|
1770
|
+
renderLimits = input(null, ...(ngDevMode ? [{ debugName: "renderLimits" }] : /* istanbul ignore next */ []));
|
|
1771
|
+
/**
|
|
1772
|
+
* The catalog the spec was generated for. Given one, `validate` also checks
|
|
1773
|
+
* what structure alone cannot: that every `type` is a component the catalog
|
|
1774
|
+
* defines, and that the props match its schema.
|
|
1775
|
+
*
|
|
1776
|
+
* Only read when `validate` is on, and — like the rest of the check —
|
|
1777
|
+
* skipped while `loading`.
|
|
1778
|
+
*/
|
|
1779
|
+
catalog = input(null, ...(ngDevMode ? [{ debugName: "catalog" }] : /* istanbul ignore next */ []));
|
|
1078
1780
|
/**
|
|
1079
1781
|
* Initial state model (uncontrolled mode). Defaults to `spec.state`.
|
|
1080
1782
|
* Ignored when `store` is provided.
|
|
@@ -1100,12 +1802,24 @@ class JsonRenderer {
|
|
|
1100
1802
|
stateStore;
|
|
1101
1803
|
/** The action dispatcher of this renderer. */
|
|
1102
1804
|
actions;
|
|
1805
|
+
/**
|
|
1806
|
+
* The spec as checked, and what the check found. While `loading`, the mode
|
|
1807
|
+
* is forced off: the spec is still arriving, and the missing children it
|
|
1808
|
+
* refers to are the normal state of a stream, not a defect.
|
|
1809
|
+
*/
|
|
1810
|
+
checked = computed(() => checkSpec(this.spec(), this.loading() ? 'off' : this.validate(), {
|
|
1811
|
+
limits: this.renderLimits(),
|
|
1812
|
+
catalog: this.catalog(),
|
|
1813
|
+
}), ...(ngDevMode ? [{ debugName: "checked" }] : /* istanbul ignore next */ []));
|
|
1103
1814
|
constructor() {
|
|
1104
1815
|
const root = inject(JsonRenderRootContext);
|
|
1105
|
-
|
|
1816
|
+
// The fixed spec, not the input: a `visible` the check moved out of
|
|
1817
|
+
// `props` has to be the one the tree renders, or the fix is cosmetic.
|
|
1818
|
+
root.spec = computed(() => this.checked().spec, ...(ngDevMode ? [{ debugName: "spec" }] : /* istanbul ignore next */ []));
|
|
1106
1819
|
root.registry = this.registry;
|
|
1107
1820
|
root.loading = this.loading;
|
|
1108
1821
|
root.fallback = this.fallback;
|
|
1822
|
+
root.limits = this.renderLimits;
|
|
1109
1823
|
root.store = this.store;
|
|
1110
1824
|
root.initialState = computed(() => this.state() ?? this.spec()?.state ?? {}, ...(ngDevMode ? [{ debugName: "initialState" }] : /* istanbul ignore next */ []));
|
|
1111
1825
|
root.handlers = this.handlers;
|
|
@@ -1122,14 +1836,64 @@ class JsonRenderer {
|
|
|
1122
1836
|
this.stateStore = inject(JsonRenderStateService);
|
|
1123
1837
|
inject(JsonRenderValidationService);
|
|
1124
1838
|
this.actions = inject(JsonRenderActionsService);
|
|
1839
|
+
// Reports once per distinct check rather than once per render: `checked`
|
|
1840
|
+
// only recomputes when the spec, the mode, the limits or the loading flag
|
|
1841
|
+
// change. Silent while loading — a limit can fire on a spec that is still
|
|
1842
|
+
// arriving, and reporting it on every patch would bury the one report
|
|
1843
|
+
// that describes the finished spec.
|
|
1844
|
+
effect(() => {
|
|
1845
|
+
if (this.loading())
|
|
1846
|
+
return;
|
|
1847
|
+
reportSpecCheck(this.checked(), this.validate());
|
|
1848
|
+
});
|
|
1125
1849
|
}
|
|
1126
1850
|
rootKey = computed(() => {
|
|
1127
|
-
const
|
|
1851
|
+
const checked = this.checked();
|
|
1852
|
+
// A spec over an enforced cap does not render in any mode. Whether to
|
|
1853
|
+
// check was the app's decision; once it has made it, honouring the answer
|
|
1854
|
+
// is not also `validate`'s to decide.
|
|
1855
|
+
if (checked.blocked)
|
|
1856
|
+
return null;
|
|
1857
|
+
// A spec whose errors survived the fixes does not render under `strict`.
|
|
1858
|
+
// Reporting it and drawing half of it anyway is the behaviour the mode
|
|
1859
|
+
// exists to refuse.
|
|
1860
|
+
if (this.validate() === 'strict' && checked.hasErrors)
|
|
1861
|
+
return null;
|
|
1862
|
+
const spec = checked.spec;
|
|
1128
1863
|
return spec?.root && spec.elements?.[spec.root] ? spec.root : null;
|
|
1129
1864
|
}, ...(ngDevMode ? [{ debugName: "rootKey" }] : /* istanbul ignore next */ []));
|
|
1130
1865
|
pendingConfirm = computed(() => this.actions.pendingConfirmation()?.action.confirm ?? null, ...(ngDevMode ? [{ debugName: "pendingConfirm" }] : /* istanbul ignore next */ []));
|
|
1866
|
+
/**
|
|
1867
|
+
* The app's own dialog, if it provided one. Read once: which component
|
|
1868
|
+
* answers a confirmation is an application decision, not something that
|
|
1869
|
+
* changes between two confirmations.
|
|
1870
|
+
*/
|
|
1871
|
+
customDialog = inject(JR_CONFIRM_DIALOG, {
|
|
1872
|
+
optional: true,
|
|
1873
|
+
});
|
|
1874
|
+
injector = inject(Injector);
|
|
1875
|
+
/**
|
|
1876
|
+
* A replacement dialog takes no inputs and emits no outputs — it injects
|
|
1877
|
+
* everything it needs, the way catalog components do. That keeps the seam
|
|
1878
|
+
* one token wide instead of a component contract the renderer would have to
|
|
1879
|
+
* bind to.
|
|
1880
|
+
*/
|
|
1881
|
+
confirmInjector = computed(() => {
|
|
1882
|
+
const config = this.pendingConfirm();
|
|
1883
|
+
if (!config)
|
|
1884
|
+
return null;
|
|
1885
|
+
const context = {
|
|
1886
|
+
config,
|
|
1887
|
+
confirm: () => this.actions.confirm(),
|
|
1888
|
+
cancel: () => this.actions.cancel(),
|
|
1889
|
+
};
|
|
1890
|
+
return Injector.create({
|
|
1891
|
+
providers: [{ provide: CONFIRM_CONTEXT, useValue: context }],
|
|
1892
|
+
parent: this.injector,
|
|
1893
|
+
});
|
|
1894
|
+
}, ...(ngDevMode ? [{ debugName: "confirmInjector" }] : /* istanbul ignore next */ []));
|
|
1131
1895
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: JsonRenderer, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
1132
|
-
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.22", type: JsonRenderer, isStandalone: true, selector: "json-render", inputs: { spec: { classPropertyName: "spec", publicName: "spec", isSignal: true, isRequired: true, transformFunction: null }, registry: { classPropertyName: "registry", publicName: "registry", isSignal: true, isRequired: true, transformFunction: null }, loading: { classPropertyName: "loading", publicName: "loading", isSignal: true, isRequired: false, transformFunction: null }, fallback: { classPropertyName: "fallback", publicName: "fallback", isSignal: true, isRequired: false, transformFunction: null }, state: { classPropertyName: "state", publicName: "state", isSignal: true, isRequired: false, transformFunction: null }, store: { classPropertyName: "store", publicName: "store", isSignal: true, isRequired: false, transformFunction: null }, handlers: { classPropertyName: "handlers", publicName: "handlers", isSignal: true, isRequired: false, transformFunction: null }, onAction: { classPropertyName: "onAction", publicName: "onAction", isSignal: true, isRequired: false, transformFunction: null }, navigate: { classPropertyName: "navigate", publicName: "navigate", isSignal: true, isRequired: false, transformFunction: null }, validationFunctions: { classPropertyName: "validationFunctions", publicName: "validationFunctions", isSignal: true, isRequired: false, transformFunction: null }, functions: { classPropertyName: "functions", publicName: "functions", isSignal: true, isRequired: false, transformFunction: null }, directives: { classPropertyName: "directives", publicName: "directives", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { stateChange: "stateChange" }, providers: [
|
|
1896
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.22", type: JsonRenderer, isStandalone: true, selector: "json-render", inputs: { spec: { classPropertyName: "spec", publicName: "spec", isSignal: true, isRequired: true, transformFunction: null }, registry: { classPropertyName: "registry", publicName: "registry", isSignal: true, isRequired: true, transformFunction: null }, loading: { classPropertyName: "loading", publicName: "loading", isSignal: true, isRequired: false, transformFunction: null }, fallback: { classPropertyName: "fallback", publicName: "fallback", isSignal: true, isRequired: false, transformFunction: null }, validate: { classPropertyName: "validate", publicName: "validate", isSignal: true, isRequired: false, transformFunction: null }, renderLimits: { classPropertyName: "renderLimits", publicName: "renderLimits", isSignal: true, isRequired: false, transformFunction: null }, catalog: { classPropertyName: "catalog", publicName: "catalog", isSignal: true, isRequired: false, transformFunction: null }, state: { classPropertyName: "state", publicName: "state", isSignal: true, isRequired: false, transformFunction: null }, store: { classPropertyName: "store", publicName: "store", isSignal: true, isRequired: false, transformFunction: null }, handlers: { classPropertyName: "handlers", publicName: "handlers", isSignal: true, isRequired: false, transformFunction: null }, onAction: { classPropertyName: "onAction", publicName: "onAction", isSignal: true, isRequired: false, transformFunction: null }, navigate: { classPropertyName: "navigate", publicName: "navigate", isSignal: true, isRequired: false, transformFunction: null }, validationFunctions: { classPropertyName: "validationFunctions", publicName: "validationFunctions", isSignal: true, isRequired: false, transformFunction: null }, functions: { classPropertyName: "functions", publicName: "functions", isSignal: true, isRequired: false, transformFunction: null }, directives: { classPropertyName: "directives", publicName: "directives", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { stateChange: "stateChange" }, providers: [
|
|
1133
1897
|
JsonRenderRootContext,
|
|
1134
1898
|
JsonRenderStateService,
|
|
1135
1899
|
JsonRenderValidationService,
|
|
@@ -1139,17 +1903,23 @@ class JsonRenderer {
|
|
|
1139
1903
|
<jr-element [elementKey]="key" />
|
|
1140
1904
|
}
|
|
1141
1905
|
@if (pendingConfirm(); as confirm) {
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1906
|
+
@if (customDialog; as dialog) {
|
|
1907
|
+
<ng-container
|
|
1908
|
+
*ngComponentOutlet="dialog; injector: confirmInjector() ?? undefined"
|
|
1909
|
+
/>
|
|
1910
|
+
} @else {
|
|
1911
|
+
<jr-confirm-dialog
|
|
1912
|
+
[config]="confirm"
|
|
1913
|
+
(confirmed)="actions.confirm()"
|
|
1914
|
+
(cancelled)="actions.cancel()"
|
|
1915
|
+
/>
|
|
1916
|
+
}
|
|
1147
1917
|
}
|
|
1148
|
-
`, isInline: true, styles: [":host{display:contents}\n"], dependencies: [{ kind: "component", type: JrElement, selector: "jr-element", inputs: ["elementKey"] }, { kind: "component", type: JrConfirmDialog, selector: "jr-confirm-dialog", inputs: ["config"], outputs: ["confirmed", "cancelled"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
1918
|
+
`, isInline: true, styles: [":host{display:contents}\n"], dependencies: [{ kind: "component", type: JrElement, selector: "jr-element", inputs: ["elementKey"] }, { kind: "component", type: JrConfirmDialog, selector: "jr-confirm-dialog", inputs: ["config"], outputs: ["confirmed", "cancelled"] }, { kind: "directive", type: NgComponentOutlet, selector: "[ngComponentOutlet]", inputs: ["ngComponentOutlet", "ngComponentOutletInputs", "ngComponentOutletInjector", "ngComponentOutletEnvironmentInjector", "ngComponentOutletContent", "ngComponentOutletNgModule"], exportAs: ["ngComponentOutlet"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
1149
1919
|
}
|
|
1150
1920
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: JsonRenderer, decorators: [{
|
|
1151
1921
|
type: Component,
|
|
1152
|
-
args: [{ selector: 'json-render', changeDetection: ChangeDetectionStrategy.OnPush, imports: [JrElement, JrConfirmDialog], providers: [
|
|
1922
|
+
args: [{ selector: 'json-render', changeDetection: ChangeDetectionStrategy.OnPush, imports: [JrElement, JrConfirmDialog, NgComponentOutlet], providers: [
|
|
1153
1923
|
JsonRenderRootContext,
|
|
1154
1924
|
JsonRenderStateService,
|
|
1155
1925
|
JsonRenderValidationService,
|
|
@@ -1159,14 +1929,20 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
|
|
|
1159
1929
|
<jr-element [elementKey]="key" />
|
|
1160
1930
|
}
|
|
1161
1931
|
@if (pendingConfirm(); as confirm) {
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1932
|
+
@if (customDialog; as dialog) {
|
|
1933
|
+
<ng-container
|
|
1934
|
+
*ngComponentOutlet="dialog; injector: confirmInjector() ?? undefined"
|
|
1935
|
+
/>
|
|
1936
|
+
} @else {
|
|
1937
|
+
<jr-confirm-dialog
|
|
1938
|
+
[config]="confirm"
|
|
1939
|
+
(confirmed)="actions.confirm()"
|
|
1940
|
+
(cancelled)="actions.cancel()"
|
|
1941
|
+
/>
|
|
1942
|
+
}
|
|
1167
1943
|
}
|
|
1168
1944
|
`, styles: [":host{display:contents}\n"] }]
|
|
1169
|
-
}], ctorParameters: () => [], propDecorators: { spec: [{ type: i0.Input, args: [{ isSignal: true, alias: "spec", required: true }] }], registry: [{ type: i0.Input, args: [{ isSignal: true, alias: "registry", required: true }] }], loading: [{ type: i0.Input, args: [{ isSignal: true, alias: "loading", required: false }] }], fallback: [{ type: i0.Input, args: [{ isSignal: true, alias: "fallback", required: false }] }], state: [{ type: i0.Input, args: [{ isSignal: true, alias: "state", required: false }] }], store: [{ type: i0.Input, args: [{ isSignal: true, alias: "store", required: false }] }], handlers: [{ type: i0.Input, args: [{ isSignal: true, alias: "handlers", required: false }] }], onAction: [{ type: i0.Input, args: [{ isSignal: true, alias: "onAction", required: false }] }], navigate: [{ type: i0.Input, args: [{ isSignal: true, alias: "navigate", required: false }] }], validationFunctions: [{ type: i0.Input, args: [{ isSignal: true, alias: "validationFunctions", required: false }] }], functions: [{ type: i0.Input, args: [{ isSignal: true, alias: "functions", required: false }] }], directives: [{ type: i0.Input, args: [{ isSignal: true, alias: "directives", required: false }] }], stateChange: [{ type: i0.Output, args: ["stateChange"] }] } });
|
|
1945
|
+
}], ctorParameters: () => [], propDecorators: { spec: [{ type: i0.Input, args: [{ isSignal: true, alias: "spec", required: true }] }], registry: [{ type: i0.Input, args: [{ isSignal: true, alias: "registry", required: true }] }], loading: [{ type: i0.Input, args: [{ isSignal: true, alias: "loading", required: false }] }], fallback: [{ type: i0.Input, args: [{ isSignal: true, alias: "fallback", required: false }] }], validate: [{ type: i0.Input, args: [{ isSignal: true, alias: "validate", required: false }] }], renderLimits: [{ type: i0.Input, args: [{ isSignal: true, alias: "renderLimits", required: false }] }], catalog: [{ type: i0.Input, args: [{ isSignal: true, alias: "catalog", required: false }] }], state: [{ type: i0.Input, args: [{ isSignal: true, alias: "state", required: false }] }], store: [{ type: i0.Input, args: [{ isSignal: true, alias: "store", required: false }] }], handlers: [{ type: i0.Input, args: [{ isSignal: true, alias: "handlers", required: false }] }], onAction: [{ type: i0.Input, args: [{ isSignal: true, alias: "onAction", required: false }] }], navigate: [{ type: i0.Input, args: [{ isSignal: true, alias: "navigate", required: false }] }], validationFunctions: [{ type: i0.Input, args: [{ isSignal: true, alias: "validationFunctions", required: false }] }], functions: [{ type: i0.Input, args: [{ isSignal: true, alias: "functions", required: false }] }], directives: [{ type: i0.Input, args: [{ isSignal: true, alias: "directives", required: false }] }], stateChange: [{ type: i0.Output, args: ["stateChange"] }] } });
|
|
1170
1946
|
|
|
1171
1947
|
/**
|
|
1172
1948
|
* Provides the repeat scope (item, index, absolute base path) to the elements
|
|
@@ -1241,13 +2017,43 @@ class JrChildren {
|
|
|
1241
2017
|
}
|
|
1242
2018
|
return resolved;
|
|
1243
2019
|
}, ...(ngDevMode ? [{ debugName: "repeatBasePath" }] : /* istanbul ignore next */ []));
|
|
1244
|
-
|
|
2020
|
+
/** Every item the repeat's state array holds, before any cap. */
|
|
2021
|
+
allRepeatItems = computed(() => {
|
|
1245
2022
|
const basePath = this.repeatBasePath();
|
|
1246
2023
|
if (basePath === undefined)
|
|
1247
2024
|
return [];
|
|
1248
2025
|
return (getByPath(this.state.state(), basePath) ?? []);
|
|
2026
|
+
}, ...(ngDevMode ? [{ debugName: "allRepeatItems" }] : /* istanbul ignore next */ []));
|
|
2027
|
+
/**
|
|
2028
|
+
* The items this repeat actually renders.
|
|
2029
|
+
*
|
|
2030
|
+
* `repeat` iterates a state array the spec may itself have supplied, which
|
|
2031
|
+
* is the one place a spec sizes the render tree out of data rather than out
|
|
2032
|
+
* of its own structure — so the cap has to be applied here, as it expands,
|
|
2033
|
+
* rather than anywhere a spec can be inspected up front.
|
|
2034
|
+
*/
|
|
2035
|
+
repeatItems = computed(() => {
|
|
2036
|
+
const items = this.allRepeatItems();
|
|
2037
|
+
const max = this.root.limits()?.maxRepeatItems;
|
|
2038
|
+
return max !== undefined && items.length > max
|
|
2039
|
+
? items.slice(0, max)
|
|
2040
|
+
: items;
|
|
1249
2041
|
}, ...(ngDevMode ? [{ debugName: "repeatItems" }] : /* istanbul ignore next */ []));
|
|
1250
2042
|
constructor() {
|
|
2043
|
+
// Warn (once) when the cap holds items back. Reading the count only after
|
|
2044
|
+
// the two cheap guards keeps the effect from tracking the state array at
|
|
2045
|
+
// all once there is nothing left to say.
|
|
2046
|
+
let warnedRepeatCap = false;
|
|
2047
|
+
effect(() => {
|
|
2048
|
+
const max = this.root.limits()?.maxRepeatItems;
|
|
2049
|
+
if (max === undefined || warnedRepeatCap)
|
|
2050
|
+
return;
|
|
2051
|
+
const total = this.allRepeatItems().length;
|
|
2052
|
+
if (total <= max)
|
|
2053
|
+
return;
|
|
2054
|
+
warnedRepeatCap = true;
|
|
2055
|
+
console.warn(`[ngx-json-render] renderLimits.maxRepeatItems (${max}) reached: "${untracked(this.repeatBasePath)}" holds ${total} items, so ${total - max} of them do not render.`);
|
|
2056
|
+
});
|
|
1251
2057
|
// Warn (once per key) about children referencing missing elements.
|
|
1252
2058
|
const warned = new Set();
|
|
1253
2059
|
effect(() => {
|
|
@@ -1891,6 +2697,12 @@ function injectUIStream(options) {
|
|
|
1891
2697
|
const error = signal(null, ...(ngDevMode ? [{ debugName: "error" }] : /* istanbul ignore next */ []));
|
|
1892
2698
|
const usage = signal(null, ...(ngDevMode ? [{ debugName: "usage" }] : /* istanbul ignore next */ []));
|
|
1893
2699
|
const rawLines = signal([], ...(ngDevMode ? [{ debugName: "rawLines" }] : /* istanbul ignore next */ []));
|
|
2700
|
+
const issues = signal([], ...(ngDevMode ? [{ debugName: "issues" }] : /* istanbul ignore next */ []));
|
|
2701
|
+
const validate = options.validate ?? 'off';
|
|
2702
|
+
const checkOptions = {
|
|
2703
|
+
limits: options.renderLimits ?? null,
|
|
2704
|
+
catalog: options.catalog ?? null,
|
|
2705
|
+
};
|
|
1894
2706
|
const session = createStreamSession();
|
|
1895
2707
|
inject(DestroyRef).onDestroy(() => session.cancel());
|
|
1896
2708
|
const stop = () => {
|
|
@@ -1905,6 +2717,7 @@ function injectUIStream(options) {
|
|
|
1905
2717
|
error.set(null);
|
|
1906
2718
|
usage.set(null);
|
|
1907
2719
|
rawLines.set([]);
|
|
2720
|
+
issues.set([]);
|
|
1908
2721
|
};
|
|
1909
2722
|
const send = async (prompt, sendOptions) => {
|
|
1910
2723
|
const gate = session.begin();
|
|
@@ -1912,6 +2725,7 @@ function injectUIStream(options) {
|
|
|
1912
2725
|
error.set(null);
|
|
1913
2726
|
usage.set(null);
|
|
1914
2727
|
rawLines.set([]);
|
|
2728
|
+
issues.set([]);
|
|
1915
2729
|
const previousSpec = sendOptions?.previousSpec;
|
|
1916
2730
|
let currentSpec = previousSpec && previousSpec.root
|
|
1917
2731
|
? { ...previousSpec, elements: { ...previousSpec.elements } }
|
|
@@ -1942,7 +2756,29 @@ function injectUIStream(options) {
|
|
|
1942
2756
|
fetch: options.fetch,
|
|
1943
2757
|
signal: gate.signal,
|
|
1944
2758
|
}, createLineConsumer(handleLine));
|
|
1945
|
-
gate.commit(() =>
|
|
2759
|
+
gate.commit(() => {
|
|
2760
|
+
const check = checkSpec(currentSpec, validate, checkOptions);
|
|
2761
|
+
reportSpecCheck(check, validate);
|
|
2762
|
+
if (check.spec) {
|
|
2763
|
+
currentSpec = check.spec;
|
|
2764
|
+
spec.set(currentSpec);
|
|
2765
|
+
}
|
|
2766
|
+
issues.set(check.issues);
|
|
2767
|
+
// Under `strict` a broken generation is a failure, not a result: the
|
|
2768
|
+
// app's onComplete is where a spec usually gets persisted, and handing
|
|
2769
|
+
// it one that cannot render is the outcome this mode is asked to
|
|
2770
|
+
// prevent. The spec stays put so the app can show or discard it.
|
|
2771
|
+
// A limit the app set fails the generation whatever the mode: the
|
|
2772
|
+
// spec it would hand `onComplete` is one the app already said it did
|
|
2773
|
+
// not want rendered.
|
|
2774
|
+
if (check.blocked || (validate === 'strict' && check.hasErrors)) {
|
|
2775
|
+
const invalid = new Error(`Generated spec failed validation:\n${formatSpecCheckIssues(check.issues)}`);
|
|
2776
|
+
error.set(invalid);
|
|
2777
|
+
options.onError?.(invalid);
|
|
2778
|
+
return;
|
|
2779
|
+
}
|
|
2780
|
+
options.onComplete?.(currentSpec);
|
|
2781
|
+
});
|
|
1946
2782
|
}
|
|
1947
2783
|
catch (err) {
|
|
1948
2784
|
if (isAbortError(err))
|
|
@@ -1963,6 +2799,7 @@ function injectUIStream(options) {
|
|
|
1963
2799
|
error: error.asReadonly(),
|
|
1964
2800
|
usage: usage.asReadonly(),
|
|
1965
2801
|
rawLines: rawLines.asReadonly(),
|
|
2802
|
+
issues: issues.asReadonly(),
|
|
1966
2803
|
send,
|
|
1967
2804
|
stop,
|
|
1968
2805
|
clear,
|
|
@@ -1990,6 +2827,12 @@ function injectChatUI(options) {
|
|
|
1990
2827
|
const messages = signal([], ...(ngDevMode ? [{ debugName: "messages" }] : /* istanbul ignore next */ []));
|
|
1991
2828
|
const isStreaming = signal(false, ...(ngDevMode ? [{ debugName: "isStreaming" }] : /* istanbul ignore next */ []));
|
|
1992
2829
|
const error = signal(null, ...(ngDevMode ? [{ debugName: "error" }] : /* istanbul ignore next */ []));
|
|
2830
|
+
const issues = signal([], ...(ngDevMode ? [{ debugName: "issues" }] : /* istanbul ignore next */ []));
|
|
2831
|
+
const validate = options.validate ?? 'off';
|
|
2832
|
+
const checkOptions = {
|
|
2833
|
+
limits: options.renderLimits ?? null,
|
|
2834
|
+
catalog: options.catalog ?? null,
|
|
2835
|
+
};
|
|
1993
2836
|
const session = createStreamSession();
|
|
1994
2837
|
inject(DestroyRef).onDestroy(() => session.cancel());
|
|
1995
2838
|
const stop = () => {
|
|
@@ -2000,6 +2843,7 @@ function injectChatUI(options) {
|
|
|
2000
2843
|
stop();
|
|
2001
2844
|
messages.set([]);
|
|
2002
2845
|
error.set(null);
|
|
2846
|
+
issues.set([]);
|
|
2003
2847
|
};
|
|
2004
2848
|
const send = async (text) => {
|
|
2005
2849
|
if (!text.trim())
|
|
@@ -2073,13 +2917,36 @@ function injectChatUI(options) {
|
|
|
2073
2917
|
fetch: options.fetch,
|
|
2074
2918
|
signal: gate.signal,
|
|
2075
2919
|
}, parser);
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2920
|
+
gate.commit(() => {
|
|
2921
|
+
// A reply that only talked has no spec to check, and reporting
|
|
2922
|
+
// "missing root" for a sentence would be nonsense.
|
|
2923
|
+
const check = checkSpec(hasSpec ? currentSpec : null, validate, checkOptions);
|
|
2924
|
+
if (hasSpec) {
|
|
2925
|
+
reportSpecCheck(check, validate);
|
|
2926
|
+
if (check.spec)
|
|
2927
|
+
currentSpec = check.spec;
|
|
2928
|
+
}
|
|
2929
|
+
issues.set(check.issues);
|
|
2930
|
+
const finalMessage = {
|
|
2931
|
+
id: assistantId,
|
|
2932
|
+
role: 'assistant',
|
|
2933
|
+
text: accumulatedText,
|
|
2934
|
+
spec: hasSpec ? currentSpec : null,
|
|
2935
|
+
};
|
|
2936
|
+
// The reply keeps the fixed spec, so what renders in the bubble is
|
|
2937
|
+
// what was checked.
|
|
2938
|
+
messages.update((prev) => prev.map((m) => (m.id === assistantId ? finalMessage : m)));
|
|
2939
|
+
// A limit the app set fails the generation whatever the mode: the
|
|
2940
|
+
// spec it would hand `onComplete` is one the app already said it did
|
|
2941
|
+
// not want rendered.
|
|
2942
|
+
if (check.blocked || (validate === 'strict' && check.hasErrors)) {
|
|
2943
|
+
const invalid = new Error(`Generated spec failed validation:\n${formatSpecCheckIssues(check.issues)}`);
|
|
2944
|
+
error.set(invalid);
|
|
2945
|
+
options.onError?.(invalid);
|
|
2946
|
+
return;
|
|
2947
|
+
}
|
|
2948
|
+
options.onComplete?.(finalMessage);
|
|
2949
|
+
});
|
|
2083
2950
|
}
|
|
2084
2951
|
catch (err) {
|
|
2085
2952
|
// Unconditional: however this turn ended, its own bubble goes with it if
|
|
@@ -2103,6 +2970,7 @@ function injectChatUI(options) {
|
|
|
2103
2970
|
messages: messages.asReadonly(),
|
|
2104
2971
|
isStreaming: isStreaming.asReadonly(),
|
|
2105
2972
|
error: error.asReadonly(),
|
|
2973
|
+
issues: issues.asReadonly(),
|
|
2106
2974
|
send,
|
|
2107
2975
|
stop,
|
|
2108
2976
|
clear,
|
|
@@ -2186,6 +3054,10 @@ const schema = defineSchema((s) => ({
|
|
|
2186
3054
|
name: 'validateForm',
|
|
2187
3055
|
description: 'Validate all registered form fields and write the result to state. Params: { statePath?: string }. Defaults to /formValidation. Result: { valid: boolean, errors: Record<string, string[]> }.',
|
|
2188
3056
|
},
|
|
3057
|
+
{
|
|
3058
|
+
name: 'submitForm',
|
|
3059
|
+
description: "Validate all registered form fields and, only if every one passes, dispatch another action. Params: { action: string, params?: object, statePath?: string }. Writes the same { valid, errors } result as validateForm. Use this on a form's submit button: it is validateForm and the submit in one binding, and the submit is skipped when the form is invalid.",
|
|
3060
|
+
},
|
|
2189
3061
|
],
|
|
2190
3062
|
defaultRules: [
|
|
2191
3063
|
// Element integrity
|
|
@@ -2214,5 +3086,5 @@ const schema = defineSchema((s) => ({
|
|
|
2214
3086
|
* Generated bundle index. Do not edit.
|
|
2215
3087
|
*/
|
|
2216
3088
|
|
|
2217
|
-
export { JrChildren, JrConfirmDialog, JrElement, JrRepeatScope, JsonRenderActionsService, JsonRenderRootContext, JsonRenderStateService, JsonRenderValidationService, JsonRenderer, RENDER_CONTEXT, REPEAT_SCOPE, applyPatch, buildSpecFromParts, createStoreSetState, defineRegistry, flatToTree, getTextFromParts, injectAction, injectActions, injectBoundProp, injectChatUI, injectDevtoolsActive, injectFieldValidation, injectRenderContext, injectRepeatScope, injectStateBinding, injectStateStore, injectStateValue, injectUIStream, injectValidation, jsonRenderMessage, schema };
|
|
3089
|
+
export { CONFIRM_CONTEXT, ELEMENT_KEY, JR_CONFIRM_DIALOG, JR_CONFIRM_LABELS, JrChildren, JrConfirmDialog, JrElement, JrRepeatScope, JsonRenderActionsService, JsonRenderRootContext, JsonRenderStateService, JsonRenderValidationService, JsonRenderer, RENDER_CONTEXT, REPEAT_SCOPE, applyPatch, buildSpecFromParts, checkSpec, createStoreSetState, defineRegistry, flatToTree, formatSpecCheckIssues, getTextFromParts, injectAction, injectActions, injectBoundProp, injectChatUI, injectConfirmContext, injectDevtoolsActive, injectElementKey, injectFieldValidation, injectRenderContext, injectRepeatScope, injectStateBinding, injectStateStore, injectStateValue, injectUIStream, injectValidation, isActionCancelled, jsonRenderMessage, schema };
|
|
2218
3090
|
//# sourceMappingURL=ngx-json-render.mjs.map
|