@stonecrop/stonecrop 0.10.16 → 0.11.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/README.md +72 -29
- package/dist/composables/lazy-link.js +125 -0
- package/dist/composables/stonecrop.js +123 -68
- package/dist/doctype.js +10 -2
- package/dist/field-triggers.js +15 -3
- package/dist/index.js +4 -3
- package/dist/registry.js +261 -101
- package/dist/schema-validator.js +105 -1
- package/dist/src/composables/lazy-link.d.ts +25 -0
- package/dist/src/composables/lazy-link.d.ts.map +1 -0
- package/dist/src/composables/operation-log.d.ts +5 -5
- package/dist/src/composables/operation-log.d.ts.map +1 -1
- package/dist/src/composables/stonecrop.d.ts +11 -1
- package/dist/src/composables/stonecrop.d.ts.map +1 -1
- package/dist/src/doctype.d.ts +9 -1
- package/dist/src/doctype.d.ts.map +1 -1
- package/dist/src/field-triggers.d.ts +6 -0
- package/dist/src/field-triggers.d.ts.map +1 -1
- package/dist/src/index.d.ts +3 -2
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/registry.d.ts +102 -23
- package/dist/src/registry.d.ts.map +1 -1
- package/dist/src/schema-validator.d.ts +8 -1
- package/dist/src/schema-validator.d.ts.map +1 -1
- package/dist/src/stonecrop.d.ts +73 -28
- package/dist/src/stonecrop.d.ts.map +1 -1
- package/dist/src/stores/hst.d.ts +5 -75
- package/dist/src/stores/hst.d.ts.map +1 -1
- package/dist/src/stores/operation-log.d.ts +14 -14
- package/dist/src/stores/operation-log.d.ts.map +1 -1
- package/dist/src/types/composable.d.ts +50 -12
- package/dist/src/types/composable.d.ts.map +1 -1
- package/dist/src/types/doctype.d.ts +6 -7
- package/dist/src/types/doctype.d.ts.map +1 -1
- package/dist/src/types/field-triggers.d.ts +1 -1
- package/dist/src/types/field-triggers.d.ts.map +1 -1
- package/dist/src/types/hst.d.ts +70 -0
- package/dist/src/types/hst.d.ts.map +1 -0
- package/dist/src/types/index.d.ts +1 -0
- package/dist/src/types/index.d.ts.map +1 -1
- package/dist/src/types/operation-log.d.ts +4 -4
- package/dist/src/types/operation-log.d.ts.map +1 -1
- package/dist/src/types/schema-validator.d.ts +2 -0
- package/dist/src/types/schema-validator.d.ts.map +1 -1
- package/dist/stonecrop.d.ts +317 -99
- package/dist/stonecrop.js +2191 -1897
- package/dist/stonecrop.js.map +1 -1
- package/dist/stores/hst.js +27 -25
- package/dist/stores/operation-log.js +59 -47
- package/dist/types/hst.js +0 -0
- package/dist/types/index.js +1 -0
- package/package.json +5 -5
- package/src/composables/lazy-link.ts +146 -0
- package/src/composables/operation-log.ts +1 -1
- package/src/composables/stonecrop.ts +142 -73
- package/src/doctype.ts +13 -4
- package/src/field-triggers.ts +18 -4
- package/src/index.ts +4 -2
- package/src/registry.ts +289 -111
- package/src/schema-validator.ts +120 -1
- package/src/stonecrop.ts +230 -106
- package/src/stores/hst.ts +29 -104
- package/src/stores/operation-log.ts +64 -50
- package/src/types/composable.ts +55 -12
- package/src/types/doctype.ts +6 -7
- package/src/types/field-triggers.ts +1 -1
- package/src/types/hst.ts +77 -0
- package/src/types/index.ts +1 -0
- package/src/types/operation-log.ts +4 -4
- package/src/types/schema-validator.ts +2 -0
- package/dist/stonecrop.css +0 -1
package/dist/field-triggers.js
CHANGED
|
@@ -39,6 +39,14 @@ export class FieldTriggerEngine {
|
|
|
39
39
|
registerAction(name, fn) {
|
|
40
40
|
this.globalActions.set(name, fn);
|
|
41
41
|
}
|
|
42
|
+
/**
|
|
43
|
+
* Look up a registered action function by name.
|
|
44
|
+
* Returns `undefined` if the action has not been registered.
|
|
45
|
+
* @param name - The action name
|
|
46
|
+
*/
|
|
47
|
+
getAction(name) {
|
|
48
|
+
return this.globalActions.get(name);
|
|
49
|
+
}
|
|
42
50
|
/**
|
|
43
51
|
* Register a global XState transition action function
|
|
44
52
|
* @param name - The name of the transition action
|
|
@@ -188,11 +196,13 @@ export class FieldTriggerEngine {
|
|
|
188
196
|
}
|
|
189
197
|
const totalExecutionTime = performance.now() - startTime;
|
|
190
198
|
// Call global error handler if configured and errors occurred
|
|
191
|
-
const failedResults = actionResults.filter(r => !r.success);
|
|
199
|
+
const failedResults = actionResults.filter(r => !r.success && r.error != null);
|
|
192
200
|
if (failedResults.length > 0 && this.options.errorHandler) {
|
|
193
201
|
for (const failedResult of failedResults) {
|
|
194
202
|
try {
|
|
195
|
-
|
|
203
|
+
if (failedResult.error) {
|
|
204
|
+
this.options.errorHandler(failedResult.error, context, failedResult.action);
|
|
205
|
+
}
|
|
196
206
|
}
|
|
197
207
|
catch (handlerError) {
|
|
198
208
|
// eslint-disable-next-line no-console
|
|
@@ -253,7 +263,9 @@ export class FieldTriggerEngine {
|
|
|
253
263
|
for (const failedResult of failedResults) {
|
|
254
264
|
try {
|
|
255
265
|
// Call with FieldChangeContext (base context type)
|
|
256
|
-
|
|
266
|
+
if (failedResult.error) {
|
|
267
|
+
this.options.errorHandler(failedResult.error, context, failedResult.action);
|
|
268
|
+
}
|
|
257
269
|
}
|
|
258
270
|
catch (handlerError) {
|
|
259
271
|
// eslint-disable-next-line no-console
|
package/dist/index.js
CHANGED
|
@@ -1,17 +1,18 @@
|
|
|
1
|
+
import { useLazyLink } from './composables/lazy-link';
|
|
1
2
|
import { useStonecrop } from './composables/stonecrop';
|
|
2
3
|
import { useOperationLog, useUndoRedoShortcuts, withBatch } from './composables/operation-log';
|
|
3
4
|
import Doctype from './doctype';
|
|
4
5
|
import { FieldTriggerEngine, getGlobalTriggerEngine, markOperationIrreversible, registerGlobalAction, registerTransitionAction, setFieldRollback, triggerTransition, } from './field-triggers';
|
|
5
6
|
import plugin from './plugins';
|
|
6
7
|
import Registry from './registry';
|
|
7
|
-
import { Stonecrop,
|
|
8
|
+
import { Stonecrop, getStonecrop } from './stonecrop';
|
|
8
9
|
import { HST, createHST } from './stores/hst';
|
|
9
10
|
import { useOperationLogStore } from './stores/operation-log';
|
|
10
11
|
import { SchemaValidator, createValidator, validateSchema } from './schema-validator';
|
|
11
12
|
import { ValidationSeverity } from './types/schema-validator';
|
|
12
13
|
// Export enum as value (enums need runtime export, not just type)
|
|
13
14
|
export { ValidationSeverity };
|
|
14
|
-
export { Doctype, Registry, Stonecrop, useStonecrop,
|
|
15
|
+
export { Doctype, Registry, Stonecrop, useLazyLink, useStonecrop,
|
|
15
16
|
// HST exports for advanced usage
|
|
16
17
|
HST, createHST,
|
|
17
18
|
// Field trigger system exports
|
|
@@ -21,6 +22,6 @@ SchemaValidator, createValidator, validateSchema,
|
|
|
21
22
|
// Operation log exports
|
|
22
23
|
useOperationLog, useOperationLogStore, useUndoRedoShortcuts, withBatch,
|
|
23
24
|
// Utility functions
|
|
24
|
-
|
|
25
|
+
getStonecrop, };
|
|
25
26
|
// Default export is the Vue plugin
|
|
26
27
|
export default plugin;
|
package/dist/registry.js
CHANGED
|
@@ -13,12 +13,30 @@ export default class Registry {
|
|
|
13
13
|
*
|
|
14
14
|
* @defaultValue 'Registry'
|
|
15
15
|
*/
|
|
16
|
-
name;
|
|
16
|
+
name = 'Registry';
|
|
17
17
|
/**
|
|
18
18
|
* The registry property contains a collection of doctypes
|
|
19
|
+
*
|
|
20
|
+
* @defaultValue `{}`
|
|
19
21
|
* @see {@link Doctype}
|
|
20
22
|
*/
|
|
21
|
-
registry;
|
|
23
|
+
registry = {};
|
|
24
|
+
/**
|
|
25
|
+
* Reverse index: backlink fieldname → list of \{ doctype slug, link fieldname \}.
|
|
26
|
+
* Multiple doctypes can declare a link with the same backlink name, so each key
|
|
27
|
+
* maps to an array. Built at schema load time for O(1) ancestor lookups.
|
|
28
|
+
*
|
|
29
|
+
* @defaultValue `new Map()`
|
|
30
|
+
* @internal
|
|
31
|
+
*/
|
|
32
|
+
_ancestorIndex = new Map();
|
|
33
|
+
/**
|
|
34
|
+
* Whether the ancestor index needs rebuilding
|
|
35
|
+
*
|
|
36
|
+
* @defaultValue `true`
|
|
37
|
+
* @internal
|
|
38
|
+
*/
|
|
39
|
+
_ancestorIndexDirty = true;
|
|
22
40
|
/**
|
|
23
41
|
* The Vue router instance
|
|
24
42
|
* @see {@link https://router.vuejs.org/}
|
|
@@ -34,8 +52,6 @@ export default class Registry {
|
|
|
34
52
|
return Registry._root;
|
|
35
53
|
}
|
|
36
54
|
Registry._root = this;
|
|
37
|
-
this.name = 'Registry';
|
|
38
|
-
this.registry = {};
|
|
39
55
|
this.router = router;
|
|
40
56
|
this.getMeta = getMeta;
|
|
41
57
|
}
|
|
@@ -53,6 +69,7 @@ export default class Registry {
|
|
|
53
69
|
addDoctype(doctype) {
|
|
54
70
|
if (!(doctype.slug in this.registry)) {
|
|
55
71
|
this.registry[doctype.slug] = doctype;
|
|
72
|
+
this._ancestorIndexDirty = true;
|
|
56
73
|
}
|
|
57
74
|
// Register actions (including field triggers) with the field trigger engine
|
|
58
75
|
const triggerEngine = getGlobalTriggerEngine();
|
|
@@ -72,95 +89,120 @@ export default class Registry {
|
|
|
72
89
|
/**
|
|
73
90
|
* Resolve nested Doctype fields in a schema by embedding child schemas inline.
|
|
74
91
|
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
92
|
+
* Accepts a Doctype and extracts `fields` and `links` internally.
|
|
93
|
+
* Fields array contains both scalar fields and link fields (with fieldtype: 'Link').
|
|
94
|
+
* Render order is determined by the order of fields in the fields array.
|
|
78
95
|
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
* -
|
|
82
|
-
* `
|
|
96
|
+
* For each link field:
|
|
97
|
+
* - Looks up the corresponding link declaration in `links` by fieldname
|
|
98
|
+
* - `cardinality: 'noneOrMany'` or `'atLeastOne'`: auto-derives `columns` from the target's schema,
|
|
99
|
+
* sets `component` to `link.component ?? 'ATable'`, `config: { view: 'list' }`, `rows: []`.
|
|
100
|
+
* - `cardinality: 'one'` or `'atMostOne'`: embeds the target schema as the entry's
|
|
101
|
+
* `schema` property, sets `component` to `link.component ?? 'AForm'`.
|
|
83
102
|
*
|
|
84
103
|
* Recurses for deeply nested doctypes. Circular references are protected against.
|
|
104
|
+
* Returns a new array — does not mutate the original.
|
|
85
105
|
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
* @
|
|
89
|
-
* @returns A new schema array with nested Doctype fields resolved
|
|
90
|
-
*
|
|
91
|
-
* @example
|
|
92
|
-
* ```ts
|
|
93
|
-
* registry.addDoctype(addressDoctype)
|
|
94
|
-
* registry.addDoctype(customerDoctype)
|
|
95
|
-
*
|
|
96
|
-
* // Before: customer schema has { fieldname: 'address', fieldtype: 'Doctype', options: 'address' }
|
|
97
|
-
* const resolved = registry.resolveSchema(customerSchema)
|
|
98
|
-
* // After: address field now has schema: [...address fields...]
|
|
99
|
-
* ```
|
|
106
|
+
* @param doctype - The doctype to resolve
|
|
107
|
+
* @param visited - Internal — set of already-visited doctype slugs for cycle detection
|
|
108
|
+
* @returns A new schema array with nested links resolved
|
|
100
109
|
*
|
|
101
110
|
* @public
|
|
102
111
|
*/
|
|
103
|
-
resolveSchema(
|
|
104
|
-
const seen = visited
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
112
|
+
resolveSchema(doctype, visited) {
|
|
113
|
+
const seen = visited ?? new Set();
|
|
114
|
+
const slug = doctype.slug;
|
|
115
|
+
// Prevent circular resolution
|
|
116
|
+
if (seen.has(slug)) {
|
|
117
|
+
return doctype.schema ? (Array.isArray(doctype.schema) ? doctype.schema : Array.from(doctype.schema)) : [];
|
|
118
|
+
}
|
|
119
|
+
seen.add(slug);
|
|
120
|
+
// Convert schema to array
|
|
121
|
+
const schemaArray = doctype.schema
|
|
122
|
+
? Array.isArray(doctype.schema)
|
|
123
|
+
? doctype.schema
|
|
124
|
+
: Array.from(doctype.schema)
|
|
125
|
+
: [];
|
|
126
|
+
// Build a map of link declarations by fieldname for quick lookup
|
|
127
|
+
// Use the link's fieldname property if set, otherwise use the key
|
|
128
|
+
const linksByFieldname = new Map();
|
|
129
|
+
if (doctype.links) {
|
|
130
|
+
for (const [key, link] of Object.entries(doctype.links)) {
|
|
131
|
+
const linkFieldname = link.fieldname ?? key;
|
|
132
|
+
linksByFieldname.set(linkFieldname, link);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
// Process fields in order: scalar fields copied as-is, link fields resolved
|
|
136
|
+
const resolvedFields = [];
|
|
137
|
+
for (const field of schemaArray) {
|
|
138
|
+
// Check if this field is a link field (fieldtype: 'Link')
|
|
139
|
+
if ('fieldtype' in field && field.fieldtype === 'Link') {
|
|
140
|
+
const link = linksByFieldname.get(field.fieldname);
|
|
141
|
+
if (!link) {
|
|
142
|
+
// Link field without corresponding link declaration - copy as-is
|
|
143
|
+
resolvedFields.push({ ...field });
|
|
144
|
+
continue;
|
|
115
145
|
}
|
|
116
|
-
const
|
|
117
|
-
if (
|
|
118
|
-
//
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
}));
|
|
136
|
-
}
|
|
137
|
-
// Set default component if not already specified
|
|
138
|
-
if (!resolved.component) {
|
|
139
|
-
resolved.component = 'ATable';
|
|
140
|
-
}
|
|
141
|
-
// Set default config if not already specified
|
|
142
|
-
if (!('config' in field) || !field.config) {
|
|
143
|
-
resolved.config = { view: 'list' };
|
|
144
|
-
}
|
|
145
|
-
// Initialize rows to empty array so componentProps fallback
|
|
146
|
-
// routes data from the form's dataModel[fieldname]
|
|
147
|
-
if (!('rows' in field) || !field.rows) {
|
|
148
|
-
resolved.rows = [];
|
|
149
|
-
}
|
|
150
|
-
return resolved;
|
|
151
|
-
}
|
|
152
|
-
else {
|
|
153
|
-
// 1:1 nested form (default cardinality: 'one')
|
|
154
|
-
// Recurse into child schema to resolve deeply nested doctypes
|
|
155
|
-
seen.add(doctypeSlug);
|
|
156
|
-
const resolvedChild = this.resolveSchema(childSchema, seen);
|
|
157
|
-
seen.delete(doctypeSlug);
|
|
158
|
-
return { ...field, schema: resolvedChild };
|
|
159
|
-
}
|
|
146
|
+
const targetDoctype = this.registry[link.target];
|
|
147
|
+
if (!targetDoctype) {
|
|
148
|
+
// Target not found - copy as-is
|
|
149
|
+
resolvedFields.push({ ...field });
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
const childSchema = this.resolveSchema(targetDoctype, seen);
|
|
153
|
+
if (link.cardinality === 'noneOrMany' || link.cardinality === 'atLeastOne') {
|
|
154
|
+
// Many relationship — build table config
|
|
155
|
+
resolvedFields.push(this.buildTableConfig({ fieldname: field.fieldname, label: field.label || field.fieldname }, childSchema, link.component));
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
// One relationship — embed form schema
|
|
159
|
+
resolvedFields.push({
|
|
160
|
+
fieldname: field.fieldname,
|
|
161
|
+
label: field.label || field.fieldname,
|
|
162
|
+
component: link.component || 'AForm',
|
|
163
|
+
schema: childSchema,
|
|
164
|
+
});
|
|
160
165
|
}
|
|
161
166
|
}
|
|
162
|
-
|
|
163
|
-
|
|
167
|
+
else {
|
|
168
|
+
// Scalar field — copy as-is
|
|
169
|
+
resolvedFields.push({ ...field });
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
seen.delete(slug);
|
|
173
|
+
return resolvedFields;
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Build an ATable configuration from a field and child schema
|
|
177
|
+
* @internal
|
|
178
|
+
*/
|
|
179
|
+
buildTableConfig(field, childSchema, component) {
|
|
180
|
+
const resolved = {
|
|
181
|
+
fieldname: field.fieldname,
|
|
182
|
+
component: component || field.component || 'ATable',
|
|
183
|
+
columns: field.columns,
|
|
184
|
+
config: field.config,
|
|
185
|
+
rows: field.rows,
|
|
186
|
+
};
|
|
187
|
+
if (!resolved.columns) {
|
|
188
|
+
resolved.columns = childSchema
|
|
189
|
+
.filter(childField => 'fieldtype' in childField)
|
|
190
|
+
.map(childField => ({
|
|
191
|
+
name: childField.fieldname,
|
|
192
|
+
label: ('label' in childField && childField.label) || childField.fieldname,
|
|
193
|
+
fieldtype: 'fieldtype' in childField ? childField.fieldtype : 'Data',
|
|
194
|
+
align: 'align' in childField ? childField.align : 'left',
|
|
195
|
+
edit: 'edit' in childField ? childField.edit : true,
|
|
196
|
+
width: ('width' in childField && childField.width) || '20ch',
|
|
197
|
+
}));
|
|
198
|
+
}
|
|
199
|
+
if (!resolved.config) {
|
|
200
|
+
resolved.config = { view: 'list' };
|
|
201
|
+
}
|
|
202
|
+
if (!resolved.rows) {
|
|
203
|
+
resolved.rows = [];
|
|
204
|
+
}
|
|
205
|
+
return resolved;
|
|
164
206
|
}
|
|
165
207
|
/**
|
|
166
208
|
* Initialize a new record with default values based on a schema.
|
|
@@ -172,7 +214,7 @@ export default class Registry {
|
|
|
172
214
|
* - Check → `false`
|
|
173
215
|
* - Int, Float, Decimal, Currency, Quantity → `0`
|
|
174
216
|
* - JSON → `{}`
|
|
175
|
-
* - Doctype with `cardinality: '
|
|
217
|
+
* - Doctype with `cardinality: 'noneOrMany'` or `'atLeastOne'` → `[]`
|
|
176
218
|
* - Doctype without `cardinality` or `cardinality: 'one'` → recursively initializes nested record
|
|
177
219
|
* - All others → `null`
|
|
178
220
|
*
|
|
@@ -194,6 +236,22 @@ export default class Registry {
|
|
|
194
236
|
const record = {};
|
|
195
237
|
schema.forEach(field => {
|
|
196
238
|
const fieldtype = 'fieldtype' in field ? field.fieldtype : 'Data';
|
|
239
|
+
const cardinality = 'cardinality' in field ? field.cardinality : undefined;
|
|
240
|
+
// 1:many — cardinality signals an array
|
|
241
|
+
if (cardinality === 'noneOrMany' || cardinality === 'atLeastOne') {
|
|
242
|
+
record[field.fieldname] = [];
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
// Resolved 1:many table entry — has rows property
|
|
246
|
+
if ('rows' in field) {
|
|
247
|
+
record[field.fieldname] = [];
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
// Resolved 1:1 link entry — has schema property (e.g., FieldsetSchema with nested schema)
|
|
251
|
+
if ('schema' in field && Array.isArray(field.schema)) {
|
|
252
|
+
record[field.fieldname] = this.initializeRecord(field.schema);
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
197
255
|
switch (fieldtype) {
|
|
198
256
|
case 'Data':
|
|
199
257
|
case 'Text':
|
|
@@ -213,23 +271,6 @@ export default class Registry {
|
|
|
213
271
|
case 'JSON':
|
|
214
272
|
record[field.fieldname] = {};
|
|
215
273
|
break;
|
|
216
|
-
case 'Doctype': {
|
|
217
|
-
// Check cardinality to determine initial value
|
|
218
|
-
const cardinality = 'cardinality' in field ? field.cardinality : undefined;
|
|
219
|
-
if (cardinality === 'many') {
|
|
220
|
-
// 1:many child table - initialize as empty array
|
|
221
|
-
record[field.fieldname] = [];
|
|
222
|
-
}
|
|
223
|
-
else if ('schema' in field && Array.isArray(field.schema)) {
|
|
224
|
-
// 1:1 nested form with resolved schema - recursively initialize
|
|
225
|
-
record[field.fieldname] = this.initializeRecord(field.schema);
|
|
226
|
-
}
|
|
227
|
-
else {
|
|
228
|
-
// 1:1 without resolved schema - empty object
|
|
229
|
-
record[field.fieldname] = {};
|
|
230
|
-
}
|
|
231
|
-
break;
|
|
232
|
-
}
|
|
233
274
|
default:
|
|
234
275
|
record[field.fieldname] = null;
|
|
235
276
|
}
|
|
@@ -245,4 +286,123 @@ export default class Registry {
|
|
|
245
286
|
getDoctype(slug) {
|
|
246
287
|
return this.registry[slug];
|
|
247
288
|
}
|
|
289
|
+
/**
|
|
290
|
+
* Get all links declared on a doctype.
|
|
291
|
+
*
|
|
292
|
+
* @param doctypeSlug - The doctype slug to get links for
|
|
293
|
+
* @returns Array of link declarations with fieldname, or empty array if none
|
|
294
|
+
*
|
|
295
|
+
* @example
|
|
296
|
+
* ```ts
|
|
297
|
+
* const links = registry.getDescendantLinks('recipe')
|
|
298
|
+
* // [{ fieldname: 'tasks', target: 'recipe-task', cardinality: 'noneOrMany', backlink: 'recipe' }]
|
|
299
|
+
* ```
|
|
300
|
+
*
|
|
301
|
+
* @public
|
|
302
|
+
*/
|
|
303
|
+
getDescendantLinks(doctypeSlug) {
|
|
304
|
+
const doctype = this.registry[doctypeSlug];
|
|
305
|
+
if (!doctype?.links)
|
|
306
|
+
return [];
|
|
307
|
+
return Object.entries(doctype.links).map(([fieldname, link]) => ({
|
|
308
|
+
...link,
|
|
309
|
+
fieldname,
|
|
310
|
+
}));
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* Get links on other doctypes that target the given doctype.
|
|
314
|
+
*
|
|
315
|
+
* @param doctypeSlug - The doctype slug to find ancestor links for
|
|
316
|
+
* @returns Array of link declarations with fieldname and declaring doctype slug, or empty array
|
|
317
|
+
*
|
|
318
|
+
* @example
|
|
319
|
+
* ```ts
|
|
320
|
+
* const ancestors = registry.getAncestorLinks('recipe-task')
|
|
321
|
+
* // [{ fieldname: 'tasks', target: 'recipe-task', cardinality: 'noneOrMany', backlink: 'recipe', doctype: 'recipe' }]
|
|
322
|
+
* ```
|
|
323
|
+
*
|
|
324
|
+
* @public
|
|
325
|
+
*/
|
|
326
|
+
getAncestorLinks(doctypeSlug) {
|
|
327
|
+
this._ensureAncestorIndex();
|
|
328
|
+
const results = [];
|
|
329
|
+
for (const [_backlink, entries] of this._ancestorIndex) {
|
|
330
|
+
for (const { slug: declaringSlug, fieldname } of entries) {
|
|
331
|
+
const declaringDoctype = this.registry[declaringSlug];
|
|
332
|
+
if (!declaringDoctype?.links)
|
|
333
|
+
continue;
|
|
334
|
+
const link = declaringDoctype.links[fieldname];
|
|
335
|
+
if (link?.target === doctypeSlug) {
|
|
336
|
+
results.push({
|
|
337
|
+
...link,
|
|
338
|
+
fieldname,
|
|
339
|
+
doctype: declaringSlug,
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
return results;
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* Ensure the ancestor index is up to date
|
|
348
|
+
* @internal
|
|
349
|
+
*/
|
|
350
|
+
_ensureAncestorIndex() {
|
|
351
|
+
if (!this._ancestorIndexDirty)
|
|
352
|
+
return;
|
|
353
|
+
this._ancestorIndexDirty = false;
|
|
354
|
+
this._ancestorIndex.clear();
|
|
355
|
+
for (const [slug, doctype] of Object.entries(this.registry)) {
|
|
356
|
+
if (!doctype.links)
|
|
357
|
+
continue;
|
|
358
|
+
for (const [fieldname, link] of Object.entries(doctype.links)) {
|
|
359
|
+
if (link.backlink) {
|
|
360
|
+
const existing = this._ancestorIndex.get(link.backlink);
|
|
361
|
+
if (existing) {
|
|
362
|
+
existing.push({ slug, fieldname });
|
|
363
|
+
}
|
|
364
|
+
else {
|
|
365
|
+
this._ancestorIndex.set(link.backlink, [{ slug, fieldname }]);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* Convert the registry to a Map of DoctypeMeta objects for use with StonecropClient.
|
|
373
|
+
*
|
|
374
|
+
* This allows passing a Registry instance to StonecropClient by deriving the
|
|
375
|
+
* Map\<string, DoctypeMeta\> that StonecropClient needs for building nested GraphQL queries.
|
|
376
|
+
*
|
|
377
|
+
* @returns Map of doctype metadata keyed by doctype name
|
|
378
|
+
*
|
|
379
|
+
* @example
|
|
380
|
+
* ```typescript
|
|
381
|
+
* const registry = new Registry()
|
|
382
|
+
* registry.addDoctype(Doctype.fromObject(customerSchema))
|
|
383
|
+
* registry.addDoctype(Doctype.fromObject(orderSchema))
|
|
384
|
+
*
|
|
385
|
+
* const client = new StonecropClient({
|
|
386
|
+
* endpoint: '/graphql',
|
|
387
|
+
* registry: registry.toMetaMap(), // Convert once, use with client
|
|
388
|
+
* })
|
|
389
|
+
* ```
|
|
390
|
+
*
|
|
391
|
+
* @public
|
|
392
|
+
*/
|
|
393
|
+
toMetaMap() {
|
|
394
|
+
const map = new Map();
|
|
395
|
+
for (const [slug, doctype] of Object.entries(this.registry)) {
|
|
396
|
+
const fields = doctype.schema ? doctype.schema.toArray() : [];
|
|
397
|
+
const meta = {
|
|
398
|
+
name: doctype.name,
|
|
399
|
+
slug: slug,
|
|
400
|
+
fields: fields,
|
|
401
|
+
links: doctype.links,
|
|
402
|
+
workflow: doctype.workflow,
|
|
403
|
+
};
|
|
404
|
+
map.set(doctype.name, meta);
|
|
405
|
+
}
|
|
406
|
+
return map;
|
|
407
|
+
}
|
|
248
408
|
}
|
package/dist/schema-validator.js
CHANGED
|
@@ -19,6 +19,7 @@ export class SchemaValidator {
|
|
|
19
19
|
this.options = {
|
|
20
20
|
registry: options.registry || null,
|
|
21
21
|
validateLinkTargets: options.validateLinkTargets ?? true,
|
|
22
|
+
validateLinks: options.validateLinks ?? true,
|
|
22
23
|
validateActions: options.validateActions ?? true,
|
|
23
24
|
validateWorkflows: options.validateWorkflows ?? true,
|
|
24
25
|
validateRequiredProperties: options.validateRequiredProperties ?? true,
|
|
@@ -30,9 +31,10 @@ export class SchemaValidator {
|
|
|
30
31
|
* @param schema - Schema fields (List or Array)
|
|
31
32
|
* @param workflow - Optional workflow configuration
|
|
32
33
|
* @param actions - Optional actions map
|
|
34
|
+
* @param links - Optional links object
|
|
33
35
|
* @returns Validation result
|
|
34
36
|
*/
|
|
35
|
-
validate(doctype, schema, workflow, actions) {
|
|
37
|
+
validate(doctype, schema, workflow, actions, links) {
|
|
36
38
|
const issues = [];
|
|
37
39
|
// Convert schema to array for easier iteration
|
|
38
40
|
const schemaArray = schema ? (Array.isArray(schema) ? schema : schema.toArray()) : [];
|
|
@@ -44,6 +46,10 @@ export class SchemaValidator {
|
|
|
44
46
|
if (this.options.validateLinkTargets && this.options.registry) {
|
|
45
47
|
issues.push(...this.validateLinkFields(doctype, schemaArray, this.options.registry));
|
|
46
48
|
}
|
|
49
|
+
// Validate links object
|
|
50
|
+
if (this.options.validateLinks && this.options.registry && links) {
|
|
51
|
+
issues.push(...this.validateLinkDeclarations(doctype, links, schemaArray, this.options.registry));
|
|
52
|
+
}
|
|
47
53
|
// Validate workflow configuration
|
|
48
54
|
if (this.options.validateWorkflows && workflow) {
|
|
49
55
|
issues.push(...this.validateWorkflow(doctype, workflow));
|
|
@@ -157,6 +163,104 @@ export class SchemaValidator {
|
|
|
157
163
|
}
|
|
158
164
|
return issues;
|
|
159
165
|
}
|
|
166
|
+
/**
|
|
167
|
+
* Validates link declarations: target resolution, backlink consistency, Link field correspondence
|
|
168
|
+
* @internal
|
|
169
|
+
*/
|
|
170
|
+
validateLinkDeclarations(doctype, links, schema, registry) {
|
|
171
|
+
const issues = [];
|
|
172
|
+
// Build a map of Link fields by fieldname for quick lookup
|
|
173
|
+
const linkFieldsByFieldname = new Map();
|
|
174
|
+
for (const field of schema) {
|
|
175
|
+
if ('fieldtype' in field && field.fieldtype === 'Link') {
|
|
176
|
+
linkFieldsByFieldname.set(field.fieldname, field);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
for (const [fieldname, link] of Object.entries(links)) {
|
|
180
|
+
// Check target resolves in registry
|
|
181
|
+
const targetDoctype = registry.registry[link.target];
|
|
182
|
+
if (!targetDoctype) {
|
|
183
|
+
issues.push({
|
|
184
|
+
severity: ValidationSeverity.ERROR,
|
|
185
|
+
rule: 'link-invalid-target',
|
|
186
|
+
message: `Link "${fieldname}" references non-existent doctype: "${link.target}"`,
|
|
187
|
+
doctype,
|
|
188
|
+
fieldname,
|
|
189
|
+
context: { target: link.target },
|
|
190
|
+
});
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
// Warn on self-referential target
|
|
194
|
+
if (link.target === doctype) {
|
|
195
|
+
issues.push({
|
|
196
|
+
severity: ValidationSeverity.WARNING,
|
|
197
|
+
rule: 'link-self-referential',
|
|
198
|
+
message: `Link "${fieldname}" is self-referential (target: "${link.target}")`,
|
|
199
|
+
doctype,
|
|
200
|
+
fieldname,
|
|
201
|
+
context: { target: link.target },
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
// Check backlink consistency
|
|
205
|
+
if (link.backlink && targetDoctype.links) {
|
|
206
|
+
const reciprocalLink = targetDoctype.links[link.backlink];
|
|
207
|
+
if (!reciprocalLink) {
|
|
208
|
+
issues.push({
|
|
209
|
+
severity: ValidationSeverity.ERROR,
|
|
210
|
+
rule: 'link-backlink-missing',
|
|
211
|
+
message: `Backlink "${link.backlink}" not found on target doctype "${link.target}"`,
|
|
212
|
+
doctype,
|
|
213
|
+
fieldname,
|
|
214
|
+
context: { backlink: link.backlink, target: link.target },
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
else if (reciprocalLink.target !== doctype) {
|
|
218
|
+
issues.push({
|
|
219
|
+
severity: ValidationSeverity.WARNING,
|
|
220
|
+
rule: 'link-backlink-mismatch',
|
|
221
|
+
message: `Backlink "${link.backlink}" on "${link.target}" points to "${reciprocalLink.target}" instead of "${doctype}"`,
|
|
222
|
+
doctype,
|
|
223
|
+
fieldname,
|
|
224
|
+
context: { backlink: link.backlink, target: link.target, actualTarget: reciprocalLink.target },
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
// If Link field exists with same fieldname, verify it has matching target
|
|
229
|
+
// Only check if link has fieldname set (otherwise it's a standalone link without a field)
|
|
230
|
+
if (link.fieldname) {
|
|
231
|
+
const linkField = linkFieldsByFieldname.get(link.fieldname);
|
|
232
|
+
if (linkField) {
|
|
233
|
+
const linkFieldOptions = 'options' in linkField ? linkField.options : undefined;
|
|
234
|
+
const linkFieldTarget = typeof linkFieldOptions === 'string' ? linkFieldOptions : undefined;
|
|
235
|
+
if (linkFieldTarget && linkFieldTarget !== link.target) {
|
|
236
|
+
issues.push({
|
|
237
|
+
severity: ValidationSeverity.ERROR,
|
|
238
|
+
rule: 'link-field-target-mismatch',
|
|
239
|
+
message: `Link field "${link.fieldname}" targets "${linkFieldTarget}" but link declaration targets "${link.target}"`,
|
|
240
|
+
doctype,
|
|
241
|
+
fieldname: link.fieldname,
|
|
242
|
+
context: { linkFieldTarget, linkTarget: link.target },
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
// Check that every Link field has a corresponding link declaration
|
|
249
|
+
// A Link field corresponds to a link if the link's fieldname property matches the field's fieldname
|
|
250
|
+
for (const [fieldname, _field] of linkFieldsByFieldname) {
|
|
251
|
+
const hasCorrespondingLink = Object.values(links).some(link => link.fieldname === fieldname);
|
|
252
|
+
if (!hasCorrespondingLink) {
|
|
253
|
+
issues.push({
|
|
254
|
+
severity: ValidationSeverity.ERROR,
|
|
255
|
+
rule: 'link-field-without-declaration',
|
|
256
|
+
message: `Link field "${fieldname}" has no corresponding link declaration`,
|
|
257
|
+
doctype,
|
|
258
|
+
fieldname,
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return issues;
|
|
263
|
+
}
|
|
160
264
|
/**
|
|
161
265
|
* Validates workflow state machine configuration
|
|
162
266
|
* @internal
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import Doctype from '../doctype';
|
|
2
|
+
import type { LazyLink } from '../types/composable';
|
|
3
|
+
/**
|
|
4
|
+
* Get the lazy link state for a specific link field on a doctype record.
|
|
5
|
+
*
|
|
6
|
+
* This composable provides reactive state for lazy-loaded links:
|
|
7
|
+
* - `loading`: true while fetching
|
|
8
|
+
* - `loaded`: true after successful fetch (permanent until reload)
|
|
9
|
+
* - `error`: error state if any
|
|
10
|
+
* - `reload()`: explicitly trigger a fetch
|
|
11
|
+
* - `data`: computed from HST, or undefined if not loaded
|
|
12
|
+
*
|
|
13
|
+
* The reload() function respects the link's fetch strategy:
|
|
14
|
+
* - `sync`: fetches via GraphQL query through fetchNestedData
|
|
15
|
+
* - `lazy`: fetches via GraphQL query through fetchNestedData
|
|
16
|
+
* - `custom`: invokes the serialized handler function directly
|
|
17
|
+
*
|
|
18
|
+
* @param doctype - The doctype instance
|
|
19
|
+
* @param recordId - The record ID
|
|
20
|
+
* @param linkFieldname - The link fieldname to load
|
|
21
|
+
* @returns LazyLink with loading, loaded, error, reload, and data
|
|
22
|
+
* @public
|
|
23
|
+
*/
|
|
24
|
+
export declare function useLazyLink(doctype: Doctype, recordId: string, linkFieldname: string): LazyLink;
|
|
25
|
+
//# sourceMappingURL=lazy-link.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"lazy-link.d.ts","sourceRoot":"","sources":["../../../src/composables/lazy-link.ts"],"names":[],"mappings":"AAGA,OAAO,OAAO,MAAM,YAAY,CAAA;AAEhC,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,qBAAqB,CAAA;AAEnD;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,WAAW,CAAC,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,GAAG,QAAQ,CAqH/F"}
|