@stonecrop/stonecrop 0.13.7 → 0.13.9

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.13.7",
3
+ "version": "0.13.9",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "author": {
@@ -34,7 +34,7 @@
34
34
  "pinia-shared-state": "^1.0.1",
35
35
  "pinia-xstate": "^3.0.0",
36
36
  "xstate": "^5.25.0",
37
- "@stonecrop/schema": "0.13.7"
37
+ "@stonecrop/schema": "0.13.9"
38
38
  },
39
39
  "peerDependencies": {
40
40
  "pinia": "^3.0.4",
@@ -56,8 +56,8 @@
56
56
  "vitest": "^4.1.5",
57
57
  "vue": "^3.5.33",
58
58
  "vue-router": "^5.0.6",
59
- "@stonecrop/aform": "0.13.7",
60
- "@stonecrop/atable": "0.13.7",
59
+ "@stonecrop/aform": "0.13.9",
60
+ "@stonecrop/atable": "0.13.9",
61
61
  "stonecrop-rig": "0.7.0"
62
62
  },
63
63
  "description": "Schema-driven framework with XState workflows and HST state management",
@@ -1,4 +1,4 @@
1
- import { type SchemaTypes } from '@stonecrop/aform'
1
+ import { type ResolvedField } from '@stonecrop/aform'
2
2
  import { storeToRefs } from 'pinia'
3
3
  import { inject, onMounted, Ref, ref, watch, provide, computed } from 'vue'
4
4
 
