@stonecrop/stonecrop 0.7.8 → 0.8.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stonecrop/stonecrop",
3
- "version": "0.7.8",
3
+ "version": "0.8.0",
4
4
  "description": "schema helper",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -60,9 +60,9 @@
60
60
  "typescript-eslint": "^8.53.0",
61
61
  "vite": "^7.3.1",
62
62
  "vitest": "^4.0.17",
63
- "@stonecrop/aform": "0.7.8",
63
+ "@stonecrop/aform": "0.8.0",
64
64
  "stonecrop-rig": "0.7.0",
65
- "@stonecrop/atable": "0.7.8"
65
+ "@stonecrop/atable": "0.8.0"
66
66
  },
67
67
  "publishConfig": {
68
68
  "access": "public"
package/src/composable.ts CHANGED
@@ -8,6 +8,7 @@ import type { HSTNode } from './stores/hst'
8
8
  import { RouteContext } from './types/registry'
9
9
  import { storeToRefs } from 'pinia'
10
10
  import type { HSTOperation, OperationLogConfig, OperationLogSnapshot } from './types/operation-log'
11
+ import { SchemaTypes, DoctypeSchema } from '@stonecrop/aform'
11
12
 
12
13
  /**
13
14
  * Operation Log API - nested object containing all operation log functionality
@@ -64,6 +65,16 @@ export type HSTStonecropReturn = BaseStonecropReturn & {
64
65
  handleHSTChange: (changeData: HSTChangeData) => void
65
66
  hstStore: Ref<HSTNode | undefined>
66
67
  formData: Ref<Record<string, any>>
68
+ resolvedSchema: Ref<SchemaTypes[]>
69
+ loadNestedData: (parentPath: string, childDoctype: DoctypeMeta, recordId?: string) => Record<string, any>
70
+ saveRecursive: (doctype: DoctypeMeta, recordId: string) => Promise<Record<string, any>>
71
+ createNestedContext: (
72
+ basePath: string,
73
+ childDoctype: DoctypeMeta
74
+ ) => {
75
+ provideHSTPath: (fieldname: string) => string
76
+ handleHSTChange: (changeData: HSTChangeData) => void
77
+ }
67
78
  }
68
79
 
69
80
  /**
@@ -117,6 +128,19 @@ export function useStonecrop(options?: {
117
128
  const routerDoctype = ref<DoctypeMeta | undefined>()
118
129
  const routerRecordId = ref<string | undefined>()
119
130
 
131
+ // Resolved schema with nested Doctype fields expanded
132
+ const resolvedSchema = ref<SchemaTypes[]>([])
133
+
134
+ // Auto-resolve schema when doctype is available
135
+ if (options.doctype && registry) {
136
+ const schemaArray = options.doctype.schema
137
+ ? Array.isArray(options.doctype.schema)
138
+ ? options.doctype.schema
139
+ : Array.from(options.doctype.schema)
140
+ : []
141
+ resolvedSchema.value = registry.resolveSchema(schemaArray as SchemaTypes[])
142
+ }
143
+
120
144
  // Operation log state and methods - will be populated after stonecrop instance is created
121
145
  const operations = ref<HSTOperation[]>([])
122
146
  const currentIndex = ref(-1)
@@ -254,6 +278,16 @@ export function useStonecrop(options?: {
254
278
  routerRecordId.value = recordId
255
279
  hstStore.value = stonecrop.value.getStore()
256
280
 
281
+ // Resolve schema for router-loaded doctype
282
+ if (registry) {
283
+ const schemaArray = doctype.schema
284
+ ? Array.isArray(doctype.schema)
285
+ ? doctype.schema
286
+ : Array.from(doctype.schema)
287
+ : []
288
+ resolvedSchema.value = registry.resolveSchema(schemaArray as SchemaTypes[])
289
+ }
290
+
257
291
  if (recordId && recordId !== 'new') {
258
292
  const existingRecord = stonecrop.value.getRecordById(doctype, recordId)
259
293
  if (existingRecord) {
@@ -378,6 +412,103 @@ export function useStonecrop(options?: {
378
412
  provide('hstChangeHandler', handleHSTChange)
379
413
  }
380
414
 
415
+ /**
416
+ * Load nested doctype data from API or initialize empty structure
417
+ * @param parentPath - The parent path (e.g., "customer.123.address")
418
+ * @param childDoctype - The child doctype metadata
419
+ * @param recordId - Optional record ID to load
420
+ * @returns Promise resolving to the loaded or initialized data
421
+ */
422
+ const loadNestedData = (parentPath: string, childDoctype: DoctypeMeta, recordId?: string): Record<string, any> => {
423
+ if (!stonecrop.value) {
424
+ return initializeNewRecord(childDoctype)
425
+ }
426
+
427
+ // If recordId provided, try to load existing data
428
+ if (recordId) {
429
+ try {
430
+ // Check if data already exists in HST
431
+ const existingData = hstStore.value?.get(parentPath)
432
+ if (existingData && typeof existingData === 'object') {
433
+ return existingData as Record<string, any>
434
+ }
435
+
436
+ // TODO: Add API fetch logic here if needed
437
+ // For now, initialize new record
438
+ return initializeNewRecord(childDoctype)
439
+ } catch {
440
+ return initializeNewRecord(childDoctype)
441
+ }
442
+ }
443
+
444
+ // Initialize new record
445
+ return initializeNewRecord(childDoctype)
446
+ }
447
+
448
+ /**
449
+ * Recursively save a record with all nested doctype fields
450
+ * @param doctype - The doctype metadata
451
+ * @param recordId - The record ID to save
452
+ * @returns Promise resolving to the complete save payload
453
+ */
454
+ const saveRecursive = async (doctype: DoctypeMeta, recordId: string): Promise<Record<string, any>> => {
455
+ if (!hstStore.value || !stonecrop.value) {
456
+ throw new Error('HST store not initialized')
457
+ }
458
+
459
+ const recordPath = `${doctype.slug}.${recordId}`
460
+ const recordData = hstStore.value.get(recordPath) || {}
461
+
462
+ // Build the save payload using resolved schema
463
+ const payload: Record<string, any> = { ...recordData }
464
+
465
+ // Use resolveSchema to get the full resolved tree, then walk Doctype fields
466
+ const schemaArray = (
467
+ doctype.schema ? (Array.isArray(doctype.schema) ? doctype.schema : Array.from(doctype.schema)) : []
468
+ ) as SchemaTypes[]
469
+ const resolved = registry ? registry.resolveSchema(schemaArray) : schemaArray
470
+ const doctypeFields = resolved.filter(
471
+ field => 'fieldtype' in field && field.fieldtype === 'Doctype' && 'schema' in field && Array.isArray(field.schema)
472
+ )
473
+
474
+ // Recursively collect nested data from HST using resolved schemas
475
+ for (const field of doctypeFields) {
476
+ const doctypeField = field as DoctypeSchema
477
+ const fieldPath = `${recordPath}.${doctypeField.fieldname}`
478
+ const nestedData = collectNestedData(doctypeField.schema!, fieldPath, hstStore.value)
479
+ payload[doctypeField.fieldname] = nestedData
480
+ }
481
+
482
+ return payload
483
+ }
484
+
485
+ /**
486
+ * Create a nested context for child forms
487
+ * @param basePath - The base path for the nested context (e.g., "customer.123.address")
488
+ * @param _childDoctype - The child doctype metadata (unused but kept for API consistency)
489
+ * @returns Object with scoped provideHSTPath and handleHSTChange
490
+ */
491
+ const createNestedContext = (basePath: string, _childDoctype: DoctypeMeta) => {
492
+ const nestedProvideHSTPath = (fieldname: string): string => {
493
+ return `${basePath}.${fieldname}`
494
+ }
495
+
496
+ const nestedHandleHSTChange = (changeData: HSTChangeData): void => {
497
+ // Update the path to be relative to the nested base path
498
+ const nestedPath = changeData.path.startsWith(basePath) ? changeData.path : `${basePath}.${changeData.fieldname}`
499
+
500
+ handleHSTChange({
501
+ ...changeData,
502
+ path: nestedPath,
503
+ })
504
+ }
505
+
506
+ return {
507
+ provideHSTPath: nestedProvideHSTPath,
508
+ handleHSTChange: nestedHandleHSTChange,
509
+ }
510
+ }
511
+
381
512
  // Create operation log API object
382
513
  const operationLog: OperationLogAPI = {
383
514
  operations,
@@ -409,6 +540,10 @@ export function useStonecrop(options?: {
409
540
  handleHSTChange,
410
541
  hstStore,
411
542
  formData,
543
+ resolvedSchema,
544
+ loadNestedData,
545
+ saveRecursive,
546
+ createNestedContext,
412
547
  } as HSTStonecropReturn
413
548
  } else if (!options.doctype && registry?.router) {
414
549
  // Router-based - return HST (will be populated after mount)
@@ -419,6 +554,10 @@ export function useStonecrop(options?: {
419
554
  handleHSTChange,
420
555
  hstStore,
421
556
  formData,
557
+ resolvedSchema,
558
+ loadNestedData,
559
+ saveRecursive,
560
+ createNestedContext,
422
561
  } as HSTStonecropReturn
423
562
  }
424
563
 
@@ -514,3 +653,30 @@ function updateNestedObject(obj: any, path: string[], value: any): void {
514
653
  const finalKey = path[path.length - 1]
515
654
  current[finalKey] = value
516
655
  }
656
+
657
+ /**
658
+ * Recursively collect nested data from HST using pre-resolved schemas
659
+ * @param resolvedSchema - The already-resolved schema (with nested schemas embedded)
660
+ * @param basePath - The base path in HST (e.g., "customer.123.address")
661
+ * @param hstStore - The HST store instance
662
+ * @returns The collected data object
663
+ */
664
+ function collectNestedData(resolvedSchema: SchemaTypes[], basePath: string, hstStore: HSTNode): Record<string, any> {
665
+ const data = hstStore.get(basePath) || {}
666
+ const payload: Record<string, any> = { ...data }
667
+
668
+ // Find Doctype fields that have resolved child schemas
669
+ const doctypeFields = resolvedSchema.filter(
670
+ field => 'fieldtype' in field && field.fieldtype === 'Doctype' && 'schema' in field && Array.isArray(field.schema)
671
+ )
672
+
673
+ // Recursively collect nested data
674
+ for (const field of doctypeFields) {
675
+ const doctypeField = field as DoctypeSchema
676
+ const fieldPath = `${basePath}.${doctypeField.fieldname}`
677
+ const nestedData = collectNestedData(doctypeField.schema!, fieldPath, hstStore)
678
+ payload[doctypeField.fieldname] = nestedData
679
+ }
680
+
681
+ return payload
682
+ }
package/src/registry.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { SchemaTypes } from '@stonecrop/aform'
1
2
  import { Router } from 'vue-router'
2
3
 
3
4
  import DoctypeMeta from './doctype'
@@ -83,6 +84,194 @@ export default class Registry {
83
84
  }
84
85
  }
85
86
 
87
+ /**
88
+ * Resolve nested Doctype and Table fields in a schema by embedding child schemas inline.
89
+ *
90
+ * @remarks
91
+ * Walks the schema array and for each field with `fieldtype: 'Doctype'` and a string
92
+ * `options` value, looks up the referenced doctype in the registry and embeds its schema
93
+ * as the field's `schema` property. Recurses for deeply nested doctypes.
94
+ *
95
+ * For fields with `fieldtype: 'Table'`, looks up the referenced child doctype and
96
+ * auto-derives `columns` from its schema fields (unless columns are already provided).
97
+ * Also sets sensible defaults for `component` (`'ATable'`) and `config` (`{ view: 'list' }`).
98
+ * Row data is expected to come from the parent form's data model at `data[fieldname]`.
99
+ *
100
+ * Returns a new array — does not mutate the original schema.
101
+ *
102
+ * @param schema - The schema array to resolve
103
+ * @returns A new schema array with nested Doctype fields resolved
104
+ *
105
+ * @example
106
+ * ```ts
107
+ * registry.addDoctype(addressDoctype)
108
+ * registry.addDoctype(customerDoctype)
109
+ *
110
+ * // Before: customer schema has { fieldname: 'address', fieldtype: 'Doctype', options: 'address' }
111
+ * const resolved = registry.resolveSchema(customerSchema)
112
+ * // After: address field now has schema: [...address fields...]
113
+ * ```
114
+ *
115
+ * @public
116
+ */
117
+ resolveSchema(schema: SchemaTypes[], visited?: Set<string>): SchemaTypes[] {
118
+ const seen = visited || new Set<string>()
119
+
120
+ return schema.map(field => {
121
+ // Check for Doctype fieldtype with a string options (slug reference)
122
+ if (
123
+ 'fieldtype' in field &&
124
+ field.fieldtype === 'Doctype' &&
125
+ 'options' in field &&
126
+ typeof field.options === 'string'
127
+ ) {
128
+ const doctypeSlug = field.options
129
+
130
+ // Circular reference protection
131
+ if (seen.has(doctypeSlug)) {
132
+ return { ...field }
133
+ }
134
+
135
+ const doctype = this.registry[doctypeSlug]
136
+ if (doctype && doctype.schema) {
137
+ // Convert Immutable.List to plain array if needed
138
+ const childSchema: SchemaTypes[] = Array.isArray(doctype.schema) ? doctype.schema : Array.from(doctype.schema)
139
+
140
+ // Recurse into child schema to resolve deeply nested doctypes
141
+ seen.add(doctypeSlug)
142
+ const resolvedChild = this.resolveSchema(childSchema, seen)
143
+ seen.delete(doctypeSlug)
144
+
145
+ return { ...field, schema: resolvedChild }
146
+ }
147
+ }
148
+
149
+ // Resolve Table fieldtype — 1:many child doctype rendered as ATable
150
+ if (
151
+ 'fieldtype' in field &&
152
+ field.fieldtype === 'Table' &&
153
+ 'options' in field &&
154
+ typeof field.options === 'string'
155
+ ) {
156
+ const doctypeSlug = field.options as string
157
+
158
+ // Circular reference protection
159
+ if (seen.has(doctypeSlug)) {
160
+ return { ...field }
161
+ }
162
+
163
+ const doctype = this.registry[doctypeSlug]
164
+ if (doctype && doctype.schema) {
165
+ const childSchema: SchemaTypes[] = Array.isArray(doctype.schema) ? doctype.schema : Array.from(doctype.schema)
166
+ const resolved: Record<string, any> = { ...field }
167
+
168
+ // Auto-derive columns from child schema fields if not already provided
169
+ if (!('columns' in field) || !field.columns) {
170
+ resolved.columns = childSchema.map(childField => ({
171
+ name: childField.fieldname,
172
+ fieldname: childField.fieldname,
173
+ label: ('label' in childField && childField.label) || childField.fieldname,
174
+ fieldtype: 'fieldtype' in childField ? childField.fieldtype : 'Data',
175
+ align: ('align' in childField && childField.align) || 'left',
176
+ edit: 'edit' in childField ? childField.edit : true,
177
+ width: ('width' in childField && childField.width) || '20ch',
178
+ }))
179
+ }
180
+
181
+ // Set default component if not already specified
182
+ if (!resolved.component) {
183
+ resolved.component = 'ATable'
184
+ }
185
+
186
+ // Set default config if not already specified
187
+ if (!('config' in field) || !field.config) {
188
+ resolved.config = { view: 'list' }
189
+ }
190
+
191
+ // Initialize rows to empty array so componentProps fallback
192
+ // routes data from the form's dataModel[fieldname]
193
+ if (!('rows' in field) || !field.rows) {
194
+ resolved.rows = []
195
+ }
196
+
197
+ return resolved as SchemaTypes
198
+ }
199
+ }
200
+
201
+ return { ...field }
202
+ })
203
+ }
204
+
205
+ /**
206
+ * Initialize a new record with default values based on a schema.
207
+ *
208
+ * @remarks
209
+ * Creates a plain object with keys from the schema's fieldnames and default values
210
+ * derived from each field's `fieldtype`:
211
+ * - Data, Text → `''`
212
+ * - Check → `false`
213
+ * - Int, Float, Decimal, Currency, Quantity → `0`
214
+ * - Table → `[]`
215
+ * - JSON, Doctype → `{}`
216
+ * - All others → `null`
217
+ *
218
+ * For Doctype fields with a resolved `schema` array, recursively initializes the nested record.
219
+ *
220
+ * @param schema - The schema array to derive defaults from
221
+ * @returns A plain object with default values for each field
222
+ *
223
+ * @example
224
+ * ```ts
225
+ * const defaults = registry.initializeRecord(addressSchema)
226
+ * // { street: '', city: '', state: '', zip_code: '' }
227
+ * ```
228
+ *
229
+ * @public
230
+ */
231
+ initializeRecord(schema: SchemaTypes[]): Record<string, any> {
232
+ const record: Record<string, any> = {}
233
+
234
+ schema.forEach(field => {
235
+ const fieldtype = 'fieldtype' in field ? (field.fieldtype as string) : 'Data'
236
+
237
+ switch (fieldtype) {
238
+ case 'Data':
239
+ case 'Text':
240
+ case 'Code':
241
+ record[field.fieldname] = ''
242
+ break
243
+ case 'Check':
244
+ record[field.fieldname] = false
245
+ break
246
+ case 'Int':
247
+ case 'Float':
248
+ case 'Decimal':
249
+ case 'Currency':
250
+ case 'Quantity':
251
+ record[field.fieldname] = 0
252
+ break
253
+ case 'Table':
254
+ record[field.fieldname] = []
255
+ break
256
+ case 'JSON':
257
+ record[field.fieldname] = {}
258
+ break
259
+ case 'Doctype':
260
+ // If nested schema is resolved, recursively initialize
261
+ if ('schema' in field && Array.isArray(field.schema)) {
262
+ record[field.fieldname] = this.initializeRecord(field.schema)
263
+ } else {
264
+ record[field.fieldname] = {}
265
+ }
266
+ break
267
+ default:
268
+ record[field.fieldname] = null
269
+ }
270
+ })
271
+
272
+ return record
273
+ }
274
+
86
275
  // TODO: should we allow clearing the registry at all?
87
276
  // clear() {
88
277
  // this.registry = {}
package/src/stonecrop.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { reactive } from 'vue'
1
2
  import DoctypeMeta from './doctype'
2
3
  import Registry from './registry'
3
4
  import { createHST, type HSTNode } from './stores/hst'
@@ -58,7 +59,9 @@ export class Stonecrop {
58
59
  initialStoreStructure[doctypeSlug] = {}
59
60
  })
60
61
 
61
- this.hstStore = createHST(initialStoreStructure, 'StonecropStore')
62
+ // Wrap the store in Vue's reactive() for automatic change detection
63
+ // This enables Vue computed properties to track HST store changes
64
+ this.hstStore = createHST(reactive(initialStoreStructure), 'StonecropStore')
62
65
  }
