@stonecrop/stonecrop 0.12.7 → 0.13.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 (55) hide show
  1. package/dist/composable.js +1 -0
  2. package/dist/composables/lazy-link.js +125 -0
  3. package/dist/composables/operation-log.js +224 -0
  4. package/dist/composables/stonecrop.js +504 -0
  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 +242 -0
  8. package/dist/exceptions.js +16 -0
  9. package/dist/field-triggers.js +575 -0
  10. package/dist/index.js +27 -0
  11. package/dist/operation-log-DB-dGNT9.js +593 -0
  12. package/dist/operation-log-DB-dGNT9.js.map +1 -0
  13. package/dist/plugins/index.js +99 -0
  14. package/dist/registry.js +423 -0
  15. package/dist/schema-validator.js +407 -0
  16. package/dist/src/composable.d.ts +11 -0
  17. package/dist/src/composable.d.ts.map +1 -0
  18. package/dist/src/composable.js +477 -0
  19. package/dist/src/composables/use-lazy-link-state.d.ts +25 -0
  20. package/dist/src/composables/use-lazy-link-state.d.ts.map +1 -0
  21. package/dist/src/composables/use-stonecrop.d.ts +93 -0
  22. package/dist/src/composables/use-stonecrop.d.ts.map +1 -0
  23. package/dist/src/composables/useNestedSchema.d.ts +110 -0
  24. package/dist/src/composables/useNestedSchema.d.ts.map +1 -0
  25. package/dist/src/composables/useNestedSchema.js +155 -0
  26. package/dist/src/stores/data.d.ts +11 -0
  27. package/dist/src/stores/data.d.ts.map +1 -0
  28. package/dist/src/stores/xstate.d.ts +31 -0
  29. package/dist/src/stores/xstate.d.ts.map +1 -0
  30. package/dist/src/tsdoc-metadata.json +11 -0
  31. package/dist/src/utils.d.ts +24 -0
  32. package/dist/src/utils.d.ts.map +1 -0
  33. package/dist/stonecrop.css +1 -0
  34. package/dist/stonecrop.umd.cjs +6 -0
  35. package/dist/stonecrop.umd.cjs.map +1 -0
  36. package/dist/stores/data.js +7 -0
  37. package/dist/stores/hst.js +496 -0
  38. package/dist/stores/index.js +12 -0
  39. package/dist/stores/operation-log.js +580 -0
  40. package/dist/stores/xstate.js +29 -0
  41. package/dist/tests/setup.d.ts +5 -0
  42. package/dist/tests/setup.d.ts.map +1 -0
  43. package/dist/tests/setup.js +15 -0
  44. package/dist/types/composable.js +0 -0
  45. package/dist/types/doctype.js +0 -0
  46. package/dist/types/field-triggers.js +4 -0
  47. package/dist/types/hst.js +0 -0
  48. package/dist/types/index.js +10 -0
  49. package/dist/types/operation-log.js +0 -0
  50. package/dist/types/plugin.js +0 -0
  51. package/dist/types/registry.js +0 -0
  52. package/dist/types/schema-validator.js +13 -0
  53. package/dist/types/stonecrop.js +0 -0
  54. package/dist/utils.js +46 -0
  55. package/package.json +4 -4
