@stonecrop/stonecrop 0.10.15 → 0.11.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.
Files changed (117) hide show
  1. package/README.md +72 -29
  2. package/dist/composable.js +1 -0
  3. package/dist/composables/lazy-link.js +125 -0
  4. package/dist/composables/stonecrop.js +123 -68
  5. package/dist/composables/use-lazy-link-state.js +125 -0
  6. package/dist/composables/use-stonecrop.js +476 -0
  7. package/dist/doctype.js +10 -2
  8. package/dist/field-triggers.js +15 -3
  9. package/dist/index.js +4 -3
  10. package/dist/operation-log-DB-dGNT9.js +593 -0
  11. package/dist/operation-log-DB-dGNT9.js.map +1 -0
  12. package/dist/registry.js +261 -101
  13. package/dist/schema-validator.js +105 -1
  14. package/dist/src/composable.d.ts +11 -0
  15. package/dist/src/composable.d.ts.map +1 -0
  16. package/dist/src/composable.js +477 -0
  17. package/dist/src/composables/lazy-link.d.ts +25 -0
  18. package/dist/src/composables/lazy-link.d.ts.map +1 -0
  19. package/dist/src/composables/operation-log.d.ts +5 -5
  20. package/dist/src/composables/operation-log.d.ts.map +1 -1
  21. package/dist/src/composables/operation-log.js +224 -0
  22. package/dist/src/composables/stonecrop.d.ts +11 -1
  23. package/dist/src/composables/stonecrop.d.ts.map +1 -1
  24. package/dist/src/composables/stonecrop.js +574 -0
  25. package/dist/src/composables/use-lazy-link-state.d.ts +25 -0
  26. package/dist/src/composables/use-lazy-link-state.d.ts.map +1 -0
  27. package/dist/src/composables/use-stonecrop.d.ts +93 -0
  28. package/dist/src/composables/use-stonecrop.d.ts.map +1 -0
  29. package/dist/src/composables/useNestedSchema.d.ts +110 -0
  30. package/dist/src/composables/useNestedSchema.d.ts.map +1 -0
  31. package/dist/src/composables/useNestedSchema.js +155 -0
  32. package/dist/src/doctype.d.ts +9 -1
  33. package/dist/src/doctype.d.ts.map +1 -1
  34. package/dist/src/doctype.js +234 -0
  35. package/dist/src/exceptions.js +16 -0
  36. package/dist/src/field-triggers.d.ts +6 -0
  37. package/dist/src/field-triggers.d.ts.map +1 -1
  38. package/dist/src/field-triggers.js +567 -0
  39. package/dist/src/index.d.ts +3 -2
  40. package/dist/src/index.d.ts.map +1 -1
  41. package/dist/src/index.js +23 -0
  42. package/dist/src/plugins/index.js +96 -0
  43. package/dist/src/registry.d.ts +102 -23
  44. package/dist/src/registry.d.ts.map +1 -1
  45. package/dist/src/registry.js +246 -0
  46. package/dist/src/schema-validator.d.ts +8 -1
  47. package/dist/src/schema-validator.d.ts.map +1 -1
  48. package/dist/src/schema-validator.js +315 -0
  49. package/dist/src/stonecrop.d.ts +73 -28
  50. package/dist/src/stonecrop.d.ts.map +1 -1
  51. package/dist/src/stonecrop.js +339 -0
  52. package/dist/src/stores/data.d.ts +11 -0
  53. package/dist/src/stores/data.d.ts.map +1 -0
  54. package/dist/src/stores/hst.d.ts +5 -75
  55. package/dist/src/stores/hst.d.ts.map +1 -1
  56. package/dist/src/stores/hst.js +495 -0
  57. package/dist/src/stores/index.js +12 -0
  58. package/dist/src/stores/operation-log.d.ts +14 -14
  59. package/dist/src/stores/operation-log.d.ts.map +1 -1
  60. package/dist/src/stores/operation-log.js +568 -0
  61. package/dist/src/stores/xstate.d.ts +31 -0
  62. package/dist/src/stores/xstate.d.ts.map +1 -0
  63. package/dist/src/tsdoc-metadata.json +11 -0
  64. package/dist/src/types/composable.d.ts +50 -12
  65. package/dist/src/types/composable.d.ts.map +1 -1
  66. package/dist/src/types/doctype.d.ts +6 -7
  67. package/dist/src/types/doctype.d.ts.map +1 -1
  68. package/dist/src/types/field-triggers.d.ts +1 -1
  69. package/dist/src/types/field-triggers.d.ts.map +1 -1
  70. package/dist/src/types/field-triggers.js +4 -0
  71. package/dist/src/types/hst.d.ts +70 -0
  72. package/dist/src/types/hst.d.ts.map +1 -0
  73. package/dist/src/types/index.d.ts +1 -0
  74. package/dist/src/types/index.d.ts.map +1 -1
  75. package/dist/src/types/index.js +4 -0
  76. package/dist/src/types/operation-log.d.ts +4 -4
  77. package/dist/src/types/operation-log.d.ts.map +1 -1
  78. package/dist/src/types/operation-log.js +0 -0
  79. package/dist/src/types/registry.js +0 -0
  80. package/dist/src/types/schema-validator.d.ts +2 -0
  81. package/dist/src/types/schema-validator.d.ts.map +1 -1
  82. package/dist/src/utils.d.ts +24 -0
  83. package/dist/src/utils.d.ts.map +1 -0
  84. package/dist/stonecrop.d.ts +317 -99
  85. package/dist/stonecrop.js +2191 -1897
  86. package/dist/stonecrop.js.map +1 -1
  87. package/dist/stonecrop.umd.cjs +6 -0
  88. package/dist/stonecrop.umd.cjs.map +1 -0
  89. package/dist/stores/data.js +7 -0
  90. package/dist/stores/hst.js +27 -25
  91. package/dist/stores/operation-log.js +59 -47
  92. package/dist/stores/xstate.js +29 -0
  93. package/dist/tests/setup.d.ts +5 -0
  94. package/dist/tests/setup.d.ts.map +1 -0
  95. package/dist/tests/setup.js +15 -0
  96. package/dist/types/hst.js +0 -0
  97. package/dist/types/index.js +1 -0
  98. package/dist/utils.js +46 -0
  99. package/package.json +4 -4
  100. package/src/composables/lazy-link.ts +146 -0
  101. package/src/composables/operation-log.ts +1 -1
  102. package/src/composables/stonecrop.ts +142 -73
  103. package/src/doctype.ts +13 -4
  104. package/src/field-triggers.ts +18 -4
  105. package/src/index.ts +4 -2
  106. package/src/registry.ts +289 -111
  107. package/src/schema-validator.ts +120 -1
  108. package/src/stonecrop.ts +230 -106
  109. package/src/stores/hst.ts +29 -104
  110. package/src/stores/operation-log.ts +64 -50
  111. package/src/types/composable.ts +55 -12
  112. package/src/types/doctype.ts +6 -7
  113. package/src/types/field-triggers.ts +1 -1
  114. package/src/types/hst.ts +77 -0
  115. package/src/types/index.ts +1 -0
  116. package/src/types/operation-log.ts +4 -4
  117. package/src/types/schema-validator.ts +2 -0
