ngx-json-render 0.7.4 → 0.7.6
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 +9 -1
- package/fesm2022/ngx-json-render.mjs +132 -29
- package/fesm2022/ngx-json-render.mjs.map +1 -1
- package/package.json +1 -1
- package/types/ngx-json-render.d.ts +14 -2
package/README.md
CHANGED
|
@@ -661,6 +661,12 @@ const bound = injectBoundProp<string>(() => ctx.props().value, () => ctx.binding
|
|
|
661
661
|
|
|
662
662
|
Share one store across renderers (or drive it from your own state management) by passing a core `StateStore` — `createStateStore()`, or `createStoreAdapter()` over Redux/NgRx/etc. — via the `store` input. `createStoreSetState(store)` adapts a whole-state updater to fine-grained path writes.
|
|
663
663
|
|
|
664
|
+
`watch` also fires for writes made on that store from outside the renderer: on
|
|
665
|
+
every store notification the renderer compares the new snapshot with the last
|
|
666
|
+
one, branch by branch by reference, and reports each changed path. A store
|
|
667
|
+
that mutates its snapshot in place therefore re-renders but triggers no
|
|
668
|
+
`watch`; replace objects rather than editing them.
|
|
669
|
+
|
|
664
670
|
### What a state write re-renders
|
|
665
671
|
|
|
666
672
|
A write reaches only the components whose resolved props actually changed. Writing `/user/name` re-runs the template of the text that shows it, not the rest of the tree. So `ctx.props()` and `ctx.element()` are the signals to read: a template that reads anything else, such as a mutable object or `Date.now()`, is no longer refreshed by unrelated writes.
|
|
@@ -849,7 +855,9 @@ Spec checking: `checkSpec`, `formatSpecCheckIssues`, and the types
|
|
|
849
855
|
|
|
850
856
|
`injectActions().execute()` rejects when the user dismisses a `confirm`
|
|
851
857
|
dialog, which is a normal gesture rather than a failure — `isActionCancelled(error)`
|
|
852
|
-
is how you tell the two apart.
|
|
858
|
+
is how you tell the two apart. One dialog is open at a time: an action with a
|
|
859
|
+
`confirm` dispatched while another is waiting cancels the waiting one, so its
|
|
860
|
+
`execute()` rejects the same way.
|
|
853
861
|
|
|
854
862
|
Registry & schema: `defineRegistry`, `createStoreSetState`, `schema`.
|
|
855
863
|
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import { NgComponentOutlet } from '@angular/common';
|
|
2
2
|
import * as i0 from '@angular/core';
|
|
3
|
-
import { signal, Injectable, inject, computed, untracked, effect,
|
|
3
|
+
import { signal, Injectable, isDevMode, inject, computed, untracked, effect, DestroyRef, InjectionToken, input, output, viewChild, afterNextRender, ChangeDetectionStrategy, Component, Injector, forwardRef } from '@angular/core';
|
|
4
4
|
import { createStateStore, getByPath, runValidation, resolveAction, nextActionDispatchId, notifyActionDispatch, notifyActionSettle, executeAction, isDevtoolsActive, subscribeDevtoolsActive, resolveRepeatStatePath, evaluateVisibility, resolveElementProps, resolveBindings, resolveActionParam, autoFixSpec, validateSpec, createDirectiveRegistry, resolveRepeatItemStatePath, removeByPath, addByPath, setByPath, SPEC_DATA_PART_TYPE, nestedToFlat, createMixedStreamParser, defineSchema } from '@json-render/core';
|
|
5
5
|
export { autoFixSpec, createStateStore, formatSpecIssues, nestedToFlat, validateSpec } from '@json-render/core';
|
|
6
|
-
import { flattenToPointers } from '@json-render/core/store-utils';
|
|
7
6
|
|
|
8
7
|
const EMPTY = signal(undefined, ...(ngDevMode ? [{ debugName: "EMPTY" }] : /* istanbul ignore next */ []));
|
|
9
8
|
/**
|
|
@@ -49,6 +48,50 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
|
|
|
49
48
|
type: Injectable
|
|
50
49
|
}] });
|
|
51
50
|
|
|
51
|
+
/** Nesting below this is kept as one leaf, the same cut-off core uses. */
|
|
52
|
+
const MAX_FLATTEN_DEPTH = 20;
|
|
53
|
+
/** Escape one key for a JSON Pointer: `~` → `~0`, `/` → `~1`. */
|
|
54
|
+
function escapePointerSegment(key) {
|
|
55
|
+
return key.replace(/~/g, '~0').replace(/\//g, '~1');
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Flatten plain objects into a `{ pointer: leaf }` map; arrays and anything
|
|
59
|
+
* that is not a plain object are leaves.
|
|
60
|
+
*
|
|
61
|
+
* Core's `flattenToPointers` does the same but joins keys unescaped, so a key
|
|
62
|
+
* such as `a/b` comes back as `/a/b` and a store write puts it at `a` → `b`.
|
|
63
|
+
* Every path built here is escaped, so it reads back the key it came from.
|
|
64
|
+
*/
|
|
65
|
+
function flattenToPointers(obj) {
|
|
66
|
+
const result = {};
|
|
67
|
+
const seen = new Set();
|
|
68
|
+
let warned = false;
|
|
69
|
+
const walk = (value, prefix, depth) => {
|
|
70
|
+
for (const [key, child] of Object.entries(value)) {
|
|
71
|
+
const pointer = `${prefix}/${escapePointerSegment(key)}`;
|
|
72
|
+
if (isPlainObject(child) && !seen.has(child)) {
|
|
73
|
+
if (depth < MAX_FLATTEN_DEPTH) {
|
|
74
|
+
seen.add(child);
|
|
75
|
+
walk(child, pointer, depth + 1);
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (isDevMode() && !warned) {
|
|
79
|
+
warned = true;
|
|
80
|
+
console.warn(`[ngx-json-render] state nested deeper than ${MAX_FLATTEN_DEPTH} levels is treated as one value.`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
result[pointer] = child;
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
walk(obj, '', 0);
|
|
87
|
+
return result;
|
|
88
|
+
}
|
|
89
|
+
function isPlainObject(value) {
|
|
90
|
+
return (value !== null &&
|
|
91
|
+
typeof value === 'object' &&
|
|
92
|
+
Object.getPrototypeOf(value) === Object.prototype);
|
|
93
|
+
}
|
|
94
|
+
|
|
52
95
|
/**
|
|
53
96
|
* The state store of a `<json-render>` subtree.
|
|
54
97
|
*
|
|
@@ -63,6 +106,10 @@ class JsonRenderStateService {
|
|
|
63
106
|
root = inject(JsonRenderRootContext);
|
|
64
107
|
internalStore = createStateStore({});
|
|
65
108
|
changeListeners = new Set();
|
|
109
|
+
/** The snapshot the change listeners last heard about. */
|
|
110
|
+
lastSnapshot = {};
|
|
111
|
+
/** True while this service writes, so the store's echo is not reported. */
|
|
112
|
+
writing = false;
|
|
66
113
|
_state = signal({}, { ...(ngDevMode ? { debugName: "_state" } : /* istanbul ignore next */ {}), equal: () => false });
|
|
67
114
|
/** The current state model as a signal. */
|
|
68
115
|
state = this._state.asReadonly();
|
|
@@ -82,9 +129,23 @@ class JsonRenderStateService {
|
|
|
82
129
|
console.warn(`[ngx-json-render] switching from ${initialMode} to ${mode} mode is not supported.`);
|
|
83
130
|
}
|
|
84
131
|
}
|
|
85
|
-
|
|
132
|
+
const controlled = store !== this.internalStore;
|
|
133
|
+
this.lastSnapshot = store.getSnapshot();
|
|
134
|
+
untracked(() => this._state.set(this.lastSnapshot));
|
|
86
135
|
const unsubscribe = store.subscribe(() => {
|
|
87
|
-
this.
|
|
136
|
+
const prev = this.lastSnapshot;
|
|
137
|
+
const next = store.getSnapshot();
|
|
138
|
+
// Recorded before the listeners run: a watch action may write again.
|
|
139
|
+
this.lastSnapshot = next;
|
|
140
|
+
this._state.set(next);
|
|
141
|
+
// A write the host made on its own store reaches no `set`/`update`
|
|
142
|
+
// here, so the diff is the only way `watch` hears about it. Our own
|
|
143
|
+
// writes report themselves, with the paths they were given.
|
|
144
|
+
if (controlled && !this.writing && this.changeListeners.size > 0) {
|
|
145
|
+
const changes = diffState(prev, next);
|
|
146
|
+
if (changes.length > 0)
|
|
147
|
+
this.notifyChanges(changes);
|
|
148
|
+
}
|
|
88
149
|
});
|
|
89
150
|
onCleanup(unsubscribe);
|
|
90
151
|
});
|
|
@@ -124,7 +185,7 @@ class JsonRenderStateService {
|
|
|
124
185
|
const store = untracked(this.currentStore);
|
|
125
186
|
const prev = store.getSnapshot();
|
|
126
187
|
const prevValue = getByPath(prev, path);
|
|
127
|
-
store.set(path, value);
|
|
188
|
+
this.write(store, () => store.set(path, value));
|
|
128
189
|
if (prevValue !== value) {
|
|
129
190
|
const changes = [{ path, value }];
|
|
130
191
|
this.notifyChanges(changes);
|
|
@@ -137,7 +198,7 @@ class JsonRenderStateService {
|
|
|
137
198
|
update(updates) {
|
|
138
199
|
const store = untracked(this.currentStore);
|
|
139
200
|
const prev = store.getSnapshot();
|
|
140
|
-
store.update(updates);
|
|
201
|
+
this.write(store, () => store.update(updates));
|
|
141
202
|
const changes = [];
|
|
142
203
|
for (const [path, value] of Object.entries(updates)) {
|
|
143
204
|
if (getByPath(prev, path) !== value) {
|
|
@@ -157,8 +218,9 @@ class JsonRenderStateService {
|
|
|
157
218
|
}
|
|
158
219
|
/**
|
|
159
220
|
* Register a listener called with the list of changed paths whenever state
|
|
160
|
-
* is written through this service
|
|
161
|
-
*
|
|
221
|
+
* is written through this service, or, in controlled mode, whenever the
|
|
222
|
+
* external store changes (element `watch` fields rely on this). Returns an
|
|
223
|
+
* unsubscribe function.
|
|
162
224
|
*/
|
|
163
225
|
subscribeChanges(listener) {
|
|
164
226
|
this.changeListeners.add(listener);
|
|
@@ -166,6 +228,17 @@ class JsonRenderStateService {
|
|
|
166
228
|
this.changeListeners.delete(listener);
|
|
167
229
|
};
|
|
168
230
|
}
|
|
231
|
+
write(store, apply) {
|
|
232
|
+
const wasWriting = this.writing;
|
|
233
|
+
this.writing = true;
|
|
234
|
+
try {
|
|
235
|
+
apply();
|
|
236
|
+
}
|
|
237
|
+
finally {
|
|
238
|
+
this.writing = wasWriting;
|
|
239
|
+
}
|
|
240
|
+
this.lastSnapshot = store.getSnapshot();
|
|
241
|
+
}
|
|
169
242
|
notifyChanges(changes) {
|
|
170
243
|
for (const listener of this.changeListeners) {
|
|
171
244
|
listener(changes);
|
|
@@ -177,6 +250,35 @@ class JsonRenderStateService {
|
|
|
177
250
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: JsonRenderStateService, decorators: [{
|
|
178
251
|
type: Injectable
|
|
179
252
|
}], ctorParameters: () => [] });
|
|
253
|
+
/**
|
|
254
|
+
* Every path whose value differs between two state snapshots, each container
|
|
255
|
+
* before what it holds, values taken from `next`. Subtrees are compared by
|
|
256
|
+
* reference, so an unchanged branch costs nothing, and a store that mutates a
|
|
257
|
+
* snapshot in place reports no change: the same rule as a signal's `set`.
|
|
258
|
+
*/
|
|
259
|
+
function diffState(prev, next) {
|
|
260
|
+
const changes = [];
|
|
261
|
+
const walk = (a, b, path) => {
|
|
262
|
+
if (a === b)
|
|
263
|
+
return;
|
|
264
|
+
if (path)
|
|
265
|
+
changes.push({ path, value: b });
|
|
266
|
+
const keys = new Set([...childKeys(a), ...childKeys(b)]);
|
|
267
|
+
for (const key of keys) {
|
|
268
|
+
walk(child(a, key), child(b, key), `${path}/${escapePointerSegment(key)}`);
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
walk(prev, next, '');
|
|
272
|
+
return changes;
|
|
273
|
+
}
|
|
274
|
+
function childKeys(value) {
|
|
275
|
+
return value !== null && typeof value === 'object' ? Object.keys(value) : [];
|
|
276
|
+
}
|
|
277
|
+
function child(value, key) {
|
|
278
|
+
return value !== null && typeof value === 'object'
|
|
279
|
+
? value[key]
|
|
280
|
+
: undefined;
|
|
281
|
+
}
|
|
180
282
|
/**
|
|
181
283
|
* Inject the state store of the nearest `<json-render>` renderer.
|
|
182
284
|
* Must be called from within the renderer's subtree (e.g. a catalog
|
|
@@ -348,17 +450,9 @@ class JsonRenderValidationService {
|
|
|
348
450
|
/** Validate a single field and record the result. */
|
|
349
451
|
validate(path, config) {
|
|
350
452
|
const currentState = this.state.getSnapshot();
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
if (value != null && typeof value === 'object') {
|
|
355
|
-
value = value[seg];
|
|
356
|
-
}
|
|
357
|
-
else {
|
|
358
|
-
value = undefined;
|
|
359
|
-
break;
|
|
360
|
-
}
|
|
361
|
-
}
|
|
453
|
+
// The store's own pointer walk, so `~1` and `~0` mean what they mean
|
|
454
|
+
// everywhere else a path is read.
|
|
455
|
+
const value = getByPath(currentState, path);
|
|
362
456
|
const result = runValidation(config, {
|
|
363
457
|
value,
|
|
364
458
|
stateModel: currentState,
|
|
@@ -665,21 +759,19 @@ class JsonRenderActionsService {
|
|
|
665
759
|
return;
|
|
666
760
|
}
|
|
667
761
|
if (resolved.confirm) {
|
|
762
|
+
// One question at a time: a confirm arriving while another is open
|
|
763
|
+
// cancels that one, so its caller settles instead of waiting forever.
|
|
764
|
+
untracked(this._pendingConfirmation)?.reject();
|
|
668
765
|
// Awaited, not returned: a returned promise would let the `finally`
|
|
669
766
|
// below settle the observers before the user has even answered.
|
|
670
767
|
await new Promise((resolve, reject) => {
|
|
671
|
-
|
|
768
|
+
const pending = {
|
|
672
769
|
action: resolved,
|
|
673
770
|
handler,
|
|
674
|
-
resolve: () =>
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
reject: () => {
|
|
679
|
-
this._pendingConfirmation.set(null);
|
|
680
|
-
reject(new ActionCancelledError());
|
|
681
|
-
},
|
|
682
|
-
});
|
|
771
|
+
resolve: () => this.answer(pending, resolve),
|
|
772
|
+
reject: () => this.answer(pending, () => reject(new ActionCancelledError())),
|
|
773
|
+
};
|
|
774
|
+
this._pendingConfirmation.set(pending);
|
|
683
775
|
});
|
|
684
776
|
}
|
|
685
777
|
await this.runHandler(resolved, handler);
|
|
@@ -709,6 +801,17 @@ class JsonRenderActionsService {
|
|
|
709
801
|
cancel() {
|
|
710
802
|
untracked(this._pendingConfirmation)?.reject();
|
|
711
803
|
}
|
|
804
|
+
/**
|
|
805
|
+
* Close `pending` and run `settle`, but only while it is still the open
|
|
806
|
+
* question: a late answer to one that was replaced must not close its
|
|
807
|
+
* successor.
|
|
808
|
+
*/
|
|
809
|
+
answer(pending, settle) {
|
|
810
|
+
if (untracked(this._pendingConfirmation) !== pending)
|
|
811
|
+
return;
|
|
812
|
+
this._pendingConfirmation.set(null);
|
|
813
|
+
settle();
|
|
814
|
+
}
|
|
712
815
|
/**
|
|
713
816
|
* Validate every registered field and write `{ valid, errors }` to the
|
|
714
817
|
* action's `statePath` (default `/formValidation`). Returns null when there
|