@json-layout/core 2.5.1 → 2.6.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 (33) hide show
  1. package/package.json +2 -1
  2. package/src/compile/serialize.js +4 -0
  3. package/src/state/index.js +25 -1
  4. package/src/state/state-node.js +122 -73
  5. package/src/state/types.ts +5 -1
  6. package/src/state/utils/modified.js +32 -0
  7. package/src/webmcp/index.js +141 -22
  8. package/src/webmcp/project.js +210 -35
  9. package/src/webmcp/tools/describe-state.js +28 -3
  10. package/src/webmcp/tools/edit-array.js +97 -0
  11. package/src/webmcp/tools/fill-form-skill.js +2 -2
  12. package/src/webmcp/tools/set-data.js +8 -6
  13. package/src/webmcp/tools/set-field-value.js +19 -7
  14. package/types/compile/serialize.d.ts.map +1 -1
  15. package/types/state/index.d.ts +14 -1
  16. package/types/state/index.d.ts.map +1 -1
  17. package/types/state/state-node.d.ts.map +1 -1
  18. package/types/state/types.d.ts +5 -1
  19. package/types/state/types.d.ts.map +1 -1
  20. package/types/state/utils/modified.d.ts +17 -0
  21. package/types/state/utils/modified.d.ts.map +1 -0
  22. package/types/webmcp/index.d.ts +21 -9
  23. package/types/webmcp/index.d.ts.map +1 -1
  24. package/types/webmcp/project.d.ts +80 -28
  25. package/types/webmcp/project.d.ts.map +1 -1
  26. package/types/webmcp/tools/describe-state.d.ts +10 -1
  27. package/types/webmcp/tools/describe-state.d.ts.map +1 -1
  28. package/types/webmcp/tools/edit-array.d.ts +88 -0
  29. package/types/webmcp/tools/edit-array.d.ts.map +1 -0
  30. package/types/webmcp/tools/set-data.d.ts +11 -16
  31. package/types/webmcp/tools/set-data.d.ts.map +1 -1
  32. package/types/webmcp/tools/set-field-value.d.ts +35 -17
  33. package/types/webmcp/tools/set-field-value.d.ts.map +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@json-layout/core",
3
- "version": "2.5.1",
3
+ "version": "2.6.0",
4
4
  "description": "Compilation and state management utilities for JSON Layout.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -90,6 +90,7 @@
90
90
  "ajv-formats": "^3.0.1",
91
91
  "ajv-i18n": "^4.2.0",
92
92
  "debug": "^4.3.4",
93
+ "fast-deep-equal": "^3.1.3",
93
94
  "immer": "^10.0.3",
94
95
  "magicast": "^0.3.3",
95
96
  "marked": "^15.0.7"
@@ -62,6 +62,10 @@ export async function serialize (compiledLayout) {
62
62
  code = 'import ucs2length from "ajv/dist/runtime/ucs2length.js";\n' + code
63
63
  code = code.replace(/require\("ajv\/dist\/runtime\/ucs2length"\)/g, 'ucs2length')
64
64
  }
65
+ if (code.includes('require("ajv/dist/runtime/equal")')) {
66
+ code = 'import equal from "ajv/dist/runtime/equal.js";\n' + code
67
+ code = code.replace(/require\("ajv\/dist\/runtime\/equal"\)\.default/g, 'equal')
68
+ }
65
69
 
66
70
  // import only the current locale from ajv-i18n
67
71
  let ajvI18nPath = `ajv-i18n/localize/${compiledLayout.locale}/index.js`
