@cynodia/axiom-ui 0.7.0-alpha.1
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/LICENSE +21 -0
- package/README.md +83 -0
- package/dist/catalog.d.ts +32 -0
- package/dist/catalog.js +29 -0
- package/dist/example/app.d.ts +6 -0
- package/dist/example/app.js +365 -0
- package/dist/example/domain.d.ts +71 -0
- package/dist/example/domain.js +469 -0
- package/dist/example/index.d.ts +3 -0
- package/dist/example/index.js +2 -0
- package/dist/expand.d.ts +119 -0
- package/dist/expand.js +227 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +11 -0
- package/dist/inference.d.ts +55 -0
- package/dist/inference.js +113 -0
- package/dist/pattern.d.ts +192 -0
- package/dist/pattern.js +39 -0
- package/dist/patterns/action-bar.d.ts +24 -0
- package/dist/patterns/action-bar.js +94 -0
- package/dist/patterns/entity-form.d.ts +93 -0
- package/dist/patterns/entity-form.js +340 -0
- package/dist/patterns/entity-list.d.ts +45 -0
- package/dist/patterns/entity-list.js +236 -0
- package/dist/patterns/metric-grid.d.ts +31 -0
- package/dist/patterns/metric-grid.js +107 -0
- package/dist/patterns/page.d.ts +28 -0
- package/dist/patterns/page.js +104 -0
- package/dist/queries.d.ts +60 -0
- package/dist/queries.js +53 -0
- package/dist/toolkit.d.ts +8 -0
- package/dist/toolkit.js +19 -0
- package/docs/OWNERSHIP.md +75 -0
- package/docs/PATTERN_AUTHORING.md +71 -0
- package/docs/PATTERN_CATALOG.json +504 -0
- package/docs/PROVENANCE.md +55 -0
- package/docs/TOOLKIT_AGENT_REFERENCE.md +205 -0
- package/package.json +47 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Expression, NodeId } from '@cynodia/axiom-core';
|
|
2
|
+
/**
|
|
3
|
+
* A group of controls bound to actions, with emphasis taken from what the actions *are*.
|
|
4
|
+
*
|
|
5
|
+
* The caller lists action ids and nothing else. Which button is primary comes from position
|
|
6
|
+
* — the first non-destructive action — and which is destructive comes from the action's own
|
|
7
|
+
* `destructive` flag, already in the graph. An author who had to write
|
|
8
|
+
* `variant: 'danger'` beside an action already marked destructive would be restating a fact
|
|
9
|
+
* Axiom holds, and could contradict it.
|
|
10
|
+
*/
|
|
11
|
+
export interface ActionBarDeclaration {
|
|
12
|
+
pattern: 'action-bar';
|
|
13
|
+
instance: string;
|
|
14
|
+
actions: NodeId[];
|
|
15
|
+
/** Arguments per action id, keyed by action parameter id. */
|
|
16
|
+
arguments?: Record<string, Record<string, Expression>>;
|
|
17
|
+
/** Labels per action id. Absent, the action's own `name` is used. */
|
|
18
|
+
labels?: Record<string, string>;
|
|
19
|
+
/** Which action is the primary one. Absent, the first non-destructive action is. */
|
|
20
|
+
primary?: NodeId;
|
|
21
|
+
alignment?: 'start' | 'end';
|
|
22
|
+
}
|
|
23
|
+
export declare const actionBar: import("../pattern.js").PatternDefinition<ActionBarDeclaration>;
|
|
24
|
+
//# sourceMappingURL=action-bar.d.ts.map
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { definePattern } from '../pattern.js';
|
|
2
|
+
import { actionOf, roleForAction } from '../inference.js';
|
|
3
|
+
export const actionBar = definePattern({
|
|
4
|
+
name: 'action-bar',
|
|
5
|
+
version: '0.7.0',
|
|
6
|
+
purpose: 'A row of action controls whose emphasis is inferred from the actions themselves.',
|
|
7
|
+
inputs: {
|
|
8
|
+
actions: { kind: 'action-list', required: true, purpose: 'The actions to expose, in order.' },
|
|
9
|
+
primary: {
|
|
10
|
+
kind: 'action',
|
|
11
|
+
required: false,
|
|
12
|
+
purpose: 'Which action is the primary one.',
|
|
13
|
+
inferredWhenAbsent: 'The first action that is not destructive.',
|
|
14
|
+
},
|
|
15
|
+
labels: { kind: 'text', required: false, purpose: 'Override a control’s label.', inferredWhenAbsent: 'The action’s own name.' },
|
|
16
|
+
arguments: { kind: 'nodes', required: false, purpose: 'Arguments per action, keyed by action parameter id.' },
|
|
17
|
+
alignment: { kind: 'token', required: false, purpose: 'Where the group sits along its axis.', inferredWhenAbsent: 'start' },
|
|
18
|
+
},
|
|
19
|
+
slots: [],
|
|
20
|
+
produces: ['container', 'button'],
|
|
21
|
+
expansion: [
|
|
22
|
+
{ part: 'root', kind: 'container', role: 'the action-group' },
|
|
23
|
+
{ part: 'button', kind: 'button', role: 'one per action, emphasis from the action' },
|
|
24
|
+
],
|
|
25
|
+
check(declaration, { graph, instance }) {
|
|
26
|
+
const findings = [];
|
|
27
|
+
declaration.actions.forEach((actionId, index) => {
|
|
28
|
+
const action = actionOf(graph, actionId);
|
|
29
|
+
if (!action) {
|
|
30
|
+
findings.push({
|
|
31
|
+
code: 'ACTION_NOT_FOUND',
|
|
32
|
+
message: `${String(actionId)} is not an action in this graph.`,
|
|
33
|
+
severity: 'error',
|
|
34
|
+
path: `${instance}.actions[${index}]`,
|
|
35
|
+
});
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
// A control that cannot supply a required parameter would be rejected by
|
|
39
|
+
// validateGraph later; saying so here points at the declaration instead.
|
|
40
|
+
const missing = (action.parameters ?? [])
|
|
41
|
+
.filter((parameter) => parameter.required)
|
|
42
|
+
.filter((parameter) => declaration.arguments?.[String(actionId)]?.[String(parameter.id)] === undefined)
|
|
43
|
+
.map((parameter) => String(parameter.id));
|
|
44
|
+
if (missing.length > 0) {
|
|
45
|
+
findings.push({
|
|
46
|
+
code: 'MISSING_ACTION_ARGUMENT',
|
|
47
|
+
message: `${action.name ?? String(actionId)} requires ${missing.join(', ')}; supply it under arguments.${String(actionId)}.`,
|
|
48
|
+
severity: 'error',
|
|
49
|
+
path: `${instance}.actions[${index}]`,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
return findings;
|
|
54
|
+
},
|
|
55
|
+
expand(declaration, context) {
|
|
56
|
+
const resolved = declaration.actions
|
|
57
|
+
.map((actionId) => ({ actionId, action: actionOf(context.graph, actionId) }))
|
|
58
|
+
.filter((entry) => entry.action !== undefined);
|
|
59
|
+
const primary = declaration.primary ?? resolved.find((entry) => !entry.action.destructive)?.actionId;
|
|
60
|
+
if (declaration.primary === undefined && primary !== undefined) {
|
|
61
|
+
context.explain(`primary action inferred as ${String(primary)}: the first action that is not destructive`);
|
|
62
|
+
}
|
|
63
|
+
const buttons = resolved.map((entry, index) => {
|
|
64
|
+
const uxRole = roleForAction(entry.action, entry.actionId === primary);
|
|
65
|
+
if (entry.action.destructive) {
|
|
66
|
+
context.explain(`${String(entry.actionId)} presented as destructive because the action declares it`);
|
|
67
|
+
}
|
|
68
|
+
const args = declaration.arguments?.[String(entry.actionId)];
|
|
69
|
+
return context.add({
|
|
70
|
+
id: context.id('button', index),
|
|
71
|
+
kind: 'button',
|
|
72
|
+
label: declaration.labels?.[String(entry.actionId)] ?? entry.action.name ?? String(entry.actionId),
|
|
73
|
+
actionId: entry.actionId,
|
|
74
|
+
...(args ? { arguments: args } : {}),
|
|
75
|
+
presentation: { uxRole },
|
|
76
|
+
}, 'button');
|
|
77
|
+
});
|
|
78
|
+
return context.add({
|
|
79
|
+
id: context.id('root'),
|
|
80
|
+
kind: 'container',
|
|
81
|
+
children: buttons,
|
|
82
|
+
presentation: {
|
|
83
|
+
uxRole: 'action-group',
|
|
84
|
+
layout: {
|
|
85
|
+
kind: 'horizontal',
|
|
86
|
+
gap: 'small',
|
|
87
|
+
align: 'center',
|
|
88
|
+
justify: declaration.alignment === 'end' ? 'end' : 'start',
|
|
89
|
+
wrap: true,
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
}, 'root');
|
|
93
|
+
},
|
|
94
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { Expression, FieldId, InputOptionsSource, NodeId } from '@cynodia/axiom-core';
|
|
2
|
+
import type { PatternText } from '../pattern.js';
|
|
3
|
+
/**
|
|
4
|
+
* A form over a record, and the pattern where inference earns the most.
|
|
5
|
+
*
|
|
6
|
+
* From `EntityDef` and `FieldDef` alone the toolkit knows every field's label, its control,
|
|
7
|
+
* whether it is required, and what order the entity declares them in. From the submit action
|
|
8
|
+
* it knows what the form commits. The author restates none of it — and cannot contradict it,
|
|
9
|
+
* which is the more important property.
|
|
10
|
+
*
|
|
11
|
+
* It covers both halves of the CRUD it is named after, and the two are different semantics
|
|
12
|
+
* rather than a flag:
|
|
13
|
+
*
|
|
14
|
+
* | | writes to | governed |
|
|
15
|
+
* | --- | --- | --- |
|
|
16
|
+
* | `draft: S` | a field of a draft state | not per keystroke — a new record is incomplete until it is committed |
|
|
17
|
+
* | `target: { state, identity }` | that field of the addressed member | per keystroke, against every hard invariant |
|
|
18
|
+
*
|
|
19
|
+
* Which one an application uses is visible in the graph: look at what the input's location is
|
|
20
|
+
* rooted in. The pattern never chooses for the author, because the choice changes what is
|
|
21
|
+
* enforced and when.
|
|
22
|
+
*/
|
|
23
|
+
/**
|
|
24
|
+
* The existing record an edit form addresses.
|
|
25
|
+
*
|
|
26
|
+
* The capability Phase 2 found missing was **addressing a collection member by expression**:
|
|
27
|
+
* an edit form needs to say "the product whose code is the one in the route", and no pattern
|
|
28
|
+
* input could. This is that, declared semantically — a state and an identity expression —
|
|
29
|
+
* rather than as application-specific JavaScript that builds the location by hand.
|
|
30
|
+
*
|
|
31
|
+
* The identity field is the entity's own `identityFieldId`; nothing else can address an
|
|
32
|
+
* instance, so nothing else is asked for.
|
|
33
|
+
*/
|
|
34
|
+
export interface EntityFormTarget {
|
|
35
|
+
/** The collection state holding the record. */
|
|
36
|
+
state: NodeId;
|
|
37
|
+
/** Which member: an expression, usually `ref(routeParameter)`. */
|
|
38
|
+
identity: Expression;
|
|
39
|
+
}
|
|
40
|
+
export interface EntityFormDeclaration {
|
|
41
|
+
pattern: 'entity-form';
|
|
42
|
+
instance: string;
|
|
43
|
+
/**
|
|
44
|
+
* Create mode: the draft state the controls write into.
|
|
45
|
+
*
|
|
46
|
+
* Exactly one of `draft` and `target` is given. That is what makes the mode unambiguous
|
|
47
|
+
* without a flag to forget.
|
|
48
|
+
*/
|
|
49
|
+
draft?: NodeId;
|
|
50
|
+
/**
|
|
51
|
+
* Edit mode: the existing collection member the controls write into.
|
|
52
|
+
*
|
|
53
|
+
* Writes go straight into canonical state, so each one is transactional against every
|
|
54
|
+
* hard invariant — a value that would break one is rolled back and the control re-renders
|
|
55
|
+
* with what is actually stored. That is the semantics an edit form wants; a create form
|
|
56
|
+
* wants a draft, because a half-filled new record is incomplete by definition.
|
|
57
|
+
*/
|
|
58
|
+
target?: EntityFormTarget;
|
|
59
|
+
/**
|
|
60
|
+
* Whether this form creates a new instance or edits an existing one.
|
|
61
|
+
*
|
|
62
|
+
* It changes exactly one thing — whether the identity field is offered — and it is explicit
|
|
63
|
+
* because inference cannot tell the two apart from a draft state alone. Phase 1 inferred
|
|
64
|
+
* "always omit the identity", which silently produced create forms that could never submit.
|
|
65
|
+
* Default `create`, which is the failure-visible direction: a redundant field is obvious,
|
|
66
|
+
* a missing required one is not.
|
|
67
|
+
*/
|
|
68
|
+
mode?: 'create' | 'edit';
|
|
69
|
+
/**
|
|
70
|
+
* A choice drawn from application data, per field id — the canonical
|
|
71
|
+
* `InputOptionsSource`. This is how a field that identifies another record is entered: a
|
|
72
|
+
* product picker, a category, a customer.
|
|
73
|
+
*
|
|
74
|
+
* Phase 2 had to hand-build a form for exactly this, because no pattern input could
|
|
75
|
+
* carry it.
|
|
76
|
+
*/
|
|
77
|
+
options?: Record<string, InputOptionsSource>;
|
|
78
|
+
/** Fields to edit, in order. Absent, every field but the identity. */
|
|
79
|
+
fields?: FieldId[];
|
|
80
|
+
/** The action the form submits. */
|
|
81
|
+
submit: NodeId;
|
|
82
|
+
/** Arguments for the submit action, keyed by action parameter id. */
|
|
83
|
+
submitArguments?: Record<string, Expression>;
|
|
84
|
+
submitLabel?: PatternText;
|
|
85
|
+
title?: PatternText;
|
|
86
|
+
/** The title's outline level. Default 2, which is the level below a page title. */
|
|
87
|
+
titleLevel?: 2 | 3 | 4 | 5 | 6;
|
|
88
|
+
description?: PatternText;
|
|
89
|
+
/** Extra controls beside the submit button — cancel, reset, a destructive action. */
|
|
90
|
+
secondaryActions?: unknown;
|
|
91
|
+
}
|
|
92
|
+
export declare const entityForm: import("../pattern.js").PatternDefinition<EntityFormDeclaration>;
|
|
93
|
+
//# sourceMappingURL=entity-form.d.ts.map
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
import { binary, field, fieldLocation, find, identitySelector, itemLocation, nodeId, ref, stateLocation, } from '@cynodia/axiom-core';
|
|
2
|
+
import { definePattern } from '../pattern.js';
|
|
3
|
+
import { actionOf, controlFor, defaultFormFields, entityOf, fieldOf, isCollection, labelFor, memberEntityId, stateOf, } from '../inference.js';
|
|
4
|
+
/**
|
|
5
|
+
* Where one control writes.
|
|
6
|
+
*
|
|
7
|
+
* Create: a field of the draft state. Edit: the same field of the addressed member, which is
|
|
8
|
+
* an ordinary `Location` — the mutation engine, the transaction and every invariant apply to
|
|
9
|
+
* it exactly as they would to a location an author wrote by hand.
|
|
10
|
+
*/
|
|
11
|
+
function fieldTarget(declaration, entity, fieldId) {
|
|
12
|
+
if (!declaration.target) {
|
|
13
|
+
return fieldLocation(stateLocation(declaration.draft), fieldId);
|
|
14
|
+
}
|
|
15
|
+
return fieldLocation(itemLocation(stateLocation(declaration.target.state), identitySelector(entity.identityFieldId, declaration.target.identity)), fieldId);
|
|
16
|
+
}
|
|
17
|
+
/** The record the form is about, as an expression. Never used to decide where a control writes. */
|
|
18
|
+
function recordExpression(declaration, entity) {
|
|
19
|
+
if (!declaration.target) {
|
|
20
|
+
return ref(declaration.draft);
|
|
21
|
+
}
|
|
22
|
+
// A scope of the pattern's own, derived from the instance so it is deterministic and
|
|
23
|
+
// cannot collide with a caller's: nothing else names a scope this way.
|
|
24
|
+
const scope = nodeId(`scope_${declaration.instance}_record`.replace(/[^a-zA-Z0-9_]/g, '_'));
|
|
25
|
+
return find(ref(declaration.target.state), scope, binary('eq', field(ref(scope), entity.identityFieldId), declaration.target.identity));
|
|
26
|
+
}
|
|
27
|
+
export const entityForm = definePattern({
|
|
28
|
+
name: 'entity-form',
|
|
29
|
+
version: '0.7.0',
|
|
30
|
+
purpose: 'A form that creates a record in a draft state or edits an existing one addressed by expression, with labels, controls, required markers and a submit action inferred from the graph.',
|
|
31
|
+
inputs: {
|
|
32
|
+
draft: {
|
|
33
|
+
kind: 'state',
|
|
34
|
+
required: false,
|
|
35
|
+
purpose: 'Create mode: the draft state the controls write into. Its entity supplies every field’s label, control and required status.',
|
|
36
|
+
inferredWhenAbsent: 'Required unless `target` is given; exactly one of the two is.',
|
|
37
|
+
},
|
|
38
|
+
target: {
|
|
39
|
+
kind: 'nodes',
|
|
40
|
+
required: false,
|
|
41
|
+
purpose: 'Edit mode: { state, identity } addressing an existing collection member — identity is an expression, usually a route parameter.',
|
|
42
|
+
inferredWhenAbsent: 'Required unless `draft` is given; exactly one of the two is.',
|
|
43
|
+
},
|
|
44
|
+
options: {
|
|
45
|
+
kind: 'nodes',
|
|
46
|
+
required: false,
|
|
47
|
+
purpose: 'A canonical InputOptionsSource per field id, for a field whose value identifies another record.',
|
|
48
|
+
inferredWhenAbsent: 'A field with no options source uses the control its declared type implies.',
|
|
49
|
+
},
|
|
50
|
+
submit: { kind: 'action', required: true, purpose: 'The action the form submits.' },
|
|
51
|
+
mode: {
|
|
52
|
+
kind: 'token',
|
|
53
|
+
required: false,
|
|
54
|
+
purpose: '"create" offers the identity field; "edit" omits it, because an identity is not editable.',
|
|
55
|
+
inferredWhenAbsent: 'edit when `target` is given, create when `draft` is. Declaring it is only needed to edit through a draft state.',
|
|
56
|
+
},
|
|
57
|
+
fields: {
|
|
58
|
+
kind: 'field-list',
|
|
59
|
+
required: false,
|
|
60
|
+
purpose: 'Which fields to edit, in order.',
|
|
61
|
+
inferredWhenAbsent: 'Every field of the entity, in declaration order, identity included — omitting a required field would produce a form that can never submit.',
|
|
62
|
+
},
|
|
63
|
+
submitArguments: { kind: 'nodes', required: false, purpose: 'Arguments for the submit action.' },
|
|
64
|
+
submitLabel: { kind: 'text', required: false, purpose: 'The submit control’s label.', inferredWhenAbsent: 'The action’s own name.' },
|
|
65
|
+
title: { kind: 'text', required: false, purpose: 'A heading above the form.' },
|
|
66
|
+
titleLevel: {
|
|
67
|
+
kind: 'token',
|
|
68
|
+
required: false,
|
|
69
|
+
purpose: 'The title’s document-outline level.',
|
|
70
|
+
inferredWhenAbsent: '2 — the level below a page title, so a page and its form do not skip a level.',
|
|
71
|
+
},
|
|
72
|
+
description: { kind: 'text', required: false, purpose: 'A caption under the heading.' },
|
|
73
|
+
secondaryActions: { kind: 'slot', required: false, purpose: 'Controls placed beside the submit button.' },
|
|
74
|
+
},
|
|
75
|
+
slots: ['secondaryActions'],
|
|
76
|
+
produces: ['form', 'input', 'button', 'container', 'text', 'diagnostic'],
|
|
77
|
+
expansion: [
|
|
78
|
+
{
|
|
79
|
+
part: 'root',
|
|
80
|
+
kind: 'form',
|
|
81
|
+
role: 'the form; submitButtonId is the generated submit, and target reads the record being edited',
|
|
82
|
+
},
|
|
83
|
+
{ part: 'title', kind: 'text', role: 'optional heading, level 2' },
|
|
84
|
+
{ part: 'description', kind: 'text', role: 'optional caption' },
|
|
85
|
+
{
|
|
86
|
+
part: 'input',
|
|
87
|
+
kind: 'input',
|
|
88
|
+
role: 'one per field, in entity declaration order; bound to the draft field or to the addressed member’s field',
|
|
89
|
+
},
|
|
90
|
+
{ part: 'diagnostic', kind: 'diagnostic', role: 'where a refusal of the submit action appears' },
|
|
91
|
+
{ part: 'actions', kind: 'container', role: 'action-group holding submit and the secondaryActions slot' },
|
|
92
|
+
{ part: 'submit', kind: 'button', role: 'the primary action' },
|
|
93
|
+
],
|
|
94
|
+
check(declaration, { graph, instance }) {
|
|
95
|
+
const findings = [];
|
|
96
|
+
if ((declaration.draft === undefined) === (declaration.target === undefined)) {
|
|
97
|
+
// Neither, or both. Either way the mode would have to be guessed, and a create form
|
|
98
|
+
// that quietly omits identity semantics is the failure this refuses to allow.
|
|
99
|
+
return [
|
|
100
|
+
{
|
|
101
|
+
code: 'FORM_TARGET_AMBIGUOUS',
|
|
102
|
+
message: declaration.draft === undefined
|
|
103
|
+
? 'A form needs either draft (to create) or target (to edit an existing record).'
|
|
104
|
+
: 'A form declares both draft and target; it writes to one place, so give one.',
|
|
105
|
+
severity: 'error',
|
|
106
|
+
path: `${instance}.${declaration.draft === undefined ? 'draft' : 'target'}`,
|
|
107
|
+
},
|
|
108
|
+
];
|
|
109
|
+
}
|
|
110
|
+
if (declaration.target && declaration.mode === 'create') {
|
|
111
|
+
findings.push({
|
|
112
|
+
code: 'MODE_CONTRADICTS_TARGET',
|
|
113
|
+
message: 'A form with a target edits an existing record; it cannot be a create form.',
|
|
114
|
+
severity: 'error',
|
|
115
|
+
path: `${instance}.mode`,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
const written = declaration.target ? declaration.target.state : declaration.draft;
|
|
119
|
+
const draft = stateOf(graph, written);
|
|
120
|
+
if (!draft) {
|
|
121
|
+
return [
|
|
122
|
+
{
|
|
123
|
+
code: declaration.target ? 'TARGET_NOT_A_STATE' : 'DRAFT_NOT_A_STATE',
|
|
124
|
+
message: `${String(written)} is not a state in this graph.`,
|
|
125
|
+
severity: 'error',
|
|
126
|
+
path: `${instance}.${declaration.target ? 'target.state' : 'draft'}`,
|
|
127
|
+
},
|
|
128
|
+
];
|
|
129
|
+
}
|
|
130
|
+
if (declaration.target && !isCollection(draft.valueType)) {
|
|
131
|
+
return [
|
|
132
|
+
{
|
|
133
|
+
code: 'TARGET_NOT_A_COLLECTION',
|
|
134
|
+
message: `${draft.name ?? String(written)} is not a collection, so it has no member to address by identity.`,
|
|
135
|
+
severity: 'error',
|
|
136
|
+
path: `${instance}.target.state`,
|
|
137
|
+
},
|
|
138
|
+
];
|
|
139
|
+
}
|
|
140
|
+
if (draft.derivation) {
|
|
141
|
+
return [
|
|
142
|
+
{
|
|
143
|
+
code: 'DRAFT_IS_DERIVED',
|
|
144
|
+
message: `${draft.name ?? String(written)} is derived and therefore read-only; a form cannot write to it.`,
|
|
145
|
+
severity: 'error',
|
|
146
|
+
path: `${instance}.${declaration.target ? 'target.state' : 'draft'}`,
|
|
147
|
+
},
|
|
148
|
+
];
|
|
149
|
+
}
|
|
150
|
+
if (draft.authority === 'server') {
|
|
151
|
+
return [
|
|
152
|
+
{
|
|
153
|
+
code: 'DRAFT_IS_SERVER_OWNED',
|
|
154
|
+
message: `${draft.name ?? String(written)} is server-authoritative, so a control may not write it. ` +
|
|
155
|
+
'Bind the form to a draft state and let the submit action commit it.',
|
|
156
|
+
severity: 'error',
|
|
157
|
+
path: `${instance}.${declaration.target ? 'target.state' : 'draft'}`,
|
|
158
|
+
},
|
|
159
|
+
];
|
|
160
|
+
}
|
|
161
|
+
// An edit form writes canonical state on purpose; only a *create* form staged in
|
|
162
|
+
// unmarked state would be checked against every invariant on every keystroke.
|
|
163
|
+
if (!declaration.target && !draft.draft) {
|
|
164
|
+
findings.push({
|
|
165
|
+
code: 'STATE_NOT_MARKED_DRAFT',
|
|
166
|
+
message: `${draft.name ?? String(written)} is not marked draft: true, so every keystroke is checked against ` +
|
|
167
|
+
'every invariant and a partially filled form will be refused.',
|
|
168
|
+
severity: 'warning',
|
|
169
|
+
path: `${instance}.draft`,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
const entityId = memberEntityId(draft.valueType);
|
|
173
|
+
const entity = entityId ? entityOf(graph, entityId) : undefined;
|
|
174
|
+
if (!entity) {
|
|
175
|
+
return [
|
|
176
|
+
{
|
|
177
|
+
code: 'DRAFT_NOT_AN_ENTITY',
|
|
178
|
+
message: `${draft.name ?? String(written)} does not hold an entity instance, so it has no fields to edit.`,
|
|
179
|
+
severity: 'error',
|
|
180
|
+
path: `${instance}.${declaration.target ? 'target.state' : 'draft'}`,
|
|
181
|
+
},
|
|
182
|
+
];
|
|
183
|
+
}
|
|
184
|
+
if (declaration.target && !entity.identityFieldId) {
|
|
185
|
+
findings.push({
|
|
186
|
+
code: 'NO_IDENTITY_FIELD',
|
|
187
|
+
message: `${entity.name ?? String(entity.id)} declares no identityFieldId, so no expression can address one of its ` +
|
|
188
|
+
'instances. Declare one, or bind the form to a draft and commit it with an action.',
|
|
189
|
+
severity: 'error',
|
|
190
|
+
path: `${instance}.target.identity`,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
(declaration.fields ?? []).forEach((fieldId, index) => {
|
|
194
|
+
if (!fieldOf(entity, fieldId)) {
|
|
195
|
+
findings.push({
|
|
196
|
+
code: 'FIELD_NOT_ON_ENTITY',
|
|
197
|
+
message: `${String(fieldId)} is not a field of ${entity.name ?? String(entity.id)}.`,
|
|
198
|
+
severity: 'error',
|
|
199
|
+
path: `${instance}.fields[${index}]`,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
for (const key of Object.keys(declaration.options ?? {})) {
|
|
204
|
+
if (!fieldOf(entity, key)) {
|
|
205
|
+
findings.push({
|
|
206
|
+
code: 'FIELD_NOT_ON_ENTITY',
|
|
207
|
+
message: `${key} is not a field of ${entity.name ?? String(entity.id)}, so it cannot take an options source.`,
|
|
208
|
+
severity: 'error',
|
|
209
|
+
path: `${instance}.options.${key}`,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
const action = actionOf(graph, declaration.submit);
|
|
214
|
+
if (!action) {
|
|
215
|
+
findings.push({
|
|
216
|
+
code: 'ACTION_NOT_FOUND',
|
|
217
|
+
message: `${String(declaration.submit)} is not an action in this graph.`,
|
|
218
|
+
severity: 'error',
|
|
219
|
+
path: `${instance}.submit`,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
else {
|
|
223
|
+
const missing = (action.parameters ?? [])
|
|
224
|
+
.filter((parameter) => parameter.required)
|
|
225
|
+
.filter((parameter) => declaration.submitArguments?.[String(parameter.id)] === undefined)
|
|
226
|
+
.map((parameter) => String(parameter.id));
|
|
227
|
+
if (missing.length > 0) {
|
|
228
|
+
findings.push({
|
|
229
|
+
code: 'MISSING_ACTION_ARGUMENT',
|
|
230
|
+
message: `${action.name ?? String(declaration.submit)} requires ${missing.join(', ')}; supply it under submitArguments.`,
|
|
231
|
+
severity: 'error',
|
|
232
|
+
path: `${instance}.submitArguments`,
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return findings;
|
|
237
|
+
},
|
|
238
|
+
expand(declaration, context) {
|
|
239
|
+
const written = declaration.target ? declaration.target.state : declaration.draft;
|
|
240
|
+
const state = stateOf(context.graph, written);
|
|
241
|
+
if (!state) {
|
|
242
|
+
throw new Error(`entity-form ${declaration.instance}: ${String(written)} disappeared between check and expansion`);
|
|
243
|
+
}
|
|
244
|
+
const entity = entityOf(context.graph, memberEntityId(state.valueType));
|
|
245
|
+
if (!entity) {
|
|
246
|
+
throw new Error(`entity-form ${declaration.instance}: entity disappeared between check and expansion`);
|
|
247
|
+
}
|
|
248
|
+
const action = actionOf(context.graph, declaration.submit);
|
|
249
|
+
// The mode is not a flag an author has to remember: a target is an existing record and a
|
|
250
|
+
// draft is a new one. `mode` only remains meaningful for editing *through* a draft.
|
|
251
|
+
const mode = declaration.target ? 'edit' : (declaration.mode ?? 'create');
|
|
252
|
+
if (declaration.target) {
|
|
253
|
+
context.explain(`edit mode: every control writes into ${state.name ?? String(written)}, addressed by ${String(entity.identityFieldId)}`);
|
|
254
|
+
}
|
|
255
|
+
const fields = declaration.fields ?? defaultFormFields(entity, mode);
|
|
256
|
+
if (!declaration.fields) {
|
|
257
|
+
context.explain(mode === 'create'
|
|
258
|
+
? `fields inferred from ${entity.name ?? String(entity.id)} in declaration order, identity included because this is a create form`
|
|
259
|
+
: `fields inferred from ${entity.name ?? String(entity.id)} in declaration order, identity ${String(entity.identityFieldId)} omitted because this is an edit form`);
|
|
260
|
+
}
|
|
261
|
+
const children = [];
|
|
262
|
+
if (declaration.title !== undefined) {
|
|
263
|
+
children.push(context.add({
|
|
264
|
+
id: context.id('title'),
|
|
265
|
+
kind: 'text',
|
|
266
|
+
value: declaration.title,
|
|
267
|
+
presentation: { textRole: 'heading', headingLevel: declaration.titleLevel ?? 2 },
|
|
268
|
+
}, 'title'));
|
|
269
|
+
}
|
|
270
|
+
if (declaration.description !== undefined) {
|
|
271
|
+
children.push(context.add({
|
|
272
|
+
id: context.id('description'),
|
|
273
|
+
kind: 'text',
|
|
274
|
+
value: declaration.description,
|
|
275
|
+
presentation: { textRole: 'caption', headingLevel: 'none', emphasis: 'subtle' },
|
|
276
|
+
}, 'description'));
|
|
277
|
+
}
|
|
278
|
+
fields.forEach((fieldId, index) => {
|
|
279
|
+
const definition = fieldOf(entity, fieldId);
|
|
280
|
+
const options = declaration.options?.[String(fieldId)];
|
|
281
|
+
// An options source decides the control: a value drawn from application data is a
|
|
282
|
+
// choice, whatever the primitive type underneath it is.
|
|
283
|
+
const control = options ? 'select' : definition ? controlFor(definition.valueType) : undefined;
|
|
284
|
+
if (options) {
|
|
285
|
+
context.explain(`${String(fieldId)} offers a choice drawn from application data`);
|
|
286
|
+
}
|
|
287
|
+
else if (control) {
|
|
288
|
+
context.explain(`${String(fieldId)} uses the ${control} control, from its declared type`);
|
|
289
|
+
}
|
|
290
|
+
children.push(context.add({
|
|
291
|
+
id: context.id('input', index),
|
|
292
|
+
kind: 'input',
|
|
293
|
+
// The location is the field of whatever the form writes: a draft state, or the
|
|
294
|
+
// addressed member of a collection. `locationRequired` in the IR then marks the
|
|
295
|
+
// control required from the field's own declaration — the author never restates
|
|
296
|
+
// it, and cannot disagree with it.
|
|
297
|
+
binding: { location: fieldTarget(declaration, entity, fieldId) },
|
|
298
|
+
...(definition && labelFor(definition) ? { label: labelFor(definition) } : {}),
|
|
299
|
+
...(options ? { options } : {}),
|
|
300
|
+
presentation: { ...(control ? { control } : {}) },
|
|
301
|
+
}, 'input'));
|
|
302
|
+
});
|
|
303
|
+
// A refusal has somewhere to appear before anyone asks. Without this the action's guards
|
|
304
|
+
// would be invisible, and an author would be tempted to duplicate them as derived state.
|
|
305
|
+
children.push(context.add({
|
|
306
|
+
id: context.id('diagnostic'),
|
|
307
|
+
kind: 'diagnostic',
|
|
308
|
+
actionId: declaration.submit,
|
|
309
|
+
presentation: { uxRole: 'error-state' },
|
|
310
|
+
}, 'diagnostic'));
|
|
311
|
+
context.explain(`a diagnostic region is generated for ${String(declaration.submit)} so a refusal is presented, not logged`);
|
|
312
|
+
const submitButton = context.add({
|
|
313
|
+
id: context.id('submit'),
|
|
314
|
+
kind: 'button',
|
|
315
|
+
label: declaration.submitLabel ?? action?.name ?? 'Save',
|
|
316
|
+
actionId: declaration.submit,
|
|
317
|
+
...(declaration.submitArguments ? { arguments: declaration.submitArguments } : {}),
|
|
318
|
+
presentation: { uxRole: 'primary-action' },
|
|
319
|
+
}, 'submit');
|
|
320
|
+
const actionChildren = [submitButton, ...context.slot('secondaryActions')];
|
|
321
|
+
children.push(context.add({
|
|
322
|
+
id: context.id('actions'),
|
|
323
|
+
kind: 'container',
|
|
324
|
+
children: actionChildren,
|
|
325
|
+
presentation: { uxRole: 'action-group', layout: { kind: 'horizontal', gap: 'small', align: 'center' } },
|
|
326
|
+
}, 'actions'));
|
|
327
|
+
return context.add({
|
|
328
|
+
id: context.id('root'),
|
|
329
|
+
kind: 'form',
|
|
330
|
+
// What the form is *about*, which is a read: an edit form is about the record it
|
|
331
|
+
// addresses, a create form about the draft being filled in.
|
|
332
|
+
target: recordExpression(declaration, entity),
|
|
333
|
+
children,
|
|
334
|
+
// The declared button is the submit control, so its arguments survive submission and
|
|
335
|
+
// presentation inference sees a form with a primary action.
|
|
336
|
+
submitButtonId: submitButton,
|
|
337
|
+
presentation: { uxRole: 'form-section', layout: { kind: 'vertical', gap: 'medium' } },
|
|
338
|
+
}, 'root');
|
|
339
|
+
},
|
|
340
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { Expression, FieldId, NodeId, ValueFormat } from '@cynodia/axiom-core';
|
|
2
|
+
/**
|
|
3
|
+
* A list of entity instances — the pattern most applications need most often.
|
|
4
|
+
*
|
|
5
|
+
* Everything below is read from the graph rather than restated by the author: which entity
|
|
6
|
+
* the collection holds, what each field is called, how a value of that type is formatted,
|
|
7
|
+
* which field is the row's identity, and whether an action is destructive. The author says
|
|
8
|
+
* *which* collection and *which* fields matter; the pattern says how a list is built.
|
|
9
|
+
*
|
|
10
|
+
* It deliberately does not infer currency from a field named `price`, or a label from an id.
|
|
11
|
+
* Those are guesses, and a guess that is usually right is worse than an omission, because an
|
|
12
|
+
* agent cannot tell the two apart.
|
|
13
|
+
*/
|
|
14
|
+
export interface EntityListDeclaration {
|
|
15
|
+
pattern: 'entity-list';
|
|
16
|
+
instance: string;
|
|
17
|
+
/** The collection state to list. */
|
|
18
|
+
source: NodeId;
|
|
19
|
+
/** Fields to show, in order. Absent, every field but the identity. */
|
|
20
|
+
fields?: FieldId[];
|
|
21
|
+
/** Per-field format, where the type cannot say it — currency, percentage. */
|
|
22
|
+
formats?: Record<string, ValueFormat>;
|
|
23
|
+
/** Actions offered on each row. Arguments are supplied per action. */
|
|
24
|
+
rowActions?: NodeId[];
|
|
25
|
+
/** Arguments per row action, evaluated in the row's scope. Use `rowRef` for the member. */
|
|
26
|
+
rowArguments?: Record<string, Record<string, Expression>>;
|
|
27
|
+
/** Caption shown when the collection is empty. */
|
|
28
|
+
emptyMessage?: string;
|
|
29
|
+
/** An action offered *from* the empty state, so it says what to do rather than only that there is nothing. */
|
|
30
|
+
emptyAction?: NodeId;
|
|
31
|
+
emptyActionArguments?: Record<string, Expression>;
|
|
32
|
+
/**
|
|
33
|
+
* Extra content inside each row, after the fields. Semantic nodes or nested patterns.
|
|
34
|
+
*
|
|
35
|
+
* This is also how a requirement the pattern never anticipated is met — a per-row badge, a
|
|
36
|
+
* warning when a value is low — without copying the generated structure or leaving the
|
|
37
|
+
* toolkit. A `conditional` node placed here is ordinary Axiom UI.
|
|
38
|
+
*/
|
|
39
|
+
rowExtra?: unknown;
|
|
40
|
+
}
|
|
41
|
+
/** The expression that names the current row inside a `repeat`: the repeat node's own id. */
|
|
42
|
+
export declare function rowRef(instance: string): Expression;
|
|
43
|
+
export declare function rowField(instance: string, fieldId: FieldId): Expression;
|
|
44
|
+
export declare const entityList: import("../pattern.js").PatternDefinition<EntityListDeclaration>;
|
|
45
|
+
//# sourceMappingURL=entity-list.d.ts.map
|