@@ -0,0 +1,125 @@
1
+ import { computed, inject, ref } from 'vue';
2
+ import { Stonecrop } from '../stonecrop';
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 LazyLinkState with loading, loaded, error, reload, and data
22
+ * @public
23
+ */
24
+ export function useLazyLinkState(doctype, recordId, linkFieldname) {
25
+ const stonecropInstance = inject('$stonecrop') || Stonecrop._root;
26
+ if (!stonecropInstance) {
27
+ throw new Error('Stonecrop instance not available. Ensure useStonecrop() has been called first.');
28
+ }
29
+ const loading = ref(false);
30
+ const loaded = ref(false);
31
+ const error = ref(null);
32
+ const hstStore = stonecropInstance.getStore();
33
+ /**
34
+ * Build the HST path for a lazy link field
35
+ */
36
+ const getLinkPath = () => {
37
+ const slug = doctype.slug || doctype.doctype;
38
+ return `${slug}.${recordId}.${linkFieldname}`;
39
+ };
40
+ /**
41
+ * Get the link declaration from the doctype schema
42
+ */
43
+ const getLinkDeclaration = () => {
44
+ return doctype.links?.[linkFieldname]?.fetch;
45
+ };
46
+ /**
47
+ * Invoke a custom fetch handler
48
+ * The handler is a serialized function string that we execute via new Function()
49
+ */
50
+ const invokeCustomHandler = async (handler) => {
51
+ try {
52
+ // Create function from serialized string and invoke it
53
+ // The function receives the stonecrop instance and path as parameters
54
+ // eslint-disable-next-line @typescript-eslint/no-implied-eval
55
+ const fn = new Function('stonecrop', 'path', 'hst', `
56
+ return (${handler})(stonecrop, path, hst)
57
+ `);
58
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-call
59
+ return await fn(stonecropInstance, getLinkPath(), hstStore);
60
+ }
61
+ catch (err) {
62
+ throw new Error(`Custom handler failed: ${err instanceof Error ? err.message : String(err)}`);
63
+ }
64
+ };
65
+ /**
66
+ * Fetch the link data using the appropriate strategy
67
+ */
68
+ const fetchLinkData = async () => {
69
+ const linkFetch = getLinkDeclaration();
70
+ const ancestorPath = `${doctype.slug || doctype.doctype}.${recordId}`;
71
+ if (linkFetch?.method === 'custom') {
72
+ // Ensure ancestor path exists before invoking custom handler
73
+ if (!hstStore.has(ancestorPath)) {
74
+ hstStore.set(ancestorPath, {}, 'system');
75
+ }
76
+ // Custom handler - invoke directly
77
+ const result = await invokeCustomHandler(linkFetch.handler);
78
+ // Store result in HST at the link path
79
+ hstStore.set(getLinkPath(), result, 'system');
80
+ }
81
+ else {
82
+ // sync or lazy (both use fetchNestedData but with different includeNested)
83
+ // For lazy links, we still use fetchNestedData but only for this specific link
84
+ await stonecropInstance.fetchNestedData(ancestorPath, doctype, recordId, { includeNested: [linkFieldname] });
85
+ }
86
+ };
87
+ /**
88
+ * Explicitly reload the lazy link data
89
+ */
90
+ const reload = async () => {
91
+ if (loading.value)
92
+ return;
93
+ loading.value = true;
94
+ error.value = null;
95
+ try {
96
+ await fetchLinkData();
97
+ loaded.value = true;
98
+ }
99
+ catch (err) {
100
+ error.value = err instanceof Error ? err : new Error(String(err));
101
+ throw err;
102
+ }
103
+ finally {
104
+ loading.value = false;
105
+ }
106
+ };
107
+ /**
108
+ * Computed property that returns the loaded data from HST
109
+ */
110
+ const data = computed(() => {
111
+ if (!loaded.value)
112
+ return undefined;
113
+ return hstStore.get(getLinkPath());
114
+ });
115
+ return {
116
+ // State
117
+ loading,
118
+ loaded,
119
+ error,
120
+ // Computed
121
+ data,
122
+ // Actions
123
+ reload,
124
+ };
125
+ }
@@ -0,0 +1,476 @@
1
+ import { inject, onMounted, ref, watch, provide, computed } from 'vue';
2
+ import { Stonecrop } from '../stonecrop';
3
+ import { storeToRefs } from 'pinia';
4
+ /**
5
+ * @public
6
+ */
7
+ export function useStonecrop(options) {
8
+ if (!options)
9
+ options = {};
10
+ const registry = options.registry || inject('$registry');
11
+ const providedStonecrop = inject('$stonecrop');
12
+ const stonecrop = ref();
13
+ const hstStore = ref();
14
+ const formData = ref({});
15
+ // Use refs for router-loaded doctype to maintain reactivity
16
+ const routerDoctype = ref();
17
+ const routerRecordId = ref();
18
+ // Resolved schema with nested Doctype fields expanded
19
+ const resolvedSchema = ref([]);
20
+ // Auto-resolve schema when doctype is available
21
+ if (options.doctype && registry) {
22
+ const schemaArray = options.doctype.schema
23
+ ? Array.isArray(options.doctype.schema)
24
+ ? options.doctype.schema
25
+ : Array.from(options.doctype.schema)
26
+ : [];
27
+ resolvedSchema.value = registry.resolveSchema(schemaArray);
28
+ }
29
+ // Operation log state and methods - will be populated after stonecrop instance is created
30
+ const operations = ref([]);
31
+ const currentIndex = ref(-1);
32
+ const canUndo = computed(() => stonecrop.value?.getOperationLogStore().canUndo ?? false);
33
+ const canRedo = computed(() => stonecrop.value?.getOperationLogStore().canRedo ?? false);
34
+ const undoCount = computed(() => stonecrop.value?.getOperationLogStore().undoCount ?? 0);
35
+ const redoCount = computed(() => stonecrop.value?.getOperationLogStore().redoCount ?? 0);
36
+ const undoRedoState = computed(() => stonecrop.value?.getOperationLogStore().undoRedoState ?? {
37
+ canUndo: false,
38
+ canRedo: false,
39
+ undoCount: 0,
40
+ redoCount: 0,
41
+ currentIndex: -1,
42
+ });
43
+ // Operation log methods
44
+ const undo = (hstStore) => {
45
+ return stonecrop.value?.getOperationLogStore().undo(hstStore) ?? false;
46
+ };
47
+ const redo = (hstStore) => {
48
+ return stonecrop.value?.getOperationLogStore().redo(hstStore) ?? false;
49
+ };
50
+ const startBatch = () => {
51
+ stonecrop.value?.getOperationLogStore().startBatch();
52
+ };
53
+ const commitBatch = (description) => {
54
+ return stonecrop.value?.getOperationLogStore().commitBatch(description) ?? null;
55
+ };
56
+ const cancelBatch = () => {
57
+ stonecrop.value?.getOperationLogStore().cancelBatch();
58
+ };
59
+ const clear = () => {
60
+ stonecrop.value?.getOperationLogStore().clear();
61
+ };
62
+ const getOperationsFor = (doctype, recordId) => {
63
+ return stonecrop.value?.getOperationLogStore().getOperationsFor(doctype, recordId) ?? [];
64
+ };
65
+ const getSnapshot = () => {
66
+ return (stonecrop.value?.getOperationLogStore().getSnapshot() ?? {
67
+ operations: [],
68
+ currentIndex: -1,
69
+ totalOperations: 0,
70
+ reversibleOperations: 0,
71
+ irreversibleOperations: 0,
72
+ });
73
+ };
74
+ const markIrreversible = (operationId, reason) => {
75
+ stonecrop.value?.getOperationLogStore().markIrreversible(operationId, reason);
76
+ };
77
+ const logAction = (doctype, actionName, recordIds, result = 'success', error) => {
78
+ return stonecrop.value?.getOperationLogStore().logAction(doctype, actionName, recordIds, result, error) ?? '';
79
+ };
80
+ const configure = (config) => {
81
+ stonecrop.value?.getOperationLogStore().configure(config);
82
+ };
83
+ // Initialize Stonecrop instance
84
+ onMounted(async () => {
85
+ if (!registry) {
86
+ return;
87
+ }
88
+ stonecrop.value = providedStonecrop || new Stonecrop(registry);
89
+ // Set up reactive refs from operation log store - only if Pinia is available
90
+ try {
91
+ const opLogStore = stonecrop.value.getOperationLogStore();
92
+ const opLogRefs = storeToRefs(opLogStore);
93
+ operations.value = opLogRefs.operations.value;
94
+ currentIndex.value = opLogRefs.currentIndex.value;
95
+ // Watch for changes in operation log state
96
+ watch(() => opLogRefs.operations.value, newOps => {
97
+ operations.value = newOps;
98
+ });
99
+ watch(() => opLogRefs.currentIndex.value, newIndex => {
100
+ currentIndex.value = newIndex;
101
+ });
102
+ }
103
+ catch {
104
+ // Pinia not available (e.g., in tests) - operation log features will not be available
105
+ // Silently fail - operation log is optional
106
+ }
107
+ // Handle router-based setup if no specific doctype provided
108
+ if (!options.doctype && registry.router) {
109
+ const route = registry.router.currentRoute.value;
110
+ // Parse route path - let the application determine the doctype from the route
111
+ if (!route.path)
112
+ return; // Early return if no path available
113
+ const pathSegments = route.path.split('/').filter(segment => segment.length > 0);
114
+ const recordId = pathSegments[1]?.toLowerCase();
115
+ if (pathSegments.length > 0) {
116
+ // Create route context for getMeta function
117
+ const routeContext = {
118
+ path: route.path,
119
+ segments: pathSegments,
120
+ };
121
+ const doctype = await registry.getMeta?.(routeContext);
122
+ if (doctype) {
123
+ registry.addDoctype(doctype);
124
+ stonecrop.value.setup(doctype);
125
+ // Set reactive refs for router-based doctype
126
+ routerDoctype.value = doctype;
127
+ routerRecordId.value = recordId;
128
+ hstStore.value = stonecrop.value.getStore();
129
+ // Resolve schema for router-loaded doctype
130
+ if (registry) {
131
+ const schemaArray = doctype.schema
132
+ ? Array.isArray(doctype.schema)
133
+ ? doctype.schema
134
+ : Array.from(doctype.schema)
135
+ : [];
136
+ resolvedSchema.value = registry.resolveSchema(schemaArray);
137
+ }
138
+ if (recordId && recordId !== 'new') {
139
+ const existingRecord = stonecrop.value.getRecordById(doctype, recordId);
140
+ if (existingRecord) {
141
+ formData.value = existingRecord.get('') || {};
142
+ }
143
+ else {
144
+ try {
145
+ await stonecrop.value.getRecord(doctype, recordId);
146
+ const loadedRecord = stonecrop.value.getRecordById(doctype, recordId);
147
+ if (loadedRecord) {
148
+ formData.value = loadedRecord.get('') || {};
149
+ }
150
+ }
151
+ catch {
152
+ formData.value = initializeNewRecord(doctype);
153
+ }
154
+ }
155
+ }
156
+ else {
157
+ formData.value = initializeNewRecord(doctype);
158
+ }
159
+ if (hstStore.value) {
160
+ setupDeepReactivity(doctype, recordId || 'new', formData, hstStore.value);
161
+ }
162
+ stonecrop.value.runAction(doctype, 'load', recordId ? [recordId] : undefined);
163
+ }
164
+ }
165
+ }
166
+ // Handle HST integration if doctype is provided explicitly
167
+ if (options.doctype) {
168
+ hstStore.value = stonecrop.value.getStore();
169
+ const doctype = options.doctype;
170
+ const recordId = options.recordId;
171
+ if (recordId && recordId !== 'new') {
172
+ const existingRecord = stonecrop.value.getRecordById(doctype, recordId);
173
+ if (existingRecord) {
174
+ formData.value = existingRecord.get('') || {};
175
+ }
176
+ else {
177
+ try {
178
+ await stonecrop.value.getRecord(doctype, recordId);
179
+ const loadedRecord = stonecrop.value.getRecordById(doctype, recordId);
180
+ if (loadedRecord) {
181
+ formData.value = loadedRecord.get('') || {};
182
+ }
183
+ }
184
+ catch {
185
+ formData.value = initializeNewRecord(doctype);
186
+ }
187
+ }
188
+ }
189
+ else {
190
+ formData.value = initializeNewRecord(doctype);
191
+ }
192
+ if (hstStore.value) {
193
+ setupDeepReactivity(doctype, recordId || 'new', formData, hstStore.value);
194
+ }
195
+ }
196
+ });
197
+ // HST integration functions - always created but only populated when HST is available
198
+ const provideHSTPath = (fieldname, customRecordId) => {
199
+ const doctype = options.doctype || routerDoctype.value;
200
+ if (!doctype)
201
+ return '';
202
+ const actualRecordId = customRecordId || options.recordId || routerRecordId.value || 'new';
203
+ return `${doctype.slug}.${actualRecordId}.${fieldname}`;
204
+ };
205
+ const handleHSTChange = (changeData) => {
206
+ const doctype = options.doctype || routerDoctype.value;
207
+ if (!hstStore.value || !stonecrop.value || !doctype) {
208
+ return;
209
+ }
210
+ try {
211
+ const pathParts = changeData.path.split('.');
212
+ if (pathParts.length >= 2) {
213
+ const doctypeSlug = pathParts[0];
214
+ const recordId = pathParts[1];
215
+ if (!hstStore.value.has(`${doctypeSlug}.${recordId}`)) {
216
+ stonecrop.value.addRecord(doctype, recordId, { ...formData.value });
217
+ }
218
+ if (pathParts.length > 3) {
219
+ const recordPath = `${doctypeSlug}.${recordId}`;
220
+ const nestedParts = pathParts.slice(2);
221
+ let currentPath = recordPath;
222
+ for (let i = 0; i < nestedParts.length - 1; i++) {
223
+ currentPath += `.${nestedParts[i]}`;
224
+ if (!hstStore.value.has(currentPath)) {
225
+ const nextPart = nestedParts[i + 1];
226
+ const isArray = !isNaN(Number(nextPart));
227
+ hstStore.value.set(currentPath, isArray ? [] : {});
228
+ }
229
+ }
230
+ }
231
+ }
232
+ hstStore.value.set(changeData.path, changeData.value);
233
+ const fieldParts = changeData.fieldname.split('.');
234
+ const newFormData = { ...formData.value };
235
+ if (fieldParts.length === 1) {
236
+ newFormData[fieldParts[0]] = changeData.value;
237
+ }
238
+ else {
239
+ updateNestedObject(newFormData, fieldParts, changeData.value);
240
+ }
241
+ formData.value = newFormData;
242
+ }
243
+ catch {
244
+ // Silently handle errors
245
+ }
246
+ };
247
+ // Provide injection tokens if HST will be available
248
+ if (options.doctype || registry?.router) {
249
+ provide('hstPathProvider', provideHSTPath);
250
+ provide('hstChangeHandler', handleHSTChange);
251
+ }
252
+ /**
253
+ * Load nested doctype data from API or initialize empty structure
254
+ * @param parentPath - The parent path (e.g., "customer.123.address")
255
+ * @param childDoctype - The child doctype metadata
256
+ * @param recordId - Optional record ID to load
257
+ * @returns Promise resolving to the loaded or initialized data
258
+ */
259
+ const loadNestedData = (parentPath, childDoctype, recordId) => {
260
+ if (!stonecrop.value) {
261
+ return initializeNewRecord(childDoctype);
262
+ }
263
+ // If recordId provided, try to load existing data
264
+ if (recordId) {
265
+ try {
266
+ // Check if data already exists in HST
267
+ const existingData = hstStore.value?.get(parentPath);
268
+ if (existingData && typeof existingData === 'object') {
269
+ return existingData;
270
+ }
271
+ // TODO: Add API fetch logic here if needed
272
+ // For now, initialize new record
273
+ return initializeNewRecord(childDoctype);
274
+ }
275
+ catch {
276
+ return initializeNewRecord(childDoctype);
277
+ }
278
+ }
279
+ // Initialize new record
280
+ return initializeNewRecord(childDoctype);
281
+ };
282
+ /**
283
+ * Recursively save a record with all nested doctype fields
284
+ * @param doctype - The doctype metadata
285
+ * @param recordId - The record ID to save
286
+ * @returns The complete save payload
287
+ */
288
+ const saveRecursive = (doctype, recordId) => {
289
+ if (!hstStore.value || !stonecrop.value) {
290
+ throw new Error('HST store not initialized');
291
+ }
292
+ const recordPath = `${doctype.slug}.${recordId}`;
293
+ const recordData = hstStore.value.get(recordPath) || {};
294
+ // Build the save payload using resolved schema
295
+ const payload = { ...recordData };
296
+ // Use resolveSchema to get the full resolved tree, then walk Doctype fields
297
+ const schemaArray = (doctype.schema ? (Array.isArray(doctype.schema) ? doctype.schema : Array.from(doctype.schema)) : []);
298
+ const resolved = registry ? registry.resolveSchema(schemaArray) : schemaArray;
299
+ const doctypeFields = resolved.filter(field => 'fieldtype' in field && field.fieldtype === 'Doctype' && 'schema' in field && Array.isArray(field.schema));
300
+ // Recursively collect nested data from HST using resolved schemas
301
+ for (const field of doctypeFields) {
302
+ const doctypeField = field;
303
+ const fieldPath = `${recordPath}.${doctypeField.fieldname}`;
304
+ const nestedData = collectNestedData(doctypeField.schema, fieldPath, hstStore.value);
305
+ payload[doctypeField.fieldname] = nestedData;
306
+ }
307
+ return payload;
308
+ };
309
+ /**
310
+ * Create a nested context for child forms
311
+ * @param basePath - The base path for the nested context (e.g., "customer.123.address")
312
+ * @param _childDoctype - The child doctype metadata (unused but kept for API consistency)
313
+ * @returns Object with scoped provideHSTPath and handleHSTChange
314
+ */
315
+ const createNestedContext = (basePath, _childDoctype) => {
316
+ const nestedProvideHSTPath = (fieldname) => {
317
+ return `${basePath}.${fieldname}`;
318
+ };
319
+ const nestedHandleHSTChange = (changeData) => {
320
+ // Update the path to be relative to the nested base path
321
+ const nestedPath = changeData.path.startsWith(basePath) ? changeData.path : `${basePath}.${changeData.fieldname}`;
322
+ handleHSTChange({
323
+ ...changeData,
324
+ path: nestedPath,
325
+ });
326
+ };
327
+ return {
328
+ provideHSTPath: nestedProvideHSTPath,
329
+ handleHSTChange: nestedHandleHSTChange,
330
+ };
331
+ };
332
+ // Create operation log API object
333
+ const operationLog = {
334
+ operations,
335
+ currentIndex,
336
+ undoRedoState,
337
+ canUndo,
338
+ canRedo,
339
+ undoCount,
340
+ redoCount,
341
+ undo,
342
+ redo,
343
+ startBatch,
344
+ commitBatch,
345
+ cancelBatch,
346
+ clear,
347
+ getOperationsFor,
348
+ getSnapshot,
349
+ markIrreversible,
350
+ logAction,
351
+ configure,
352
+ };
353
+ // Always return HST functions if doctype is provided or will be loaded from router
354
+ if (options.doctype) {
355
+ // Explicit doctype - return HST immediately
356
+ return {
357
+ stonecrop,
358
+ operationLog,
359
+ provideHSTPath,
360
+ handleHSTChange,
361
+ hstStore,
362
+ formData,
363
+ resolvedSchema,
364
+ loadNestedData,
365
+ saveRecursive,
366
+ createNestedContext,
367
+ };
368
+ }
369
+ else if (!options.doctype && registry?.router) {
370
+ // Router-based - return HST (will be populated after mount)
371
+ return {
372
+ stonecrop,
373
+ operationLog,
374
+ provideHSTPath,
375
+ handleHSTChange,
376
+ hstStore,
377
+ formData,
378
+ resolvedSchema,
379
+ loadNestedData,
380
+ saveRecursive,
381
+ createNestedContext,
382
+ };
383
+ }
384
+ // No doctype and no router - basic mode
385
+ return {
386
+ stonecrop,
387
+ operationLog,
388
+ };
389
+ }
390
+ /**
391
+ * Initialize new record structure based on doctype schema
392
+ */
393
+ function initializeNewRecord(doctype) {
394
+ const initialData = {};
395
+ if (!doctype.schema) {
396
+ return initialData;
397
+ }
398
+ doctype.schema.forEach(field => {
399
+ const fieldtype = 'fieldtype' in field ? field.fieldtype : 'Data';
400
+ switch (fieldtype) {
401
+ case 'Data':
402
+ case 'Text':
403
+ initialData[field.fieldname] = '';
404
+ break;
405
+ case 'Check':
406
+ initialData[field.fieldname] = false;
407
+ break;
408
+ case 'Int':
409
+ case 'Float':
410
+ initialData[field.fieldname] = 0;
411
+ break;
412
+ case 'Table':
413
+ initialData[field.fieldname] = [];
414
+ break;
415
+ case 'JSON':
416
+ initialData[field.fieldname] = {};
417
+ break;
418
+ default:
419
+ initialData[field.fieldname] = null;
420
+ }
421
+ });
422
+ return initialData;
423
+ }
424
+ /**
425
+ * Setup deep reactivity between form data and HST store
426
+ */
427
+ function setupDeepReactivity(doctype, recordId, formData, hstStore) {
428
+ watch(formData, newData => {
429
+ const recordPath = `${doctype.slug}.${recordId}`;
430
+ Object.keys(newData).forEach(fieldname => {
431
+ const path = `${recordPath}.${fieldname}`;
432
+ try {
433
+ hstStore.set(path, newData[fieldname]);
434
+ }
435
+ catch {
436
+ // Silently handle errors
437
+ }
438
+ });
439
+ }, { deep: true });
440
+ }
441
+ /**
442
+ * Update nested object with dot-notation path
443
+ */
444
+ function updateNestedObject(obj, path, value) {
445
+ let current = obj;
446
+ for (let i = 0; i < path.length - 1; i++) {
447
+ const key = path[i];
448
+ if (!(key in current) || typeof current[key] !== 'object') {
449
+ current[key] = isNaN(Number(path[i + 1])) ? {} : [];
450
+ }
451
+ current = current[key];
452
+ }
453
+ const finalKey = path[path.length - 1];
454
+ current[finalKey] = value;
455
+ }
456
+ /**
457
+ * Recursively collect nested data from HST using pre-resolved schemas
458
+ * @param resolvedSchema - The already-resolved schema (with nested schemas embedded)
459
+ * @param basePath - The base path in HST (e.g., "customer.123.address")
460
+ * @param hstStore - The HST store instance
461
+ * @returns The collected data object
462
+ */
463
+ function collectNestedData(resolvedSchema, basePath, hstStore) {
464
+ const data = hstStore.get(basePath) || {};
465
+ const payload = { ...data };
466
+ // Find Doctype fields that have resolved child schemas
467
+ const doctypeFields = resolvedSchema.filter(field => 'fieldtype' in field && field.fieldtype === 'Doctype' && 'schema' in field && Array.isArray(field.schema));
468
+ // Recursively collect nested data
469
+ for (const field of doctypeFields) {
470
+ const doctypeField = field;
471
+ const fieldPath = `${basePath}.${doctypeField.fieldname}`;
472
+ const nestedData = collectNestedData(doctypeField.schema, fieldPath, hstStore);
473
+ payload[doctypeField.fieldname] = nestedData;
474
+ }
475
+ return payload;
476
+ }
package/dist/doctype.js CHANGED
@@ -42,6 +42,12 @@ export default class Doctype {
42
42
  * @readonly
43
43
  */
44
44
  component;
45
+ /**
46
+ * Relationship links to other doctypes
47
+ * @public
48
+ * @readonly
49
+ */
50
+ links;
45
51
  /**
46
52
  * Creates a new Doctype instance
47
53
  * @param doctype - The doctype name
@@ -49,13 +55,15 @@ export default class Doctype {
49
55
  * @param workflow - The doctype workflow configuration (XState machine)
50
56
  * @param actions - The doctype actions and field triggers
51
57
  * @param component - Optional Vue component for rendering the doctype
58
+ * @param links - Optional relationship links to other doctypes
52
59
  */
53
- constructor(doctype, schema, workflow, actions, component) {
60
+ constructor(doctype, schema, workflow, actions, component, links) {
54
61
  this.doctype = doctype;
55
62
  this.schema = schema;
56
63
  this.workflow = workflow;
57
64
  this.actions = actions;
58
65
  this.component = component;
66
+ this.links = links;
59
67
  }
60
68
  /**
61
69
  * Creates a Doctype instance from a plain configuration object.
@@ -98,7 +106,7 @@ export default class Doctype {
98
106
  static fromObject(config) {
99
107
  const schema = config.fields ? List(config.fields) : List();
100
108
  const actions = config.actions ? Map(config.actions) : Map();
101
- return new Doctype(config.name, schema, config.workflow, actions);
109
+ return new Doctype(config.name, schema, config.workflow, actions, undefined, config.links);
102
110
  }
103
111
  /**
104
112
  * Returns the schema as a plain array for use with components that expect
@@ -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
- this.options.errorHandler(failedResult.error, context, failedResult.action);
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
- this.options.errorHandler(failedResult.error, context, failedResult.action);
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