63
66
 
64
67
  /**
@@ -235,10 +238,10 @@ export class Stonecrop {
235
238
  */
236
239
  async getRecords(doctype: DoctypeMeta): Promise<void> {
237
240
  const response = await fetch(`/${doctype.slug}`)
238
- const records = await response.json()
241
+ const records = (await response.json()) as { id: string }[]
239
242
 
240
243
  // Store each record in HST
241
- records.forEach((record: any) => {
244
+ records.forEach(record => {
242
245
  if (record.id) {
243
246
  this.addRecord(doctype, record.id, record)
244
247
  }
package/src/stores/hst.ts CHANGED
@@ -138,6 +138,7 @@ interface PropertyAccessible {
138
138
 
139
139
  // Extend global interfaces
140
140
  declare global {
141
+ // eslint-disable-next-line @typescript-eslint/no-empty-object-type
141
142
  interface Window extends RegistryGlobal {}
142
143
  const global: RegistryGlobal | undefined
143
144
  }
@@ -291,7 +292,6 @@ class HSTProxy implements HSTNode {
291
292
  const isDelete = value === undefined && beforeValue !== undefined
292
293
  const operationType: 'set' | 'delete' = isDelete ? 'delete' : 'set'
293
294
 
294
- // eslint-disable-next-line @typescript-eslint/no-unsafe-call
295
295
  logStore.addOperation(
296
296
  {
297
297
  type: operationType,
@@ -429,7 +429,6 @@ class HSTProxy implements HSTNode {
429
429
  // Log FSM transition operation
430
430
  const logStore = getOperationLogStore()
431
431
  if (logStore && typeof logStore.addOperation === 'function') {
432
- // eslint-disable-next-line @typescript-eslint/no-unsafe-call
433
432
  logStore.addOperation(
434
433
  {
435
434
  type: 'transition' as const,
@@ -678,9 +677,20 @@ class HSTProxy implements HSTNode {
678
677
  )
679
678
  }
680
679
 
680
+ /**
681
+ * Parse a path string into segments, handling both dot notation and array bracket notation
682
+ * @param path - The path string to parse (e.g., "order.456.line_items[0].product")
683
+ * @returns Array of path segments (e.g., ['order', '456', 'line_items', '0', 'product'])
684
+ */
681
685
  private parsePath(path: string): string[] {
682
686
  if (!path) return []
683
- return path.split('.').filter(segment => segment.length > 0)
687
+
688
+ // Replace array bracket notation with dot notation
689
+ // items[0] → items.0
690
+ // items[0][1] → items.0.1
691
+ const normalizedPath = path.replace(/\[(\d+)\]/g, '.$1')
692
+
693
+ return normalizedPath.split('.').filter(segment => segment.length > 0)
684
694
  }
685
695
  }
686
696