@@ -183,13 +183,20 @@ export class StatefulLayout {
183
183
  */
184
184
  files = []
185
185
 
186
+ /**
187
+ * @private
188
+ * @type {unknown}
189
+ */
190
+ _savedData
191
+
186
192
  /**
187
193
  * @param {import("../index.js").CompiledLayout} compiledLayout
188
194
  * @param {import("../index.js").SkeletonTree} skeletonTree
189
195
  * @param {Partial<StatefulLayoutOptions>} options
190
196
  * @param {unknown} [data]
197
+ * @param {unknown} [savedData]
191
198
  */
192
- constructor (compiledLayout, skeletonTree, options, data) {
199
+ constructor (compiledLayout, skeletonTree, options, data, savedData) {
193
200
  logDataBinding('create stateful layout', compiledLayout, skeletonTree, options, data)
194
201
  this._compiledLayout = compiledLayout
195
202
  this.skeletonTree = skeletonTree
@@ -197,6 +204,7 @@ export class StatefulLayout {
197
204
  this._autofocusTarget = this.options.autofocus ? '' : null
198
205
  this._previousAutofocusTarget = null
199
206
  this._data = data
207
+ this._savedData = savedData
200
208
  this._previousData = data
201
209
  this.initValidationState()
202
210
  this.activatedItems = {}
@@ -284,6 +292,7 @@ export class StatefulLayout {
284
292
  rehydrate,
285
293
  cacheKeys: this._lastCreateStateTreeContext?.cacheKeys ?? {},
286
294
  rootData: this._data,
295
+ savedData: this._savedData,
287
296
  files: [],
288
297
  nodes: [],
289
298
  nodesMap: new Map(),
@@ -362,6 +371,21 @@ export class StatefulLayout {
362
371
  return this._lastCreateStateTreeContext.nodes.findIndex(node => node.error && !node.validated) !== -1
363
372
  }
364
373
 
374
+ /**
375
+ * @returns {boolean}
376
+ */
377
+ get modified () {
378
+ if (this._savedData === undefined) return false
379
+ return !!(this._stateTree.root.modified || this._stateTree.root.childModified)
380
+ }
381
+
382
+ get savedData () { return this._savedData }
383
+ /** @param {unknown} savedData */
384
+ set savedData (savedData) {
385
+ this._savedData = savedData
386
+ this.updateState()
387
+ }
388
+
365
389
  /**
366
390
  * @private
367
391
  * @param {StateNode} node
@@ -5,6 +5,7 @@ import { Display, getChildDisplay } from './utils/display.js'
5
5
  import { shallowEqualArray, shallowProduceArray, shallowProduceObject } from './utils/immutable.js'
6
6
  import { getRegexp } from './utils/regexps.js'
7
7
  import { pathURL } from './utils/urls.js'
8
+ import { resolveDataPath, isNodeModified } from './utils/modified.js'
8
9
 
9
10
  const logStateNode = debug('jl:state-node')
10
11
  const logValidation = debug('jl:validation')
@@ -34,8 +35,8 @@ export const useDefaultData = (data, layout, options) => {
34
35
  }
35
36
 
36
37
  // use Immer for efficient updating with immutability and no-op detection
37
- /** @type {(draft: import('./types.js').StateNode, key: string | number, fullKey: string, parentFullKey: string | null, dataPath: string, parentDataPath: string | null, skeleton: import('../index.js').SkeletonNode, layout: import('@json-layout/vocabulary').BaseCompObject, width: number, cols: number, data: unknown, error: string | undefined, validated: boolean, options: import('./types.js').StateNodeOptions, titleDepth: number, autofocus: boolean, shouldLoadData: boolean, props: import('@json-layout/vocabulary').StateNodePropsLib, slots: import('@json-layout/vocabulary').Slots | undefined, itemsCacheKey: any, children: import('../index.js').StateNode[] | undefined) => import('../index.js').StateNode} */
38
- const produceStateNode = produce((draft, key, fullKey, parentFullKey, dataPath, parentDataPath, skeleton, layout, width, cols, data, error, validated, options, titleDepth, autofocus, shouldLoadData, props, slots, itemsCacheKey, children) => {
38
+ /** @type {(draft: import('./types.js').StateNode, key: string | number, fullKey: string, parentFullKey: string | null, dataPath: string, parentDataPath: string | null, skeleton: import('../index.js').SkeletonNode, layout: import('@json-layout/vocabulary').BaseCompObject, width: number, cols: number, data: unknown, error: string | undefined, validated: boolean, options: import('./types.js').StateNodeOptions, titleDepth: number, autofocus: boolean, shouldLoadData: boolean, props: import('@json-layout/vocabulary').StateNodePropsLib, slots: import('@json-layout/vocabulary').Slots | undefined, itemsCacheKey: any, children: import('../index.js').StateNode[] | undefined, modified: boolean | undefined, childModified: boolean | undefined) => import('../index.js').StateNode} */
39
+ const produceStateNode = produce((draft, key, fullKey, parentFullKey, dataPath, parentDataPath, skeleton, layout, width, cols, data, error, validated, options, titleDepth, autofocus, shouldLoadData, props, slots, itemsCacheKey, children, modified, childModified) => {
39
40
  draft.messages = layout.messages ? produceStateNodeMessages(draft.messages || {}, layout.messages, options) : options.messages
40
41
 
41
42
  draft.key = key
@@ -55,6 +56,8 @@ const produceStateNode = produce((draft, key, fullKey, parentFullKey, dataPath,
55
56
  draft.error = error
56
57
  draft.itemsCacheKey = itemsCacheKey
57
58
  draft.childError = children && (children.findIndex(c => c.error || c.childError) !== -1)
59
+ draft.modified = modified
60
+ draft.childModified = childModified
58
61
  draft.validated = validated
59
62
  if (autofocus) {
60
63
  draft.autofocus = true
@@ -374,6 +377,10 @@ export function createStateNode (
374
377
  ) {
375
378
  logStateNode('createStateNode', fullKey)
376
379
 
380
+ // Resolve saved data for this node (needed for cache key and modified computation)
381
+ const nodeSavedData = context.savedData !== undefined ? resolveDataPath(context.savedData, dataPath) : undefined
382
+ let suppressChildModified = false
383
+
377
384
  /** @type {import('./types.js').StateNodeCacheKey | null} */
378
385
  let cacheKey = null
379
386
 
@@ -395,7 +402,8 @@ export function createStateNode (
395
402
  context.initial,
396
403
  context.rehydrateErrors?.length ?? 0,
397
404
  data,
398
- titleDepth
405
+ titleDepth,
406
+ context.savedData !== undefined ? nodeSavedData : undefined
399
407
  ]
400
408
  const hasNewErrors = context.errors?.some(e => matchLocalError(e, skeleton, dataPath, parentDataPath))
401
409
  if (!hasNewErrors && reusedNode && context.cacheKeys[fullKey] && shallowEqualArray(context.cacheKeys[fullKey], cacheKey)) {
@@ -607,30 +615,39 @@ export function createStateNode (
607
615
  const activeChildKey = `${fullKey}/${activeChildTreeIndex}`
608
616
  if (context.autofocusTarget === fullKey) context.autofocusTarget = activeChildKey
609
617
 
610
- const child = createStateNode(
611
- context,
612
- options,
613
- compiledLayout,
614
- activeChildTreeIndex,
615
- activeChildKey,
616
- fullKey,
617
- dataPath,
618
- dataPath,
619
- activeChildNode,
620
- null,
621
- display,
622
- nodeData,
623
- { parent: parentContext, data: nodeData },
624
- validationState,
625
- titleDepth,
626
- reusedNode?.children?.[0]
627
- )
628
- // the oneOf was hydrated
629
- if (child.data !== nodeData) {
630
- // nodeData = produceHydratedStateNodeData(nodeData, dataPath, child, skeleton.children?.map(childSkeletonKey => compiledLayout.skeletonNodes[childSkeletonKey]))
631
- nodeData = child.data
618
+ const savedDataBackup = context.savedData
619
+ if (context.savedData !== undefined && isNodeModified(nodeSavedData, nodeData)) {
620
+ suppressChildModified = true
621
+ context.savedData = undefined
622
+ }
623
+ try {
624
+ const child = createStateNode(
625
+ context,
626
+ options,
627
+ compiledLayout,
628
+ activeChildTreeIndex,
629
+ activeChildKey,
630
+ fullKey,
631
+ dataPath,
632
+ dataPath,
633
+ activeChildNode,
634
+ null,
635
+ display,
636
+ nodeData,
637
+ { parent: parentContext, data: nodeData },
638
+ validationState,
639
+ titleDepth,
640
+ reusedNode?.children?.[0]
641
+ )
642
+ // the oneOf was hydrated
643
+ if (child.data !== nodeData) {
644
+ // nodeData = produceHydratedStateNodeData(nodeData, dataPath, child, skeleton.children?.map(childSkeletonKey => compiledLayout.skeletonNodes[childSkeletonKey]))
645
+ nodeData = child.data
646
+ }
647
+ children = [child]
648
+ } finally {
649
+ context.savedData = savedDataBackup
632
650
  }
633
- children = [child]
634
651
  }
635
652
  }
636
653
 
@@ -698,54 +715,64 @@ export function createStateNode (
698
715
  const listItemOptions = layout.listEditMode === 'inline' ? options : produceReadonlyArrayItemOptions(options)
699
716
  children = []
700
717
  let focusChild = context.autofocusTarget === fullKey
701
- for (let i = 0; i < arrayData.length; i++) {
702
- const itemData = arrayData[i]
703
- const childFullKey = `${fullKey}/${i}`
704
- if (focusChild) context.autofocusTarget = childFullKey
705
- const child = createStateNode(
706
- context,
707
- (layout.listEditMode === 'inline-single' && context.activatedItems[fullKey] === i) ? options : listItemOptions,
708
- compiledLayout,
709
- i,
710
- childFullKey,
711
- fullKey,
712
- `${dataPath}/${i}`,
713
- dataPath,
714
- childSkeleton,
715
- null,
716
- display,
717
- itemData,
718
- { parent: parentContext, data: arrayData },
719
- validationState,
720
- titleDepth,
721
- reusedNode?.children?.[i]
722
- )
723
- if (child.autofocus || child.autofocusChild !== undefined) focusChild = false
724
- children.push(child)
718
+ // For arrays: check if saved data differs, suppress child modified tracking if so
719
+ const savedDataBackup = context.savedData
720
+ if (context.savedData !== undefined && isNodeModified(nodeSavedData, nodeData)) {
721
+ suppressChildModified = true
722
+ context.savedData = undefined
725
723
  }
724
+ try {
725
+ for (let i = 0; i < arrayData.length; i++) {
726
+ const itemData = arrayData[i]
727
+ const childFullKey = `${fullKey}/${i}`
728
+ if (focusChild) context.autofocusTarget = childFullKey
729
+ const child = createStateNode(
730
+ context,
731
+ (layout.listEditMode === 'inline-single' && context.activatedItems[fullKey] === i) ? options : listItemOptions,
732
+ compiledLayout,
733
+ i,
734
+ childFullKey,
735
+ fullKey,
736
+ `${dataPath}/${i}`,
737
+ dataPath,
738
+ childSkeleton,
739
+ null,
740
+ display,
741
+ itemData,
742
+ { parent: parentContext, data: arrayData },
743
+ validationState,
744
+ titleDepth,
745
+ reusedNode?.children?.[i]
746
+ )
747
+ if (child.autofocus || child.autofocusChild !== undefined) focusChild = false
748
+ children.push(child)
749
+ }
726
750
 
727
- // duplicate active child at the end of the list in case of dialog/menu edition
728
- if (context.activatedItems[fullKey] !== undefined && (layout.listEditMode === 'menu' || layout.listEditMode === 'dialog')) {
729
- const i = /** @type {number} */(context.activatedItems[fullKey])
730
- const activeChild = createStateNode(
731
- context,
732
- options,
733
- compiledLayout,
734
- i,
735
- `${fullKey}/${i}`,
736
- fullKey,
737
- `${dataPath}/${i}`,
738
- dataPath,
739
- childSkeleton,
740
- null,
741
- new Display(layout.listEditMode === 'menu' ? options.listMenuWidth : options.listDialogWidth),
742
- arrayData[i],
743
- { parent: parentContext, data: arrayData },
744
- validationState,
745
- titleDepth,
746
- reusedNode?.children?.[i]
747
- )
748
- children.push(activeChild)
751
+ // duplicate active child at the end of the list in case of dialog/menu edition
752
+ if (context.activatedItems[fullKey] !== undefined && (layout.listEditMode === 'menu' || layout.listEditMode === 'dialog')) {
753
+ const i = /** @type {number} */(context.activatedItems[fullKey])
754
+ const activeChild = createStateNode(
755
+ context,
756
+ options,
757
+ compiledLayout,
758
+ i,
759
+ `${fullKey}/${i}`,
760
+ fullKey,
761
+ `${dataPath}/${i}`,
762
+ dataPath,
763
+ childSkeleton,
764
+ null,
765
+ new Display(layout.listEditMode === 'menu' ? options.listMenuWidth : options.listDialogWidth),
766
+ arrayData[i],
767
+ { parent: parentContext, data: arrayData },
768
+ validationState,
769
+ titleDepth,
770
+ reusedNode?.children?.[i]
771
+ )
772
+ children.push(activeChild)
773
+ }
774
+ } finally {
775
+ context.savedData = savedDataBackup
749
776
  }
750
777
  }
751
778
  }
@@ -893,6 +920,26 @@ export function createStateNode (
893
920
  logGetItems(fullKey, 'list component with unchanged getItems cache key, no fetch will be triggered', itemsCacheKey)
894
921
  }
895
922
 
923
+ // Compute modified flags
924
+ /** @type {boolean | undefined} */
925
+ let modified
926
+ /** @type {boolean | undefined} */
927
+ let childModified
928
+ if (context.savedData !== undefined) {
929
+ if (suppressChildModified) {
930
+ // Array or oneOf that differs from saved — mark as modified, children were not individually compared
931
+ modified = true
932
+ childModified = true
933
+ } else if (children) {
934
+ // Composite node (including equal arrays/oneOf): aggregate from children
935
+ modified = false
936
+ childModified = children.some(c => c.modified || c.childModified) || false
937
+ } else {
938
+ // Leaf node: compare directly
939
+ modified = isNodeModified(nodeSavedData, nodeData)
940
+ }
941
+ }
942
+
896
943
  const node = produceStateNode(
897
944
  reusedNode ?? /** @type {import('./types.js').StateNode} */({}),
898
945
  key,
@@ -914,7 +961,9 @@ export function createStateNode (
914
961
  props,
915
962
  slots,
916
963
  itemsCacheKey,
917
- children && shallowProduceArray(reusedNode?.children, children)
964
+ children && shallowProduceArray(reusedNode?.children, children),
965
+ modified,
966
+ childModified
918
967
  )
919
968
 
920
969
  if (cacheKey) {
@@ -45,6 +45,8 @@ export interface StateNode {
45
45
  data: unknown
46
46
  error: string | undefined
47
47
  childError: boolean | undefined
48
+ modified: boolean | undefined
49
+ childModified: boolean | undefined
48
50
  validated: boolean
49
51
  width: number
50
52
  options: StateNodeOptions
@@ -78,6 +80,7 @@ export interface CreateStateTreeContext {
78
80
  rehydrate: boolean
79
81
  cacheKeys: Record<string, StateNodeCacheKey>
80
82
  rootData: unknown
83
+ savedData: unknown
81
84
  nodes: StateNode[],
82
85
  nodesMap: Map<string, StateNode>,
83
86
  getItemsDataRequests: StateNode[]
@@ -102,7 +105,8 @@ export type StateNodeCacheKey = [
102
105
  boolean,
103
106
  number,
104
107
  unknown,
105
- number
108
+ number,
109
+ unknown // savedData resolved for this node
106
110
  ]
107
111
 
108
112
  export interface ValidationState {
@@ -0,0 +1,32 @@
1
+ import equal from 'fast-deep-equal'
2
+
3
+ /**
4
+ * Resolve a JSON pointer data path against a data object.
5
+ * @param {unknown} data
6
+ * @param {string} dataPath - JSON pointer (e.g. '', '/a/b', '/arr/0')
7
+ * @returns {unknown}
8
+ */
9
+ export function resolveDataPath (data, dataPath) {
10
+ if (dataPath === '') return data
11
+ if (data === undefined || data === null) return undefined
12
+ const segments = dataPath.slice(1).split('/')
13
+ let current = data
14
+ for (const segment of segments) {
15
+ if (current === undefined || current === null || typeof current !== 'object') return undefined
16
+ current = /** @type {any} */(current)[segment]
17
+ }
18
+ return current
19
+ }
20
+
21
+ /**
22
+ * Compare a saved value against a current value.
23
+ * The caller is responsible for checking if the feature is active (context.savedData !== undefined)
24
+ * before calling this function.
25
+ * @param {unknown} savedValue
26
+ * @param {unknown} currentValue
27
+ * @returns {boolean}
28
+ */
29
+ export function isNodeModified (savedValue, currentValue) {
30
+ if (savedValue === currentValue) return false
31
+ return !equal(savedValue, currentValue)
32
+ }