@entifix/react-integration 0.1.0-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/LICENSE +21 -0
- package/README.md +7 -0
- package/dist/context/adapters-context/adapters-context.d.ts +4 -0
- package/dist/context/adapters-context/adapters-context.d.ts.map +1 -0
- package/dist/context/adapters-context/adapters-context.js +19 -0
- package/dist/context/adapters-context/adapters-context.js.map +1 -0
- package/dist/context/adapters-context/index.d.ts +2 -0
- package/dist/context/adapters-context/index.d.ts.map +1 -0
- package/dist/context/adapters-context/index.js +3 -0
- package/dist/context/adapters-context/index.js.map +1 -0
- package/dist/hooks/use-data-loading/index.d.ts +3 -0
- package/dist/hooks/use-data-loading/index.d.ts.map +1 -0
- package/dist/hooks/use-data-loading/index.js +4 -0
- package/dist/hooks/use-data-loading/index.js.map +1 -0
- package/dist/hooks/use-data-loading/use-data-loading.d.ts +26 -0
- package/dist/hooks/use-data-loading/use-data-loading.d.ts.map +1 -0
- package/dist/hooks/use-data-loading/use-data-loading.js +167 -0
- package/dist/hooks/use-data-loading/use-data-loading.js.map +1 -0
- package/dist/hooks/use-data-loading/use-data-loading.types.d.ts +53 -0
- package/dist/hooks/use-data-loading/use-data-loading.types.d.ts.map +1 -0
- package/dist/hooks/use-data-loading/use-data-loading.types.js +3 -0
- package/dist/hooks/use-data-loading/use-data-loading.types.js.map +1 -0
- package/dist/hooks/use-entity-form/index.d.ts +4 -0
- package/dist/hooks/use-entity-form/index.d.ts.map +1 -0
- package/dist/hooks/use-entity-form/index.js +5 -0
- package/dist/hooks/use-entity-form/index.js.map +1 -0
- package/dist/hooks/use-entity-form/use-entity-form.d.ts +31 -0
- package/dist/hooks/use-entity-form/use-entity-form.d.ts.map +1 -0
- package/dist/hooks/use-entity-form/use-entity-form.helpers.d.ts +136 -0
- package/dist/hooks/use-entity-form/use-entity-form.helpers.d.ts.map +1 -0
- package/dist/hooks/use-entity-form/use-entity-form.helpers.js +337 -0
- package/dist/hooks/use-entity-form/use-entity-form.helpers.js.map +1 -0
- package/dist/hooks/use-entity-form/use-entity-form.js +209 -0
- package/dist/hooks/use-entity-form/use-entity-form.js.map +1 -0
- package/dist/hooks/use-entity-form/use-entity-form.types.d.ts +173 -0
- package/dist/hooks/use-entity-form/use-entity-form.types.d.ts.map +1 -0
- package/dist/hooks/use-entity-form/use-entity-form.types.js +3 -0
- package/dist/hooks/use-entity-form/use-entity-form.types.js.map +1 -0
- package/dist/hooks/use-entity-link-resolver/index.d.ts +2 -0
- package/dist/hooks/use-entity-link-resolver/index.d.ts.map +1 -0
- package/dist/hooks/use-entity-link-resolver/index.js +3 -0
- package/dist/hooks/use-entity-link-resolver/index.js.map +1 -0
- package/dist/hooks/use-entity-link-resolver/use-entity-link-resolver.d.ts +17 -0
- package/dist/hooks/use-entity-link-resolver/use-entity-link-resolver.d.ts.map +1 -0
- package/dist/hooks/use-entity-link-resolver/use-entity-link-resolver.js +26 -0
- package/dist/hooks/use-entity-link-resolver/use-entity-link-resolver.js.map +1 -0
- package/dist/hooks/use-entity-link-source/index.d.ts +3 -0
- package/dist/hooks/use-entity-link-source/index.d.ts.map +1 -0
- package/dist/hooks/use-entity-link-source/index.js +4 -0
- package/dist/hooks/use-entity-link-source/index.js.map +1 -0
- package/dist/hooks/use-entity-link-source/use-entity-link-source.d.ts +25 -0
- package/dist/hooks/use-entity-link-source/use-entity-link-source.d.ts.map +1 -0
- package/dist/hooks/use-entity-link-source/use-entity-link-source.js +169 -0
- package/dist/hooks/use-entity-link-source/use-entity-link-source.js.map +1 -0
- package/dist/hooks/use-entity-link-source/use-entity-link-source.types.d.ts +49 -0
- package/dist/hooks/use-entity-link-source/use-entity-link-source.types.d.ts.map +1 -0
- package/dist/hooks/use-entity-link-source/use-entity-link-source.types.js +3 -0
- package/dist/hooks/use-entity-link-source/use-entity-link-source.types.js.map +1 -0
- package/dist/hooks/use-entity-mutation/index.d.ts +3 -0
- package/dist/hooks/use-entity-mutation/index.d.ts.map +1 -0
- package/dist/hooks/use-entity-mutation/index.js +4 -0
- package/dist/hooks/use-entity-mutation/index.js.map +1 -0
- package/dist/hooks/use-entity-mutation/use-entity-mutation.d.ts +22 -0
- package/dist/hooks/use-entity-mutation/use-entity-mutation.d.ts.map +1 -0
- package/dist/hooks/use-entity-mutation/use-entity-mutation.js +88 -0
- package/dist/hooks/use-entity-mutation/use-entity-mutation.js.map +1 -0
- package/dist/hooks/use-entity-mutation/use-entity-mutation.types.d.ts +19 -0
- package/dist/hooks/use-entity-mutation/use-entity-mutation.types.d.ts.map +1 -0
- package/dist/hooks/use-entity-mutation/use-entity-mutation.types.js +3 -0
- package/dist/hooks/use-entity-mutation/use-entity-mutation.types.js.map +1 -0
- package/dist/hooks/use-entity-record/index.d.ts +3 -0
- package/dist/hooks/use-entity-record/index.d.ts.map +1 -0
- package/dist/hooks/use-entity-record/index.js +4 -0
- package/dist/hooks/use-entity-record/index.js.map +1 -0
- package/dist/hooks/use-entity-record/use-entity-record.d.ts +21 -0
- package/dist/hooks/use-entity-record/use-entity-record.d.ts.map +1 -0
- package/dist/hooks/use-entity-record/use-entity-record.js +93 -0
- package/dist/hooks/use-entity-record/use-entity-record.js.map +1 -0
- package/dist/hooks/use-entity-record/use-entity-record.types.d.ts +22 -0
- package/dist/hooks/use-entity-record/use-entity-record.types.d.ts.map +1 -0
- package/dist/hooks/use-entity-record/use-entity-record.types.js +3 -0
- package/dist/hooks/use-entity-record/use-entity-record.types.js.map +1 -0
- package/dist/hooks/use-entity-use-cases/index.d.ts +2 -0
- package/dist/hooks/use-entity-use-cases/index.d.ts.map +1 -0
- package/dist/hooks/use-entity-use-cases/index.js +3 -0
- package/dist/hooks/use-entity-use-cases/index.js.map +1 -0
- package/dist/hooks/use-entity-use-cases/use-entity-use-cases.d.ts +25 -0
- package/dist/hooks/use-entity-use-cases/use-entity-use-cases.d.ts.map +1 -0
- package/dist/hooks/use-entity-use-cases/use-entity-use-cases.js +47 -0
- package/dist/hooks/use-entity-use-cases/use-entity-use-cases.js.map +1 -0
- package/dist/hooks/use-wizard/index.d.ts +3 -0
- package/dist/hooks/use-wizard/index.d.ts.map +1 -0
- package/dist/hooks/use-wizard/index.js +4 -0
- package/dist/hooks/use-wizard/index.js.map +1 -0
- package/dist/hooks/use-wizard/use-wizard.d.ts +19 -0
- package/dist/hooks/use-wizard/use-wizard.d.ts.map +1 -0
- package/dist/hooks/use-wizard/use-wizard.js +136 -0
- package/dist/hooks/use-wizard/use-wizard.js.map +1 -0
- package/dist/hooks/use-wizard/use-wizard.types.d.ts +87 -0
- package/dist/hooks/use-wizard/use-wizard.types.d.ts.map +1 -0
- package/dist/hooks/use-wizard/use-wizard.types.js +3 -0
- package/dist/hooks/use-wizard/use-wizard.types.js.map +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +18 -0
- package/dist/index.js.map +1 -0
- package/dist/query/combine-filtering.d.ts +11 -0
- package/dist/query/combine-filtering.d.ts.map +1 -0
- package/dist/query/combine-filtering.js +18 -0
- package/dist/query/combine-filtering.js.map +1 -0
- package/dist/query/entity-query-key.d.ts +28 -0
- package/dist/query/entity-query-key.d.ts.map +1 -0
- package/dist/query/entity-query-key.js +28 -0
- package/dist/query/entity-query-key.js.map +1 -0
- package/dist/query/query-provider.d.ts +21 -0
- package/dist/query/query-provider.d.ts.map +1 -0
- package/dist/query/query-provider.js +35 -0
- package/dist/query/query-provider.js.map +1 -0
- package/dist/reactive/event-source-channel.d.ts +22 -0
- package/dist/reactive/event-source-channel.d.ts.map +1 -0
- package/dist/reactive/event-source-channel.js +93 -0
- package/dist/reactive/event-source-channel.js.map +1 -0
- package/dist/reactive/reactive-channel.d.ts +75 -0
- package/dist/reactive/reactive-channel.d.ts.map +1 -0
- package/dist/reactive/reactive-channel.js +55 -0
- package/dist/reactive/reactive-channel.js.map +1 -0
- package/dist/reactive/use-reactive-invalidation.d.ts +10 -0
- package/dist/reactive/use-reactive-invalidation.d.ts.map +1 -0
- package/dist/reactive/use-reactive-invalidation.js +25 -0
- package/dist/reactive/use-reactive-invalidation.js.map +1 -0
- package/dist/reactive/use-transaction-settlement.d.ts +41 -0
- package/dist/reactive/use-transaction-settlement.d.ts.map +1 -0
- package/dist/reactive/use-transaction-settlement.js +123 -0
- package/dist/reactive/use-transaction-settlement.js.map +1 -0
- package/package.json +73 -0
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
2
|
+
import { describeChildColumns, editableChildColumns, EntifixLogicError, EntityCollectionLink, EntityLink, isRowDraftArray, joinFieldPath, readDraftString, readRowDrafts, ROW_KEY, rowFieldPath, seededRowKey } from '@entifix/core';
|
|
3
|
+
/**
|
|
4
|
+
* A member no form writes back, whatever the draft says about it — and the only
|
|
5
|
+
* one is a read-only member, because it is the only one no editor may ever
|
|
6
|
+
* exist for.
|
|
7
|
+
*
|
|
8
|
+
* `linkCollection` used to be here too, which meant `required` on a to-many
|
|
9
|
+
* relation was silently unenforced: a rule the entity declared and the form
|
|
10
|
+
* never applied. Whether a collection has an editor yet is a question about the
|
|
11
|
+
* controls, not about what the domain insists on, so it is excluded from the
|
|
12
|
+
* *format* check below instead.
|
|
13
|
+
*/ function isNeverEdited(descriptor) {
|
|
14
|
+
return descriptor.readonly;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* A member whose *format* the metadata can judge. Both relation shapes are
|
|
18
|
+
* excluded: their draft value is a foreign key (or a joined list of them), and
|
|
19
|
+
* "is this a real id" is a question only the service can answer — but whether
|
|
20
|
+
* one is present at all is checked, which is what `required` on a relation has
|
|
21
|
+
* to mean.
|
|
22
|
+
*
|
|
23
|
+
* A `composition` is excluded here too, but it is no longer unjudged: its rows
|
|
24
|
+
* are checked one at a time by {@link validateRowDrafts}, against the child's
|
|
25
|
+
* own descriptors. What has no meaning is a *format* rule on the collection
|
|
26
|
+
* itself — there is no string to judge, only rows.
|
|
27
|
+
*
|
|
28
|
+
* `scalarCollection` is deliberately *not* excluded — its comma list is real
|
|
29
|
+
* text the user typed.
|
|
30
|
+
*/ function hasCheckableFormat(descriptor) {
|
|
31
|
+
return descriptor.type !== 'link' && descriptor.type !== 'linkCollection' && descriptor.type !== 'composition';
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* The value a field seeds with from a record.
|
|
35
|
+
*
|
|
36
|
+
* A scalar seeds as a **string**: links with their foreign key(s), dates with a
|
|
37
|
+
* `yyyy-mm-dd` value a `date` input accepts, everything else stringified.
|
|
38
|
+
*
|
|
39
|
+
* The array branch is explicit rather than left to `String(raw)`, even though
|
|
40
|
+
* the two produce the same characters today. `String(['a','b'])` is `'a,b'` by
|
|
41
|
+
* way of `Array.prototype.toString`, which is an accident: it was doing the
|
|
42
|
+
* work of a `scalarCollection` seed before the type existed, and
|
|
43
|
+
* `reconstructEntity` was handing the same string straight back as a `string`.
|
|
44
|
+
* Both halves were wrong in the same direction, so the fixed-point spec could
|
|
45
|
+
* not see it. Now the join is declared here and the split is declared there,
|
|
46
|
+
* and they are inverses on purpose.
|
|
47
|
+
*
|
|
48
|
+
* A **`composition`** is the one member that does not seed as a string, because
|
|
49
|
+
* it has no lossless string form at all — its rows seed as row drafts, each
|
|
50
|
+
* with a freshly minted key. That key is the only thing here the record does not
|
|
51
|
+
* supply, and it is minted at seed time rather than at render so that the rows a
|
|
52
|
+
* form starts with are already addressable.
|
|
53
|
+
*/ export function seedFieldValue(descriptor, entity) {
|
|
54
|
+
if (descriptor.type === 'composition') {
|
|
55
|
+
return seedRowDrafts(descriptor, entity);
|
|
56
|
+
}
|
|
57
|
+
return seedScalarValue(descriptor, entity);
|
|
58
|
+
}
|
|
59
|
+
/** {@link seedFieldValue} for every member that round-trips through a string. */ function seedScalarValue(descriptor, entity) {
|
|
60
|
+
if (entity == null) return '';
|
|
61
|
+
const raw = entity[descriptor.name];
|
|
62
|
+
if (raw == null) return '';
|
|
63
|
+
if (raw instanceof EntityLink) return raw.id == null ? '' : String(raw.id);
|
|
64
|
+
if (raw instanceof EntityCollectionLink) return raw.ids.map(String).join(',');
|
|
65
|
+
if (raw instanceof Date) return raw.toISOString().slice(0, 10);
|
|
66
|
+
if (Array.isArray(raw)) return raw.map(String).join(',');
|
|
67
|
+
return String(raw);
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* The rows a composition member seeds with.
|
|
71
|
+
*
|
|
72
|
+
* Each row is walked with the child's **own** descriptors, through the same
|
|
73
|
+
* `seedScalarValue` the master's members use — so a child's `date` seeds as
|
|
74
|
+
* `yyyy-mm-dd` on a line exactly as it would on a record, and
|
|
75
|
+
* `reconstructChild` is its inverse for the same reason `coerceFieldValue` is.
|
|
76
|
+
*
|
|
77
|
+
* A row arrives as a plain object as readily as an instance (a payload off the
|
|
78
|
+
* wire, a fixture), which is fine: nothing here asks what class it is, matching
|
|
79
|
+
* `ChildConstructor`'s rule that a child is never `instanceof`-tested.
|
|
80
|
+
*
|
|
81
|
+
* A member with no declared child seeds as no rows — there are no columns to
|
|
82
|
+
* walk, so there is nothing a row could be.
|
|
83
|
+
*/ function seedRowDrafts(descriptor, entity) {
|
|
84
|
+
if (entity == null || descriptor.childType === undefined) return [];
|
|
85
|
+
const raw = entity[descriptor.name];
|
|
86
|
+
if (!Array.isArray(raw)) return [];
|
|
87
|
+
const columns = describeChildColumns(descriptor.childType);
|
|
88
|
+
return raw.map((row, index)=>{
|
|
89
|
+
// Deterministic, so re-seeding the same record produces the same draft —
|
|
90
|
+
// see `seededRowKey` for the render loop the random one causes.
|
|
91
|
+
const draft = {
|
|
92
|
+
[ROW_KEY]: seededRowKey(index)
|
|
93
|
+
};
|
|
94
|
+
for (const column of columns){
|
|
95
|
+
draft[column.name] = seedScalarValue(column, row);
|
|
96
|
+
}
|
|
97
|
+
return draft;
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
/** Builds the initial draft for a record (or an empty one for a create). */ export function seedEntityDraft(descriptors, entity) {
|
|
101
|
+
const draft = {};
|
|
102
|
+
for (const descriptor of descriptors){
|
|
103
|
+
draft[descriptor.name] = seedFieldValue(descriptor, entity);
|
|
104
|
+
}
|
|
105
|
+
return draft;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Restores a persisted draft **over** a freshly seeded one, rather than in place
|
|
109
|
+
* of it.
|
|
110
|
+
*
|
|
111
|
+
* A draft outlives the entity that wrote it — it sits in IndexedDB across
|
|
112
|
+
* refreshes and deployments — so the two can disagree by the time it comes back.
|
|
113
|
+
* Replacing the seed outright is how that disagreement becomes a bug the user
|
|
114
|
+
* sees: a member added since the draft was written has no entry, reaches the form
|
|
115
|
+
* engine as `undefined`, and its input flips from controlled to uncontrolled
|
|
116
|
+
* mid-render. A member since removed goes the other way, surviving as a value
|
|
117
|
+
* nothing renders and `reconstructEntity` cannot assign.
|
|
118
|
+
*
|
|
119
|
+
* So the seed decides the *keys* and the draft decides the *values*: a name the
|
|
120
|
+
* entity no longer declares is dropped, and a name it declares that the draft
|
|
121
|
+
* lacks keeps its seeded value.
|
|
122
|
+
*
|
|
123
|
+
* **A value is restored only in the shape its member can hold**, which is now
|
|
124
|
+
* two shapes rather than one. A scalar takes a string; a `composition` takes a
|
|
125
|
+
* readable row list. Anything else is dropped back to its seed — a member that
|
|
126
|
+
* changed type between the write and the read, or a row list from a build that
|
|
127
|
+
* did not carry keys, restores as the record's own value instead of as
|
|
128
|
+
* something no editor can render. This is per member, so one unreadable entry
|
|
129
|
+
* never costs the user the rest of the form.
|
|
130
|
+
*
|
|
131
|
+
* This is the shape guard at the one place the shape is actually known. The
|
|
132
|
+
* store-level version check (`drafts-state.ts`) can only decide that a whole
|
|
133
|
+
* generation of drafts is unreadable; it cannot know which members one entity
|
|
134
|
+
* declares.
|
|
135
|
+
*/ export function restoreEntityDraft(descriptors, seed, persisted) {
|
|
136
|
+
if (persisted === undefined) return seed;
|
|
137
|
+
const restored = _extends({}, seed);
|
|
138
|
+
for (const descriptor of descriptors){
|
|
139
|
+
const value = persisted[descriptor.name];
|
|
140
|
+
if (value === undefined) continue;
|
|
141
|
+
if (descriptor.type === 'composition') {
|
|
142
|
+
if (isRowDraftArray(value)) restored[descriptor.name] = value;
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
if (typeof value === 'string') restored[descriptor.name] = value;
|
|
146
|
+
}
|
|
147
|
+
return restored;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Flattens the form engine's per-field state into the `name → message` map the
|
|
151
|
+
* agnostic `EntityForm` renders. Only the first message per field survives: a
|
|
152
|
+
* row shows one line, and the metadata rules already stop at the first failure.
|
|
153
|
+
*
|
|
154
|
+
* Kept structural rather than typed against the engine so the rest of this
|
|
155
|
+
* module stays independent of which form library backs the hook.
|
|
156
|
+
*/ export function readFieldErrors(fieldMeta) {
|
|
157
|
+
const errors = {};
|
|
158
|
+
for (const [name, meta] of Object.entries(fieldMeta)){
|
|
159
|
+
var _meta_errors;
|
|
160
|
+
const message = meta == null ? void 0 : (_meta_errors = meta.errors) == null ? void 0 : _meta_errors.find((entry)=>typeof entry === 'string');
|
|
161
|
+
if (typeof message === 'string') errors[name] = message;
|
|
162
|
+
}
|
|
163
|
+
return errors;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Validates a draft against what the metadata implies — `required` members must
|
|
167
|
+
* be filled, and a filled `number`/`date`/`enum` must be well-formed. Read-only
|
|
168
|
+
* members are skipped entirely; a relation is checked for presence but never
|
|
169
|
+
* for format, since its draft value is a foreign key this side cannot resolve.
|
|
170
|
+
*
|
|
171
|
+
* Metadata is only the first of three rule sources; {@link composeEntityFormErrors}
|
|
172
|
+
* is what layers a schema and the caller's own rules over this.
|
|
173
|
+
*/ export function validateEntityDraft(descriptors, values, messages) {
|
|
174
|
+
const errors = {};
|
|
175
|
+
for (const descriptor of descriptors){
|
|
176
|
+
if (isNeverEdited(descriptor)) continue;
|
|
177
|
+
if (descriptor.type === 'composition') {
|
|
178
|
+
Object.assign(errors, validateComposition(descriptor, values, messages));
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
const raw = readDraftString(values, descriptor.name);
|
|
182
|
+
if (descriptor.required && raw.trim() === '') {
|
|
183
|
+
errors[descriptor.name] = messages.required(descriptor.label);
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
if (raw === '' || !hasCheckableFormat(descriptor)) continue;
|
|
187
|
+
const message = formatMessage(descriptor, raw, messages);
|
|
188
|
+
if (message !== undefined) errors[descriptor.name] = message;
|
|
189
|
+
}
|
|
190
|
+
return errors;
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* The metadata rule for one filled scalar draft value, or `undefined` when it
|
|
194
|
+
* passes.
|
|
195
|
+
*
|
|
196
|
+
* Extracted so a row's cell is judged by the **same** rule as a record's field.
|
|
197
|
+
* Two copies is how a child's `number` member would quietly start accepting
|
|
198
|
+
* something its master's would reject.
|
|
199
|
+
*/ function formatMessage(descriptor, raw, messages) {
|
|
200
|
+
if (descriptor.type === 'number' && Number.isNaN(Number(raw))) {
|
|
201
|
+
return messages.number(descriptor.label);
|
|
202
|
+
}
|
|
203
|
+
if (descriptor.type === 'date' && Number.isNaN(new Date(raw).getTime())) {
|
|
204
|
+
return messages.date(descriptor.label);
|
|
205
|
+
}
|
|
206
|
+
if (descriptor.type === 'enum' && descriptor.enumValues && !descriptor.enumValues.includes(raw)) {
|
|
207
|
+
return messages.option(descriptor.label);
|
|
208
|
+
}
|
|
209
|
+
return undefined;
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* The rules an owned collection carries, at both of its levels.
|
|
213
|
+
*
|
|
214
|
+
* **`required` on the collection means "at least one row"** — the only thing a
|
|
215
|
+
* master can assert about a collection it owns, and a genuinely different fact
|
|
216
|
+
* from `required` on a child member, which is per row. Conflating the two would
|
|
217
|
+
* make an order with three lines, one of them blank, indistinguishable from an
|
|
218
|
+
* order with none.
|
|
219
|
+
*
|
|
220
|
+
* A row's own members are judged against the child's descriptors and reported at
|
|
221
|
+
* `items[2].quantity`, so the grid can put the message in the cell that caused
|
|
222
|
+
* it. Read-only child members are skipped for the reason they always are: no
|
|
223
|
+
* editor exists for them, so no rule the user could satisfy applies.
|
|
224
|
+
*/ function validateComposition(descriptor, values, messages) {
|
|
225
|
+
const errors = {};
|
|
226
|
+
const rows = readRowDrafts(values[descriptor.name]);
|
|
227
|
+
if (descriptor.required && rows.length === 0) {
|
|
228
|
+
errors[descriptor.name] = messages.required(descriptor.label);
|
|
229
|
+
return errors;
|
|
230
|
+
}
|
|
231
|
+
if (descriptor.childType === undefined) return errors;
|
|
232
|
+
const columns = editableChildColumns(describeChildColumns(descriptor.childType));
|
|
233
|
+
rows.forEach((row, index)=>{
|
|
234
|
+
for (const column of columns){
|
|
235
|
+
var _row_column_name;
|
|
236
|
+
if (isNeverEdited(column)) continue;
|
|
237
|
+
const raw = (_row_column_name = row[column.name]) != null ? _row_column_name : '';
|
|
238
|
+
const path = rowFieldPath(descriptor.name, index, column.name);
|
|
239
|
+
if (column.required && raw.trim() === '') {
|
|
240
|
+
errors[path] = messages.required(column.label);
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
if (raw === '') continue;
|
|
244
|
+
const message = formatMessage(column, raw, messages);
|
|
245
|
+
if (message !== undefined) errors[path] = message;
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
return errors;
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* The error-map key an issue belongs to — the **whole** path, not its head.
|
|
252
|
+
*
|
|
253
|
+
* It used to read `issue.path?.[0]`, which was right while every member was a
|
|
254
|
+
* scalar and wrong the moment one held rows: `['items', 2, 'quantity']`
|
|
255
|
+
* collapsed to `'items'`, so a rule about the third line's quantity reported
|
|
256
|
+
* itself against the collection and the grid had no way to know which cell it
|
|
257
|
+
* meant. Joining instead produces `items[2].quantity`, which is the key
|
|
258
|
+
* `validateComposition` already writes — so a schema rule and a metadata rule
|
|
259
|
+
* address the same cell the same way and `composeEntityFormErrors` can merge
|
|
260
|
+
* them.
|
|
261
|
+
*
|
|
262
|
+
* A one-segment path still yields exactly the head, so no existing rule changes
|
|
263
|
+
* meaning.
|
|
264
|
+
*/ function issueFieldName(issue) {
|
|
265
|
+
const path = issue.path;
|
|
266
|
+
if (path == null || path.length === 0) return undefined;
|
|
267
|
+
const segments = path.map((segment)=>{
|
|
268
|
+
const raw = typeof segment === 'object' && segment !== null && 'key' in segment ? segment.key : segment;
|
|
269
|
+
return typeof raw === 'number' ? raw : String(raw);
|
|
270
|
+
});
|
|
271
|
+
return joinFieldPath(segments);
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Runs a Standard Schema over the draft and sorts its issues into per-field
|
|
275
|
+
* messages plus a form-level one (an issue with no path — a cross-field rule).
|
|
276
|
+
*
|
|
277
|
+
* **Schemas are authored against the string draft**, so they coerce
|
|
278
|
+
* (`z.coerce.number()`) rather than expect typed values: the draft is what the
|
|
279
|
+
* inputs round-trip, and the same schema then validates exactly what the user
|
|
280
|
+
* sees. Only the first issue per field survives, matching how a row renders.
|
|
281
|
+
*
|
|
282
|
+
* Synchronous rules only. An async schema returns a Promise the form's
|
|
283
|
+
* synchronous validation pass cannot wait on, so it is rejected loudly rather
|
|
284
|
+
* than silently passing.
|
|
285
|
+
*/ export function readSchemaIssues(schema, values, translateIssue) {
|
|
286
|
+
const result = schema['~standard'].validate(values);
|
|
287
|
+
if (result instanceof Promise) {
|
|
288
|
+
throw new EntifixLogicError('useEntityForm cannot run an async schema: its validation pass is ' + 'synchronous. Move the asynchronous rule into the `validate` option.');
|
|
289
|
+
}
|
|
290
|
+
if (!result.issues) return {
|
|
291
|
+
fields: {}
|
|
292
|
+
};
|
|
293
|
+
const fields = {};
|
|
294
|
+
let form;
|
|
295
|
+
for (const issue of result.issues){
|
|
296
|
+
var _fields, _name, _;
|
|
297
|
+
const name = issueFieldName(issue);
|
|
298
|
+
const message = translateIssue(issue.message, name);
|
|
299
|
+
if (name === undefined) form != null ? form : form = message;
|
|
300
|
+
else (_ = (_fields = fields)[_name = name]) != null ? _ : _fields[_name] = message;
|
|
301
|
+
}
|
|
302
|
+
return {
|
|
303
|
+
fields,
|
|
304
|
+
form
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* The single composition point for a draft's errors: metadata rules first, then
|
|
309
|
+
* the entity's schema, then the caller's `validate` callback. Later sources win
|
|
310
|
+
* on conflict, so a caller can always override a message it disagrees with.
|
|
311
|
+
*/ export function composeEntityFormErrors({ descriptors, values, messages, schema, translateIssue, validate }) {
|
|
312
|
+
const metadata = validateEntityDraft(descriptors, values, messages);
|
|
313
|
+
const schemaIssues = schema ? readSchemaIssues(schema, values, translateIssue) : {
|
|
314
|
+
fields: {},
|
|
315
|
+
form: undefined
|
|
316
|
+
};
|
|
317
|
+
return {
|
|
318
|
+
fields: _extends({}, metadata, schemaIssues.fields, validate == null ? void 0 : validate(values)),
|
|
319
|
+
form: schemaIssues.form
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* What a caller who supplies no {@link ValidationMessages} gets: the catalog key
|
|
324
|
+
* itself, with the field appended.
|
|
325
|
+
*
|
|
326
|
+
* Honest rather than English — there is no catalog in this package to resolve
|
|
327
|
+
* against — and visible enough in a form that a host notices it has wired no
|
|
328
|
+
* copy. It sits beside the validator rather than in `.types.ts`, which this
|
|
329
|
+
* repository keeps type-only.
|
|
330
|
+
*/ export const DEFAULT_VALIDATION_MESSAGES = {
|
|
331
|
+
required: (field)=>`validation.required:${field}`,
|
|
332
|
+
number: (field)=>`validation.number:${field}`,
|
|
333
|
+
date: (field)=>`validation.date:${field}`,
|
|
334
|
+
option: (field)=>`validation.option:${field}`
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
//# sourceMappingURL=use-entity-form.helpers.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../src/hooks/use-entity-form/use-entity-form.helpers.ts"],"sourcesContent":["import {\n describeChildColumns,\n editableChildColumns,\n EntifixLogicError,\n EntityCollectionLink,\n type EntityDraft,\n type EntityDraftValue,\n type EntityFieldDescriptor,\n EntityLink,\n type EntityRowDraft,\n isRowDraftArray,\n joinFieldPath,\n readDraftString,\n readRowDrafts,\n ROW_KEY,\n rowFieldPath,\n seededRowKey,\n type StandardSchemaV1,\n type StandardSchemaV1Issue,\n} from '@entifix/core';\n\n/**\n * A member no form writes back, whatever the draft says about it — and the only\n * one is a read-only member, because it is the only one no editor may ever\n * exist for.\n *\n * `linkCollection` used to be here too, which meant `required` on a to-many\n * relation was silently unenforced: a rule the entity declared and the form\n * never applied. Whether a collection has an editor yet is a question about the\n * controls, not about what the domain insists on, so it is excluded from the\n * *format* check below instead.\n */\nfunction isNeverEdited(descriptor: EntityFieldDescriptor): boolean {\n return descriptor.readonly;\n}\n\n/**\n * A member whose *format* the metadata can judge. Both relation shapes are\n * excluded: their draft value is a foreign key (or a joined list of them), and\n * \"is this a real id\" is a question only the service can answer — but whether\n * one is present at all is checked, which is what `required` on a relation has\n * to mean.\n *\n * A `composition` is excluded here too, but it is no longer unjudged: its rows\n * are checked one at a time by {@link validateRowDrafts}, against the child's\n * own descriptors. What has no meaning is a *format* rule on the collection\n * itself — there is no string to judge, only rows.\n *\n * `scalarCollection` is deliberately *not* excluded — its comma list is real\n * text the user typed.\n */\nfunction hasCheckableFormat(descriptor: EntityFieldDescriptor): boolean {\n return (\n descriptor.type !== 'link' &&\n descriptor.type !== 'linkCollection' &&\n descriptor.type !== 'composition'\n );\n}\n\n/**\n * The value a field seeds with from a record.\n *\n * A scalar seeds as a **string**: links with their foreign key(s), dates with a\n * `yyyy-mm-dd` value a `date` input accepts, everything else stringified.\n *\n * The array branch is explicit rather than left to `String(raw)`, even though\n * the two produce the same characters today. `String(['a','b'])` is `'a,b'` by\n * way of `Array.prototype.toString`, which is an accident: it was doing the\n * work of a `scalarCollection` seed before the type existed, and\n * `reconstructEntity` was handing the same string straight back as a `string`.\n * Both halves were wrong in the same direction, so the fixed-point spec could\n * not see it. Now the join is declared here and the split is declared there,\n * and they are inverses on purpose.\n *\n * A **`composition`** is the one member that does not seed as a string, because\n * it has no lossless string form at all — its rows seed as row drafts, each\n * with a freshly minted key. That key is the only thing here the record does not\n * supply, and it is minted at seed time rather than at render so that the rows a\n * form starts with are already addressable.\n */\nexport function seedFieldValue(\n descriptor: EntityFieldDescriptor,\n entity: unknown,\n): EntityDraftValue {\n if (descriptor.type === 'composition') {\n return seedRowDrafts(descriptor, entity);\n }\n return seedScalarValue(descriptor, entity);\n}\n\n/** {@link seedFieldValue} for every member that round-trips through a string. */\nfunction seedScalarValue(\n descriptor: EntityFieldDescriptor,\n entity: unknown,\n): string {\n if (entity == null) return '';\n const raw = (entity as Record<string, unknown>)[descriptor.name];\n if (raw == null) return '';\n if (raw instanceof EntityLink) return raw.id == null ? '' : String(raw.id);\n if (raw instanceof EntityCollectionLink) return raw.ids.map(String).join(',');\n if (raw instanceof Date) return raw.toISOString().slice(0, 10);\n if (Array.isArray(raw)) return raw.map(String).join(',');\n return String(raw);\n}\n\n/**\n * The rows a composition member seeds with.\n *\n * Each row is walked with the child's **own** descriptors, through the same\n * `seedScalarValue` the master's members use — so a child's `date` seeds as\n * `yyyy-mm-dd` on a line exactly as it would on a record, and\n * `reconstructChild` is its inverse for the same reason `coerceFieldValue` is.\n *\n * A row arrives as a plain object as readily as an instance (a payload off the\n * wire, a fixture), which is fine: nothing here asks what class it is, matching\n * `ChildConstructor`'s rule that a child is never `instanceof`-tested.\n *\n * A member with no declared child seeds as no rows — there are no columns to\n * walk, so there is nothing a row could be.\n */\nfunction seedRowDrafts(\n descriptor: EntityFieldDescriptor,\n entity: unknown,\n): EntityRowDraft[] {\n if (entity == null || descriptor.childType === undefined) return [];\n const raw = (entity as Record<string, unknown>)[descriptor.name];\n if (!Array.isArray(raw)) return [];\n\n const columns = describeChildColumns(descriptor.childType);\n\n return raw.map((row, index) => {\n // Deterministic, so re-seeding the same record produces the same draft —\n // see `seededRowKey` for the render loop the random one causes.\n const draft: EntityRowDraft = { [ROW_KEY]: seededRowKey(index) };\n for (const column of columns) {\n draft[column.name] = seedScalarValue(column, row);\n }\n return draft;\n });\n}\n\n/** Builds the initial draft for a record (or an empty one for a create). */\nexport function seedEntityDraft(\n descriptors: readonly EntityFieldDescriptor[],\n entity: unknown,\n): EntityDraft {\n const draft: EntityDraft = {};\n for (const descriptor of descriptors) {\n draft[descriptor.name] = seedFieldValue(descriptor, entity);\n }\n return draft;\n}\n\n/**\n * Restores a persisted draft **over** a freshly seeded one, rather than in place\n * of it.\n *\n * A draft outlives the entity that wrote it — it sits in IndexedDB across\n * refreshes and deployments — so the two can disagree by the time it comes back.\n * Replacing the seed outright is how that disagreement becomes a bug the user\n * sees: a member added since the draft was written has no entry, reaches the form\n * engine as `undefined`, and its input flips from controlled to uncontrolled\n * mid-render. A member since removed goes the other way, surviving as a value\n * nothing renders and `reconstructEntity` cannot assign.\n *\n * So the seed decides the *keys* and the draft decides the *values*: a name the\n * entity no longer declares is dropped, and a name it declares that the draft\n * lacks keeps its seeded value.\n *\n * **A value is restored only in the shape its member can hold**, which is now\n * two shapes rather than one. A scalar takes a string; a `composition` takes a\n * readable row list. Anything else is dropped back to its seed — a member that\n * changed type between the write and the read, or a row list from a build that\n * did not carry keys, restores as the record's own value instead of as\n * something no editor can render. This is per member, so one unreadable entry\n * never costs the user the rest of the form.\n *\n * This is the shape guard at the one place the shape is actually known. The\n * store-level version check (`drafts-state.ts`) can only decide that a whole\n * generation of drafts is unreadable; it cannot know which members one entity\n * declares.\n */\nexport function restoreEntityDraft(\n descriptors: readonly EntityFieldDescriptor[],\n seed: EntityDraft,\n persisted: EntityDraft | undefined,\n): EntityDraft {\n if (persisted === undefined) return seed;\n\n const restored: EntityDraft = { ...seed };\n for (const descriptor of descriptors) {\n const value = persisted[descriptor.name];\n if (value === undefined) continue;\n\n if (descriptor.type === 'composition') {\n if (isRowDraftArray(value)) restored[descriptor.name] = value;\n continue;\n }\n if (typeof value === 'string') restored[descriptor.name] = value;\n }\n return restored;\n}\n\n/** The slice of a form field's state this module reads: its error list. */\nexport interface FieldErrorMeta {\n errors?: readonly unknown[];\n}\n\n/**\n * Flattens the form engine's per-field state into the `name → message` map the\n * agnostic `EntityForm` renders. Only the first message per field survives: a\n * row shows one line, and the metadata rules already stop at the first failure.\n *\n * Kept structural rather than typed against the engine so the rest of this\n * module stays independent of which form library backs the hook.\n */\nexport function readFieldErrors(\n fieldMeta: Partial<Record<string, FieldErrorMeta | undefined>>,\n): Record<string, string> {\n const errors: Record<string, string> = {};\n\n for (const [name, meta] of Object.entries(fieldMeta)) {\n const message = meta?.errors?.find(entry => typeof entry === 'string');\n if (typeof message === 'string') errors[name] = message;\n }\n\n return errors;\n}\n\n/**\n * The four metadata-derived messages, already localized. Taken as an argument\n * rather than built here so this stays a pure function — and because the\n * sentence cannot be assembled from a label plus a hardcoded suffix: word order\n * differs between locales.\n */\nexport interface EntityDraftMessages {\n required(field: string): string;\n number(field: string): string;\n date(field: string): string;\n option(field: string): string;\n}\n\n/**\n * Validates a draft against what the metadata implies — `required` members must\n * be filled, and a filled `number`/`date`/`enum` must be well-formed. Read-only\n * members are skipped entirely; a relation is checked for presence but never\n * for format, since its draft value is a foreign key this side cannot resolve.\n *\n * Metadata is only the first of three rule sources; {@link composeEntityFormErrors}\n * is what layers a schema and the caller's own rules over this.\n */\nexport function validateEntityDraft(\n descriptors: readonly EntityFieldDescriptor[],\n values: EntityDraft,\n messages: EntityDraftMessages,\n): Record<string, string> {\n const errors: Record<string, string> = {};\n\n for (const descriptor of descriptors) {\n if (isNeverEdited(descriptor)) continue;\n\n if (descriptor.type === 'composition') {\n Object.assign(errors, validateComposition(descriptor, values, messages));\n continue;\n }\n\n const raw = readDraftString(values, descriptor.name);\n\n if (descriptor.required && raw.trim() === '') {\n errors[descriptor.name] = messages.required(descriptor.label);\n continue;\n }\n if (raw === '' || !hasCheckableFormat(descriptor)) continue;\n\n const message = formatMessage(descriptor, raw, messages);\n if (message !== undefined) errors[descriptor.name] = message;\n }\n\n return errors;\n}\n\n/**\n * The metadata rule for one filled scalar draft value, or `undefined` when it\n * passes.\n *\n * Extracted so a row's cell is judged by the **same** rule as a record's field.\n * Two copies is how a child's `number` member would quietly start accepting\n * something its master's would reject.\n */\nfunction formatMessage(\n descriptor: EntityFieldDescriptor,\n raw: string,\n messages: EntityDraftMessages,\n): string | undefined {\n if (descriptor.type === 'number' && Number.isNaN(Number(raw))) {\n return messages.number(descriptor.label);\n }\n if (descriptor.type === 'date' && Number.isNaN(new Date(raw).getTime())) {\n return messages.date(descriptor.label);\n }\n if (\n descriptor.type === 'enum' &&\n descriptor.enumValues &&\n !descriptor.enumValues.includes(raw)\n ) {\n return messages.option(descriptor.label);\n }\n return undefined;\n}\n\n/**\n * The rules an owned collection carries, at both of its levels.\n *\n * **`required` on the collection means \"at least one row\"** — the only thing a\n * master can assert about a collection it owns, and a genuinely different fact\n * from `required` on a child member, which is per row. Conflating the two would\n * make an order with three lines, one of them blank, indistinguishable from an\n * order with none.\n *\n * A row's own members are judged against the child's descriptors and reported at\n * `items[2].quantity`, so the grid can put the message in the cell that caused\n * it. Read-only child members are skipped for the reason they always are: no\n * editor exists for them, so no rule the user could satisfy applies.\n */\nfunction validateComposition(\n descriptor: EntityFieldDescriptor,\n values: EntityDraft,\n messages: EntityDraftMessages,\n): Record<string, string> {\n const errors: Record<string, string> = {};\n const rows = readRowDrafts(values[descriptor.name]);\n\n if (descriptor.required && rows.length === 0) {\n errors[descriptor.name] = messages.required(descriptor.label);\n return errors;\n }\n if (descriptor.childType === undefined) return errors;\n\n const columns = editableChildColumns(\n describeChildColumns(descriptor.childType),\n );\n\n rows.forEach((row, index) => {\n for (const column of columns) {\n if (isNeverEdited(column)) continue;\n\n const raw = row[column.name] ?? '';\n const path = rowFieldPath(descriptor.name, index, column.name);\n\n if (column.required && raw.trim() === '') {\n errors[path] = messages.required(column.label);\n continue;\n }\n if (raw === '') continue;\n\n const message = formatMessage(column, raw, messages);\n if (message !== undefined) errors[path] = message;\n }\n });\n\n return errors;\n}\n\n/**\n * The error-map key an issue belongs to — the **whole** path, not its head.\n *\n * It used to read `issue.path?.[0]`, which was right while every member was a\n * scalar and wrong the moment one held rows: `['items', 2, 'quantity']`\n * collapsed to `'items'`, so a rule about the third line's quantity reported\n * itself against the collection and the grid had no way to know which cell it\n * meant. Joining instead produces `items[2].quantity`, which is the key\n * `validateComposition` already writes — so a schema rule and a metadata rule\n * address the same cell the same way and `composeEntityFormErrors` can merge\n * them.\n *\n * A one-segment path still yields exactly the head, so no existing rule changes\n * meaning.\n */\nfunction issueFieldName(issue: StandardSchemaV1Issue): string | undefined {\n const path = issue.path;\n if (path == null || path.length === 0) return undefined;\n\n const segments = path.map(segment => {\n const raw =\n typeof segment === 'object' && segment !== null && 'key' in segment\n ? segment.key\n : segment;\n return typeof raw === 'number' ? raw : String(raw);\n });\n\n return joinFieldPath(segments);\n}\n\n/**\n * Runs a Standard Schema over the draft and sorts its issues into per-field\n * messages plus a form-level one (an issue with no path — a cross-field rule).\n *\n * **Schemas are authored against the string draft**, so they coerce\n * (`z.coerce.number()`) rather than expect typed values: the draft is what the\n * inputs round-trip, and the same schema then validates exactly what the user\n * sees. Only the first issue per field survives, matching how a row renders.\n *\n * Synchronous rules only. An async schema returns a Promise the form's\n * synchronous validation pass cannot wait on, so it is rejected loudly rather\n * than silently passing.\n */\nexport function readSchemaIssues(\n schema: StandardSchemaV1,\n values: EntityDraft,\n translateIssue: (message: string, field: string | undefined) => string,\n): { fields: Record<string, string>; form?: string } {\n const result = schema['~standard'].validate(values);\n\n if (result instanceof Promise) {\n throw new EntifixLogicError(\n 'useEntityForm cannot run an async schema: its validation pass is ' +\n 'synchronous. Move the asynchronous rule into the `validate` option.',\n );\n }\n if (!result.issues) return { fields: {} };\n\n const fields: Record<string, string> = {};\n let form: string | undefined;\n\n for (const issue of result.issues) {\n const name = issueFieldName(issue);\n const message = translateIssue(issue.message, name);\n\n if (name === undefined) form ??= message;\n else fields[name] ??= message;\n }\n\n return { fields, form };\n}\n\n/** Everything {@link composeEntityFormErrors} needs to judge one draft. */\nexport interface ComposeEntityFormErrorsOptions {\n descriptors: readonly EntityFieldDescriptor[];\n values: EntityDraft;\n messages: EntityDraftMessages;\n schema?: StandardSchemaV1;\n translateIssue: (message: string, field: string | undefined) => string;\n validate?: (values: EntityDraft) => Record<string, string>;\n}\n\n/**\n * The single composition point for a draft's errors: metadata rules first, then\n * the entity's schema, then the caller's `validate` callback. Later sources win\n * on conflict, so a caller can always override a message it disagrees with.\n */\nexport function composeEntityFormErrors({\n descriptors,\n values,\n messages,\n schema,\n translateIssue,\n validate,\n}: ComposeEntityFormErrorsOptions): {\n fields: Record<string, string>;\n form?: string;\n} {\n const metadata = validateEntityDraft(descriptors, values, messages);\n const schemaIssues = schema\n ? readSchemaIssues(schema, values, translateIssue)\n : { fields: {}, form: undefined };\n\n return {\n fields: { ...metadata, ...schemaIssues.fields, ...validate?.(values) },\n form: schemaIssues.form,\n };\n}\n\n/**\n * What a caller who supplies no {@link ValidationMessages} gets: the catalog key\n * itself, with the field appended.\n *\n * Honest rather than English — there is no catalog in this package to resolve\n * against — and visible enough in a form that a host notices it has wired no\n * copy. It sits beside the validator rather than in `.types.ts`, which this\n * repository keeps type-only.\n */\nexport const DEFAULT_VALIDATION_MESSAGES: EntityDraftMessages = {\n required: field => `validation.required:${field}`,\n number: field => `validation.number:${field}`,\n date: field => `validation.date:${field}`,\n option: field => `validation.option:${field}`,\n};\n"],"names":["describeChildColumns","editableChildColumns","EntifixLogicError","EntityCollectionLink","EntityLink","isRowDraftArray","joinFieldPath","readDraftString","readRowDrafts","ROW_KEY","rowFieldPath","seededRowKey","isNeverEdited","descriptor","readonly","hasCheckableFormat","type","seedFieldValue","entity","seedRowDrafts","seedScalarValue","raw","name","id","String","ids","map","join","Date","toISOString","slice","Array","isArray","childType","undefined","columns","row","index","draft","column","seedEntityDraft","descriptors","restoreEntityDraft","seed","persisted","restored","value","readFieldErrors","fieldMeta","errors","meta","Object","entries","message","find","entry","validateEntityDraft","values","messages","assign","validateComposition","required","trim","label","formatMessage","Number","isNaN","number","getTime","date","enumValues","includes","option","rows","length","forEach","path","issueFieldName","issue","segments","segment","key","readSchemaIssues","schema","translateIssue","result","validate","Promise","issues","fields","form","composeEntityFormErrors","metadata","schemaIssues","DEFAULT_VALIDATION_MESSAGES","field"],"mappings":"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"}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
3
|
+
import { describeEntityColumns, seedEntityLinkSelection } from '@entifix/core';
|
|
4
|
+
import { revalidateLogic, useForm, useStore } from '@tanstack/react-form';
|
|
5
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
6
|
+
import { composeEntityFormErrors, DEFAULT_VALIDATION_MESSAGES, readFieldErrors, restoreEntityDraft, seedEntityDraft } from './use-entity-form.helpers.js';
|
|
7
|
+
/**
|
|
8
|
+
* The write half of an entity form: holds the string draft and reports
|
|
9
|
+
* metadata-derived validation, the counterpart to {@link useEntityRecord}
|
|
10
|
+
* (which reads the record it seeds from).
|
|
11
|
+
*
|
|
12
|
+
* It stays out of the transport: it neither fetches nor saves. `onSubmit`
|
|
13
|
+
* receives the validated draft, and the per-entity wrapper reconstructs the
|
|
14
|
+
* domain entity and calls the mutation. The result plugs straight into the
|
|
15
|
+
* agnostic `EntityForm` (`values`/`onFieldChange`/`errors`/`onSubmit`).
|
|
16
|
+
*
|
|
17
|
+
* TanStack Form owns the draft and the submission lifecycle; that is an
|
|
18
|
+
* implementation detail, and the returned shape is deliberately the plain
|
|
19
|
+
* `values`/`errors`/`setField`/`submit`/`isDirty` facade so `EntityForm` — which
|
|
20
|
+
* renders native inputs and knows nothing about any form library — stays
|
|
21
|
+
* agnostic. Fields are therefore never registered through `form.Field`; a
|
|
22
|
+
* form-level validator reporting `{ fields }` reaches them anyway, because
|
|
23
|
+
* `FormApi` creates meta for any field name an error arrives for.
|
|
24
|
+
*
|
|
25
|
+
* `revalidateLogic` is what makes a pristine form quiet: nothing validates until
|
|
26
|
+
* the first submit, and every keystroke revalidates after it. Running one
|
|
27
|
+
* validator under a single cause also keeps a fixed field from holding a stale
|
|
28
|
+
* message left behind by another cause.
|
|
29
|
+
*
|
|
30
|
+
* The seed applies once, so a caller that loads its record after mount keys the
|
|
31
|
+
* form by the record id to reseed — the same convention `EntityForm`'s wrappers
|
|
32
|
+
* already follow.
|
|
33
|
+
*/ export function useEntityForm({ entityConstructor, entity, draft, fields, schema, validate, validationMessages, translateKey, onSubmit }) {
|
|
34
|
+
// Keyed on the **names**, not the array, so a caller may write the list inline
|
|
35
|
+
// — which every wizard step does, since its field set is part of its own
|
|
36
|
+
// declaration rather than something worth hoisting.
|
|
37
|
+
const scope = fields == null ? void 0 : fields.join(',');
|
|
38
|
+
const descriptors = useMemo(()=>{
|
|
39
|
+
const described = describeEntityColumns(entityConstructor, entity);
|
|
40
|
+
return scope === undefined ? described : described.filter((descriptor)=>scope.split(',').includes(descriptor.name));
|
|
41
|
+
}, [
|
|
42
|
+
entityConstructor,
|
|
43
|
+
entity,
|
|
44
|
+
scope
|
|
45
|
+
]);
|
|
46
|
+
// A persisted draft is layered over the seed, never substituted for it: the
|
|
47
|
+
// entity decides which members exist, the draft only decides their values.
|
|
48
|
+
// See `restoreEntityDraft` for what a straight substitution costs.
|
|
49
|
+
const restored = draft == null ? void 0 : draft.draft;
|
|
50
|
+
const seed = useMemo(()=>restoreEntityDraft(descriptors, seedEntityDraft(descriptors, entity), restored), [
|
|
51
|
+
descriptors,
|
|
52
|
+
entity,
|
|
53
|
+
restored
|
|
54
|
+
]);
|
|
55
|
+
// The relations the record already carries, so opening a loaded record needs no
|
|
56
|
+
// label lookup and a save keeps whatever embedded shape it arrived in. Seeded
|
|
57
|
+
// once, like the draft — a caller that loads late keys the form by record id.
|
|
58
|
+
const [links, setLinks] = useState(()=>seedEntityLinkSelection(descriptors, entity));
|
|
59
|
+
// Supplied by the caller, and that is the seam.
|
|
60
|
+
//
|
|
61
|
+
// ⚠️ This used to reach into react-i18next and `@entifix/i18n` directly,
|
|
62
|
+
// because both React packages are `entifix:react` and the boundary rule
|
|
63
|
+
// forbids a sideways import of the controls package that owns the `controls`
|
|
64
|
+
// catalog. The effect was that anyone taking the integration hooks also took
|
|
65
|
+
// i18next and a Spanish catalog — the composition defect the tier register
|
|
66
|
+
// was written to catch. The one real caller, `entity-crud-form.tsx`, sits a
|
|
67
|
+
// layer up where `useT('controls')` is reachable, so it resolves the four
|
|
68
|
+
// sentences and hands them over.
|
|
69
|
+
const messages = useMemo(()=>validationMessages != null ? validationMessages : DEFAULT_VALIDATION_MESSAGES, [
|
|
70
|
+
validationMessages
|
|
71
|
+
]);
|
|
72
|
+
// A schema's message is authored as a catalog key (`validation.minLength`),
|
|
73
|
+
// because a rule written in one language would reach the user untranslated —
|
|
74
|
+
// the one thing the i18n gate exists to prevent. `defaultValue` keeps an
|
|
75
|
+
// unkeyed literal readable instead of rendering the key back at the user, and
|
|
76
|
+
// `field` is offered as a parameter so a message can name its own label.
|
|
77
|
+
// Supplied by the caller for the same reason the four sentences above are —
|
|
78
|
+
// and this one is `useTranslateKey`'s job rather than `useT`'s, because a
|
|
79
|
+
// schema's message is a key only known at runtime. With nothing supplied the
|
|
80
|
+
// message renders as authored, which is what `defaultValue` always did.
|
|
81
|
+
const translateIssue = useCallback((message, field)=>{
|
|
82
|
+
var _ref;
|
|
83
|
+
var _descriptors_find;
|
|
84
|
+
return translateKey === undefined ? message : translateKey(message, {
|
|
85
|
+
defaultValue: message,
|
|
86
|
+
field: (_ref = (_descriptors_find = descriptors.find((entry)=>entry.name === field)) == null ? void 0 : _descriptors_find.label) != null ? _ref : field
|
|
87
|
+
});
|
|
88
|
+
}, [
|
|
89
|
+
translateKey,
|
|
90
|
+
descriptors
|
|
91
|
+
]);
|
|
92
|
+
/**
|
|
93
|
+
* Whether the last {@link submit} reached the caller's `onSubmit`.
|
|
94
|
+
*
|
|
95
|
+
* A ref rather than state: nothing renders from it, and making it state would
|
|
96
|
+
* re-render the whole form on every submit attempt for a value only the
|
|
97
|
+
* caller awaiting `submit()` ever reads.
|
|
98
|
+
*/ const submitted = useRef(false);
|
|
99
|
+
const form = useForm({
|
|
100
|
+
defaultValues: seed,
|
|
101
|
+
// Quiet until the first submit, then revalidate on every change — the
|
|
102
|
+
// behaviour the hand-rolled `submitted` flag used to produce.
|
|
103
|
+
validationLogic: revalidateLogic(),
|
|
104
|
+
validators: {
|
|
105
|
+
onDynamic: ({ value })=>{
|
|
106
|
+
const { fields, form } = composeEntityFormErrors({
|
|
107
|
+
descriptors,
|
|
108
|
+
values: value,
|
|
109
|
+
messages,
|
|
110
|
+
schema,
|
|
111
|
+
translateIssue,
|
|
112
|
+
validate
|
|
113
|
+
});
|
|
114
|
+
if (Object.keys(fields).length === 0 && form === undefined) return undefined;
|
|
115
|
+
// `{ fields }` is how a form-level validator addresses individual
|
|
116
|
+
// fields; the bare `form` message is what has no field to sit under.
|
|
117
|
+
return {
|
|
118
|
+
form,
|
|
119
|
+
fields
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
},
|
|
123
|
+
onSubmit: ({ value })=>{
|
|
124
|
+
// The engine calls this only when validation passed, so recording that it
|
|
125
|
+
// ran *is* the answer {@link UseEntityFormResult.submit} returns. Reading
|
|
126
|
+
// `form.state.isValid` afterwards would ask the engine a second question
|
|
127
|
+
// whose answer depends on when it settled.
|
|
128
|
+
submitted.current = true;
|
|
129
|
+
return onSubmit(value);
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
const values = useStore(form.store, (state)=>state.values);
|
|
133
|
+
const fieldMeta = useStore(form.store, (state)=>state.fieldMeta);
|
|
134
|
+
const formError = useStore(form.store, (state)=>state.errorMap.onDynamic);
|
|
135
|
+
// `isDefaultValue`, not `isDirty`: the draft is dirty when it *differs* from
|
|
136
|
+
// its seed, so editing a field and typing the seed value back is not a change
|
|
137
|
+
// the workspace should autosave.
|
|
138
|
+
const isDefaultValue = useStore(form.store, (state)=>state.isDefaultValue);
|
|
139
|
+
const errors = useMemo(()=>readFieldErrors(fieldMeta), [
|
|
140
|
+
fieldMeta
|
|
141
|
+
]);
|
|
142
|
+
/**
|
|
143
|
+
* Validate, then submit when clean — and **say whether it went**.
|
|
144
|
+
*
|
|
145
|
+
* The promise is what lets a caller gate on the result. A wizard's "Siguiente"
|
|
146
|
+
* is the step's own submit, and before this it had nothing to wait for:
|
|
147
|
+
* `handleSubmit()`'s promise was discarded, so the only way to learn the
|
|
148
|
+
* outcome was to await a tick and re-read `errors`, which is the trap this
|
|
149
|
+
* hook's own specs worked around.
|
|
150
|
+
*/ const submit = useCallback(async ()=>{
|
|
151
|
+
submitted.current = false;
|
|
152
|
+
await form.handleSubmit();
|
|
153
|
+
return submitted.current;
|
|
154
|
+
}, [
|
|
155
|
+
form
|
|
156
|
+
]);
|
|
157
|
+
// Both halves of a pick, in one call: the id the draft persists and the
|
|
158
|
+
// instance only memory holds.
|
|
159
|
+
const setLink = useCallback((name, picked)=>{
|
|
160
|
+
setLinks((current)=>_extends({}, current, {
|
|
161
|
+
[name]: picked
|
|
162
|
+
}));
|
|
163
|
+
form.setFieldValue(name, (picked == null ? void 0 : picked.id) == null ? '' : String(picked.id));
|
|
164
|
+
}, [
|
|
165
|
+
form
|
|
166
|
+
]);
|
|
167
|
+
// The sidecar half alone. A restored draft holds the id and lost the instance,
|
|
168
|
+
// and something resolved it back — that is not a user pick, so it must not
|
|
169
|
+
// touch the draft: writing the field would be an edit the user did not make,
|
|
170
|
+
// and on a form whose seed *is* the restored draft it would also be a no-op
|
|
171
|
+
// that only risks disagreeing with the engine about equality.
|
|
172
|
+
const hydrateLink = useCallback((name, resolved)=>{
|
|
173
|
+
setLinks((current)=>current[name] === undefined ? _extends({}, current, {
|
|
174
|
+
[name]: resolved
|
|
175
|
+
}) : current);
|
|
176
|
+
}, []);
|
|
177
|
+
// Autosave. The seam lives here rather than in each page, so a form gets
|
|
178
|
+
// persistence by naming a store and no call site threads a draft through.
|
|
179
|
+
//
|
|
180
|
+
// Two properties keep it from writing more than it means to. It writes only
|
|
181
|
+
// while the draft *differs* from its seed — and on mount over a restored
|
|
182
|
+
// draft the seed **is** that draft, so opening a tab re-persists nothing. And
|
|
183
|
+
// it is keyed on `save`, which the port requires to be referentially stable;
|
|
184
|
+
// a new identity per render would make every render a write.
|
|
185
|
+
//
|
|
186
|
+
// Nothing here clears: a draft is spent when the write commits, and this hook
|
|
187
|
+
// never learns whether it did. See `EntityDraftStore.clear`.
|
|
188
|
+
const save = draft == null ? void 0 : draft.save;
|
|
189
|
+
useEffect(()=>{
|
|
190
|
+
if (!isDefaultValue) save == null ? void 0 : save(values);
|
|
191
|
+
}, [
|
|
192
|
+
values,
|
|
193
|
+
isDefaultValue,
|
|
194
|
+
save
|
|
195
|
+
]);
|
|
196
|
+
return {
|
|
197
|
+
values,
|
|
198
|
+
errors,
|
|
199
|
+
formError: typeof formError === 'string' ? formError : undefined,
|
|
200
|
+
setField: form.setFieldValue,
|
|
201
|
+
links,
|
|
202
|
+
setLink,
|
|
203
|
+
hydrateLink,
|
|
204
|
+
submit,
|
|
205
|
+
isDirty: !isDefaultValue
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
//# sourceMappingURL=use-entity-form.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../src/hooks/use-entity-form/use-entity-form.ts"],"sourcesContent":["'use client';\n\nimport {\n describeEntityColumns,\n type Entity,\n type EntityDraft,\n type EntityLinkSelection,\n seedEntityLinkSelection,\n} from '@entifix/core';\nimport { revalidateLogic, useForm, useStore } from '@tanstack/react-form';\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react';\n\nimport {\n composeEntityFormErrors,\n DEFAULT_VALIDATION_MESSAGES,\n readFieldErrors,\n restoreEntityDraft,\n seedEntityDraft,\n} from './use-entity-form.helpers.js';\nimport type {\n UseEntityFormOptions,\n UseEntityFormResult,\n} from './use-entity-form.types.js';\n\n/**\n * The write half of an entity form: holds the string draft and reports\n * metadata-derived validation, the counterpart to {@link useEntityRecord}\n * (which reads the record it seeds from).\n *\n * It stays out of the transport: it neither fetches nor saves. `onSubmit`\n * receives the validated draft, and the per-entity wrapper reconstructs the\n * domain entity and calls the mutation. The result plugs straight into the\n * agnostic `EntityForm` (`values`/`onFieldChange`/`errors`/`onSubmit`).\n *\n * TanStack Form owns the draft and the submission lifecycle; that is an\n * implementation detail, and the returned shape is deliberately the plain\n * `values`/`errors`/`setField`/`submit`/`isDirty` facade so `EntityForm` — which\n * renders native inputs and knows nothing about any form library — stays\n * agnostic. Fields are therefore never registered through `form.Field`; a\n * form-level validator reporting `{ fields }` reaches them anyway, because\n * `FormApi` creates meta for any field name an error arrives for.\n *\n * `revalidateLogic` is what makes a pristine form quiet: nothing validates until\n * the first submit, and every keystroke revalidates after it. Running one\n * validator under a single cause also keeps a fixed field from holding a stale\n * message left behind by another cause.\n *\n * The seed applies once, so a caller that loads its record after mount keys the\n * form by the record id to reseed — the same convention `EntityForm`'s wrappers\n * already follow.\n */\nexport function useEntityForm<TEntity extends Entity>({\n entityConstructor,\n entity,\n draft,\n fields,\n schema,\n validate,\n validationMessages,\n translateKey,\n onSubmit,\n}: UseEntityFormOptions<TEntity>): UseEntityFormResult {\n // Keyed on the **names**, not the array, so a caller may write the list inline\n // — which every wizard step does, since its field set is part of its own\n // declaration rather than something worth hoisting.\n const scope = fields?.join(',');\n const descriptors = useMemo(() => {\n const described = describeEntityColumns(entityConstructor, entity);\n return scope === undefined\n ? described\n : described.filter(descriptor =>\n scope.split(',').includes(descriptor.name),\n );\n }, [entityConstructor, entity, scope]);\n // A persisted draft is layered over the seed, never substituted for it: the\n // entity decides which members exist, the draft only decides their values.\n // See `restoreEntityDraft` for what a straight substitution costs.\n const restored = draft?.draft;\n const seed = useMemo(\n () =>\n restoreEntityDraft(\n descriptors,\n seedEntityDraft(descriptors, entity),\n restored,\n ),\n [descriptors, entity, restored],\n );\n\n // The relations the record already carries, so opening a loaded record needs no\n // label lookup and a save keeps whatever embedded shape it arrived in. Seeded\n // once, like the draft — a caller that loads late keys the form by record id.\n const [links, setLinks] = useState<EntityLinkSelection>(() =>\n seedEntityLinkSelection(descriptors, entity),\n );\n\n // Supplied by the caller, and that is the seam.\n //\n // ⚠️ This used to reach into react-i18next and `@entifix/i18n` directly,\n // because both React packages are `entifix:react` and the boundary rule\n // forbids a sideways import of the controls package that owns the `controls`\n // catalog. The effect was that anyone taking the integration hooks also took\n // i18next and a Spanish catalog — the composition defect the tier register\n // was written to catch. The one real caller, `entity-crud-form.tsx`, sits a\n // layer up where `useT('controls')` is reachable, so it resolves the four\n // sentences and hands them over.\n const messages = useMemo(\n () => validationMessages ?? DEFAULT_VALIDATION_MESSAGES,\n [validationMessages],\n );\n\n // A schema's message is authored as a catalog key (`validation.minLength`),\n // because a rule written in one language would reach the user untranslated —\n // the one thing the i18n gate exists to prevent. `defaultValue` keeps an\n // unkeyed literal readable instead of rendering the key back at the user, and\n // `field` is offered as a parameter so a message can name its own label.\n // Supplied by the caller for the same reason the four sentences above are —\n // and this one is `useTranslateKey`'s job rather than `useT`'s, because a\n // schema's message is a key only known at runtime. With nothing supplied the\n // message renders as authored, which is what `defaultValue` always did.\n const translateIssue = useCallback(\n (message: string, field: string | undefined) =>\n translateKey === undefined\n ? message\n : translateKey(message, {\n defaultValue: message,\n field:\n descriptors.find(entry => entry.name === field)?.label ?? field,\n }),\n [translateKey, descriptors],\n );\n\n /**\n * Whether the last {@link submit} reached the caller's `onSubmit`.\n *\n * A ref rather than state: nothing renders from it, and making it state would\n * re-render the whole form on every submit attempt for a value only the\n * caller awaiting `submit()` ever reads.\n */\n const submitted = useRef(false);\n\n const form = useForm({\n defaultValues: seed,\n // Quiet until the first submit, then revalidate on every change — the\n // behaviour the hand-rolled `submitted` flag used to produce.\n validationLogic: revalidateLogic(),\n validators: {\n onDynamic: ({ value }: { value: EntityDraft }) => {\n const { fields, form } = composeEntityFormErrors({\n descriptors,\n values: value,\n messages,\n schema,\n translateIssue,\n validate,\n });\n\n if (Object.keys(fields).length === 0 && form === undefined)\n return undefined;\n // `{ fields }` is how a form-level validator addresses individual\n // fields; the bare `form` message is what has no field to sit under.\n return { form, fields };\n },\n },\n onSubmit: ({ value }) => {\n // The engine calls this only when validation passed, so recording that it\n // ran *is* the answer {@link UseEntityFormResult.submit} returns. Reading\n // `form.state.isValid` afterwards would ask the engine a second question\n // whose answer depends on when it settled.\n submitted.current = true;\n return onSubmit(value);\n },\n });\n\n const values = useStore(form.store, state => state.values);\n const fieldMeta = useStore(form.store, state => state.fieldMeta);\n const formError = useStore(form.store, state => state.errorMap.onDynamic);\n // `isDefaultValue`, not `isDirty`: the draft is dirty when it *differs* from\n // its seed, so editing a field and typing the seed value back is not a change\n // the workspace should autosave.\n const isDefaultValue = useStore(form.store, state => state.isDefaultValue);\n\n const errors = useMemo(() => readFieldErrors(fieldMeta), [fieldMeta]);\n\n /**\n * Validate, then submit when clean — and **say whether it went**.\n *\n * The promise is what lets a caller gate on the result. A wizard's \"Siguiente\"\n * is the step's own submit, and before this it had nothing to wait for:\n * `handleSubmit()`'s promise was discarded, so the only way to learn the\n * outcome was to await a tick and re-read `errors`, which is the trap this\n * hook's own specs worked around.\n */\n const submit = useCallback(async () => {\n submitted.current = false;\n await form.handleSubmit();\n return submitted.current;\n }, [form]);\n\n // Both halves of a pick, in one call: the id the draft persists and the\n // instance only memory holds.\n const setLink = useCallback(\n (name: string, picked: Entity | undefined) => {\n setLinks(current => ({ ...current, [name]: picked }));\n form.setFieldValue(name, picked?.id == null ? '' : String(picked.id));\n },\n [form],\n );\n\n // The sidecar half alone. A restored draft holds the id and lost the instance,\n // and something resolved it back — that is not a user pick, so it must not\n // touch the draft: writing the field would be an edit the user did not make,\n // and on a form whose seed *is* the restored draft it would also be a no-op\n // that only risks disagreeing with the engine about equality.\n const hydrateLink = useCallback((name: string, resolved: Entity) => {\n setLinks(current =>\n current[name] === undefined ? { ...current, [name]: resolved } : current,\n );\n }, []);\n\n // Autosave. The seam lives here rather than in each page, so a form gets\n // persistence by naming a store and no call site threads a draft through.\n //\n // Two properties keep it from writing more than it means to. It writes only\n // while the draft *differs* from its seed — and on mount over a restored\n // draft the seed **is** that draft, so opening a tab re-persists nothing. And\n // it is keyed on `save`, which the port requires to be referentially stable;\n // a new identity per render would make every render a write.\n //\n // Nothing here clears: a draft is spent when the write commits, and this hook\n // never learns whether it did. See `EntityDraftStore.clear`.\n const save = draft?.save;\n useEffect(() => {\n if (!isDefaultValue) save?.(values);\n }, [values, isDefaultValue, save]);\n\n return {\n values,\n errors,\n formError: typeof formError === 'string' ? formError : undefined,\n setField: form.setFieldValue,\n links,\n setLink,\n hydrateLink,\n submit,\n isDirty: !isDefaultValue,\n };\n}\n"],"names":["describeEntityColumns","seedEntityLinkSelection","revalidateLogic","useForm","useStore","useCallback","useEffect","useMemo","useRef","useState","composeEntityFormErrors","DEFAULT_VALIDATION_MESSAGES","readFieldErrors","restoreEntityDraft","seedEntityDraft","useEntityForm","entityConstructor","entity","draft","fields","schema","validate","validationMessages","translateKey","onSubmit","scope","join","descriptors","described","undefined","filter","descriptor","split","includes","name","restored","seed","links","setLinks","messages","translateIssue","message","field","defaultValue","find","entry","label","submitted","form","defaultValues","validationLogic","validators","onDynamic","value","values","Object","keys","length","current","store","state","fieldMeta","formError","errorMap","isDefaultValue","errors","submit","handleSubmit","setLink","picked","setFieldValue","id","String","hydrateLink","resolved","save","setField","isDirty"],"mappings":"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"}
|