@jarenjs/rules 0.83.2
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/ARCHITECTURE.md +17 -0
- package/README.md +49 -0
- package/dist/types/component/index.d.ts +88 -0
- package/dist/types/index.d.ts +40 -0
- package/package.json +59 -0
- package/src/component/index.js +84 -0
- package/src/index.js +76 -0
- package/styles/rules.css +10 -0
package/ARCHITECTURE.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Rules component architecture
|
|
2
|
+
|
|
3
|
+
`src/index.js` is the DOM-free draft/review controller. It imports core JSON
|
|
4
|
+
snapshot helpers and receives preview/command functions structurally. It owns
|
|
5
|
+
no query evaluator, database adapter, transaction, ledger or virtual range engine.
|
|
6
|
+
|
|
7
|
+
`src/component/index.js` builds schema field guidance through forms and renders
|
|
8
|
+
through view. Input updates the authoritative draft immediately; independent
|
|
9
|
+
preview publication never replaces typed text or its caret. Review pages only
|
|
10
|
+
present a bounded portion of the plan. Selection lives by immutable change IDs.
|
|
11
|
+
The existing app widget lifecycle owns mounting and disposal. CSS binds host
|
|
12
|
+
light/dark theme tokens.
|
|
13
|
+
|
|
14
|
+
The host composes json/formula, json/rules, contract/command and linq/db receipts.
|
|
15
|
+
It reads current state and revalidates the plan inside one existing transaction.
|
|
16
|
+
The website supplies neutral application declarations and an independent virtual
|
|
17
|
+
inventory grid, while all reusable UI remains in this workspace.
|
package/README.md
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# @jarenjs/rules
|
|
2
|
+
|
|
3
|
+
The [combined adoption recipe](../../docs/ADOPTION-EVIDENCE.md) connects the editor to current-data validation and a durable receipt transaction. Preview grants no write authority, and unresolved original formulas remain preserved until application review.
|
|
4
|
+
|
|
5
|
+
Reusable schema-guided JSON rule authoring and reviewed change selection. The
|
|
6
|
+
engine imports core only; evaluator and authoritative command services are
|
|
7
|
+
injected. The component layer uses view/forms and the existing app widget lifecycle.
|
|
8
|
+
|
|
9
|
+
```js
|
|
10
|
+
import { mountRuleEditor } from '@jarenjs/rules/component';
|
|
11
|
+
import '@jarenjs/rules/styles/rules.css';
|
|
12
|
+
const editor = mountRuleEditor(host, {
|
|
13
|
+
text: JSON.stringify(savedRule, null, 2),
|
|
14
|
+
schema: rowSchema,
|
|
15
|
+
preview: async (text) => previewSavedRule(JSON.parse(text)),
|
|
16
|
+
command: async (request) => validatedCommand.execute(request),
|
|
17
|
+
});
|
|
18
|
+
// editor.dispose() on unmount
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
The host supplies saved rule/dataset revisions and policy. Preview is read-only;
|
|
22
|
+
`command` must revalidate current authorization, rows, rule identity and proposed
|
|
23
|
+
values inside its transaction. Omitting it enables report-only authoring.
|
|
24
|
+
[The formula/plan contract](../../packages/json/docs/FORMULA-FORMAT.md) specifies
|
|
25
|
+
outcomes, conflicts, migration, refused shapes and qualification limits.
|
|
26
|
+
|
|
27
|
+
`createRuleEditor` owns draft text/caret, stable selection IDs and generation
|
|
28
|
+
fences. Call `edit`, `preview`, `select`, `commit`, `state` and `dispose`.
|
|
29
|
+
`mountRuleEditor` presents schema fields, a controlled JSON textarea, bounded
|
|
30
|
+
review pages and commit status. `createRuleEditorWidget` uses existing mount /
|
|
31
|
+
update / unmount semantics. The default page holds 20 changes; hosts can select
|
|
32
|
+
1–100. `maxChanges` defaults to 10000 and refuses larger preview documents.
|
|
33
|
+
Selection survives unmounted pages, but a changed draft or plan clears it.
|
|
34
|
+
Repeated commit admission shares one pending command; durable replay belongs to
|
|
35
|
+
the host. Disposal fences preview publication; an accepted domain command stays
|
|
36
|
+
host-owned. CSS uses the host theme tokens with standalone fallbacks.
|
|
37
|
+
|
|
38
|
+
Automated desktop/mobile browser checks cover typing, caret preservation,
|
|
39
|
+
selection, preview failure and durable commits. Physical-device and manual
|
|
40
|
+
accessibility qualification remain pending.
|
|
41
|
+
|
|
42
|
+
<!--fact:exports.rules-->
|
|
43
|
+
| Import | Kind | Declarations |
|
|
44
|
+
|---|---|---|
|
|
45
|
+
| `@jarenjs/rules` | JavaScript | declared |
|
|
46
|
+
| `@jarenjs/rules/component` | JavaScript | declared |
|
|
47
|
+
| `@jarenjs/rules/package.json` | metadata | — |
|
|
48
|
+
| `@jarenjs/rules/styles/rules.css` | asset | — |
|
|
49
|
+
<!--/fact-->
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mount a reusable rule editor. Preview rows are a bounded presentation page;
|
|
3
|
+
* selection remains in the editor by stable ID. Schema fields guide JSON authors.
|
|
4
|
+
* @param {HTMLElement} host @param {any} options
|
|
5
|
+
*/
|
|
6
|
+
export declare function mountRuleEditor(host: HTMLElement, options: any): {
|
|
7
|
+
controller: {
|
|
8
|
+
state: () => {
|
|
9
|
+
text: string;
|
|
10
|
+
selectionStart: number;
|
|
11
|
+
selectionEnd: number;
|
|
12
|
+
plan: any;
|
|
13
|
+
selected: any[];
|
|
14
|
+
phase: string;
|
|
15
|
+
message: string;
|
|
16
|
+
reportOnly: boolean;
|
|
17
|
+
};
|
|
18
|
+
edit(value: any, selectionStart?: any, selectionEnd?: any): void;
|
|
19
|
+
preview(): Promise<{
|
|
20
|
+
text: string;
|
|
21
|
+
selectionStart: number;
|
|
22
|
+
selectionEnd: number;
|
|
23
|
+
plan: any;
|
|
24
|
+
selected: any[];
|
|
25
|
+
phase: string;
|
|
26
|
+
message: string;
|
|
27
|
+
reportOnly: boolean;
|
|
28
|
+
}>;
|
|
29
|
+
select(id: any, enabled?: boolean): boolean;
|
|
30
|
+
commit(): any;
|
|
31
|
+
dispose(): void;
|
|
32
|
+
};
|
|
33
|
+
refresh: (state?: {
|
|
34
|
+
text: string;
|
|
35
|
+
selectionStart: number;
|
|
36
|
+
selectionEnd: number;
|
|
37
|
+
plan: any;
|
|
38
|
+
selected: any[];
|
|
39
|
+
phase: string;
|
|
40
|
+
message: string;
|
|
41
|
+
reportOnly: boolean;
|
|
42
|
+
}) => void;
|
|
43
|
+
dispose(): void;
|
|
44
|
+
};
|
|
45
|
+
/** Existing app widget lifecycle, with all host services injected through options. */
|
|
46
|
+
export declare function createRuleEditorWidget(options: any): {
|
|
47
|
+
mount: (host: any, props: any) => {
|
|
48
|
+
controller: {
|
|
49
|
+
state: () => {
|
|
50
|
+
text: string;
|
|
51
|
+
selectionStart: number;
|
|
52
|
+
selectionEnd: number;
|
|
53
|
+
plan: any;
|
|
54
|
+
selected: any[];
|
|
55
|
+
phase: string;
|
|
56
|
+
message: string;
|
|
57
|
+
reportOnly: boolean;
|
|
58
|
+
};
|
|
59
|
+
edit(value: any, selectionStart?: any, selectionEnd?: any): void;
|
|
60
|
+
preview(): Promise<{
|
|
61
|
+
text: string;
|
|
62
|
+
selectionStart: number;
|
|
63
|
+
selectionEnd: number;
|
|
64
|
+
plan: any;
|
|
65
|
+
selected: any[];
|
|
66
|
+
phase: string;
|
|
67
|
+
message: string;
|
|
68
|
+
reportOnly: boolean;
|
|
69
|
+
}>;
|
|
70
|
+
select(id: any, enabled?: boolean): boolean;
|
|
71
|
+
commit(): any;
|
|
72
|
+
dispose(): void;
|
|
73
|
+
};
|
|
74
|
+
refresh: (state?: {
|
|
75
|
+
text: string;
|
|
76
|
+
selectionStart: number;
|
|
77
|
+
selectionEnd: number;
|
|
78
|
+
plan: any;
|
|
79
|
+
selected: any[];
|
|
80
|
+
phase: string;
|
|
81
|
+
message: string;
|
|
82
|
+
reportOnly: boolean;
|
|
83
|
+
}) => void;
|
|
84
|
+
dispose(): void;
|
|
85
|
+
};
|
|
86
|
+
update: (handle: any) => any;
|
|
87
|
+
unmount: (handle: any) => any;
|
|
88
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Preserve draft text/caret independently of asynchronous preview publications.
|
|
3
|
+
* @param {{text:string,preview:(text:string)=>any,command?:(request:any)=>any,onChange?:(state:any)=>void,maxChanges?:number}} options
|
|
4
|
+
*/
|
|
5
|
+
export declare function createRuleEditor(options: {
|
|
6
|
+
text: string;
|
|
7
|
+
preview: (text: string) => any;
|
|
8
|
+
command?: (request: any) => any;
|
|
9
|
+
onChange?: (state: any) => void;
|
|
10
|
+
maxChanges?: number;
|
|
11
|
+
}): {
|
|
12
|
+
state: () => {
|
|
13
|
+
text: string;
|
|
14
|
+
selectionStart: number;
|
|
15
|
+
selectionEnd: number;
|
|
16
|
+
plan: any;
|
|
17
|
+
selected: any[];
|
|
18
|
+
phase: string;
|
|
19
|
+
message: string;
|
|
20
|
+
reportOnly: boolean;
|
|
21
|
+
};
|
|
22
|
+
/** Every input event updates the authoritative typing buffer before another render. */
|
|
23
|
+
edit(value: any, selectionStart?: any, selectionEnd?: any): void;
|
|
24
|
+
preview(): Promise<{
|
|
25
|
+
text: string;
|
|
26
|
+
selectionStart: number;
|
|
27
|
+
selectionEnd: number;
|
|
28
|
+
plan: any;
|
|
29
|
+
selected: any[];
|
|
30
|
+
phase: string;
|
|
31
|
+
message: string;
|
|
32
|
+
reportOnly: boolean;
|
|
33
|
+
}>;
|
|
34
|
+
/** Stable change IDs survive unmounted or evicted presentation rows. */
|
|
35
|
+
select(id: any, enabled?: boolean): boolean;
|
|
36
|
+
/** Double admission shares one command; the injected command owns transactional revalidation. */
|
|
37
|
+
commit(): any;
|
|
38
|
+
/** Fence late preview callbacks; a command already accepted remains host-owned. */
|
|
39
|
+
dispose(): void;
|
|
40
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@jarenjs/rules",
|
|
3
|
+
"private": false,
|
|
4
|
+
"version": "0.83.2",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.js",
|
|
7
|
+
"types": "./dist/types/index.d.ts",
|
|
8
|
+
"sideEffects": false,
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/types/index.d.ts",
|
|
12
|
+
"default": "./src/index.js"
|
|
13
|
+
},
|
|
14
|
+
"./component": {
|
|
15
|
+
"types": "./dist/types/component/index.d.ts",
|
|
16
|
+
"default": "./src/component/index.js"
|
|
17
|
+
},
|
|
18
|
+
"./package.json": "./package.json",
|
|
19
|
+
"./styles/rules.css": "./styles/rules.css"
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"src/",
|
|
23
|
+
"dist/types/",
|
|
24
|
+
"styles/",
|
|
25
|
+
"docs/",
|
|
26
|
+
"ARCHITECTURE.md"
|
|
27
|
+
],
|
|
28
|
+
"description": "Saved rule authoring and reviewed plans through injected formula and command services",
|
|
29
|
+
"author": "joham",
|
|
30
|
+
"repository": {
|
|
31
|
+
"type": "git",
|
|
32
|
+
"url": "git+https://github.com/jklarenbeek/jarenjs.git",
|
|
33
|
+
"directory": "components/rules"
|
|
34
|
+
},
|
|
35
|
+
"license": "MIT",
|
|
36
|
+
"engines": {
|
|
37
|
+
"node": ">=24"
|
|
38
|
+
},
|
|
39
|
+
"publishConfig": {
|
|
40
|
+
"access": "public",
|
|
41
|
+
"registry": "https://registry.npmjs.org/"
|
|
42
|
+
},
|
|
43
|
+
"keywords": [
|
|
44
|
+
"jaren",
|
|
45
|
+
"rules",
|
|
46
|
+
"formula"
|
|
47
|
+
],
|
|
48
|
+
"scripts": {
|
|
49
|
+
"build": "npm run build:types",
|
|
50
|
+
"build:types": "tsc -p tsconfig.json",
|
|
51
|
+
"prepack": "npm run build:types"
|
|
52
|
+
},
|
|
53
|
+
"dependencies": {
|
|
54
|
+
"@jarenjs/core": "^0.83.2",
|
|
55
|
+
"@jarenjs/view": "^0.83.2",
|
|
56
|
+
"@jarenjs/app": "^0.83.2",
|
|
57
|
+
"@jarenjs/forms": "^0.83.2"
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/** Schema-guided authoring and bounded review pages over the injected editor. */
|
|
3
|
+
import { createDomRenderer } from '@jarenjs/view';
|
|
4
|
+
import { buildFormModel } from '@jarenjs/forms';
|
|
5
|
+
import { createRuleEditor } from '../index.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Mount a reusable rule editor. Preview rows are a bounded presentation page;
|
|
9
|
+
* selection remains in the editor by stable ID. Schema fields guide JSON authors.
|
|
10
|
+
* @param {HTMLElement} host @param {any} options
|
|
11
|
+
*/
|
|
12
|
+
export function mountRuleEditor(host, options) {
|
|
13
|
+
const document = host.ownerDocument;
|
|
14
|
+
const model = buildFormModel(options.schema ?? { type: 'object' });
|
|
15
|
+
const pageSize = options.pageSize ?? 20;
|
|
16
|
+
if (!Number.isSafeInteger(pageSize) || pageSize < 1 || pageSize > 100) throw new TypeError('Invalid review page size');
|
|
17
|
+
let page = 0, disposed = false, targetIndex = 0;
|
|
18
|
+
const render = createDomRenderer(host, { document });
|
|
19
|
+
const controller = createRuleEditor({ ...options, onChange: (state) => { refresh(state); options.onChange?.(state); } });
|
|
20
|
+
function refresh(state = controller.state()) {
|
|
21
|
+
if (disposed) return;
|
|
22
|
+
const changes = state.plan?.changes ?? [];
|
|
23
|
+
page = Math.min(page, Math.max(0, Math.ceil(changes.length / pageSize) - 1));
|
|
24
|
+
const fields = (model.children ?? []).map((field) => `${field.pointer ?? field.path ?? field.key}: ${field.label ?? field.title ?? field.key}`);
|
|
25
|
+
let draft = null;
|
|
26
|
+
try { draft = JSON.parse(state.text); } catch { /* Keep incomplete draft text editable. */ }
|
|
27
|
+
const targets = Array.isArray(draft?.targets) ? draft.targets : [];
|
|
28
|
+
targetIndex = Math.min(targetIndex, Math.max(0, targets.length - 1));
|
|
29
|
+
const editableTarget = targets[targetIndex] && typeof targets[targetIndex] === 'object' && !Array.isArray(targets[targetIndex]);
|
|
30
|
+
const writable = (model.children ?? []).filter((field) => !field.readOnly && (!options.writableFields || options.writableFields.includes(field.pointer)));
|
|
31
|
+
render(['section', { class: 'jr-editor', 'aria-label': 'Rule editor' },
|
|
32
|
+
['div', { class: 'jr-actions' }, ['label', {}, 'Target ', ['select', { 'aria-label': 'Rule target', 'data-rule-target': '', value: String(targetIndex), disabled: !targets.length },
|
|
33
|
+
...targets.map((target, index) => ['option', { value: String(index) }, target?.id ?? String(index)])]],
|
|
34
|
+
['label', {}, 'Writable field ', ['select', { 'aria-label': 'Writable field', 'data-rule-field': '', value: targets[targetIndex]?.field ?? '', disabled: !editableTarget },
|
|
35
|
+
...writable.map((field) => ['option', { value: field.pointer }, field.label])]],
|
|
36
|
+
['label', {}, ['input', { type: 'checkbox', 'aria-label': 'Target enabled', 'data-rule-enabled': '', checked: targets[targetIndex]?.enabled !== false, disabled: !editableTarget }], 'Enabled']],
|
|
37
|
+
['label', { class: 'jr-draft-label' }, 'Rule document', ['textarea', { 'data-rule-draft': '', rows: 12, value: state.text, spellcheck: 'false', autocapitalize: 'off', autocomplete: 'off', 'aria-label': 'Rule document' }]],
|
|
38
|
+
['details', {}, ['summary', {}, 'Available schema fields'], ['pre', {}, fields.join('\n') || JSON.stringify(options.schema ?? {}, null, 2)]],
|
|
39
|
+
['div', { class: 'jr-actions' }, ['button', { type: 'button', 'data-rule-preview': '', disabled: state.phase === 'previewing' }, 'Preview rules'],
|
|
40
|
+
['button', { type: 'button', 'data-rule-commit': '', disabled: state.reportOnly || state.phase !== 'review' || !state.selected.length }, `Commit selected (${state.selected.length})`]],
|
|
41
|
+
['p', { role: 'status', 'data-rule-status': '' }, state.message || (state.phase === 'previewing' ? 'Evaluating preview…' : `${changes.length} proposed changes · ${state.selected.length} selected`)],
|
|
42
|
+
['ul', { 'aria-label': 'Preview diagnostics' }, ...(state.plan?.errors ?? []).map((error) => ['li', {}, `${error.rowId ?? ''} ${error.targetId ?? ''}: ${error.code ?? ''} ${error.message ?? ''} ${error.docPath ?? ''}`]),
|
|
43
|
+
...(state.plan?.omittedErrors ? [['li', {}, `${state.plan.omittedErrors} further diagnostics omitted by the configured limit.`]] : [])],
|
|
44
|
+
['ul', { class: 'jr-changes', 'aria-label': 'Proposed changes' }, ...changes.slice(page * pageSize, (page + 1) * pageSize).map((change) =>
|
|
45
|
+
['li', { key: change.id }, ['label', {}, ['input', { type: 'checkbox', 'data-rule-change': change.id, checked: state.selected.includes(change.id) }],
|
|
46
|
+
`${change.entityId} ${change.field}: ${change.before.present ? JSON.stringify(change.before.value) : '(absent)'} → ${JSON.stringify(change.proposed)}${change.explanation ? ` · ${change.explanation}` : ''}`]])],
|
|
47
|
+
['nav', { class: 'jr-actions', 'aria-label': 'Preview pages' }, ['button', { type: 'button', 'data-rule-previous': '', disabled: page === 0 }, 'Previous'],
|
|
48
|
+
['span', {}, `Page ${page + 1} of ${Math.max(1, Math.ceil(changes.length / pageSize))}`],
|
|
49
|
+
['button', { type: 'button', 'data-rule-next': '', disabled: (page + 1) * pageSize >= changes.length }, 'Next']]]);
|
|
50
|
+
}
|
|
51
|
+
function input(event) {
|
|
52
|
+
const target = event.target;
|
|
53
|
+
if (target.hasAttribute('data-rule-draft')) controller.edit(target.value, target.selectionStart ?? 0, target.selectionEnd ?? 0);
|
|
54
|
+
}
|
|
55
|
+
function change(event) {
|
|
56
|
+
const target = event.target;
|
|
57
|
+
if (target.hasAttribute('data-rule-target')) { targetIndex = Number(target.value); refresh(); return; }
|
|
58
|
+
if (target.hasAttribute('data-rule-field') || target.hasAttribute('data-rule-enabled')) {
|
|
59
|
+
const draft = JSON.parse(controller.state().text);
|
|
60
|
+
if (target.hasAttribute('data-rule-field')) draft.targets[targetIndex].field = target.value;
|
|
61
|
+
else draft.targets[targetIndex].enabled = target.checked;
|
|
62
|
+
controller.edit(JSON.stringify(draft, null, 2)); return;
|
|
63
|
+
}
|
|
64
|
+
const id = target.getAttribute('data-rule-change');
|
|
65
|
+
if (id !== null) controller.select(id, event.target.checked);
|
|
66
|
+
}
|
|
67
|
+
function click(event) {
|
|
68
|
+
const target = event.target;
|
|
69
|
+
if (target.hasAttribute('data-rule-preview')) void controller.preview();
|
|
70
|
+
else if (target.hasAttribute('data-rule-commit')) void controller.commit();
|
|
71
|
+
else if (target.hasAttribute('data-rule-previous')) { page = Math.max(0, page - 1); refresh(); }
|
|
72
|
+
else if (target.hasAttribute('data-rule-next')) { page++; refresh(); }
|
|
73
|
+
}
|
|
74
|
+
host.addEventListener('input', input); host.addEventListener('change', change); host.addEventListener('click', click);
|
|
75
|
+
refresh();
|
|
76
|
+
return { controller, refresh,
|
|
77
|
+
dispose() { if (disposed) return; disposed = true; host.removeEventListener('input', input); host.removeEventListener('change', change); host.removeEventListener('click', click); controller.dispose(); render.destroy(); },
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Existing app widget lifecycle, with all host services injected through options. */
|
|
82
|
+
export function createRuleEditorWidget(options) {
|
|
83
|
+
return { mount: (host, props) => mountRuleEditor(host, { ...options, ...props }), update: (handle) => handle.refresh(), unmount: (handle) => handle.dispose() };
|
|
84
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/** Draft and review state; all evaluation and domain command services are injected. */
|
|
3
|
+
import { cloneJson, deepFreeze, isJsonValue } from '@jarenjs/core/object';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Preserve draft text/caret independently of asynchronous preview publications.
|
|
7
|
+
* @param {{text:string,preview:(text:string)=>any,command?:(request:any)=>any,onChange?:(state:any)=>void,maxChanges?:number}} options
|
|
8
|
+
*/
|
|
9
|
+
export function createRuleEditor(options) {
|
|
10
|
+
if (typeof options?.text !== 'string' || typeof options.preview !== 'function') throw new TypeError('Rule editor needs a draft and preview service');
|
|
11
|
+
let text = options.text, start = 0, end = 0, plan = null, phase = 'draft', message = '', generation = 0, disposed = false, committing = null;
|
|
12
|
+
const selected = new Set();
|
|
13
|
+
const maxChanges = options.maxChanges ?? 10000;
|
|
14
|
+
if (!Number.isSafeInteger(maxChanges) || maxChanges < 1) throw new TypeError('Invalid preview change credit');
|
|
15
|
+
const state = () => deepFreeze(cloneJson({ text, selectionStart: start, selectionEnd: end, plan, selected: [...selected], phase, message, reportOnly: typeof options.command !== 'function' }));
|
|
16
|
+
const publish = () => { if (!disposed) options.onChange?.(state()); };
|
|
17
|
+
return {
|
|
18
|
+
state,
|
|
19
|
+
/** Every input event updates the authoritative typing buffer before another render. */
|
|
20
|
+
edit(value, selectionStart = value.length, selectionEnd = selectionStart) {
|
|
21
|
+
if (disposed) return;
|
|
22
|
+
if (typeof value !== 'string' || !Number.isSafeInteger(selectionStart) || !Number.isSafeInteger(selectionEnd)
|
|
23
|
+
|| selectionStart < 0 || selectionEnd < selectionStart || selectionEnd > value.length) throw new TypeError('Invalid draft/caret');
|
|
24
|
+
if (value !== text) { generation++; plan = null; selected.clear(); phase = 'draft'; message = ''; }
|
|
25
|
+
text = value; start = selectionStart; end = selectionEnd; publish();
|
|
26
|
+
},
|
|
27
|
+
async preview() {
|
|
28
|
+
if (disposed) return state();
|
|
29
|
+
const mine = ++generation; phase = 'previewing'; message = ''; publish();
|
|
30
|
+
try {
|
|
31
|
+
const result = await options.preview(text);
|
|
32
|
+
if (disposed || mine !== generation) return state();
|
|
33
|
+
if (!isJsonValue(result) || !result || typeof result.id !== 'string' || !Array.isArray(result.changes)
|
|
34
|
+
|| result.changes.length > maxChanges || result.changes.some((change) => !change || typeof change.id !== 'string' || !change.id
|
|
35
|
+
|| typeof change.field !== 'string' || !change.before || typeof change.before.present !== 'boolean' || !Object.hasOwn(change, 'proposed'))
|
|
36
|
+
|| result.errors !== undefined && !Array.isArray(result.errors)
|
|
37
|
+
|| new Set(result.changes.map((change) => change.id)).size !== result.changes.length)
|
|
38
|
+
throw new TypeError('Invalid or oversized preview');
|
|
39
|
+
if (plan?.id !== result.id) selected.clear();
|
|
40
|
+
plan = deepFreeze(cloneJson(result)); phase = 'review';
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
if (!disposed && mine === generation) {
|
|
44
|
+
let reason = 'Check the rule document.';
|
|
45
|
+
try { if (typeof error?.message === 'string') reason = error.message.slice(0, 512); }
|
|
46
|
+
catch { /* Host failures may contain inaccessible properties. */ }
|
|
47
|
+
phase = 'error'; message = `Preview failed: ${reason}`; plan = null; selected.clear();
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
publish(); return state();
|
|
51
|
+
},
|
|
52
|
+
/** Stable change IDs survive unmounted or evicted presentation rows. */
|
|
53
|
+
select(id, enabled = true) {
|
|
54
|
+
if (disposed || !plan?.changes.some((change) => change.id === id)) return false;
|
|
55
|
+
if (enabled) selected.add(id); else selected.delete(id);
|
|
56
|
+
publish(); return true;
|
|
57
|
+
},
|
|
58
|
+
/** Double admission shares one command; the injected command owns transactional revalidation. */
|
|
59
|
+
commit() {
|
|
60
|
+
if (committing) return committing;
|
|
61
|
+
if (disposed || !plan || phase !== 'review' || typeof options.command !== 'function' || !selected.size)
|
|
62
|
+
return Promise.resolve({ state: 'refused', reason: 'no-reviewed-selection' });
|
|
63
|
+
const selection = [...selected].sort(), request = deepFreeze(cloneJson({ plan, selection, key: JSON.stringify([plan.id, selection]) }));
|
|
64
|
+
const mine = generation; phase = 'committing'; message = ''; publish();
|
|
65
|
+
committing = Promise.resolve().then(() => options.command(request)).then((result) => {
|
|
66
|
+
if (!isJsonValue(result)) throw new TypeError('Command result must be JSON');
|
|
67
|
+
if (!disposed && mine === generation) { phase = 'review'; message = ['committed', 'replay'].includes(result?.state) ? 'Selected changes committed.' : 'Command refused. Preview current data again.'; }
|
|
68
|
+
return result;
|
|
69
|
+
}).catch(() => { if (!disposed && mine === generation) { phase = 'review'; message = 'Command failed. Preview current data again.'; } return { state: 'error', reason: 'command-failed' }; })
|
|
70
|
+
.finally(() => { committing = null; publish(); });
|
|
71
|
+
return committing;
|
|
72
|
+
},
|
|
73
|
+
/** Fence late preview callbacks; a command already accepted remains host-owned. */
|
|
74
|
+
dispose() { disposed = true; generation++; selected.clear(); plan = null; },
|
|
75
|
+
};
|
|
76
|
+
}
|
package/styles/rules.css
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
.jr-editor { color: var(--fg, #172033); background: var(--surface, #fff); min-width: 0; }
|
|
2
|
+
.jr-draft-label { display: grid; gap: var(--space-2, .5rem); }
|
|
3
|
+
.jr-editor textarea { box-sizing: border-box; width: 100%; color: inherit; background: var(--bg, #fff); border: 1px solid var(--border, #64748b); border-radius: var(--radius-sm, 8px); padding: var(--space-3, .75rem); font-family: var(--mono, monospace); }
|
|
4
|
+
.jr-actions { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-3, .75rem); margin-block: var(--space-4, 1rem); }
|
|
5
|
+
.jr-editor select { max-width: 100%; min-height: 44px; color: inherit; background: var(--bg, #fff); border: 1px solid var(--border, #64748b); border-radius: var(--radius-sm, 8px); padding: var(--space-2, .5rem); }
|
|
6
|
+
.jr-editor button { color: var(--accent-fg, #fff); background: var(--accent, #2563eb); border: 1px solid var(--border, #64748b); border-radius: var(--radius-sm, 8px); min-height: 44px; padding: var(--space-2, .5rem) var(--space-4, 1rem); cursor: pointer; }
|
|
7
|
+
.jr-editor button:disabled { color: var(--muted, #475569); background: var(--surface, #fff); cursor: default; }
|
|
8
|
+
.jr-changes { list-style: none; margin: 0; padding: 0; }
|
|
9
|
+
.jr-changes label { display: flex; align-items: center; gap: var(--space-3, .75rem); min-height: 44px; overflow-wrap: anywhere; }
|
|
10
|
+
.jr-editor :focus-visible { outline: 2px solid var(--accent, #2563eb); outline-offset: 2px; }
|