@@ -0,0 +1,504 @@
1
+ import { storeToRefs } from 'pinia';
2
+ import { inject, onMounted, ref, watch, provide, computed } from 'vue';
3
+ import { Stonecrop } from '../stonecrop';
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
+ // Loading state for lazy-loaded doctypes
21
+ const isLoading = ref(false);
22
+ const error = ref(null);
23
+ const resolvedDoctype = ref();
24
+ // Workflow readiness computed properties
25
+ const isWorkflowReady = computed(() => {
26
+ if (!stonecrop.value || !resolvedDoctype.value || !options.recordId || options.recordId === 'new') {
27
+ return true;
28
+ }
29
+ const status = stonecrop.value.isWorkflowReady(resolvedDoctype.value, options.recordId);
30
+ return status.ready;
31
+ });
32
+ const blockedLinks = computed(() => {
33
+ if (!stonecrop.value || !resolvedDoctype.value || !options.recordId || options.recordId === 'new') {
34
+ return [];
35
+ }
36
+ const status = stonecrop.value.isWorkflowReady(resolvedDoctype.value, options.recordId);
37
+ return status.blockedLinks ?? [];
38
+ });
39
+ // Initialize stonecrop instance synchronously using singleton pattern
40
+ // Use injected instance if available, otherwise fall back to the singleton root
41
+ const stonecropInstance = providedStonecrop || Stonecrop._root;
42
+ if (stonecropInstance) {
43
+ stonecrop.value = stonecropInstance;
44
+ }
45
+ // If doctype is a Doctype instance (not string), set resolved immediately
46
+ if (options?.doctype && typeof options.doctype !== 'string') {
47
+ resolvedDoctype.value = options.doctype;
48
+ }
49
+ // Operation log state and methods
50
+ const operations = ref([]);
51
+ const currentIndex = ref(-1);
52
+ const canUndo = computed(() => stonecrop.value?.getOperationLogStore().canUndo ?? false);
53
+ const canRedo = computed(() => stonecrop.value?.getOperationLogStore().canRedo ?? false);
54
+ const undoCount = computed(() => stonecrop.value?.getOperationLogStore().undoCount ?? 0);
55
+ const redoCount = computed(() => stonecrop.value?.getOperationLogStore().redoCount ?? 0);
56
+ const undoRedoState = computed(() => stonecrop.value?.getOperationLogStore().undoRedoState ?? {
57
+ canUndo: false,
58
+ canRedo: false,
59
+ undoCount: 0,
60
+ redoCount: 0,
61
+ currentIndex: -1,
62
+ });
63
+ // Operation log methods
64
+ const undo = (hstStore) => {
65
+ return stonecrop.value?.getOperationLogStore().undo(hstStore) ?? false;
66
+ };
67
+ const redo = (hstStore) => {
68
+ return stonecrop.value?.getOperationLogStore().redo(hstStore) ?? false;
69
+ };
70
+ const startBatch = () => {
71
+ stonecrop.value?.getOperationLogStore().startBatch();
72
+ };
73
+ const commitBatch = (description) => {
74
+ return stonecrop.value?.getOperationLogStore().commitBatch(description) ?? null;
75
+ };
76
+ const cancelBatch = () => {
77
+ stonecrop.value?.getOperationLogStore().cancelBatch();
78
+ };
79
+ const clear = () => {
80
+ stonecrop.value?.getOperationLogStore().clear();
81
+ };
82
+ const getOperationsFor = (doctype, recordId) => {
83
+ return stonecrop.value?.getOperationLogStore().getOperationsFor(doctype, recordId) ?? [];
84
+ };
85
+ const getSnapshot = () => {
86
+ return (stonecrop.value?.getOperationLogStore().getSnapshot() ?? {
87
+ operations: [],
88
+ currentIndex: -1,
89
+ totalOperations: 0,
90
+ reversibleOperations: 0,
91
+ irreversibleOperations: 0,
92
+ });
93
+ };
94
+ const markIrreversible = (operationId, reason) => {
95
+ stonecrop.value?.getOperationLogStore().markIrreversible(operationId, reason);
96
+ };
97
+ const logAction = (doctype, actionName, recordIds, result = 'success', error) => {
98
+ return stonecrop.value?.getOperationLogStore().logAction(doctype, actionName, recordIds, result, error) ?? '';
99
+ };
100
+ const configure = (config) => {
101
+ stonecrop.value?.getOperationLogStore().configure(config);
102
+ };
103
+ // Wire operation log reactive state synchronously — no lifecycle hook needed.
104
+ // storeToRefs and watch are both safe to call in setup() body.
105
+ if (registry && stonecrop.value) {
106
+ try {
107
+ const opLogStore = stonecrop.value.getOperationLogStore();
108
+ const opLogRefs = storeToRefs(opLogStore);
109
+ operations.value = opLogRefs.operations.value;
110
+ currentIndex.value = opLogRefs.currentIndex.value;
111
+ // Watch for changes in operation log state
112
+ watch(() => opLogRefs.operations.value, newOps => {
113
+ operations.value = newOps;
114
+ });
115
+ watch(() => opLogRefs.currentIndex.value, newIndex => {
116
+ currentIndex.value = newIndex;
117
+ });
118
+ }
119
+ catch {
120
+ // Pinia not available — operation log is optional, silently skip
121
+ }
122
+ }
123
+ // Synchronous HST initialisation for an explicit Doctype instance.
124
+ // When the caller passes a Doctype object (not a slug string), every piece of
125
+ // setup that doesn't require network I/O runs here during setup() so that
126
+ // hstStore, resolvedSchema, and formData are populated before the first render
127
+ // and are immediately available to callers without any await.
128
+ if (options.doctype && typeof options.doctype !== 'string' && registry && stonecrop.value) {
129
+ hstStore.value = stonecrop.value.getStore();
130
+ resolvedSchema.value = registry.resolveSchema(options.doctype);
131
+ if (!options.recordId || options.recordId === 'new') {
132
+ formData.value = registry.initializeRecord(resolvedSchema.value);
133
+ }
134
+ if (hstStore.value) {
135
+ setupDeepReactivity(options.doctype, options.recordId || 'new', formData, hstStore.value);
136
+ }
137
+ }
138
+ // onMounted handles only work that is genuinely async: lazy-loading a doctype
139
+ // by slug, fetching an existing record from the server, and router-based setup.
140
+ onMounted(async () => {
141
+ if (!registry || !stonecrop.value) {
142
+ return;
143
+ }
144
+ // Handle router-based setup if no specific doctype provided
145
+ if (!options.doctype && registry.router) {
146
+ const route = registry.router.currentRoute.value;
147
+ // Parse route path - let the application determine the doctype from the route
148
+ if (!route.path)
149
+ return; // Early return if no path available
150
+ const pathSegments = route.path.split('/').filter(segment => segment.length > 0);
151
+ const recordId = pathSegments[1]?.toLowerCase();
152
+ if (pathSegments.length > 0) {
153
+ // Create route context for getMeta function
154
+ const routeContext = {
155
+ path: route.path,
156
+ segments: pathSegments,
157
+ };
158
+ const doctype = await registry.getMeta?.(routeContext);
159
+ if (doctype) {
160
+ registry.addDoctype(doctype);
161
+ stonecrop.value.setup(doctype);
162
+ // Set reactive refs for router-based doctype
163
+ routerDoctype.value = doctype;
164
+ routerRecordId.value = recordId;
165
+ hstStore.value = stonecrop.value.getStore();
166
+ // Resolve schema for router-loaded doctype
167
+ if (registry) {
168
+ resolvedSchema.value = registry.resolveSchema(doctype);
169
+ }
170
+ if (recordId && recordId !== 'new') {
171
+ const existingRecord = stonecrop.value.getRecordById(doctype, recordId);
172
+ if (existingRecord) {
173
+ formData.value = existingRecord.get('') || {};
174
+ }
175
+ else {
176
+ try {
177
+ await stonecrop.value.getRecord(doctype, recordId);
178
+ const loadedRecord = stonecrop.value.getRecordById(doctype, recordId);
179
+ if (loadedRecord) {
180
+ formData.value = loadedRecord.get('') || {};
181
+ }
182
+ }
183
+ catch {
184
+ formData.value = registry.initializeRecord(resolvedSchema.value);
185
+ }
186
+ }
187
+ }
188
+ else {
189
+ formData.value = registry.initializeRecord(resolvedSchema.value);
190
+ }
191
+ if (hstStore.value) {
192
+ setupDeepReactivity(doctype, recordId || 'new', formData, hstStore.value);
193
+ }
194
+ stonecrop.value.runAction(doctype, 'load', recordId ? [recordId] : undefined);
195
+ }
196
+ }
197
+ }
198
+ // Handle HST integration if doctype is provided explicitly
199
+ if (options.doctype) {
200
+ const recordId = options.recordId;
201
+ if (typeof options.doctype === 'string') {
202
+ // String doctype — resolve lazily, then do full sync-equivalent setup here.
203
+ const doctypeSlug = options.doctype;
204
+ hstStore.value = stonecrop.value.getStore();
205
+ isLoading.value = true;
206
+ error.value = null;
207
+ let doctype;
208
+ try {
209
+ // Check if already in registry
210
+ doctype = registry.getDoctype(doctypeSlug);
211
+ if (!doctype && registry.getMeta) {
212
+ // Lazy-load via getMeta
213
+ const routeContext = {
214
+ path: `/${doctypeSlug}`,
215
+ segments: [doctypeSlug],
216
+ };
217
+ doctype = await registry.getMeta(routeContext);
218
+ if (doctype) {
219
+ registry.addDoctype(doctype);
220
+ }
221
+ }
222
+ if (!doctype) {
223
+ error.value = new Error(`Doctype '${doctypeSlug}' not found in registry and getMeta returned no result`);
224
+ }
225
+ }
226
+ catch (e) {
227
+ error.value = e instanceof Error ? e : new Error(String(e));
228
+ }
229
+ finally {
230
+ isLoading.value = false;
231
+ }
232
+ resolvedDoctype.value = doctype;
233
+ if (!doctype)
234
+ return;
235
+ resolvedSchema.value = registry.resolveSchema(doctype);
236
+ if (recordId && recordId !== 'new') {
237
+ const existingRecord = stonecrop.value.getRecordById(doctype, recordId);
238
+ if (existingRecord) {
239
+ formData.value = existingRecord.get('') || {};
240
+ }
241
+ else {
242
+ try {
243
+ await stonecrop.value.getRecord(doctype, recordId);
244
+ const loadedRecord = stonecrop.value.getRecordById(doctype, recordId);
245
+ if (loadedRecord) {
246
+ formData.value = loadedRecord.get('') || {};
247
+ }
248
+ }
249
+ catch {
250
+ formData.value = registry.initializeRecord(resolvedSchema.value);
251
+ }
252
+ }
253
+ }
254
+ else {
255
+ formData.value = registry.initializeRecord(resolvedSchema.value);
256
+ }
257
+ if (hstStore.value) {
258
+ setupDeepReactivity(doctype, recordId || 'new', formData, hstStore.value);
259
+ }
260
+ }
261
+ else {
262
+ // Doctype instance — sync init was done during setup().
263
+ // Only handle the async path: fetching an existing record from the server.
264
+ if (recordId && recordId !== 'new') {
265
+ const doctype = options.doctype;
266
+ const existingRecord = stonecrop.value.getRecordById(doctype, recordId);
267
+ if (existingRecord) {
268
+ formData.value = existingRecord.get('') || {};
269
+ }
270
+ else {
271
+ try {
272
+ await stonecrop.value.getRecord(doctype, recordId);
273
+ const loadedRecord = stonecrop.value.getRecordById(doctype, recordId);
274
+ if (loadedRecord) {
275
+ formData.value = loadedRecord.get('') || {};
276
+ }
277
+ }
278
+ catch {
279
+ formData.value = registry.initializeRecord(resolvedSchema.value);
280
+ }
281
+ }
282
+ }
283
+ }
284
+ }
285
+ });
286
+ // HST integration functions - always created but only populated when HST is available
287
+ const provideHSTPath = (fieldname, customRecordId) => {
288
+ const doctype = resolvedDoctype.value || routerDoctype.value;
289
+ if (!doctype)
290
+ return '';
291
+ const actualRecordId = customRecordId || options.recordId || routerRecordId.value || 'new';
292
+ return `${doctype.slug}.${actualRecordId}.${fieldname}`;
293
+ };
294
+ const handleHSTChange = (changeData) => {
295
+ const doctype = resolvedDoctype.value || routerDoctype.value;
296
+ if (!hstStore.value || !stonecrop.value || !doctype) {
297
+ return;
298
+ }
299
+ try {
300
+ const pathParts = changeData.path.split('.');
301
+ if (pathParts.length >= 2) {
302
+ const doctypeSlug = pathParts[0];
303
+ const recordId = pathParts[1];
304
+ if (!hstStore.value.has(`${doctypeSlug}.${recordId}`)) {
305
+ stonecrop.value.addRecord(doctype, recordId, { ...formData.value });
306
+ }
307
+ if (pathParts.length > 3) {
308
+ const recordPath = `${doctypeSlug}.${recordId}`;
309
+ const nestedParts = pathParts.slice(2);
310
+ let currentPath = recordPath;
311
+ for (let i = 0; i < nestedParts.length - 1; i++) {
312
+ currentPath += `.${nestedParts[i]}`;
313
+ if (!hstStore.value.has(currentPath)) {
314
+ const nextPart = nestedParts[i + 1];
315
+ const isArray = !isNaN(Number(nextPart));
316
+ hstStore.value.set(currentPath, isArray ? [] : {});
317
+ }
318
+ }
319
+ }
320
+ }
321
+ hstStore.value.set(changeData.path, changeData.value);
322
+ const fieldParts = changeData.fieldname.split('.');
323
+ const newFormData = { ...formData.value };
324
+ if (fieldParts.length === 1) {
325
+ newFormData[fieldParts[0]] = changeData.value;
326
+ }
327
+ else {
328
+ updateNestedObject(newFormData, fieldParts, changeData.value);
329
+ }
330
+ formData.value = newFormData;
331
+ }
332
+ catch {
333
+ // Silently handle errors
334
+ }
335
+ };
336
+ // Provide injection tokens if HST will be available
337
+ if (options.doctype || registry?.router) {
338
+ provide('hstPathProvider', provideHSTPath);
339
+ provide('hstChangeHandler', handleHSTChange);
340
+ }
341
+ /**
342
+ * Scaffold empty descendant records from defaults for all descendant links.
343
+ * Delegates to Stonecrop.initializeNestedData method.
344
+ * @param path - The HST path where initialized data should be stored
345
+ * @param doctype - The doctype to initialize
346
+ */
347
+ const initializeNestedData = (path, doctype) => {
348
+ if (!stonecrop.value) {
349
+ throw new Error('Stonecrop instance not available');
350
+ }
351
+ return stonecrop.value.initializeNestedData(path, doctype);
352
+ };
353
+ /**
354
+ * Fetch a record and its nested data from the server.
355
+ * Delegates to Stonecrop.fetchNestedData method.
356
+ * @param path - The HST path (e.g., "recipe.r1")
357
+ * @param doctype - The doctype to fetch
358
+ * @param recordId - Record ID to fetch
359
+ * @param options - Query options (includeNested to control which links are fetched)
360
+ */
361
+ const fetchNestedData = async (path, doctype, recordId, options) => {
362
+ if (!stonecrop.value) {
363
+ throw new Error('Stonecrop instance not available');
364
+ }
365
+ return stonecrop.value.fetchNestedData(path, doctype, recordId, options);
366
+ };
367
+ /**
368
+ * Collect a record payload with all nested doctype fields from HST
369
+ * Delegates to Stonecrop.collectRecordPayload method
370
+ * @param doctype - The doctype metadata
371
+ * @param recordId - The record ID to collect
372
+ * @returns The complete record payload ready for API submission
373
+ */
374
+ const collectRecordPayload = (doctype, recordId) => {
375
+ if (!stonecrop.value) {
376
+ throw new Error('Stonecrop instance not available');
377
+ }
378
+ return stonecrop.value.collectRecordPayload(doctype, recordId);
379
+ };
380
+ /**
381
+ * Create a nested context for descendant forms
382
+ * @param basePath - The base path for the nested context (e.g., "customer.123.address")
383
+ * @param _descendantDoctype - The descendant doctype metadata (unused but kept for API consistency)
384
+ * @returns Object with scoped provideHSTPath and handleHSTChange
385
+ */
386
+ const createNestedContext = (basePath, _descendantDoctype) => {
387
+ const nestedProvideHSTPath = (fieldname) => {
388
+ return `${basePath}.${fieldname}`;
389
+ };
390
+ const nestedHandleHSTChange = (changeData) => {
391
+ // Update the path to be relative to the nested base path
392
+ const nestedPath = changeData.path.startsWith(basePath) ? changeData.path : `${basePath}.${changeData.fieldname}`;
393
+ handleHSTChange({
394
+ ...changeData,
395
+ path: nestedPath,
396
+ });
397
+ };
398
+ return {
399
+ provideHSTPath: nestedProvideHSTPath,
400
+ handleHSTChange: nestedHandleHSTChange,
401
+ };
402
+ };
403
+ // Create operation log API object
404
+ const operationLog = {
405
+ operations,
406
+ currentIndex,
407
+ undoRedoState,
408
+ canUndo,
409
+ canRedo,
410
+ undoCount,
411
+ redoCount,
412
+ undo,
413
+ redo,
414
+ startBatch,
415
+ commitBatch,
416
+ cancelBatch,
417
+ clear,
418
+ getOperationsFor,
419
+ getSnapshot,
420
+ markIrreversible,
421
+ logAction,
422
+ configure,
423
+ };
424
+ // Always return HST functions if doctype is provided or will be loaded from router
425
+ if (options.doctype) {
426
+ // Explicit doctype - return HST immediately
427
+ return {
428
+ stonecrop,
429
+ operationLog,
430
+ provideHSTPath,
431
+ handleHSTChange,
432
+ hstStore,
433
+ formData,
434
+ resolvedSchema,
435
+ initializeNestedData,
436
+ fetchNestedData,
437
+ collectRecordPayload,
438
+ createNestedContext,
439
+ isLoading,
440
+ error,
441
+ resolvedDoctype,
442
+ isWorkflowReady,
443
+ blockedLinks,
444
+ };
445
+ }
446
+ else if (!options.doctype && registry?.router) {
447
+ // Router-based - return HST (will be populated after mount)
448
+ return {
449
+ stonecrop,
450
+ operationLog,
451
+ provideHSTPath,
452
+ handleHSTChange,
453
+ hstStore,
454
+ formData,
455
+ resolvedSchema,
456
+ initializeNestedData,
457
+ fetchNestedData,
458
+ collectRecordPayload,
459
+ createNestedContext,
460
+ isLoading,
461
+ error,
462
+ resolvedDoctype,
463
+ isWorkflowReady,
464
+ blockedLinks,
465
+ };
466
+ }
467
+ // No doctype and no router - basic mode
468
+ return {
469
+ stonecrop,
470
+ operationLog,
471
+ };
472
+ }
473
+ /**
474
+ * Setup deep reactivity between form data and HST store
475
+ */
476
+ function setupDeepReactivity(doctype, recordId, formData, hstStore) {
477
+ watch(formData, newData => {
478
+ const recordPath = `${doctype.slug}.${recordId}`;
479
+ Object.keys(newData).forEach(fieldname => {
480
+ const path = `${recordPath}.${fieldname}`;
481
+ try {
482
+ hstStore.set(path, newData[fieldname]);
483
+ }
484
+ catch {
485
+ // Silently handle errors
486
+ }
487
+ });
488
+ }, { deep: true });
489
+ }
490
+ /**
491
+ * Update nested object with dot-notation path
492
+ */
493
+ function updateNestedObject(obj, path, value) {
494
+ let current = obj;
495
+ for (let i = 0; i < path.length - 1; i++) {
496
+ const key = path[i];
497
+ if (!(key in current) || typeof current[key] !== 'object') {
498
+ current[key] = isNaN(Number(path[i + 1])) ? {} : [];
499
+ }
500
+ current = current[key];
501
+ }
502
+ const finalKey = path[path.length - 1];
503
+ current[finalKey] = value;
504
+ }
@@ -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
+ }