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
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
Angular renderer for [json-render](https://github.com/vercel-labs/json-render) — give an LLM a catalog of your components, stream back a JSON spec, and render it as real Angular components. No `innerHTML`, no `eval`, no framework lock-in on the wire format.
|
|
6
6
|
|
|
7
|
-
Built on `@json-render/core` (the same spec format, expressions, state store, actions, and streaming compiler used by the React, Vue, Solid, and Svelte renderers) and idiomatic modern Angular: standalone components, signals,
|
|
7
|
+
Built on `@json-render/core` (the same spec format, expressions, state store, actions, and streaming compiler used by the React, Vue, Solid, and Svelte renderers) and idiomatic modern Angular: standalone components, signals, `OnPush` everywhere, and zoneless — no `NgZone`, no Zone.js.
|
|
8
8
|
|
|
9
9
|
This is an Angular **adapter over the official core**, not a second implementation of it — `@json-render/core` is a peer dependency, and the spec your model emits is the same one the React, Vue, Solid and Svelte renderers consume. A catalog and a spec written here move to another framework unchanged.
|
|
10
10
|
|
|
@@ -18,7 +18,62 @@ This is an Angular **adapter over the official core**, not a second implementati
|
|
|
18
18
|
npm install ngx-json-render @json-render/core zod
|
|
19
19
|
```
|
|
20
20
|
|
|
21
|
-
Requires Angular ≥ 20.
|
|
21
|
+
Requires Angular ≥ 20. CI proves both ends of that range on every push: the
|
|
22
|
+
library builds and its suite passes on Angular 20 (the floor) and on Angular
|
|
23
|
+
22 (the current release), as well as on the 21 the workspace itself pins.
|
|
24
|
+
|
|
25
|
+
**Zoneless, and checked as such.** Zone.js appears in no manifest here, `NgZone`
|
|
26
|
+
in no source file, and neither in the published bundles; every suite runs under
|
|
27
|
+
`provideZonelessChangeDetection()`. CI asserts all four on each commit
|
|
28
|
+
(`npm run check:zoneless`), so this is a tested property rather than a
|
|
29
|
+
statement of intent — which matters because zoneless is the default for new
|
|
30
|
+
applications from Angular 21 on.
|
|
31
|
+
|
|
32
|
+
## The shortest path: a ready-made catalog
|
|
33
|
+
|
|
34
|
+
Writing a catalog is the honest first step, but you do not have to take it to
|
|
35
|
+
see a spec render. [`ngx-json-render-material`](https://www.npmjs.com/package/ngx-json-render-material)
|
|
36
|
+
ships 28 Angular Material components already registered, so a generated spec
|
|
37
|
+
renders with nothing else wired up:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
npm install ngx-json-render-material @angular/material
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Those are real Material components, so the app needs a Material theme and the
|
|
44
|
+
icon font as usual.
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
import { Component, signal } from '@angular/core';
|
|
48
|
+
import { JsonRenderer, type Spec } from 'ngx-json-render';
|
|
49
|
+
import { materialRegistry } from 'ngx-json-render-material';
|
|
50
|
+
|
|
51
|
+
@Component({
|
|
52
|
+
selector: 'app-root',
|
|
53
|
+
imports: [JsonRenderer],
|
|
54
|
+
template: `<json-render [spec]="spec()" [registry]="registry" />`,
|
|
55
|
+
})
|
|
56
|
+
export class App {
|
|
57
|
+
readonly registry = materialRegistry;
|
|
58
|
+
readonly spec = signal<Spec>({
|
|
59
|
+
root: 'card',
|
|
60
|
+
state: { name: '' },
|
|
61
|
+
elements: {
|
|
62
|
+
card: { type: 'Card', props: { title: 'Profile' }, children: ['name', 'hi'] },
|
|
63
|
+
name: { type: 'Input', props: { label: 'Name', value: { $bindState: '/name' } } },
|
|
64
|
+
hi: {
|
|
65
|
+
type: 'Text',
|
|
66
|
+
props: { content: { $template: 'Hello, ${/name}!' } },
|
|
67
|
+
visible: { $state: '/name', neq: '' },
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
`materialCatalog.prompt()` is the system prompt that teaches a model that
|
|
75
|
+
vocabulary. Everything below is the other path — your own components, which is
|
|
76
|
+
what the catalog API is for.
|
|
22
77
|
|
|
23
78
|
## Quick start
|
|
24
79
|
|
|
@@ -83,7 +138,13 @@ export class ButtonComponent {
|
|
|
83
138
|
|
|
84
139
|
```ts
|
|
85
140
|
import { Component, signal } from '@angular/core';
|
|
86
|
-
import {
|
|
141
|
+
import {
|
|
142
|
+
type ActionHandler,
|
|
143
|
+
JsonRenderer,
|
|
144
|
+
type Spec,
|
|
145
|
+
type StateChange,
|
|
146
|
+
defineRegistry,
|
|
147
|
+
} from 'ngx-json-render';
|
|
87
148
|
import { catalog } from './catalog';
|
|
88
149
|
|
|
89
150
|
const { registry } = defineRegistry(catalog, {
|
|
@@ -119,8 +180,10 @@ export class Page {
|
|
|
119
180
|
},
|
|
120
181
|
},
|
|
121
182
|
});
|
|
122
|
-
handlers
|
|
123
|
-
|
|
183
|
+
readonly handlers: Record<string, ActionHandler> = {
|
|
184
|
+
refresh: async () => { /* ... */ },
|
|
185
|
+
};
|
|
186
|
+
onStateChange(changes: StateChange[]) { console.log(changes); }
|
|
124
187
|
}
|
|
125
188
|
```
|
|
126
189
|
|
|
@@ -178,6 +241,61 @@ app.post('/api/generate', async (req, res) => {
|
|
|
178
241
|
|
|
179
242
|
The patches apply to the `spec` signal as each line arrives, so the UI assembles on screen while the model is still generating — exactly what the [demo's Streaming tab](https://shteynu.github.io/ngx-json-render/) replays. Prefer structured output? `catalog.jsonSchema()` exports a JSON Schema for `streamObject`/tool calls, and `catalog.validate(spec)` checks a finished spec against the catalog.
|
|
180
243
|
|
|
244
|
+
### Chat + GenUI
|
|
245
|
+
|
|
246
|
+
When the model answers in prose _and_ renders a UI in the same turn, reach for
|
|
247
|
+
`injectChatUI` instead. Its endpoint takes `{ messages }` and streams prose
|
|
248
|
+
mixed with ` ```spec ` fenced JSONL. Each assistant message carries `text`
|
|
249
|
+
and/or its own `spec`, so earlier turns keep the UI they generated:
|
|
250
|
+
|
|
251
|
+
```ts
|
|
252
|
+
import { Component } from '@angular/core';
|
|
253
|
+
import { JsonRenderer, injectChatUI } from 'ngx-json-render';
|
|
254
|
+
|
|
255
|
+
@Component({
|
|
256
|
+
imports: [JsonRenderer],
|
|
257
|
+
template: `
|
|
258
|
+
@for (m of chat.messages(); track m.id) {
|
|
259
|
+
<p>{{ m.text }}</p>
|
|
260
|
+
@if (m.spec) {
|
|
261
|
+
<json-render [spec]="m.spec" [registry]="registry" />
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
<button (click)="chat.send('show me revenue for the quarter')">Ask</button>
|
|
265
|
+
`,
|
|
266
|
+
})
|
|
267
|
+
export class ChatPage {
|
|
268
|
+
readonly chat = injectChatUI({ api: '/api/chat' });
|
|
269
|
+
readonly registry = registry;
|
|
270
|
+
}
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
### Reading an AI SDK message
|
|
274
|
+
|
|
275
|
+
If the UI arrives as AI SDK data parts rather than through these hooks,
|
|
276
|
+
`buildSpecFromParts` / `getTextFromParts` / `jsonRenderMessage` read a
|
|
277
|
+
`UIMessage.parts` array directly — pass it as it is, no cast:
|
|
278
|
+
|
|
279
|
+
```ts
|
|
280
|
+
readonly msg = jsonRenderMessage(() => this.message().parts);
|
|
281
|
+
// template: {{ msg.text() }} @if (msg.hasSpec()) { <json-render [spec]="msg.spec()" ... /> }
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
Two things about the parts themselves, both the SDK's semantics rather than
|
|
285
|
+
this package's, and both silent when you get them wrong:
|
|
286
|
+
|
|
287
|
+
- **Do not give patch parts an `id`.** A data part written with one is
|
|
288
|
+
_replaced_ by the next part carrying the same id, so a spec streamed as
|
|
289
|
+
patches under one id arrives as its last patch alone. An id is for a part
|
|
290
|
+
that is a snapshot of itself — a `flat` or `nested` whole-spec part.
|
|
291
|
+
- **A transient part never reaches `message.parts`.** It goes to `onData` and
|
|
292
|
+
nowhere else, so a spec written transiently cannot be rebuilt from the
|
|
293
|
+
message.
|
|
294
|
+
|
|
295
|
+
`projects/ngx-json-render/src/lib/ai-sdk-parts.spec.ts` runs the real SDK —
|
|
296
|
+
writes the chunks a server route would, reads the message a client would —
|
|
297
|
+
and asserts both of these, so this section cannot quietly go stale.
|
|
298
|
+
|
|
181
299
|
### Supplying the transport
|
|
182
300
|
|
|
183
301
|
By default the request goes through the global `fetch`. Pass your own to add
|
|
@@ -218,11 +336,167 @@ The second argument also carries `context`, forwarded to the endpoint as-is:
|
|
|
218
336
|
|
|
219
337
|
Also available:
|
|
220
338
|
|
|
221
|
-
- `injectChatUI({ api, fetch? })` — chat + GenUI: the endpoint takes `{ messages }` and streams prose mixed with ` ```spec ` fenced JSONL; each assistant message carries `text` and/or a `spec`.
|
|
222
339
|
- `applyPatch(spec, patch)` — immutably apply one RFC 6902 patch to a spec, sharing every subtree the patch did not touch. Both hooks and `buildSpecFromParts` apply through it, so the same stream produces the same spec whichever one you reach for. One deliberate deviation from the RFC: a failing `test` op is a no-op rather than an abort, because these patches come off a model's output and dropping a bad line beats killing the generation.
|
|
223
340
|
- `buildSpecFromParts` / `getTextFromParts` / `jsonRenderMessage` — derive specs from AI SDK `message.parts`.
|
|
224
341
|
- `catalog.prompt()` / `buildUserPrompt` (from `@json-render/core`) — generate the system/user prompts for your catalog.
|
|
225
342
|
|
|
343
|
+
### Checking what the model produced
|
|
344
|
+
|
|
345
|
+
A generation can end malformed — a child that never arrived, a `visible` the
|
|
346
|
+
model wrote inside `props` where nothing reads it. Opt into a structural check
|
|
347
|
+
with `validate`, on the renderer or on either hook:
|
|
348
|
+
|
|
349
|
+
```html
|
|
350
|
+
<json-render [spec]="ui.spec()" [registry]="registry" [loading]="ui.isStreaming()" validate="warn" />
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
```ts
|
|
354
|
+
readonly ui = injectUIStream({ api: '/api/generate', validate: 'strict' });
|
|
355
|
+
// ui.issues() — what was wrong with the finished spec
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
- `'off'` (the default) — render whatever arrives, as before.
|
|
359
|
+
- `'warn'` — report what is wrong and render anyway.
|
|
360
|
+
- `'strict'` — a spec with errors does not render, and a generation that ends
|
|
361
|
+
with one fails instead of completing, so it never reaches the `onComplete`
|
|
362
|
+
where apps persist it.
|
|
363
|
+
|
|
364
|
+
Both modes first apply the lossless fixes `autoFixSpec` provides: `visible`,
|
|
365
|
+
`on` and `repeat` misplaced inside `props` move back onto the element, where
|
|
366
|
+
they take effect instead of being ignored. Content is never pruned — the lossy
|
|
367
|
+
fixes are left out on purpose, because re-prompting beats a renderer that
|
|
368
|
+
silently deletes elements.
|
|
369
|
+
|
|
370
|
+
The check waits for the spec to settle. While `loading` is true a missing child
|
|
371
|
+
is a patch that has not arrived yet, not a defect, so nothing is reported and
|
|
372
|
+
`strict` keeps rendering; the hooks check once, when the generation completes.
|
|
373
|
+
|
|
374
|
+
Pass the catalog too, and the same check covers what structure alone cannot
|
|
375
|
+
see — every `type` is one the catalog defines, and the props match its schema:
|
|
376
|
+
|
|
377
|
+
```html
|
|
378
|
+
<json-render [spec]="spec()" [registry]="registry" [catalog]="catalog" validate="strict" />
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
### Capping what a spec may cost
|
|
382
|
+
|
|
383
|
+
`validate` describes a spec. `renderLimits` constrains it:
|
|
384
|
+
|
|
385
|
+
```html
|
|
386
|
+
<json-render
|
|
387
|
+
[spec]="spec()"
|
|
388
|
+
[registry]="registry"
|
|
389
|
+
[renderLimits]="{ maxElements: 500, maxDepth: 16, maxRepeatItems: 200 }"
|
|
390
|
+
/>
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
```ts
|
|
394
|
+
readonly ui = injectUIStream({
|
|
395
|
+
api: '/api/generate',
|
|
396
|
+
renderLimits: { maxElements: 500, maxDepth: 16 },
|
|
397
|
+
});
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
| Limit | What it caps | When it is passed |
|
|
401
|
+
| ---------------- | ------------------------------- | --------------------------------------------------------------- |
|
|
402
|
+
| `maxElements` | Elements in the spec | Nothing renders — there is no useful part of an oversized graph |
|
|
403
|
+
| `maxDepth` | Nesting, counting the root as 1 | Elements below the cap do not render; what fits still does |
|
|
404
|
+
| `maxRepeatItems` | Items one `repeat` expands | The surplus items do not render |
|
|
405
|
+
|
|
406
|
+
Every limit is opt-in and unset means unlimited; there are no defaults, so
|
|
407
|
+
upgrading changes nothing about what your app renders today.
|
|
408
|
+
|
|
409
|
+
An element a cap refuses does not act either. `watch` is the one thing an
|
|
410
|
+
element does without being on screen, and it stays unwired while the element is
|
|
411
|
+
capped — otherwise a spec could keep dispatching actions from behind a limit
|
|
412
|
+
that was supposed to have stopped it. The same holds for the element that
|
|
413
|
+
closes a cycle.
|
|
414
|
+
|
|
415
|
+
Two things follow from limits being a control rather than a report. They are
|
|
416
|
+
enforced in **every** mode, `validate="off"` included — the app already made
|
|
417
|
+
the decision by setting a number. And they apply while `loading`: a partial
|
|
418
|
+
spec is a subset of the finished one, so a cap can only ever fire early, never
|
|
419
|
+
falsely. Under `strict`, a spec over `maxDepth` is refused outright rather than
|
|
420
|
+
truncated, which is how an app says it would rather draw nothing than draw the
|
|
421
|
+
first sixteen levels of something hostile.
|
|
422
|
+
|
|
423
|
+
In the hooks, a limit fails the generation instead of calling `onComplete`, in
|
|
424
|
+
any mode — the same reasoning as `strict`: `onComplete` is where apps persist a
|
|
425
|
+
spec, and this is one the app has already refused.
|
|
426
|
+
|
|
427
|
+
One order is deliberate rather than incidental. The cheap caps run before the
|
|
428
|
+
structural check, because core's `validateSpec` walks the tree by recursion:
|
|
429
|
+
the specs that most need a limit are exactly the ones that would overflow the
|
|
430
|
+
stack proving they exceed it. If you accept specs you did not generate, set
|
|
431
|
+
`maxDepth` — with no cap there is nothing to stop the check from recursing as
|
|
432
|
+
deep as the spec asks.
|
|
433
|
+
|
|
434
|
+
## Testing
|
|
435
|
+
|
|
436
|
+
`ngx-json-render/testing` is a separate entry point, so nothing in it can reach
|
|
437
|
+
an application bundle by accident.
|
|
438
|
+
|
|
439
|
+
### Rendering a spec
|
|
440
|
+
|
|
441
|
+
`renderSpec` mounts a spec against a registry and hands back the few things a
|
|
442
|
+
test does to one — no host component, no TestBed module, no settling by hand:
|
|
443
|
+
|
|
444
|
+
```ts
|
|
445
|
+
import { renderSpec } from 'ngx-json-render/testing';
|
|
446
|
+
|
|
447
|
+
it('dispatches the action its spec asked for', async () => {
|
|
448
|
+
const ui = await renderSpec(
|
|
449
|
+
{
|
|
450
|
+
root: 'save',
|
|
451
|
+
elements: {
|
|
452
|
+
save: {
|
|
453
|
+
type: 'Button',
|
|
454
|
+
props: { label: 'Save' },
|
|
455
|
+
on: { press: { action: 'save' } },
|
|
456
|
+
},
|
|
457
|
+
},
|
|
458
|
+
},
|
|
459
|
+
{ registry: { Button: MyButton } },
|
|
460
|
+
);
|
|
461
|
+
|
|
462
|
+
expect(ui.text('button')).toBe('Save');
|
|
463
|
+
await ui.click('button');
|
|
464
|
+
expect(ui.dispatched).toEqual([{ name: 'save', params: {} }]);
|
|
465
|
+
});
|
|
466
|
+
```
|
|
467
|
+
|
|
468
|
+
`dispatched` records every action the spec fired, handled or not. The rest of
|
|
469
|
+
the harness: `text` / `texts` / `find` / `findAll` for the DOM, `click` /
|
|
470
|
+
`fill` for input, `read` / `write` / `state` / `changes` for state, `setSpec` /
|
|
471
|
+
`setLoading` / `settle` for later frames, and `fixture` / `element` /
|
|
472
|
+
`renderer` / `store` / `validation` for everything the harness does not cover.
|
|
473
|
+
Options mirror the renderer's inputs, plus `providers` for anything the
|
|
474
|
+
components under test inject.
|
|
475
|
+
|
|
476
|
+
### Replaying a generation
|
|
477
|
+
|
|
478
|
+
`recordedTransport` is a `fetch` that answers from a recording, so a test runs
|
|
479
|
+
the real client — request body, streamed lines, usage metadata, abort on
|
|
480
|
+
supersede — with no server and no API key:
|
|
481
|
+
|
|
482
|
+
```ts
|
|
483
|
+
import { recordedTransport, specStream, usageLine } from 'ngx-json-render/testing';
|
|
484
|
+
|
|
485
|
+
const ui = injectUIStream({
|
|
486
|
+
api: '/api/generate',
|
|
487
|
+
fetch: recordedTransport([...specStream(expectedSpec), usageLine({ totalTokens: 15 })]),
|
|
488
|
+
});
|
|
489
|
+
```
|
|
490
|
+
|
|
491
|
+
`specStream(spec)` writes the JSONL patch lines a model would emit to build
|
|
492
|
+
that spec, so a test says what it renders rather than how the wire spells it.
|
|
493
|
+
Pass a `Record<prompt, lines>` to answer each prompt differently (anything
|
|
494
|
+
else gets a 404 the hook surfaces as an error), or a function for more. The
|
|
495
|
+
options are `delayMs` for a visible pace, `fail` for an error response — as a
|
|
496
|
+
function, so it can be switched on and off between sends — and `promptOf` for
|
|
497
|
+
a request body neither hook sends. The default reads `injectUIStream`'s
|
|
498
|
+
`prompt` and `injectChatUI`'s last message.
|
|
499
|
+
|
|
226
500
|
## Spec features supported
|
|
227
501
|
|
|
228
502
|
Full parity with the baseline json-render contract:
|
|
@@ -236,14 +510,42 @@ Full parity with the baseline json-render contract:
|
|
|
236
510
|
| Computed / directives | `{ "$computed": "fmtDate", "args": {...} }`, custom `$`-directives |
|
|
237
511
|
| Visibility | `"visible": { "$state": "/count", "gte": 5 }` (incl. `$and`/`$or`, `$item`, `$index`) |
|
|
238
512
|
| Events → actions | `"on": { "press": { "action": "...", "params": {...}, "confirm": {...}, "onSuccess": ..., "onError": ... } }` |
|
|
239
|
-
| Built-in actions | `setState`, `pushState` (with `$id`), `removeState`, `push`/`pop`, `validateForm`
|
|
513
|
+
| Built-in actions | `setState`, `pushState` (with `$id`), `removeState`, `push`/`pop`, `validateForm`, `submitForm` |
|
|
240
514
|
| Repeat | `"repeat": { "statePath": "/todos", "key": "id" }`, nested via `{ "$item": "..." }` |
|
|
241
515
|
| Watch | `"watch": { "/country": { "action": "loadCities" } }` |
|
|
242
516
|
| Slots | `"slots": { "header": ["title-el"] }` + `<jr-children slot="header" />` |
|
|
243
|
-
| Validation | field checks via `ValidationConfig`, `validateForm`, `injectFieldValidation`
|
|
517
|
+
| Validation | field checks via `ValidationConfig`, `validateForm` / `submitForm`, `injectFieldValidation` |
|
|
244
518
|
| Confirm dialogs | built-in `<jr-confirm-dialog>` (auto-rendered) |
|
|
245
519
|
| Devtools hooks | action observer + `data-jr-key` picker attributes |
|
|
246
520
|
|
|
521
|
+
### Ready-made directives
|
|
522
|
+
|
|
523
|
+
`@json-render/directives` is written against the core, not against any one
|
|
524
|
+
renderer, so its `$format`, `$math`, `$concat`, `$count`, `$truncate`,
|
|
525
|
+
`$pluralize`, `$join` and `$t` work here as they are:
|
|
526
|
+
|
|
527
|
+
```ts
|
|
528
|
+
import { createI18nDirective, standardDirectives } from '@json-render/directives';
|
|
529
|
+
|
|
530
|
+
readonly directives = [
|
|
531
|
+
...standardDirectives,
|
|
532
|
+
createI18nDirective({ locale, messages }),
|
|
533
|
+
];
|
|
534
|
+
```
|
|
535
|
+
|
|
536
|
+
```html
|
|
537
|
+
<json-render [spec]="spec()" [registry]="registry" [directives]="directives" />
|
|
538
|
+
```
|
|
539
|
+
|
|
540
|
+
```json
|
|
541
|
+
{ "content": { "$format": "currency", "value": { "$state": "/price" }, "currency": "USD" } }
|
|
542
|
+
```
|
|
543
|
+
|
|
544
|
+
A directive reads state through the same resolution as any other prop, so a
|
|
545
|
+
value it derives updates when that state does.
|
|
546
|
+
`projects/ngx-json-render/src/lib/directives.spec.ts` renders every one of
|
|
547
|
+
them through the renderer, which is what keeps this paragraph honest.
|
|
548
|
+
|
|
247
549
|
## State
|
|
248
550
|
|
|
249
551
|
Each `<json-render>` owns a state store (JSON Pointer addressed). Seeding order: `store` input (controlled) → `state` input → `spec.state`.
|
|
@@ -262,6 +564,78 @@ Share one store across renderers (or drive it from your own state management) by
|
|
|
262
564
|
|
|
263
565
|
If a catalog component renders `<input [value]="ctx.props().value">`, remember that one-way bindings do not re-assert the DOM when the bound value returns to its previously applied value while the user typed in between (e.g. `pushState` + `clearStatePath`). Sync imperatively instead — see `InputComponent` in the demo app for the pattern.
|
|
264
566
|
|
|
567
|
+
## Confirmation dialogs
|
|
568
|
+
|
|
569
|
+
An action binding with a `confirm` field routes through a dialog before its
|
|
570
|
+
handler runs. The packaged one is a real modal — `role="dialog"` with
|
|
571
|
+
`aria-modal`, labelled by its title and described by its message, focus moved
|
|
572
|
+
onto _Cancel_ on open and returned to the trigger on close, Tab kept inside and
|
|
573
|
+
Escape cancelling.
|
|
574
|
+
|
|
575
|
+
Three levels of control, in the order you are likely to need them:
|
|
576
|
+
|
|
577
|
+
```ts
|
|
578
|
+
// 1. Its two words, when the spec does not supply confirmLabel / cancelLabel.
|
|
579
|
+
{ provide: JR_CONFIRM_LABELS, useValue: { confirm: 'Подтвердить', cancel: 'Отмена' } }
|
|
580
|
+
```
|
|
581
|
+
|
|
582
|
+
```css
|
|
583
|
+
/* 2. Its colours. Light and dark defaults ship; these override both. */
|
|
584
|
+
json-render { --jr-confirm-surface: #101418; --jr-confirm-accent: #4f9cf9; }
|
|
585
|
+
```
|
|
586
|
+
|
|
587
|
+
Also `--jr-confirm-ink`, `--jr-confirm-muted`, `--jr-confirm-border`,
|
|
588
|
+
`--jr-confirm-scrim`, `--jr-confirm-danger`, `--jr-confirm-on-accent` and
|
|
589
|
+
`--jr-confirm-radius`.
|
|
590
|
+
|
|
591
|
+
```ts
|
|
592
|
+
// 3. The whole dialog. Your component injects the context instead of taking
|
|
593
|
+
// inputs, the same way catalog components do.
|
|
594
|
+
@Component({
|
|
595
|
+
template: `<my-modal [title]="ctx.config.title" (ok)="ctx.confirm()" (dismiss)="ctx.cancel()" />`,
|
|
596
|
+
})
|
|
597
|
+
export class AppConfirm {
|
|
598
|
+
readonly ctx = injectConfirmContext();
|
|
599
|
+
}
|
|
600
|
+
// providers: [{ provide: JR_CONFIRM_DIALOG, useValue: AppConfirm }]
|
|
601
|
+
```
|
|
602
|
+
|
|
603
|
+
## Submitting a form
|
|
604
|
+
|
|
605
|
+
`validateForm` validates every bound field at once and writes
|
|
606
|
+
`{ valid, errors }` to `/formValidation` (or the `statePath` you pass). What it
|
|
607
|
+
cannot do is act on the answer: a model writing a submit button had to emit one
|
|
608
|
+
binding for the validation and hope the app's own handler re-checked the form.
|
|
609
|
+
|
|
610
|
+
`submitForm` is both halves in one binding — validate everything, and dispatch
|
|
611
|
+
the submit only if it all passes:
|
|
612
|
+
|
|
613
|
+
```json
|
|
614
|
+
{
|
|
615
|
+
"type": "Button",
|
|
616
|
+
"props": { "label": "Save" },
|
|
617
|
+
"on": {
|
|
618
|
+
"press": {
|
|
619
|
+
"action": "submitForm",
|
|
620
|
+
"params": {
|
|
621
|
+
"action": "saveUser",
|
|
622
|
+
"params": { "email": { "$state": "/email" } }
|
|
623
|
+
},
|
|
624
|
+
"onSuccess": { "navigate": "/thanks" }
|
|
625
|
+
}
|
|
626
|
+
},
|
|
627
|
+
"children": []
|
|
628
|
+
}
|
|
629
|
+
```
|
|
630
|
+
|
|
631
|
+
An invalid form stops there, with the errors written to state and every field
|
|
632
|
+
marked validated so its message is on screen. A valid one dispatches
|
|
633
|
+
`saveUser` as an ordinary action: the same handler lookup, the same `confirm`
|
|
634
|
+
(asked after validation — there is no point asking about a form that cannot be
|
|
635
|
+
submitted), the same `onSuccess` / `onError`, the same loading state. `params`
|
|
636
|
+
resolves `{ "$state": "/path" }` one level down, the way `pushState`'s `value`
|
|
637
|
+
does.
|
|
638
|
+
|
|
265
639
|
## Security
|
|
266
640
|
|
|
267
641
|
Specs are attacker-shaped input: whatever produced one — a model, a prompt, a
|
|
@@ -278,9 +652,12 @@ text reaches the DOM through Angular interpolation. Script injection through a
|
|
|
278
652
|
spec is not a thing you have to defend against.
|
|
279
653
|
|
|
280
654
|
**A spec can only name actions you registered.** Built-ins (`setState`,
|
|
281
|
-
`pushState`, `removeState`, `push`, `pop`, `validateForm`) are
|
|
282
|
-
the renderer; every other action name is looked up in the
|
|
283
|
-
An unrecognised name logs a warning and does nothing.
|
|
655
|
+
`pushState`, `removeState`, `push`, `pop`, `validateForm`, `submitForm`) are
|
|
656
|
+
handled inside the renderer; every other action name is looked up in the
|
|
657
|
+
`handlers` you pass. An unrecognised name logs a warning and does nothing.
|
|
658
|
+
`submitForm` is no exception to this: the action it is told to submit goes
|
|
659
|
+
through the same lookup, so it gates a handler you registered rather than
|
|
660
|
+
reaching one you did not.
|
|
284
661
|
|
|
285
662
|
The exception is `onAction`, which is a deliberate catch-all: when you pass it,
|
|
286
663
|
**every** action name in the spec reaches it, including ones you never put in
|
|
@@ -299,10 +676,36 @@ string to the `navigate` callback you provide, verbatim. Treat it as untrusted:
|
|
|
299
676
|
match it against known routes, and never hand it to `window.location` or
|
|
300
677
|
`router.navigateByUrl` unchecked.
|
|
301
678
|
|
|
302
|
-
**A spec
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
679
|
+
**A spec cannot render forever.** Two elements naming each other as children,
|
|
680
|
+
or one naming itself, would recurse until the tab died. The renderer refuses to
|
|
681
|
+
draw an element that is rendering itself again _without reading any deeper into
|
|
682
|
+
state_, so the cycle is broken where it closes and everything above it still
|
|
683
|
+
renders. This holds in every mode, including `validate="off"` and mid-stream:
|
|
684
|
+
it is a crash guard, not an opinion about spec quality. Core's `validateSpec`
|
|
685
|
+
does not report cycles, so nothing else in the stack catches this for you.
|
|
686
|
+
|
|
687
|
+
Recursion that goes somewhere is untouched. A tree — a comment thread, a file
|
|
688
|
+
browser, a nested menu — is an element repeating over a path relative to the
|
|
689
|
+
item it is already inside (`{"$item": "children"}`) and rendering itself for
|
|
690
|
+
each one, so every pass reads one level further in and the drawing ends where
|
|
691
|
+
the data does. What the guard stops is the pass that reads the same array
|
|
692
|
+
again: `repeat` over a fixed `/items` inside itself never runs out, however
|
|
693
|
+
much data there is.
|
|
694
|
+
|
|
695
|
+
**A spec sizes its own render tree, up to the caps you set.** `repeat` iterates
|
|
696
|
+
a state array the spec may itself have supplied, and nesting costs a component
|
|
697
|
+
per level, so specs are a denial-of-service surface against the browser tab.
|
|
698
|
+
`renderLimits` is the cap: `maxElements`, `maxDepth` and `maxRepeatItems`,
|
|
699
|
+
enforced in every mode. They are unset by default — a renderer cannot guess
|
|
700
|
+
what your catalog considers a reasonable page — so an app taking specs it did
|
|
701
|
+
not generate should set all three. See
|
|
702
|
+
[Capping what a spec may cost](#capping-what-a-spec-may-cost).
|
|
703
|
+
|
|
704
|
+
**A spec can name components you never built.** `validate` with a `catalog`
|
|
705
|
+
reports every `type` the catalog does not define and every prop its schema
|
|
706
|
+
rejects; `strict` refuses such a spec outright. Without a catalog the renderer
|
|
707
|
+
only warns and draws nothing in that element's place, which degrades well but
|
|
708
|
+
tells you nothing until you read the console.
|
|
306
709
|
|
|
307
710
|
**`confirm` is a UX affordance, not a security control.** It routes an action
|
|
308
711
|
through the confirmation dialog before the handler runs, but it is set on the
|
|
@@ -314,28 +717,43 @@ handler, on the server.
|
|
|
314
717
|
|
|
315
718
|
Components: `JsonRenderer` (`<json-render>`), `JrChildren`, `JrConfirmDialog`, `JrElement`, `JrRepeatScope`.
|
|
316
719
|
|
|
317
|
-
Injectables/helpers: `injectRenderContext`, `injectRepeatScope`, `injectStateStore`, `injectStateValue`, `injectStateBinding`, `injectBoundProp`, `injectActions`, `injectAction`, `injectValidation`, `injectFieldValidation`, `injectUIStream`, `injectChatUI`, `injectDevtoolsActive`, `jsonRenderMessage`.
|
|
720
|
+
Injectables/helpers: `injectRenderContext`, `injectElementKey`, `injectRepeatScope`, `injectStateStore`, `injectStateValue`, `injectStateBinding`, `injectBoundProp`, `injectActions`, `injectAction`, `injectValidation`, `injectFieldValidation`, `injectUIStream`, `injectChatUI`, `injectDevtoolsActive`, `injectConfirmContext`, `jsonRenderMessage`, `isActionCancelled`, `checkSpec`.
|
|
721
|
+
|
|
722
|
+
Tokens: `JR_CONFIRM_DIALOG` (replace the confirmation dialog), `JR_CONFIRM_LABELS` (its two words), `CONFIRM_CONTEXT`, `RENDER_CONTEXT`, `REPEAT_SCOPE`.
|
|
723
|
+
|
|
724
|
+
Spec checking: `checkSpec`, `formatSpecCheckIssues`, and the types
|
|
725
|
+
`RenderLimits`, `SpecCheck`, `SpecCheckIssue`, `SpecCheckIssueCode`,
|
|
726
|
+
`SpecCheckOptions`, `SpecCatalog`, `SpecValidationMode`.
|
|
727
|
+
|
|
728
|
+
`injectActions().execute()` rejects when the user dismisses a `confirm`
|
|
729
|
+
dialog, which is a normal gesture rather than a failure — `isActionCancelled(error)`
|
|
730
|
+
is how you tell the two apart.
|
|
318
731
|
|
|
319
732
|
Registry & schema: `defineRegistry`, `createStoreSetState`, `schema`.
|
|
320
733
|
|
|
734
|
+
Testing (`ngx-json-render/testing`): `renderSpec`, `recordedTransport`, `specStream`, `usageLine`.
|
|
735
|
+
|
|
321
736
|
Everything from `@json-render/core` (types, `createStateStore`, `nestedToFlat`, prompt builders, spec validators, SpecStream compiler) composes with this package; the most common symbols are re-exported.
|
|
322
737
|
|
|
323
738
|
## Renderer inputs
|
|
324
739
|
|
|
325
|
-
| Input | Type | Purpose
|
|
326
|
-
| --------------------- | ------------------------------------ |
|
|
327
|
-
| `spec` | `Spec \| null` | The UI spec (may be partial while streaming)
|
|
328
|
-
| `registry` | `ComponentRegistry` | Catalog type → Angular component
|
|
329
|
-
| `loading` | `boolean` | Suppress missing-element warnings while streaming
|
|
330
|
-
| `fallback` | `Type<unknown>` | Component for unknown types
|
|
331
|
-
| `
|
|
332
|
-
| `
|
|
333
|
-
| `
|
|
334
|
-
| `
|
|
335
|
-
| `
|
|
336
|
-
| `
|
|
337
|
-
| `
|
|
338
|
-
| `
|
|
740
|
+
| Input | Type | Purpose |
|
|
741
|
+
| --------------------- | ------------------------------------ | ------------------------------------------------------- |
|
|
742
|
+
| `spec` | `Spec \| null` | The UI spec (may be partial while streaming) |
|
|
743
|
+
| `registry` | `ComponentRegistry` | Catalog type → Angular component |
|
|
744
|
+
| `loading` | `boolean` | Suppress missing-element warnings while streaming |
|
|
745
|
+
| `fallback` | `Type<unknown>` | Component for unknown types |
|
|
746
|
+
| `validate` | `'off' \| 'warn' \| 'strict'` | Check the settled spec's structure (default `'off'`) |
|
|
747
|
+
| `renderLimits` | `RenderLimits` | Cap elements, depth and repeat expansion (default none) |
|
|
748
|
+
| `catalog` | `Catalog` | Also check types and props against the catalog |
|
|
749
|
+
| `state` | `StateModel` | Initial state (uncontrolled; defaults to `spec.state`) |
|
|
750
|
+
| `store` | `StateStore` | External store (controlled mode) |
|
|
751
|
+
| `handlers` | `Record<string, ActionHandler>` | Action handlers |
|
|
752
|
+
| `onAction` | `(name, params) => unknown` | Catch-all action handler |
|
|
753
|
+
| `navigate` | `(path) => void` | Used by `onSuccess: { navigate }` |
|
|
754
|
+
| `validationFunctions` | `Record<string, ValidationFunction>` | Custom validation |
|
|
755
|
+
| `functions` | `Record<string, ComputedFunction>` | `$computed` functions |
|
|
756
|
+
| `directives` | `DirectiveDefinition[]` | Custom `$`-prefixed expressions |
|
|
339
757
|
|
|
340
758
|
Output: `(stateChange)` — batched `{ path, value }[]` in uncontrolled mode.
|
|
341
759
|
|