@@ -61,7 +61,7 @@ export function useStonecrop(options?: {
61
61
  const routerRecordId = ref<string | undefined>()
62
62
 
63
63
  // Resolved schema with nested Doctype fields expanded
64
- const resolvedSchema = ref<SchemaTypes[]>([])
64
+ const resolvedSchema = ref<ResolvedField[]>([])
65
65
 
66
66
  // Loading state for lazy-loaded doctypes
67
67
  const isLoading = ref(false)
package/src/doctype.ts CHANGED
@@ -1,10 +1,8 @@
1
- import type { SchemaTypes } from '@stonecrop/aform'
2
- import type { LinkDeclaration, WorkflowMeta } from '@stonecrop/schema'
1
+ import type { DoctypeField, LinkDeclaration, WorkflowMeta } from '@stonecrop/schema'
3
2
  import { List, Map } from 'immutable'
4
3
  import { Component } from 'vue'
5
4
 
6
- import type { ImmutableDoctype } from './types'
7
- import type { DoctypeConfig } from './types/doctype'
5
+ import type { DoctypeConfig, ImmutableDoctype } from './types/doctype'
8
6
 
9
7
  /**
10
8
  * Doctype runtime class with Immutable.js collections for HST change tracking.
@@ -126,28 +124,28 @@ export default class Doctype {
126
124
  * @public
127
125
  */
128
126
  static fromObject(config: DoctypeConfig): Doctype {
129
- const schema = config.fields ? List(config.fields) : List<SchemaTypes>()
127
+ const schema = config.fields ? List(config.fields) : List<DoctypeField>()
130
128
  const actions = config.actions ? Map(config.actions) : Map<string, string[]>()
131
129
 
132
130
  return new Doctype(config.name, schema, config.workflow, actions, undefined, config.links)
133
131
  }
134
132
 
135
133
  /**
136
- * Returns the schema as a plain array for use with components that expect
137
- * plain JavaScript arrays (e.g., AForm, ATable).
134
+ * Returns the raw authoring schema as a plain array.
135
+ * For the resolved schema suitable for AForm, use `registry.resolveSchema(doctype)`.
138
136
  *
139
- * @returns Array of schema fields
137
+ * @returns Array of raw DoctypeField authoring definitions
140
138
  *
141
139
  * @example
142
140
  * ```ts
143
- * const schemaArray = doctype.getSchemaArray()
144
- * // Use with AForm
145
- * <AForm :schema="schemaArray" v-model:data="formData" />
141
+ * const fields = doctype.getSchemaArray()
142
+ * // Pass to resolveSchema for AForm-ready output:
143
+ * const resolved = registry.resolveSchema(doctype)
146
144
  * ```
147
145
  *
148
146
  * @public
149
147
  */
150
- getSchemaArray(): SchemaTypes[] {
148
+ getSchemaArray(): DoctypeField[] {
151
149
  if (!this.schema) return []
152
150
  return this.schema.toArray()
153
151
  }
package/src/registry.ts CHANGED
@@ -1,5 +1,5 @@
1
- import type { SchemaTypes, TableSchema } from '@stonecrop/aform'
2
- import type { FieldMeta, LinkDeclaration } from '@stonecrop/schema'
1
+ import type { ResolvedField, ResolvedLink, ResolvedScalar, ResolvedTable, ResolvedFieldset } from '@stonecrop/aform'
2
+ import type { ColumnSchema, DoctypeField, LinkDeclaration, TableViewConfig, ValueField } from '@stonecrop/schema'
3
3
  import { Router } from 'vue-router'
4
4
 
5
5
  import Doctype from './doctype'
@@ -106,67 +106,70 @@ export default class Registry {
106
106
  }
107
107
 
108
108
  /**
109
- * Resolve nested Doctype fields in a schema by embedding child schemas inline.
109
+ * Resolve a Doctype's authoring schema into a rendered schema array suitable for AForm.
110
110
  *
111
- * Accepts a Doctype and extracts `fields` and `links` internally.
112
- * Fields array contains both scalar fields and link fields (with fieldtype: 'Link').
113
- * Render order is determined by the order of fields in the fields array.
111
+ * Transforms `DoctypeField[]` (authoring space) `ResolvedField[]` (rendering space):
112
+ * - `kind: 'field'` (not Link) → `ResolvedScalar`
113
+ * - `kind: 'field'` (Link, no declaration) `ResolvedScalar` with `component: 'AFormLink'`
114
+ * - `kind: 'field'` (Link, `noneOrMany`/`atLeastOne`) → `ResolvedTable`
115
+ * - `kind: 'field'` (Link, `one`/`atMostOne`) → `ResolvedLink`
116
+ * - `kind: 'fieldset'` → `ResolvedFieldset` (children resolved recursively)
117
+ * - `kind: 'table'` → `ResolvedTable` (columns as `ColumnSchema[]`)
114
118
  *
115
- * For each link field:
116
- * - Looks up the corresponding link declaration in `links` by fieldname
117
- * - `cardinality: 'noneOrMany'` or `'atLeastOne'`: auto-derives `columns` from the target's schema,
118
- * sets `component` to `link.component ?? 'ATable'`, `config: { view: 'list' }`.
119
- * - `cardinality: 'one'` or `'atMostOne'`: embeds the target schema as the entry's
120
- * `schema` property, sets `component` to `link.component ?? 'AForm'`.
121
- *
122
- * Recurses for deeply nested doctypes. Circular references are protected against.
123
- * Returns a new array — does not mutate the original.
119
+ * Circular references are protected against via the `visited` set.
124
120
  *
125
121
  * @param doctype - The doctype to resolve
126
122
  * @param visited - Internal — set of already-visited doctype slugs for cycle detection
127
- * @returns A new schema array with nested links resolved
123
+ * @returns A resolved schema array ready for AForm
128
124
  *
129
125
  * @public
130
126
  */
131
- resolveSchema(doctype: Doctype, visited?: Set<string>): SchemaTypes[] {
127
+ resolveSchema(doctype: Doctype, visited?: Set<string>): ResolvedField[] {
132
128
  const seen = visited ?? new Set<string>()
133
129
  const slug = doctype.slug
134
130
 
135
- // Prevent circular resolution
131
+ // Prevent circular resolution — return all ValueField entries as scalars (link not expanded)
136
132
  if (seen.has(slug)) {
137
- return doctype.schema ? (Array.isArray(doctype.schema) ? doctype.schema : Array.from(doctype.schema)) : []
133
+ const fallback: DoctypeField[] = doctype.schema ? doctype.schema.toArray() : []
134
+ return fallback
135
+ .filter((f): f is ValueField => f.kind === 'field')
136
+ .map(({ cardinality: _c, ...rest }): ResolvedScalar => rest)
138
137
  }
139
138
  seen.add(slug)
140
139
 
141
- // Convert schema to array
142
- const schemaArray: SchemaTypes[] = doctype.schema
143
- ? Array.isArray(doctype.schema)
144
- ? doctype.schema
145
- : Array.from(doctype.schema)
146
- : []
140
+ const schemaArray: DoctypeField[] = doctype.schema ? doctype.schema.toArray() : []
147
141
 
148
- // Build a map of link declarations by fieldname for quick lookup
149
- // Use the link's fieldname property if set, otherwise use the key
142
+ // Map link declarations by fieldname (link.fieldname ?? key)
150
143
  const linksByFieldname = new Map<string, LinkDeclaration>()
151
144
  if (doctype.links) {
152
145
  for (const [key, link] of Object.entries(doctype.links)) {
153
- const linkFieldname = link.fieldname ?? key
154
- linksByFieldname.set(linkFieldname, link)
146
+ linksByFieldname.set(link.fieldname ?? key, link)
155
147
  }
156
148
  }
157
149
 
158
- // Process fields in order: scalar fields copied as-is, link fields resolved
159
- const resolvedFields: SchemaTypes[] = []
160
- for (const field of schemaArray) {
161
- // Check if this field is a link field (fieldtype: 'Link')
162
- if ('fieldtype' in field && field.fieldtype === 'Link') {
163
- const link = linksByFieldname.get(field.fieldname)
164
- if (!link) {
165
- // oxlint-disable typescript/no-unsafe-type-assertion -- SchemaTypes union narrowed to FieldMeta by fieldtype === 'Link' check; options may not exist on all members
166
- const linkDoctype =
167
- typeof (field as FieldMeta).options === 'string' ? ((field as FieldMeta).options as string) : undefined
168
- // oxlint-enable typescript/no-unsafe-type-assertion
150
+ const result = this.resolveFields(schemaArray, linksByFieldname, seen)
151
+ seen.delete(slug)
152
+ return result
153
+ }
154
+
155
+ /**
156
+ * Recursively resolve a `DoctypeField[]` using the provided link context.
157
+ * Called by `resolveSchema` and recursively for fieldset children.
158
+ * @internal
159
+ */
160
+ private resolveFields(
161
+ fields: DoctypeField[],
162
+ links: Map<string, LinkDeclaration>,
163
+ visited: Set<string>
164
+ ): ResolvedField[] {
165
+ const resolved: ResolvedField[] = []
169
166
 
167
+ for (const field of fields) {
168
+ if (field.kind === 'field' && field.fieldtype === 'Link') {
169
+ const link = links.get(field.fieldname)
170
+ if (!link) {
171
+ // Unresolved link — warn and produce a scalar with component: 'AFormLink'
172
+ const linkDoctype = typeof field.options === 'string' ? field.options : undefined
170
173
  if (linkDoctype === undefined) {
171
174
  console.warn(
172
175
  `[Stonecrop] Link field "${field.fieldname}" has no \`options\` or corresponding \`links\` declaration. ` +
@@ -174,227 +177,144 @@ export default class Registry {
174
177
  `Add \`"options": "<doctype-slug>"\` to the field definition.`
175
178
  )
176
179
  }
177
-
178
- // Strip any raw `doctype` from the JSON; only `options` is the authoritative source.
179
- const { doctype: _rawDoctype, ...fieldRest } = field as typeof field & {
180
- doctype?: unknown
181
- component?: string
182
- }
183
-
184
- resolvedFields.push({
185
- ...fieldRest,
186
- component: fieldRest.component || 'AFormLink',
180
+ const { cardinality: _c, ...rest } = field
181
+ resolved.push({
182
+ ...rest,
183
+ component: rest.component || 'AFormLink',
187
184
  ...(linkDoctype !== undefined ? { doctype: linkDoctype } : {}),
188
185
  })
189
-
190
186
  continue
191
187
  }
192
188
 
193
189
  const targetDoctype = this.registry[link.target]
194
190
  if (!targetDoctype) {
195
- // Target not found - copy as-is
196
- resolvedFields.push({ ...field })
191
+ // Target not registered copy as scalar
192
+ const { cardinality: _c, ...rest } = field
193
+ resolved.push({ ...rest })
197
194
  continue
198
195
  }
199
196
 
200
- const childSchema = this.resolveSchema(targetDoctype, seen)
201
-
202
- // Extract properties consumed by resolution; preserve everything else
203
- // TODO: options and cardinality are untyped runtime properties on link fields; add them to
204
- // FormSchema (or a dedicated link field type) to remove this cast
205
- const {
206
- fieldtype: _ft,
207
- options: _opt,
208
- cardinality: _card,
209
- ...fieldRest
210
- } = field as typeof field & { options?: unknown; cardinality?: unknown }
197
+ const childSchema = this.resolveSchema(targetDoctype, new Set(visited))
198
+ const { fieldtype: _ft, options: _opt, cardinality: _card, kind: _kind, ...fieldRest } = field
211
199
 
212
200
  if (link.cardinality === 'noneOrMany' || link.cardinality === 'atLeastOne') {
213
- // Many relationship — build table config
214
- resolvedFields.push(
215
- this.buildTableConfig(
216
- { ...fieldRest, label: fieldRest.label || field.fieldname },
217
- childSchema,
218
- link.component
219
- )
220
- )
201
+ resolved.push(this.buildTableConfig(field, childSchema, link.component))
221
202
  } else {
222
- // One relationship embed form schema
223
- resolvedFields.push({
203
+ const linkEntry: ResolvedLink = {
224
204
  ...fieldRest,
205
+ kind: 'link',
225
206
  label: fieldRest.label || field.fieldname,
226
207
  component: link.component || fieldRest.component || 'AForm',
227
208
  schema: childSchema,
228
- })
229
- }
230
- } else if ('schema' in field && Array.isArray(field.schema)) {
231
- // Fieldset — recursively resolve nested fields
232
- const resolvedChildren = this.resolveFields(field.schema, linksByFieldname, seen)
233
- resolvedFields.push({ ...field, schema: resolvedChildren })
234
- } else {
235
- // Scalar field — copy as-is
236
- resolvedFields.push({ ...field })
237
- }
238
- }
239
-
240
- seen.delete(slug)
241
-
242
- return resolvedFields
243
- }
244
-
245
- /**
246
- * Recursively resolve a flat fields array using the provided link context.
247
- * Used by resolveSchema to handle fieldset children.
248
- * @internal
249
- */
250
- private resolveFields(
251
- fields: SchemaTypes[],
252
- links: Map<string, LinkDeclaration>,
253
- visited: Set<string>
254
- ): SchemaTypes[] {
255
- const resolved: SchemaTypes[] = []
256
- for (const field of fields) {
257
- if ('fieldtype' in field && field.fieldtype === 'Link') {
258
- const link = links.get(field.fieldname)
259
- if (!link) {
260
- resolved.push({ ...field })
261
- continue
209
+ }
210
+ resolved.push(linkEntry)
262
211
  }
263
- const targetDoctype = this.registry[link.target]
264
- if (!targetDoctype) {
265
- resolved.push({ ...field })
266
- continue
212
+ } else if (field.kind === 'fieldset') {
213
+ const resolvedChildren = this.resolveFields(field.schema, links, visited)
214
+ const { schema: _s, ...fieldRest } = field
215
+ const fieldsetEntry: ResolvedFieldset = {
216
+ ...fieldRest,
217
+ schema: resolvedChildren,
267
218
  }
268
- const childSchema = this.resolveSchema(targetDoctype, new Set(visited))
269
- const {
270
- fieldtype: _ft,
271
- options: _opt,
272
- cardinality: _card,
273
- ...fieldRest
274
- } = field as typeof field & { options?: unknown; cardinality?: unknown }
275
- if (link.cardinality === 'noneOrMany' || link.cardinality === 'atLeastOne') {
276
- resolved.push(
277
- this.buildTableConfig(
278
- { ...fieldRest, label: fieldRest.label || field.fieldname },
279
- childSchema,
280
- link.component
281
- )
282
- )
283
- } else {
284
- resolved.push({
285
- ...fieldRest,
286
- label: fieldRest.label || field.fieldname,
287
- component: link.component || fieldRest.component || 'AForm',
288
- schema: childSchema,
289
- })
219
+ resolved.push(fieldsetEntry)
220
+ } else if (field.kind === 'table') {
221
+ // Inline table — columns become ColumnSchema[], add default config
222
+ const { columns, config, ...fieldRest } = field
223
+ const tableEntry: ResolvedTable = {
224
+ ...fieldRest,
225
+ kind: 'table',
226
+ component: fieldRest.component || 'ATable',
227
+ schema: columns,
228
+ config: config ?? { view: 'list' },
290
229
  }
291
- } else if ('schema' in field && Array.isArray(field.schema)) {
292
- resolved.push({ ...field, schema: this.resolveFields(field.schema, links, visited) })
230
+ resolved.push(tableEntry)
293
231
  } else {
294
- resolved.push({ ...field })
232
+ // Scalar field (kind: 'field', not a Link) — strip cardinality
233
+ const { cardinality: _c, ...rest } = field
234
+ resolved.push({ ...rest })
295
235
  }
296
236
  }
237
+
297
238
  return resolved
298
239
  }
299
240
 
300
241
  /**
301
- * Build an ATable configuration from a field and child schema.
302
- * Data-model properties from the source field are preserved via the spread `field` argument.
242
+ * Build a `ResolvedTable` from a resolved Link field with many cardinality.
243
+ * Extracts scalar column definitions from the child schema.
303
244
  * @internal
304
245
  */
305
- private buildTableConfig(field: Record<string, any>, childSchema: SchemaTypes[], component?: string): TableSchema {
306
- const resolved: TableSchema = {
307
- ...field,
308
- fieldname: field.fieldname,
309
- component: component || field.component || 'ATable',
310
- kind: 'table',
311
- schema: childSchema,
312
- config: field.config,
313
- }
246
+ private buildTableConfig(field: ValueField, childSchema: ResolvedField[], component?: string): ResolvedTable {
247
+ // Only scalar fields become columns; strip kind and cardinality (runtime column spec)
248
+ const columns: ColumnSchema[] = childSchema
249
+ .filter((f): f is ResolvedScalar => f.kind === 'field')
250
+ .map(({ kind: _k, ...col }) => col)
314
251
 
315
- if (!resolved.config) {
316
- resolved.config = { view: 'list' }
317
- }
252
+ const config: TableViewConfig = (field as ValueField & { config?: TableViewConfig }).config ?? { view: 'list' }
253
+ const { fieldtype: _ft, options: _opt, cardinality: _card, ...fieldRest } = field
318
254
 
319
- return resolved
255
+ return {
256
+ ...fieldRest,
257
+ kind: 'table',
258
+ label: fieldRest.label || field.fieldname,
259
+ component: component || fieldRest.component || 'ATable',
260
+ schema: columns,
261
+ config,
262
+ }
320
263
  }
321
264
 
322
265
  /**
323
- * Initialize a new record with default values based on a schema.
324
- *
325
- * @remarks
326
- * Creates a plain object with keys from the schema's fieldnames and default values
327
- * derived from each field's `fieldtype`:
328
- * - Data, Text → `''`
329
- * - Check → `false`
330
- * - Int, Float, Decimal, Currency, Quantity → `0`
331
- * - JSON → `{}`
332
- * - Doctype with `cardinality: 'noneOrMany'` or `'atLeastOne'` → `[]`
333
- * - Doctype without `cardinality` or `cardinality: 'one'` → recursively initializes nested record
334
- * - All others → `null`
266
+ * Initialize a new record with default values based on a resolved schema.
267
+ * Narrows by `kind` discriminator for precise branch selection.
335
268
  *
336
- * For Doctype fields with a resolved `schema` array (cardinality: 'one'), recursively
337
- * initializes the nested record.
269
+ * - `kind: 'table'` or `kind: 'link'` `[]` or `{}`
270
+ * - `kind: 'fieldset'` → recursively initializes children as `{}`
271
+ * - `kind: 'field'` → derives default from `fieldtype`; falls back to `null`
338
272
  *
339
- * @param schema - The schema array to derive defaults from
273
+ * @param schema - The resolved schema array to derive defaults from
340
274
  * @returns A plain object with default values for each field
341
- *
342
- * @example
343
- * ```ts
344
- * const defaults = registry.initializeRecord(addressSchema)
345
- * // { street: '', city: '', state: '', zip_code: '' }
346
- * ```
347
- *
348
275
  * @public
349
276
  */
350
- initializeRecord(schema: SchemaTypes[]): Record<string, any> {
277
+ initializeRecord(schema: ResolvedField[]): Record<string, any> {
351
278
  const record: Record<string, any> = {}
352
279
 
353
- schema.forEach(field => {
354
- const fieldtype = 'fieldtype' in field ? field.fieldtype : 'Data'
355
- const cardinality = 'cardinality' in field ? field.cardinality : undefined
356
-
357
- // 1:many — cardinality signals an array
358
- if (cardinality === 'noneOrMany' || cardinality === 'atLeastOne') {
359
- record[field.fieldname] = []
360
- return
361
- }
362
-
363
- // Resolved 1:many table entry — kind discriminant set by buildTableConfig
364
- if ('kind' in field && field.kind === 'table') {
280
+ for (const field of schema) {
281
+ if (field.kind === 'table') {
365
282
  record[field.fieldname] = []
366
- return
367
- }
368
-
369
- // Resolved 1:1 link entry — has schema property (e.g., FieldsetSchema with nested schema)
370
- if ('schema' in field && Array.isArray(field.schema)) {
283
+ } else if (field.kind === 'link') {
371
284
  record[field.fieldname] = this.initializeRecord(field.schema)
372
- return
373
- }
374
-
375
- switch (fieldtype) {
376
- case 'Data':
377
- case 'Text':
378
- case 'Code':
379
- record[field.fieldname] = ''
380
- break
381
- case 'Check':
382
- record[field.fieldname] = false
383
- break
384
- case 'Int':
385
- case 'Float':
386
- case 'Decimal':
387
- case 'Currency':
388
- case 'Quantity':
389
- record[field.fieldname] = 0
390
- break
391
- case 'JSON':
392
- record[field.fieldname] = {}
393
- break
394
- default:
395
- record[field.fieldname] = null
285
+ } else if (field.kind === 'fieldset') {
286
+ record[field.fieldname] = this.initializeRecord(field.schema)
287
+ } else {
288
+ // kind: 'field' — derive from fieldtype
289
+ const fieldDefault = field.default
290
+ if (fieldDefault !== undefined) {
291
+ record[field.fieldname] = fieldDefault
292
+ } else {
293
+ switch (field.fieldtype) {
294
+ case 'Data':
295
+ case 'Text':
296
+ case 'Code':
297
+ record[field.fieldname] = ''
298
+ break
299
+ case 'Check':
300
+ record[field.fieldname] = false
301
+ break
302
+ case 'Int':
303
+ case 'Float':
304
+ case 'Decimal':
305
+ case 'Currency':
306
+ case 'Quantity':
307
+ record[field.fieldname] = 0
308
+ break
309
+ case 'JSON':
310
+ record[field.fieldname] = {}
311
+ break
312
+ default:
313
+ record[field.fieldname] = null
314
+ }
315
+ }
396
316
  }
397
- })
317
+ }
398
318
 
399
319
  return record
400
320
  }