ngx-form-designerr 0.0.10 → 0.0.11

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.
@@ -1,6 +1,6 @@
1
1
  import * as i0 from '@angular/core';
2
- import { Injectable, InjectionToken, NgModule, signal, computed, EventEmitter, inject, DestroyRef, Injector, ViewContainerRef, Input, ViewChild, Output, Inject, ChangeDetectionStrategy, Component, effect, ElementRef, NgZone, input, output, HostListener, ContentChildren, untracked, ChangeDetectorRef } from '@angular/core';
3
- import { BehaviorSubject, Subject, merge, of, filter, map, debounceTime as debounceTime$1, firstValueFrom } from 'rxjs';
2
+ import { Injectable, InjectionToken, NgModule, inject, signal, computed, EventEmitter, DestroyRef, Injector, ViewContainerRef, Input, ViewChild, Output, Inject, ChangeDetectionStrategy, Component, effect, ElementRef, NgZone, input, output, HostListener, ContentChildren, untracked, ChangeDetectorRef } from '@angular/core';
3
+ import { BehaviorSubject, Subject, merge, of, filter, map, debounceTime as debounceTime$1, skip, firstValueFrom } from 'rxjs';
4
4
  import { v4 } from 'uuid';
5
5
  import * as i1 from '@angular/common';
6
6
  import { CommonModule, DOCUMENT } from '@angular/common';
@@ -241,7 +241,7 @@ class FormEngine {
241
241
  return;
242
242
  }
243
243
  const isRequired = this.isFieldRequired(field.id);
244
- const isEmpty = value === null || value === undefined || value === '';
244
+ const isEmpty = value === null || value === undefined || value === '' || (Array.isArray(value) && value.length === 0);
245
245
  if (isRequired && isEmpty) {
246
246
  fieldErrors.push('This field is required.');
247
247
  }
@@ -402,6 +402,12 @@ function computeAccessFlags(schema) {
402
402
  // hierarchyAccess: true if field participates in hierarchy (either direction)
403
403
  field.dataConfig.hierarchyAccess = hierarchyAccess;
404
404
  }
405
+ for (const field of schema.fields) {
406
+ const itemSchema = field.repeatable?.itemSchema;
407
+ if (!itemSchema)
408
+ continue;
409
+ computeAccessFlags(itemSchema);
410
+ }
405
411
  }
406
412
 
407
413
  /**
@@ -478,6 +484,10 @@ function checkSchemaForApiOnlySources(schema) {
478
484
  return issues;
479
485
  for (const field of schema.fields) {
480
486
  checkField(field, issues);
487
+ const itemSchema = field.repeatable?.itemSchema;
488
+ if (itemSchema) {
489
+ issues.push(...checkSchemaForApiOnlySources(itemSchema));
490
+ }
481
491
  }
482
492
  return issues;
483
493
  }
@@ -551,6 +561,9 @@ function stripEmbeddedSourceData(schema) {
551
561
  delete field.staticOptions;
552
562
  }
553
563
  }
564
+ if (field.repeatable?.itemSchema) {
565
+ field.repeatable.itemSchema = stripEmbeddedSourceData(field.repeatable.itemSchema);
566
+ }
554
567
  }
555
568
  }
556
569
  return clone;
@@ -640,25 +653,46 @@ function mergeAndNormalize(base, override) {
640
653
 
641
654
  const WIDGET_EDITOR_CONTEXT = new InjectionToken('WIDGET_EDITOR_CONTEXT');
642
655
 
643
- function buildLayoutIndex(root) {
656
+ const SCOPED_NODE_SEPARATOR = '::scope::';
657
+ function composeScopedNodeId(scopePath, nodeId) {
658
+ if (scopePath.length === 0)
659
+ return nodeId;
660
+ return `${scopePath.join('/')}${SCOPED_NODE_SEPARATOR}${nodeId}`;
661
+ }
662
+ function buildLayoutIndex(schema) {
644
663
  const index = {};
645
- const walk = (node, parentId, path, position) => {
646
- const nextPath = [...path, node.id];
647
- index[node.id] = {
664
+ const walk = (scopeSchema, node, parentId, path, position, scopePath) => {
665
+ const scopedNodeId = composeScopedNodeId(scopePath, node.id);
666
+ const nextPath = [...path, scopedNodeId];
667
+ index[scopedNodeId] = {
648
668
  node,
649
669
  parentId,
650
670
  path: nextPath,
651
- index: position
671
+ index: position,
672
+ scopePath: [...scopePath],
673
+ rawNodeId: node.id
652
674
  };
653
675
  if ('children' in node) {
654
676
  const children = node.children;
655
- children.forEach((child, idx) => walk(child, node.id, nextPath, idx));
677
+ children.forEach((child, idx) => walk(scopeSchema, child, scopedNodeId, nextPath, idx, scopePath));
678
+ }
679
+ if (node.type === 'widget') {
680
+ const widgetNode = node;
681
+ if (!widgetNode.refId)
682
+ return;
683
+ const field = scopeSchema.fields.find(candidate => candidate.id === widgetNode.refId);
684
+ const itemSchema = field?.repeatable?.itemSchema;
685
+ if (!field || !itemSchema?.layout || !Array.isArray(itemSchema.fields))
686
+ return;
687
+ const nestedScopePath = [...scopePath, field.id];
688
+ walk(itemSchema, itemSchema.layout, scopedNodeId, nextPath, 0, nestedScopePath);
656
689
  }
657
690
  };
658
- walk(root, null, [], 0);
691
+ walk(schema, schema.layout, null, [], 0, []);
659
692
  return index;
660
693
  }
661
694
  class DesignerStateService {
695
+ widgetDefs = (inject(WIDGET_DEFINITIONS, { optional: true }) ?? []).flat();
662
696
  activeFlavor = signal('form');
663
697
  schema = signal({
664
698
  id: v4(),
@@ -711,7 +745,7 @@ class DesignerStateService {
711
745
  constructor() {
712
746
  this.recordHistory(this.schema());
713
747
  }
714
- layoutIndex = computed(() => buildLayoutIndex(this.schema().layout));
748
+ layoutIndex = computed(() => buildLayoutIndex(this.schema()));
715
749
  selectedNode = computed(() => {
716
750
  const id = this.selectedNodeId();
717
751
  if (!id)
@@ -720,12 +754,16 @@ class DesignerStateService {
720
754
  return entry?.node ?? null;
721
755
  });
722
756
  selectedField = computed(() => {
723
- const node = this.selectedNode();
724
- if (node && node.type === 'widget') {
725
- const refId = node.refId;
726
- return this.schema().fields.find((f) => f.id === refId) || null;
727
- }
728
- return null;
757
+ const selectedId = this.selectedNodeId();
758
+ if (!selectedId)
759
+ return null;
760
+ const entry = this.layoutIndex()[selectedId];
761
+ if (!entry || entry.node.type !== 'widget')
762
+ return null;
763
+ const refId = entry.node.refId;
764
+ if (!refId)
765
+ return null;
766
+ return this.findFieldByIdInScope(this.schema(), entry.scopePath, refId);
729
767
  });
730
768
  updateSchema(schema) {
731
769
  this.setSchema(schema);
@@ -788,6 +826,28 @@ class DesignerStateService {
788
826
  isNodeSelected(nodeId) {
789
827
  return this.selectedNodeIds().includes(nodeId);
790
828
  }
829
+ composeScopedNodeId(scopePath, nodeId) {
830
+ return composeScopedNodeId(scopePath, nodeId);
831
+ }
832
+ getSelectedScopeFields() {
833
+ const selectedId = this.selectedNodeId();
834
+ if (!selectedId)
835
+ return this.schema().fields;
836
+ const entry = this.layoutIndex()[selectedId];
837
+ if (!entry)
838
+ return this.schema().fields;
839
+ const scopeSchema = this.resolveSchemaAtScope(this.schema(), entry.scopePath);
840
+ return scopeSchema?.fields ?? this.schema().fields;
841
+ }
842
+ isSelectionInScope(scopePath) {
843
+ const selectedId = this.selectedNodeId();
844
+ if (!selectedId)
845
+ return false;
846
+ const entry = this.layoutIndex()[selectedId];
847
+ if (!entry)
848
+ return false;
849
+ return this.isScopePrefix(scopePath, entry.scopePath);
850
+ }
791
851
  openContextMenu(position) {
792
852
  this.contextMenu.set(position);
793
853
  }
@@ -819,11 +879,14 @@ class DesignerStateService {
819
879
  const nodesToCopy = [];
820
880
  const fieldsToCopy = [];
821
881
  const index = this.layoutIndex();
822
- // 1. Gather nodes
823
- // Sort by index/position if possible to maintain order, but simple iteration is okay for now.
824
- // We should probably filter out descendants if ancestors are also selected to avoid duplication?
825
- // For simplicity: just Copy exactly what is selected. If user selects Row + its Child Col, we might duplicate stuff?
826
- // Better approach: minimal cover. If a parent is selected, don't include its children in the top-level list.
882
+ const primarySelectedId = this.selectedNodeId() ?? selectedIds[0];
883
+ const primaryEntry = primarySelectedId ? index[primarySelectedId] : undefined;
884
+ if (!primaryEntry)
885
+ return;
886
+ const scopePath = primaryEntry.scopePath;
887
+ const scopeSchema = this.resolveSchemaAtScope(current, scopePath);
888
+ if (!scopeSchema)
889
+ return;
827
890
  const isDescendantOfSelected = (id) => {
828
891
  let parentId = index[id]?.parentId;
829
892
  while (parentId) {
@@ -833,17 +896,27 @@ class DesignerStateService {
833
896
  }
834
897
  return false;
835
898
  };
836
- const topLevelSelected = selectedIds.filter(id => !isDescendantOfSelected(id));
899
+ const topLevelSelected = selectedIds.filter(id => {
900
+ const entry = index[id];
901
+ if (!entry)
902
+ return false;
903
+ if (!this.sameScope(entry.scopePath, scopePath))
904
+ return false;
905
+ return !isDescendantOfSelected(id);
906
+ });
837
907
  for (const id of topLevelSelected) {
838
908
  const entry = index[id];
839
909
  if (entry && entry.node) {
840
910
  nodesToCopy.push(entry.node);
841
- // 2. Gather fields recursively
842
- this.collectFields(entry.node, current.fields, fieldsToCopy);
911
+ this.collectFields(entry.node, scopeSchema.fields, fieldsToCopy);
843
912
  }
844
913
  }
845
914
  if (nodesToCopy.length > 0) {
846
- this.clipboard.set({ nodes: JSON.parse(JSON.stringify(nodesToCopy)), fields: JSON.parse(JSON.stringify(fieldsToCopy)) });
915
+ this.clipboard.set({
916
+ nodes: JSON.parse(JSON.stringify(nodesToCopy)),
917
+ fields: JSON.parse(JSON.stringify(fieldsToCopy)),
918
+ scopePath: [...scopePath]
919
+ });
847
920
  }
848
921
  }
849
922
  cut() {
@@ -859,83 +932,83 @@ class DesignerStateService {
859
932
  if (!data || data.nodes.length === 0)
860
933
  return;
861
934
  const current = this.schema();
862
- const newLayout = JSON.parse(JSON.stringify(current.layout));
863
- const newFields = [...current.fields];
864
- // Determine Drop Target
865
- const selectedId = this.selectedNodeId(); // Primary selection for paste target
935
+ const nextSchema = this.cloneValue(current);
936
+ const selectedId = this.selectedNodeId();
866
937
  const index = this.layoutIndex();
938
+ const selectedEntry = selectedId ? index[selectedId] : undefined;
939
+ const targetScopePath = selectedEntry
940
+ ? this.resolveInsertionScopePath(selectedEntry)
941
+ : [...(data.scopePath ?? [])];
942
+ const targetSchema = this.resolveSchemaAtScope(nextSchema, targetScopePath);
943
+ if (!targetSchema)
944
+ return;
867
945
  let targetParentId = null;
868
946
  let targetIndex = -1;
869
- let insertMode = 'append'; // append to container, or insert after item
870
- if (selectedId) {
871
- const entry = index[selectedId];
872
- if (entry) {
873
- const node = entry.node;
874
- if ((node.type === 'row' || node.type === 'col') && 'children' in node) {
875
- // Paste INSIDE the container
876
- targetParentId = node.id;
877
- targetIndex = node.children.length; // End of list
878
- insertMode = 'append';
947
+ if (selectedEntry && this.sameScope(selectedEntry.scopePath, targetScopePath)) {
948
+ if ((selectedEntry.node.type === 'row' || selectedEntry.node.type === 'col') && 'children' in selectedEntry.node) {
949
+ targetParentId = selectedEntry.rawNodeId;
950
+ targetIndex = selectedEntry.node.children.length;
951
+ }
952
+ else {
953
+ if (selectedEntry.parentId) {
954
+ const parentEntry = index[selectedEntry.parentId];
955
+ targetParentId = parentEntry?.rawNodeId ?? targetSchema.layout.id;
879
956
  }
880
957
  else {
881
- // Paste AFTER the widget (sibling)
882
- targetParentId = entry.parentId;
883
- targetIndex = entry.index + 1;
884
- insertMode = 'insert';
958
+ targetParentId = targetSchema.layout.id;
885
959
  }
960
+ targetIndex = selectedEntry.index + 1;
886
961
  }
887
962
  }
888
- // Fallback: If no selection or invalid target, paste to Root
963
+ // Fallback: If no selection or invalid target, paste to scope root.
889
964
  if (!targetParentId) {
890
- targetParentId = newLayout.id;
891
- targetIndex = newLayout.children.length;
892
- insertMode = 'append';
965
+ targetParentId = targetSchema.layout.id;
966
+ if ('children' in targetSchema.layout) {
967
+ targetIndex = targetSchema.layout.children.length;
968
+ }
969
+ else {
970
+ return;
971
+ }
893
972
  }
894
973
  const newSelectedIds = [];
974
+ const clipboardFields = new Map(data.fields.map(field => [field.id, field]));
895
975
  // Helper to clone node with new IDs
896
976
  const processNode = (node) => {
897
- const newNode = JSON.parse(JSON.stringify(node));
898
- newNode.id = v4();
977
+ const oldNode = JSON.parse(JSON.stringify(node));
978
+ const newNode = { ...oldNode, id: v4() };
899
979
  newSelectedIds.push(newNode.id);
900
980
  if (newNode.type === 'widget' && newNode.refId) {
901
- // Find corresponding field in clipboard data
902
- const originalField = data.fields.find(f => f.id === newNode.refId);
903
- // Or map via old refId?
904
- // Wait, processNode receives 'node' which is from clipboard (already has old IDs).
905
- // We need to map oldFieldId -> newFieldId.
981
+ const oldRefId = oldNode.refId;
982
+ const originalField = clipboardFields.get(oldRefId);
906
983
  if (originalField) {
907
- // Create new field
908
984
  const newFieldId = v4();
909
985
  const newField = {
910
986
  ...originalField,
911
987
  id: newFieldId,
912
988
  name: `${originalField.name}_${Date.now()}_${Math.floor(Math.random() * 1000)}`
913
989
  };
914
- newFields.push(newField);
990
+ targetSchema.fields.push(newField);
915
991
  newNode.refId = newFieldId;
916
992
  }
917
993
  }
918
994
  if ('children' in newNode) {
919
- newNode.children = newNode.children.map((child) => processNode(child));
995
+ newNode.children =
996
+ newNode.children.map((child) => processNode(child));
920
997
  }
921
998
  return newNode;
922
999
  };
923
1000
  const clonedNodes = data.nodes.map(n => processNode(n));
924
1001
  // Insert into Layout
925
1002
  if (!targetParentId)
926
- return; // Should be handled by fallback above, but satisfies TS
927
- const targetNode = this.findNode(newLayout, targetParentId);
1003
+ return;
1004
+ const targetNode = this.findNode(targetSchema.layout, targetParentId);
928
1005
  if (targetNode && 'children' in targetNode) {
929
1006
  targetNode.children.splice(targetIndex, 0, ...clonedNodes);
930
- this.setSchema({
931
- ...current,
932
- fields: newFields,
933
- layout: newLayout
934
- });
935
- // Select pasted items
936
- this.selectedNodeIds.set(newSelectedIds);
937
- if (newSelectedIds.length > 0) {
938
- this.selectedNodeId.set(newSelectedIds[0]);
1007
+ this.setSchema(nextSchema);
1008
+ const scopedSelectedIds = newSelectedIds.map(id => this.composeScopedNodeId(targetScopePath, id));
1009
+ this.selectedNodeIds.set(scopedSelectedIds);
1010
+ if (scopedSelectedIds.length > 0) {
1011
+ this.selectedNodeId.set(scopedSelectedIds[0]);
939
1012
  }
940
1013
  }
941
1014
  }
@@ -945,41 +1018,65 @@ class DesignerStateService {
945
1018
  const idsToDelete = this.selectedNodeIds();
946
1019
  if (idsToDelete.length === 0)
947
1020
  return;
948
- const current = this.schema();
949
- const newLayout = JSON.parse(JSON.stringify(current.layout));
950
- let fieldsToRemove = [];
951
- const collectFieldIds = (node) => {
952
- if (node.type === 'widget' && node.refId) {
953
- fieldsToRemove.push(node.refId);
954
- }
955
- if ('children' in node) {
956
- node.children.forEach((c) => collectFieldIds(c));
1021
+ const index = this.layoutIndex();
1022
+ const isDescendantOfSelected = (id) => {
1023
+ let parentId = index[id]?.parentId;
1024
+ while (parentId) {
1025
+ if (idsToDelete.includes(parentId))
1026
+ return true;
1027
+ parentId = index[parentId]?.parentId;
957
1028
  }
1029
+ return false;
958
1030
  };
959
- const index = this.layoutIndex(); // Use current index to find nodes quickly if needed,
960
- // but we need to walk the newLayout to mutate it.
961
- // Actually, simpler to just write a recursive remover that checks against the Set of IDs.
962
- const deleteSet = new Set(idsToDelete);
963
- const removeRecursive = (parent) => {
964
- if (!('children' in parent))
965
- return;
966
- const children = parent.children;
967
- // Iterate backwards to safely splice
968
- for (let i = children.length - 1; i >= 0; i--) {
969
- const child = children[i];
970
- if (deleteSet.has(child.id)) {
971
- collectFieldIds(child);
972
- children.splice(i, 1);
1031
+ const topLevelEntries = idsToDelete
1032
+ .filter(id => !isDescendantOfSelected(id))
1033
+ .map(id => index[id])
1034
+ .filter((entry) => !!entry);
1035
+ if (topLevelEntries.length === 0)
1036
+ return;
1037
+ const current = this.schema();
1038
+ const nextSchema = this.cloneValue(current);
1039
+ const entriesByScope = new Map();
1040
+ for (const entry of topLevelEntries) {
1041
+ const key = entry.scopePath.join('/');
1042
+ const scopeEntries = entriesByScope.get(key) ?? [];
1043
+ scopeEntries.push(entry);
1044
+ entriesByScope.set(key, scopeEntries);
1045
+ }
1046
+ for (const scopeEntries of entriesByScope.values()) {
1047
+ const scopePath = scopeEntries[0].scopePath;
1048
+ const scopeSchema = this.resolveSchemaAtScope(nextSchema, scopePath);
1049
+ if (!scopeSchema)
1050
+ continue;
1051
+ const deleteSet = new Set(scopeEntries.map(entry => entry.rawNodeId));
1052
+ const fieldsToRemove = new Set();
1053
+ const collectFieldIds = (node) => {
1054
+ if (node.type === 'widget' && node.refId) {
1055
+ fieldsToRemove.add(node.refId);
973
1056
  }
974
- else {
975
- removeRecursive(child);
1057
+ if ('children' in node) {
1058
+ node.children.forEach((child) => collectFieldIds(child));
976
1059
  }
977
- }
978
- };
979
- // Handle root children manually? No, root is just a node.
980
- removeRecursive(newLayout);
981
- const newFields = current.fields.filter(f => !fieldsToRemove.includes(f.id));
982
- this.setSchema({ ...current, fields: newFields, layout: newLayout });
1060
+ };
1061
+ const removeRecursive = (parent) => {
1062
+ if (!('children' in parent))
1063
+ return;
1064
+ const children = parent.children;
1065
+ for (let i = children.length - 1; i >= 0; i -= 1) {
1066
+ const child = children[i];
1067
+ if (deleteSet.has(child.id)) {
1068
+ collectFieldIds(child);
1069
+ children.splice(i, 1);
1070
+ }
1071
+ else {
1072
+ removeRecursive(child);
1073
+ }
1074
+ }
1075
+ };
1076
+ removeRecursive(scopeSchema.layout);
1077
+ scopeSchema.fields = scopeSchema.fields.filter(field => !fieldsToRemove.has(field.id));
1078
+ }
1079
+ this.setSchema(nextSchema);
983
1080
  this.selectNode(null);
984
1081
  }
985
1082
  // Helper to find parent of a node (simple traversal)
@@ -1063,20 +1160,21 @@ class DesignerStateService {
1063
1160
  return [];
1064
1161
  const path = [];
1065
1162
  for (const id of entry.path) {
1066
- const node = index[id]?.node;
1163
+ const indexedEntry = index[id];
1164
+ const node = indexedEntry?.node;
1067
1165
  if (!node)
1068
1166
  continue;
1069
- path.push({ id, label: this.getNodeLabel(node), type: node.type });
1167
+ path.push({ id, label: this.getNodeLabel(node, indexedEntry.scopePath), type: node.type });
1070
1168
  }
1071
1169
  return path;
1072
1170
  }
1073
- getNodeLabel(node) {
1171
+ getNodeLabel(node, scopePath) {
1074
1172
  if (node.type === 'row')
1075
1173
  return 'Row';
1076
1174
  if (node.type === 'col')
1077
1175
  return 'Column';
1078
1176
  if (node.type === 'widget') {
1079
- const field = this.schema().fields.find(f => f.id === node.refId);
1177
+ const field = this.findFieldByIdInScope(this.schema(), scopePath, node.refId ?? '');
1080
1178
  return field?.label || field?.type || 'Widget';
1081
1179
  }
1082
1180
  return 'Root';
@@ -1115,24 +1213,16 @@ class DesignerStateService {
1115
1213
  return false;
1116
1214
  }
1117
1215
  handleDrop(event, targetColId) {
1216
+ const targetEntry = this.layoutIndex()[targetColId];
1217
+ if (!targetEntry || targetEntry.node.type !== 'col')
1218
+ return;
1118
1219
  const current = this.schema();
1119
- const newLayout = JSON.parse(JSON.stringify(current.layout)); // Deep clone
1120
- const newFields = [...current.fields];
1121
- // helper to find column in newLayout
1122
- const findCol = (root, id) => {
1123
- if (root.id === id && root.type === 'col')
1124
- return root;
1125
- if ('children' in root) {
1126
- for (const child of root.children) {
1127
- const res = findCol(child, id);
1128
- if (res)
1129
- return res;
1130
- }
1131
- }
1132
- return null;
1133
- };
1134
- const targetCol = findCol(newLayout, targetColId);
1135
- if (!targetCol)
1220
+ const newSchema = this.cloneValue(current);
1221
+ const targetSchema = this.resolveSchemaAtScope(newSchema, targetEntry.scopePath);
1222
+ if (!targetSchema)
1223
+ return;
1224
+ const targetCol = this.findNode(targetSchema.layout, targetEntry.rawNodeId);
1225
+ if (!targetCol || targetCol.type !== 'col')
1136
1226
  return;
1137
1227
  if (event.previousContainer === event.container) {
1138
1228
  // Reorder
@@ -1150,44 +1240,24 @@ class DesignerStateService {
1150
1240
  console.warn('Drag data missing widget info.');
1151
1241
  return;
1152
1242
  }
1153
- // Create new
1154
- let widgetNode;
1155
- if (data.widgetKind === 'field' || data.widgetKind === 'image') {
1156
- // 1. Create Field (or static element backed by FieldSchema)
1157
- // We need the widget def to create config.
1158
- const fieldId = v4();
1159
- const field = {
1160
- id: fieldId,
1161
- name: `field_${Date.now()}`,
1162
- type: data.widgetType,
1163
- label: 'New Widget',
1164
- };
1165
- newFields.push(field);
1166
- widgetNode = {
1167
- id: v4(),
1168
- type: 'widget',
1169
- widgetKind: data.widgetKind,
1170
- refId: fieldId
1171
- };
1172
- }
1173
- else {
1174
- // other widgets
1175
- widgetNode = {
1176
- id: v4(),
1177
- type: 'widget',
1178
- widgetKind: data.widgetKind,
1179
- refId: v4()
1180
- };
1181
- }
1243
+ const widgetDef = this.resolveWidgetDefinition(data);
1244
+ const field = this.createFieldFromWidget(widgetDef, data.widgetType);
1245
+ targetSchema.fields.push(field);
1246
+ const widgetNode = {
1247
+ id: v4(),
1248
+ type: 'widget',
1249
+ widgetKind: widgetDef?.kind ?? data.widgetKind,
1250
+ refId: field.id
1251
+ };
1182
1252
  targetCol.children.splice(event.currentIndex, 0, widgetNode);
1183
1253
  }
1184
1254
  else if (data && data.type === 'new-section' && data.section) {
1185
1255
  const draft = data.section.build();
1186
1256
  targetCol.children.splice(event.currentIndex, 0, draft.layout);
1187
- newFields.push(...draft.fields);
1257
+ targetSchema.fields.push(...draft.fields);
1188
1258
  }
1189
1259
  else if (this.isLayoutNode(data)) {
1190
- const movedNode = this.detachNode(newLayout, data.id);
1260
+ const movedNode = this.detachNode(targetSchema.layout, data.id);
1191
1261
  if (!movedNode)
1192
1262
  return;
1193
1263
  targetCol.children.splice(event.currentIndex, 0, movedNode);
@@ -1196,84 +1266,76 @@ class DesignerStateService {
1196
1266
  console.warn('Unsupported drag data dropped into layout.');
1197
1267
  }
1198
1268
  }
1199
- this.setSchema({
1200
- ...current,
1201
- fields: newFields,
1202
- layout: newLayout
1203
- });
1269
+ this.setSchema(newSchema);
1204
1270
  }
1205
1271
  pushField(widgetDef) {
1206
1272
  if (this.isReadOnly())
1207
1273
  return;
1208
- // Existing logic unchanged
1209
- // Simplified Logic: Always append to the last column of the last row for now,
1210
- // or check if there is a selected column.
1211
1274
  const current = this.schema();
1212
- const newFields = [...current.fields];
1213
- const newLayout = JSON.parse(JSON.stringify(current.layout));
1214
- const fieldId = v4();
1215
- const field = {
1216
- id: fieldId,
1217
- widgetId: widgetDef.id, // Persist stable ID
1218
- name: `field_${Date.now()}`,
1219
- type: widgetDef.type,
1220
- label: widgetDef.label,
1221
- };
1222
- newFields.push(field);
1275
+ const selectedId = this.selectedNodeId();
1276
+ const selectedEntry = selectedId ? this.layoutIndex()[selectedId] : undefined;
1277
+ const insertionScopePath = this.resolveInsertionScopePath(selectedEntry);
1278
+ const nextSchema = this.cloneValue(current);
1279
+ const targetSchema = this.resolveSchemaAtScope(nextSchema, insertionScopePath);
1280
+ if (!targetSchema)
1281
+ return;
1282
+ const field = this.createFieldFromWidget(widgetDef, widgetDef.type);
1283
+ targetSchema.fields.push(field);
1223
1284
  const widgetNode = {
1224
1285
  id: v4(),
1225
1286
  type: 'widget',
1226
- refId: fieldId,
1227
- widgetKind: widgetDef.kind // Use kind from definition
1287
+ refId: field.id,
1288
+ widgetKind: widgetDef.kind
1228
1289
  };
1229
- // Find target container
1230
- let targetCol = null;
1231
- // Strategy 1: Use Selected Node if it is a Column
1232
- const selectedId = this.selectedNodeId();
1233
- if (selectedId) {
1234
- const node = this.findNode(newLayout, selectedId);
1235
- if (node && node.type === 'col') {
1236
- targetCol = node;
1237
- }
1238
- else if (node && node.type === 'widget') {
1239
- // If selected node is a widget, find its parent column (needs parent pointers or traversal)
1240
- // For now, let's just default to root behavior if widget selected
1241
- // Or we could implement findParent(root, id)
1242
- }
1290
+ const targetCol = this.resolveTargetColumnForFieldInsert(targetSchema, selectedEntry, insertionScopePath);
1291
+ if (!targetCol)
1292
+ return;
1293
+ targetCol.children.push(widgetNode);
1294
+ this.setSchema(nextSchema);
1295
+ this.selectNode(this.composeScopedNodeId(insertionScopePath, widgetNode.id));
1296
+ }
1297
+ resolveWidgetDefinition(data) {
1298
+ if (data.widgetId) {
1299
+ const byId = this.widgetDefs.find(widget => widget.id === data.widgetId);
1300
+ if (byId)
1301
+ return byId;
1243
1302
  }
1244
- // Strategy 2: Default to first column of first row (or create new)
1245
- if (!targetCol) {
1246
- // Just find the first column we can
1247
- const findFirstCol = (node) => {
1248
- if (node.type === 'col')
1249
- return node;
1250
- if ('children' in node) {
1251
- for (const child of node.children) {
1252
- const res = findFirstCol(child);
1253
- if (res)
1254
- return res;
1255
- }
1256
- }
1257
- return null;
1258
- };
1259
- targetCol = findFirstCol(newLayout);
1260
- }
1261
- if (targetCol) {
1262
- targetCol.children.push(widgetNode);
1263
- this.setSchema({
1264
- ...current,
1265
- fields: newFields,
1266
- layout: newLayout
1267
- });
1268
- // Auto Select new field
1269
- this.selectNode(widgetNode.id);
1303
+ return this.widgetDefs.find(widget => widget.kind === data.widgetKind && widget.type === data.widgetType);
1304
+ }
1305
+ createFieldFromWidget(widgetDef, fallbackType) {
1306
+ const baseConfig = widgetDef?.createConfig ? widgetDef.createConfig() : {};
1307
+ const normalizedName = typeof baseConfig.name === 'string' && baseConfig.name.trim().length > 0
1308
+ ? baseConfig.name
1309
+ : `field_${Date.now()}`;
1310
+ const normalizedType = typeof baseConfig.type === 'string' && baseConfig.type.trim().length > 0
1311
+ ? baseConfig.type
1312
+ : (widgetDef?.type || fallbackType || 'text');
1313
+ const normalizedLabel = typeof baseConfig.label === 'string' && baseConfig.label.trim().length > 0
1314
+ ? baseConfig.label
1315
+ : (widgetDef?.label || 'New Widget');
1316
+ const field = {
1317
+ ...baseConfig,
1318
+ id: v4(),
1319
+ name: normalizedName,
1320
+ type: normalizedType,
1321
+ label: normalizedLabel
1322
+ };
1323
+ if (widgetDef?.id) {
1324
+ field.widgetId = widgetDef.id;
1270
1325
  }
1326
+ return field;
1271
1327
  }
1272
1328
  pushStructure(type) {
1273
1329
  if (this.isReadOnly())
1274
1330
  return;
1275
1331
  const current = this.schema();
1276
- const newLayout = JSON.parse(JSON.stringify(current.layout));
1332
+ const selectedId = this.selectedNodeId();
1333
+ const selectedEntry = selectedId ? this.layoutIndex()[selectedId] : undefined;
1334
+ const insertionScopePath = this.resolveInsertionScopePath(selectedEntry);
1335
+ const newSchema = this.cloneValue(current);
1336
+ const targetSchema = this.resolveSchemaAtScope(newSchema, insertionScopePath);
1337
+ if (!targetSchema)
1338
+ return;
1277
1339
  // Create a new Row
1278
1340
  const newRow = {
1279
1341
  id: v4(),
@@ -1288,8 +1350,9 @@ class DesignerStateService {
1288
1350
  children: []
1289
1351
  };
1290
1352
  newRow.children.push(col1);
1353
+ let col2;
1291
1354
  if (type === '2col') {
1292
- const col2 = {
1355
+ col2 = {
1293
1356
  id: v4(),
1294
1357
  type: 'col',
1295
1358
  responsive: { xs: 6 },
@@ -1297,30 +1360,39 @@ class DesignerStateService {
1297
1360
  };
1298
1361
  newRow.children.push(col2);
1299
1362
  }
1300
- // Add the new Row to the Root (Page)
1301
- // Root is 'col', so we push to its children
1302
- if (newLayout.type === 'col') {
1303
- newLayout.children.push(newRow);
1363
+ const layoutRoot = targetSchema.layout;
1364
+ if (layoutRoot.type === 'col') {
1365
+ layoutRoot.children.push(newRow);
1366
+ }
1367
+ else if (layoutRoot.type === 'row') {
1368
+ layoutRoot.children.push(col1);
1369
+ if (col2) {
1370
+ layoutRoot.children.push(col2);
1371
+ }
1304
1372
  }
1305
1373
  else {
1306
- // Fallback if root is somehow still 'row' (migration?)
1307
- // We can't put a row in a row effectively without wrapping.
1308
- // For now assume root is fixed to col.
1309
- newLayout.children.push(newRow);
1374
+ return;
1310
1375
  }
1311
- this.setSchema({ ...current, layout: newLayout });
1376
+ this.setSchema(newSchema);
1377
+ this.selectNode(this.composeScopedNodeId(insertionScopePath, col1.id));
1312
1378
  }
1313
1379
  // --- Column Management Methods ---
1314
1380
  /** Remove a column by its id */
1315
1381
  removeColumn(columnId) {
1316
1382
  if (this.isReadOnly())
1317
1383
  return;
1384
+ const entry = this.resolveEntry(columnId);
1385
+ if (!entry)
1386
+ return;
1318
1387
  const current = this.schema();
1319
- const newLayout = JSON.parse(JSON.stringify(current.layout));
1388
+ const newSchema = this.cloneValue(current);
1389
+ const scopeSchema = this.resolveSchemaAtScope(newSchema, entry.scopePath);
1390
+ if (!scopeSchema)
1391
+ return;
1320
1392
  // Find parent row and remove column
1321
1393
  const removeFromRow = (node) => {
1322
1394
  if (node.type === 'row') {
1323
- const idx = node.children.findIndex((c) => c.id === columnId && c.type === 'col');
1395
+ const idx = node.children.findIndex((c) => c.id === entry.rawNodeId && c.type === 'col');
1324
1396
  if (idx !== -1) {
1325
1397
  node.children.splice(idx, 1);
1326
1398
  return true;
@@ -1334,16 +1406,22 @@ class DesignerStateService {
1334
1406
  }
1335
1407
  return false;
1336
1408
  };
1337
- removeFromRow(newLayout);
1338
- this.setSchema({ ...current, layout: newLayout });
1409
+ removeFromRow(scopeSchema.layout);
1410
+ this.setSchema(newSchema);
1339
1411
  }
1340
1412
  /** Set preset column layout (e.g., 3,4,6) on a row */
1341
1413
  setPresetLayout(rowId, preset) {
1342
1414
  if (this.isReadOnly())
1343
1415
  return;
1416
+ const entry = this.resolveEntry(rowId);
1417
+ if (!entry)
1418
+ return;
1344
1419
  const current = this.schema();
1345
- const newLayout = JSON.parse(JSON.stringify(current.layout));
1346
- const rowNode = this.findNode(newLayout, rowId);
1420
+ const newSchema = this.cloneValue(current);
1421
+ const scopeSchema = this.resolveSchemaAtScope(newSchema, entry.scopePath);
1422
+ if (!scopeSchema)
1423
+ return;
1424
+ const rowNode = this.findNode(scopeSchema.layout, entry.rawNodeId);
1347
1425
  if (!rowNode || rowNode.type !== 'row')
1348
1426
  return;
1349
1427
  rowNode.maxColumns = preset;
@@ -1368,15 +1446,21 @@ class DesignerStateService {
1368
1446
  rowNode.children.forEach((c) => {
1369
1447
  c.responsive = { xs: Math.floor(12 / preset) };
1370
1448
  });
1371
- this.setSchema({ ...current, layout: newLayout });
1449
+ this.setSchema(newSchema);
1372
1450
  }
1373
1451
  /** Add one or more columns to a row */
1374
1452
  addColumn(rowId, count = 1) {
1375
1453
  if (this.isReadOnly())
1376
1454
  return;
1455
+ const entry = this.resolveEntry(rowId);
1456
+ if (!entry)
1457
+ return;
1377
1458
  const current = this.schema();
1378
- const newLayout = JSON.parse(JSON.stringify(current.layout));
1379
- const rowNode = this.findNode(newLayout, rowId);
1459
+ const newSchema = this.cloneValue(current);
1460
+ const scopeSchema = this.resolveSchemaAtScope(newSchema, entry.scopePath);
1461
+ if (!scopeSchema)
1462
+ return;
1463
+ const rowNode = this.findNode(scopeSchema.layout, entry.rawNodeId);
1380
1464
  if (!rowNode || rowNode.type !== 'row')
1381
1465
  return;
1382
1466
  for (let i = 0; i < count; i++) {
@@ -1388,13 +1472,19 @@ class DesignerStateService {
1388
1472
  };
1389
1473
  rowNode.children.push(col);
1390
1474
  }
1391
- this.setSchema({ ...current, layout: newLayout });
1475
+ this.setSchema(newSchema);
1392
1476
  }
1393
1477
  // --- Node Manipulation Methods (for floating toolbar) ---
1394
1478
  /** Move a node up or down within its parent container */
1395
1479
  moveNode(nodeId, direction) {
1480
+ const entry = this.resolveEntry(nodeId);
1481
+ if (!entry)
1482
+ return;
1396
1483
  const current = this.schema();
1397
- const newLayout = JSON.parse(JSON.stringify(current.layout));
1484
+ const newSchema = this.cloneValue(current);
1485
+ const scopeSchema = this.resolveSchemaAtScope(newSchema, entry.scopePath);
1486
+ if (!scopeSchema)
1487
+ return;
1398
1488
  // Find parent that contains this node
1399
1489
  const findParentAndIndex = (parent, id) => {
1400
1490
  if ('children' in parent) {
@@ -1411,7 +1501,7 @@ class DesignerStateService {
1411
1501
  }
1412
1502
  return null;
1413
1503
  };
1414
- const result = findParentAndIndex(newLayout, nodeId);
1504
+ const result = findParentAndIndex(scopeSchema.layout, entry.rawNodeId);
1415
1505
  if (!result)
1416
1506
  return;
1417
1507
  const { parent, index } = result;
@@ -1424,13 +1514,19 @@ class DesignerStateService {
1424
1514
  // Swap with next
1425
1515
  [children[index], children[index + 1]] = [children[index + 1], children[index]];
1426
1516
  }
1427
- this.setSchema({ ...current, layout: newLayout });
1517
+ this.setSchema(newSchema);
1428
1518
  }
1429
1519
  /** Duplicate a node (row, col, or widget) */
1430
1520
  duplicateNode(nodeId) {
1521
+ const entry = this.resolveEntry(nodeId);
1522
+ if (!entry)
1523
+ return;
1431
1524
  const current = this.schema();
1432
- const newLayout = JSON.parse(JSON.stringify(current.layout));
1433
- const newFields = [...current.fields];
1525
+ const newSchema = this.cloneValue(current);
1526
+ const scopeSchema = this.resolveSchemaAtScope(newSchema, entry.scopePath);
1527
+ if (!scopeSchema)
1528
+ return;
1529
+ const scopeFields = scopeSchema.fields;
1434
1530
  // Find node and its parent
1435
1531
  const findParentAndNode = (parent, id) => {
1436
1532
  if ('children' in parent) {
@@ -1447,7 +1543,7 @@ class DesignerStateService {
1447
1543
  }
1448
1544
  return null;
1449
1545
  };
1450
- const result = findParentAndNode(newLayout, nodeId);
1546
+ const result = findParentAndNode(scopeSchema.layout, entry.rawNodeId);
1451
1547
  if (!result)
1452
1548
  return;
1453
1549
  const { parent, node, index } = result;
@@ -1458,7 +1554,7 @@ class DesignerStateService {
1458
1554
  cloned.id = v4();
1459
1555
  // If it's a widget, also duplicate the field
1460
1556
  if (cloned.type === 'widget' && cloned.refId) {
1461
- const originalField = current.fields.find(f => f.id === cloned.refId);
1557
+ const originalField = scopeFields.find(f => f.id === cloned.refId);
1462
1558
  if (originalField) {
1463
1559
  const newFieldId = v4();
1464
1560
  const newField = {
@@ -1467,7 +1563,7 @@ class DesignerStateService {
1467
1563
  name: `${originalField.name}_copy_${Date.now()}`,
1468
1564
  label: `${originalField.label || 'Field'} (Copy)`
1469
1565
  };
1470
- newFields.push(newField);
1566
+ scopeFields.push(newField);
1471
1567
  cloned.refId = newFieldId;
1472
1568
  }
1473
1569
  }
@@ -1478,18 +1574,24 @@ class DesignerStateService {
1478
1574
  };
1479
1575
  const duplicatedNode = cloneWithNewIds(node);
1480
1576
  children.splice(index + 1, 0, duplicatedNode);
1481
- this.setSchema({ ...current, fields: newFields, layout: newLayout });
1482
- this.selectNode(duplicatedNode.id);
1577
+ this.setSchema(newSchema);
1578
+ this.selectNode(this.composeScopedNodeId(entry.scopePath, duplicatedNode.id));
1483
1579
  }
1484
1580
  /** Delete a node (row, col, or widget) and associated field if widget */
1485
1581
  deleteNode(nodeId) {
1582
+ const entry = this.resolveEntry(nodeId);
1583
+ if (!entry)
1584
+ return;
1486
1585
  const current = this.schema();
1487
- const newLayout = JSON.parse(JSON.stringify(current.layout));
1488
- let fieldsToRemove = [];
1586
+ const newSchema = this.cloneValue(current);
1587
+ const scopeSchema = this.resolveSchemaAtScope(newSchema, entry.scopePath);
1588
+ if (!scopeSchema)
1589
+ return;
1590
+ const fieldsToRemove = new Set();
1489
1591
  // Collect field IDs to remove (for widgets)
1490
1592
  const collectFieldIds = (node) => {
1491
1593
  if (node.type === 'widget' && node.refId) {
1492
- fieldsToRemove.push(node.refId);
1594
+ fieldsToRemove.add(node.refId);
1493
1595
  }
1494
1596
  if ('children' in node) {
1495
1597
  node.children.forEach(c => collectFieldIds(c));
@@ -1512,82 +1614,82 @@ class DesignerStateService {
1512
1614
  }
1513
1615
  return false;
1514
1616
  };
1515
- removeFromParent(newLayout, nodeId);
1516
- // Filter out removed fields
1517
- const newFields = current.fields.filter(f => !fieldsToRemove.includes(f.id));
1617
+ removeFromParent(scopeSchema.layout, entry.rawNodeId);
1618
+ scopeSchema.fields = scopeSchema.fields.filter(field => !fieldsToRemove.has(field.id));
1518
1619
  // Clear selection
1519
- if (this.selectedNodeId() === nodeId) {
1620
+ if (this.selectedNodeId() === nodeId || this.selectedNodeId() === this.composeScopedNodeId(entry.scopePath, entry.rawNodeId)) {
1520
1621
  this.selectNode(null);
1521
1622
  }
1522
- this.setSchema({ ...current, fields: newFields, layout: newLayout });
1623
+ this.setSchema(newSchema);
1523
1624
  }
1524
1625
  // Phase 2: Duplicate field
1525
1626
  duplicateField(fieldId) {
1526
1627
  const current = this.schema();
1527
- const field = current.fields.find(f => f.id === fieldId);
1528
- if (!field)
1628
+ const nextSchema = this.cloneValue(current);
1629
+ const preferredScope = this.selectedNodeScopePath();
1630
+ const location = this.findFieldLocation(nextSchema, fieldId, preferredScope);
1631
+ if (!location)
1529
1632
  return;
1530
- // Create new field with copied properties
1531
1633
  const newFieldId = v4();
1532
1634
  const newField = {
1533
- ...JSON.parse(JSON.stringify(field)),
1635
+ ...JSON.parse(JSON.stringify(location.field)),
1534
1636
  id: newFieldId,
1535
- name: `${field.name}_copy_${Date.now()}`,
1536
- label: `${field.label || 'Field'} (Copy)`
1637
+ name: `${location.field.name}_copy_${Date.now()}`,
1638
+ label: `${location.field.label || 'Field'} (Copy)`
1537
1639
  };
1538
- // Find the widget node that references this field
1539
- const newLayout = JSON.parse(JSON.stringify(current.layout));
1540
- const widgetNode = this.findWidgetByRefId(newLayout, fieldId);
1541
- if (widgetNode) {
1542
- // Find parent column and insert duplicate after original
1543
- const parent = this.findParentCol(newLayout, widgetNode.id);
1544
- if (parent) {
1545
- const index = parent.children.findIndex((c) => c.id === widgetNode.id);
1546
- const newWidgetNode = {
1547
- id: v4(),
1548
- type: 'widget',
1549
- widgetKind: 'field',
1550
- refId: newFieldId
1551
- };
1552
- parent.children.splice(index + 1, 0, newWidgetNode);
1553
- }
1640
+ location.schema.fields.push(newField);
1641
+ const widgetNode = this.findWidgetByRefId(location.schema.layout, fieldId);
1642
+ if (!widgetNode) {
1643
+ this.setSchema(nextSchema);
1644
+ return;
1554
1645
  }
1555
- this.setSchema({
1556
- ...current,
1557
- fields: [...current.fields, newField],
1558
- layout: newLayout
1559
- });
1646
+ const parent = this.findParentCol(location.schema.layout, widgetNode.id);
1647
+ if (!parent) {
1648
+ this.setSchema(nextSchema);
1649
+ return;
1650
+ }
1651
+ const index = parent.children.findIndex((candidate) => candidate.id === widgetNode.id);
1652
+ const newWidgetNode = {
1653
+ id: v4(),
1654
+ type: 'widget',
1655
+ widgetKind: widgetNode.widgetKind ?? 'field',
1656
+ refId: newFieldId
1657
+ };
1658
+ parent.children.splice(index + 1, 0, newWidgetNode);
1659
+ this.setSchema(nextSchema);
1660
+ this.selectNode(this.composeScopedNodeId(location.scopePath, newWidgetNode.id));
1560
1661
  }
1561
1662
  // Phase 2: Remove field
1562
1663
  removeField(fieldId) {
1563
1664
  const current = this.schema();
1564
- const newFields = current.fields.filter(f => f.id !== fieldId);
1565
- const newLayout = JSON.parse(JSON.stringify(current.layout));
1566
- // Find and remove widget that references this field
1567
- this.removeWidgetByRefId(newLayout, fieldId);
1568
- // Clear selection if we removed the selected node
1569
- const selectedNode = this.selectedNode();
1570
- if (selectedNode && selectedNode.type === 'widget' && selectedNode.refId === fieldId) {
1571
- this.selectNode(null);
1665
+ const nextSchema = this.cloneValue(current);
1666
+ const preferredScope = this.selectedNodeScopePath();
1667
+ const location = this.findFieldLocation(nextSchema, fieldId, preferredScope);
1668
+ if (!location)
1669
+ return;
1670
+ location.schema.fields = location.schema.fields.filter(field => field.id !== fieldId);
1671
+ this.removeWidgetByRefId(location.schema.layout, fieldId);
1672
+ const selectedId = this.selectedNodeId();
1673
+ if (selectedId) {
1674
+ const selectedEntry = this.layoutIndex()[selectedId];
1675
+ if (selectedEntry?.node.type === 'widget' && selectedEntry.node.refId === fieldId && this.sameScope(selectedEntry.scopePath, location.scopePath)) {
1676
+ this.selectNode(null);
1677
+ }
1572
1678
  }
1573
- this.setSchema({
1574
- ...current,
1575
- fields: newFields,
1576
- layout: newLayout
1577
- });
1679
+ this.setSchema(nextSchema);
1578
1680
  }
1579
1681
  updateField(fieldId, updates) {
1580
1682
  const current = this.schema();
1581
- const newFields = current.fields.map(f => {
1582
- if (f.id === fieldId) {
1583
- return { ...f, ...updates };
1584
- }
1585
- return f;
1586
- });
1587
- this.setSchema({
1588
- ...current,
1589
- fields: newFields
1590
- });
1683
+ const nextSchema = this.cloneValue(current);
1684
+ const preferredScope = this.selectedNodeScopePath();
1685
+ const location = this.findFieldLocation(nextSchema, fieldId, preferredScope);
1686
+ if (!location)
1687
+ return;
1688
+ location.schema.fields[location.index] = {
1689
+ ...location.field,
1690
+ ...updates
1691
+ };
1692
+ this.setSchema(nextSchema);
1591
1693
  }
1592
1694
  groupSelectedFields() {
1593
1695
  const selected = this.getSelectedWidgetFields();
@@ -1638,23 +1740,23 @@ class DesignerStateService {
1638
1740
  return this.getSelectedWidgetFields().some(field => !!field.groupKey);
1639
1741
  }
1640
1742
  getSelectedWidgetFields() {
1641
- const current = this.schema();
1642
1743
  const index = this.layoutIndex();
1643
- const fieldMap = new Map(current.fields.map(field => [field.id, field]));
1644
1744
  const selectedFields = [];
1645
1745
  const seen = new Set();
1646
1746
  for (const nodeId of this.selectedNodeIds()) {
1647
- const node = index[nodeId]?.node;
1747
+ const entry = index[nodeId];
1748
+ const node = entry?.node;
1648
1749
  if (!node || node.type !== 'widget')
1649
1750
  continue;
1650
1751
  const refId = node.refId;
1651
- if (!refId || seen.has(refId))
1752
+ const scopeRefKey = `${entry.scopePath.join('/')}:${refId ?? ''}`;
1753
+ if (!refId || seen.has(scopeRefKey))
1652
1754
  continue;
1653
- const field = fieldMap.get(refId);
1755
+ const field = this.findFieldByIdInScope(this.schema(), entry.scopePath, refId);
1654
1756
  if (!field)
1655
1757
  continue;
1656
1758
  selectedFields.push(field);
1657
- seen.add(refId);
1759
+ seen.add(scopeRefKey);
1658
1760
  }
1659
1761
  return selectedFields;
1660
1762
  }
@@ -1663,20 +1765,36 @@ class DesignerStateService {
1663
1765
  * Only the path from root to the target node gets new references.
1664
1766
  */
1665
1767
  updateNodeStyle(nodeId, style) {
1768
+ const entry = this.resolveEntry(nodeId);
1769
+ if (!entry)
1770
+ return;
1666
1771
  const current = this.schema();
1667
- const newLayout = this.updateNodeInTree(current.layout, nodeId, { style });
1668
- if (newLayout !== current.layout) {
1669
- this.setSchema({ ...current, layout: newLayout });
1772
+ const nextSchema = this.cloneValue(current);
1773
+ const scopeSchema = this.resolveSchemaAtScope(nextSchema, entry.scopePath);
1774
+ if (!scopeSchema)
1775
+ return;
1776
+ const newLayout = this.updateNodeInTree(scopeSchema.layout, entry.rawNodeId, { style });
1777
+ if (newLayout !== scopeSchema.layout) {
1778
+ scopeSchema.layout = newLayout;
1779
+ this.setSchema(nextSchema);
1670
1780
  }
1671
1781
  }
1672
1782
  /**
1673
1783
  * Update a specific node's responsive settings.
1674
1784
  */
1675
1785
  updateNodeResponsive(nodeId, responsive) {
1786
+ const entry = this.resolveEntry(nodeId);
1787
+ if (!entry)
1788
+ return;
1676
1789
  const current = this.schema();
1677
- const newLayout = this.updateNodeInTree(current.layout, nodeId, { responsive });
1678
- if (newLayout !== current.layout) {
1679
- this.setSchema({ ...current, layout: newLayout });
1790
+ const nextSchema = this.cloneValue(current);
1791
+ const scopeSchema = this.resolveSchemaAtScope(nextSchema, entry.scopePath);
1792
+ if (!scopeSchema)
1793
+ return;
1794
+ const newLayout = this.updateNodeInTree(scopeSchema.layout, entry.rawNodeId, { responsive });
1795
+ if (newLayout !== scopeSchema.layout) {
1796
+ scopeSchema.layout = newLayout;
1797
+ this.setSchema(nextSchema);
1680
1798
  }
1681
1799
  }
1682
1800
  /**
@@ -1703,6 +1821,146 @@ class DesignerStateService {
1703
1821
  }
1704
1822
  return node; // Unchanged - return original reference
1705
1823
  }
1824
+ resolveEntry(nodeId) {
1825
+ const direct = this.layoutIndex()[nodeId];
1826
+ if (direct)
1827
+ return direct;
1828
+ const selectedId = this.selectedNodeId();
1829
+ if (!selectedId)
1830
+ return null;
1831
+ const selectedEntry = this.layoutIndex()[selectedId];
1832
+ if (!selectedEntry)
1833
+ return null;
1834
+ if (selectedEntry.rawNodeId !== nodeId)
1835
+ return null;
1836
+ return selectedEntry;
1837
+ }
1838
+ resolveSchemaAtScope(root, scopePath) {
1839
+ let cursor = root;
1840
+ for (const repeatableFieldId of scopePath) {
1841
+ const field = cursor.fields.find(candidate => candidate.id === repeatableFieldId);
1842
+ const itemSchema = field?.repeatable?.itemSchema;
1843
+ if (!field || !itemSchema?.layout || !Array.isArray(itemSchema.fields)) {
1844
+ return null;
1845
+ }
1846
+ cursor = itemSchema;
1847
+ }
1848
+ return cursor;
1849
+ }
1850
+ findFieldByIdInScope(root, scopePath, fieldId) {
1851
+ const scopeSchema = this.resolveSchemaAtScope(root, scopePath);
1852
+ if (!scopeSchema)
1853
+ return null;
1854
+ return scopeSchema.fields.find(field => field.id === fieldId) ?? null;
1855
+ }
1856
+ resolveInsertionScopePath(selectedEntry) {
1857
+ if (!selectedEntry)
1858
+ return [];
1859
+ if (selectedEntry.node.type !== 'widget')
1860
+ return selectedEntry.scopePath;
1861
+ const widgetNode = selectedEntry.node;
1862
+ const field = this.findFieldByIdInScope(this.schema(), selectedEntry.scopePath, widgetNode.refId ?? '');
1863
+ if (field?.type === 'repeatable-group' && field.repeatable?.itemSchema) {
1864
+ return [...selectedEntry.scopePath, field.id];
1865
+ }
1866
+ return selectedEntry.scopePath;
1867
+ }
1868
+ resolveTargetColumnForFieldInsert(targetSchema, selectedEntry, insertionScopePath) {
1869
+ if (selectedEntry && this.sameScope(selectedEntry.scopePath, insertionScopePath)) {
1870
+ if (selectedEntry.node.type === 'col') {
1871
+ const selectedCol = this.findNode(targetSchema.layout, selectedEntry.rawNodeId);
1872
+ if (selectedCol && selectedCol.type === 'col') {
1873
+ return selectedCol;
1874
+ }
1875
+ }
1876
+ if (selectedEntry.node.type === 'widget') {
1877
+ const parent = this.findParentCol(targetSchema.layout, selectedEntry.rawNodeId);
1878
+ if (parent)
1879
+ return parent;
1880
+ }
1881
+ }
1882
+ return this.findFirstColumn(targetSchema.layout);
1883
+ }
1884
+ findFirstColumn(node) {
1885
+ if (node.type === 'col')
1886
+ return node;
1887
+ if (!('children' in node))
1888
+ return null;
1889
+ for (const child of node.children) {
1890
+ const match = this.findFirstColumn(child);
1891
+ if (match)
1892
+ return match;
1893
+ }
1894
+ return null;
1895
+ }
1896
+ selectedNodeScopePath() {
1897
+ const selectedId = this.selectedNodeId();
1898
+ if (!selectedId)
1899
+ return undefined;
1900
+ const entry = this.layoutIndex()[selectedId];
1901
+ if (!entry)
1902
+ return undefined;
1903
+ return entry.scopePath;
1904
+ }
1905
+ findFieldLocation(root, fieldId, preferredScopePath) {
1906
+ const searchInScope = (scopeSchema, scopePath) => {
1907
+ const index = scopeSchema.fields.findIndex(candidate => candidate.id === fieldId);
1908
+ if (index !== -1) {
1909
+ return {
1910
+ schema: scopeSchema,
1911
+ field: scopeSchema.fields[index],
1912
+ index,
1913
+ scopePath
1914
+ };
1915
+ }
1916
+ for (const field of scopeSchema.fields) {
1917
+ const itemSchema = field.repeatable?.itemSchema;
1918
+ if (!itemSchema?.layout || !Array.isArray(itemSchema.fields))
1919
+ continue;
1920
+ const nestedScopePath = [...scopePath, field.id];
1921
+ const nestedMatch = searchInScope(itemSchema, nestedScopePath);
1922
+ if (nestedMatch)
1923
+ return nestedMatch;
1924
+ }
1925
+ return null;
1926
+ };
1927
+ if (preferredScopePath) {
1928
+ const preferredSchema = this.resolveSchemaAtScope(root, preferredScopePath);
1929
+ if (preferredSchema) {
1930
+ const preferredIndex = preferredSchema.fields.findIndex(candidate => candidate.id === fieldId);
1931
+ if (preferredIndex !== -1) {
1932
+ return {
1933
+ schema: preferredSchema,
1934
+ field: preferredSchema.fields[preferredIndex],
1935
+ index: preferredIndex,
1936
+ scopePath: preferredScopePath
1937
+ };
1938
+ }
1939
+ }
1940
+ }
1941
+ return searchInScope(root, []);
1942
+ }
1943
+ sameScope(left, right) {
1944
+ if (left.length !== right.length)
1945
+ return false;
1946
+ for (let i = 0; i < left.length; i += 1) {
1947
+ if (left[i] !== right[i])
1948
+ return false;
1949
+ }
1950
+ return true;
1951
+ }
1952
+ isScopePrefix(prefix, value) {
1953
+ if (prefix.length > value.length)
1954
+ return false;
1955
+ for (let i = 0; i < prefix.length; i += 1) {
1956
+ if (prefix[i] !== value[i])
1957
+ return false;
1958
+ }
1959
+ return true;
1960
+ }
1961
+ cloneValue(value) {
1962
+ return JSON.parse(JSON.stringify(value));
1963
+ }
1706
1964
  isLayoutNode(value) {
1707
1965
  if (!value || typeof value !== 'object')
1708
1966
  return false;
@@ -1767,9 +2025,15 @@ class DesignerStateService {
1767
2025
  * Useful for creating nested grids.
1768
2026
  */
1769
2027
  insertRowInColumn(columnId, colCount = 1) {
2028
+ const entry = this.resolveEntry(columnId);
2029
+ if (!entry)
2030
+ return;
1770
2031
  const current = this.schema();
1771
- const newLayout = JSON.parse(JSON.stringify(current.layout));
1772
- const colNode = this.findNode(newLayout, columnId);
2032
+ const newSchema = this.cloneValue(current);
2033
+ const scopeSchema = this.resolveSchemaAtScope(newSchema, entry.scopePath);
2034
+ if (!scopeSchema)
2035
+ return;
2036
+ const colNode = this.findNode(scopeSchema.layout, entry.rawNodeId);
1773
2037
  if (!colNode || colNode.type !== 'col')
1774
2038
  return;
1775
2039
  const newRow = {
@@ -1787,9 +2051,9 @@ class DesignerStateService {
1787
2051
  });
1788
2052
  }
1789
2053
  colNode.children.push(newRow);
1790
- this.setSchema({ ...current, layout: newLayout });
2054
+ this.setSchema(newSchema);
1791
2055
  // Select the first column of the new row to aid flow
1792
- this.selectNode(newRow.children[0].id);
2056
+ this.selectNode(this.composeScopedNodeId(entry.scopePath, newRow.children[0].id));
1793
2057
  }
1794
2058
  /**
1795
2059
  * Wrap an existing widget in a new Row -> Column structure.
@@ -1797,10 +2061,16 @@ class DesignerStateService {
1797
2061
  * and puts the widget inside the new Row's Column.
1798
2062
  */
1799
2063
  wrapWidgetInRow(widgetId) {
2064
+ const entry = this.resolveEntry(widgetId);
2065
+ if (!entry)
2066
+ return;
1800
2067
  const current = this.schema();
1801
- const newLayout = JSON.parse(JSON.stringify(current.layout));
2068
+ const newSchema = this.cloneValue(current);
2069
+ const scopeSchema = this.resolveSchemaAtScope(newSchema, entry.scopePath);
2070
+ if (!scopeSchema)
2071
+ return;
1802
2072
  // 1. Find the widget and its parent
1803
- const findResult = this.findParentAndIndex(newLayout, widgetId);
2073
+ const findResult = this.findParentAndIndex(scopeSchema.layout, entry.rawNodeId);
1804
2074
  if (!findResult)
1805
2075
  return;
1806
2076
  const { parent, index } = findResult;
@@ -1820,10 +2090,10 @@ class DesignerStateService {
1820
2090
  };
1821
2091
  // 3. Replace widget in parent with new Row
1822
2092
  parent.children.splice(index, 1, newRow);
1823
- this.setSchema({ ...current, layout: newLayout });
2093
+ this.setSchema(newSchema);
1824
2094
  // Keep selection on widget? Or select the new Row?
1825
2095
  // Let's keep widget selected so context remains
1826
- this.selectNode(widgetId);
2096
+ this.selectNode(this.composeScopedNodeId(entry.scopePath, entry.rawNodeId));
1827
2097
  }
1828
2098
  // Internal helper which was seemingly missing in previous steps but needed now
1829
2099
  findParentAndIndex(root, childId) {
@@ -1861,6 +2131,7 @@ class LayoutNodeComponent {
1861
2131
  engine;
1862
2132
  fields = [];
1863
2133
  designMode = false;
2134
+ scopePath = [];
1864
2135
  device = 'desktop';
1865
2136
  breakpoint = 'xl';
1866
2137
  nodeDrop = new EventEmitter();
@@ -1887,10 +2158,10 @@ class LayoutNodeComponent {
1887
2158
  this.widgetDefs = this.allWidgetDefs;
1888
2159
  }
1889
2160
  get isSelected() {
1890
- return this.designerState.isNodeSelected(this.node.id);
2161
+ return this.designerState.isNodeSelected(this.getScopedNodeId(this.node.id));
1891
2162
  }
1892
2163
  isNodeSelected(nodeId) {
1893
- return this.designerState.isNodeSelected(nodeId);
2164
+ return this.designerState.isNodeSelected(this.getScopedNodeId(nodeId));
1894
2165
  }
1895
2166
  onWidgetClick(e) {
1896
2167
  if (!this.designMode)
@@ -1898,10 +2169,10 @@ class LayoutNodeComponent {
1898
2169
  e.stopPropagation();
1899
2170
  this.designerState.closeContextMenu();
1900
2171
  if (e instanceof MouseEvent && (e.ctrlKey || e.metaKey || e.shiftKey)) {
1901
- this.designerState.toggleNodeSelection(this.node.id);
2172
+ this.designerState.toggleNodeSelection(this.getScopedNodeId(this.node.id));
1902
2173
  }
1903
2174
  else {
1904
- this.nodeSelect.emit(this.node.id);
2175
+ this.nodeSelect.emit(this.getScopedNodeId(this.node.id));
1905
2176
  }
1906
2177
  }
1907
2178
  onWidgetContextMenu(e) {
@@ -1909,8 +2180,8 @@ class LayoutNodeComponent {
1909
2180
  return;
1910
2181
  e.preventDefault();
1911
2182
  e.stopPropagation();
1912
- if (!this.designerState.isNodeSelected(this.node.id)) {
1913
- this.nodeSelect.emit(this.node.id);
2183
+ if (!this.designerState.isNodeSelected(this.getScopedNodeId(this.node.id))) {
2184
+ this.nodeSelect.emit(this.getScopedNodeId(this.node.id));
1914
2185
  }
1915
2186
  this.designerState.openContextMenu({ x: e.clientX, y: e.clientY });
1916
2187
  }
@@ -1920,10 +2191,10 @@ class LayoutNodeComponent {
1920
2191
  e.stopPropagation();
1921
2192
  this.designerState.closeContextMenu();
1922
2193
  if (e instanceof MouseEvent && (e.ctrlKey || e.metaKey || e.shiftKey)) {
1923
- this.designerState.toggleNodeSelection(this.node.id);
2194
+ this.designerState.toggleNodeSelection(this.getScopedNodeId(this.node.id));
1924
2195
  }
1925
2196
  else {
1926
- this.nodeSelect.emit(this.node.id);
2197
+ this.nodeSelect.emit(this.getScopedNodeId(this.node.id));
1927
2198
  }
1928
2199
  }
1929
2200
  onRowClick(e) {
@@ -1932,10 +2203,10 @@ class LayoutNodeComponent {
1932
2203
  e.stopPropagation();
1933
2204
  this.designerState.closeContextMenu();
1934
2205
  if (e instanceof MouseEvent && (e.ctrlKey || e.metaKey || e.shiftKey)) {
1935
- this.designerState.toggleNodeSelection(this.node.id);
2206
+ this.designerState.toggleNodeSelection(this.getScopedNodeId(this.node.id));
1936
2207
  }
1937
2208
  else {
1938
- this.nodeSelect.emit(this.node.id);
2209
+ this.nodeSelect.emit(this.getScopedNodeId(this.node.id));
1939
2210
  }
1940
2211
  }
1941
2212
  onRowContextMenu(e) {
@@ -1943,8 +2214,8 @@ class LayoutNodeComponent {
1943
2214
  return;
1944
2215
  e.preventDefault();
1945
2216
  e.stopPropagation();
1946
- if (!this.designerState.isNodeSelected(this.node.id)) {
1947
- this.nodeSelect.emit(this.node.id);
2217
+ if (!this.designerState.isNodeSelected(this.getScopedNodeId(this.node.id))) {
2218
+ this.nodeSelect.emit(this.getScopedNodeId(this.node.id));
1948
2219
  }
1949
2220
  this.designerState.openContextMenu({ x: e.clientX, y: e.clientY });
1950
2221
  }
@@ -1953,36 +2224,36 @@ class LayoutNodeComponent {
1953
2224
  return;
1954
2225
  e.preventDefault();
1955
2226
  e.stopPropagation();
1956
- if (!this.designerState.isNodeSelected(this.node.id)) {
1957
- this.nodeSelect.emit(this.node.id);
2227
+ if (!this.designerState.isNodeSelected(this.getScopedNodeId(this.node.id))) {
2228
+ this.nodeSelect.emit(this.getScopedNodeId(this.node.id));
1958
2229
  }
1959
2230
  this.designerState.openContextMenu({ x: e.clientX, y: e.clientY });
1960
2231
  }
1961
2232
  onDrop(event) {
1962
2233
  if (this.designMode) {
1963
- this.designerState.handleDrop(event, this.node.id);
2234
+ this.designerState.handleDrop(event, this.getScopedNodeId(this.node.id));
1964
2235
  }
1965
2236
  }
1966
2237
  // Floating Toolbar Actions
1967
2238
  moveNodeUp() {
1968
- this.designerState.moveNode(this.node.id, 'up');
2239
+ this.designerState.moveNode(this.getScopedNodeId(this.node.id), 'up');
1969
2240
  }
1970
2241
  moveNodeDown() {
1971
- this.designerState.moveNode(this.node.id, 'down');
2242
+ this.designerState.moveNode(this.getScopedNodeId(this.node.id), 'down');
1972
2243
  }
1973
2244
  deleteNode() {
1974
- this.designerState.deleteNode(this.node.id);
2245
+ this.designerState.deleteNode(this.getScopedNodeId(this.node.id));
1975
2246
  }
1976
2247
  duplicateNode() {
1977
- this.designerState.duplicateNode(this.node.id);
2248
+ this.designerState.duplicateNode(this.getScopedNodeId(this.node.id));
1978
2249
  }
1979
2250
  // Phase 5 Actions
1980
2251
  insertRowInColumn() {
1981
2252
  // Default to adding a single column row, maybe prompt user in future?
1982
- this.designerState.insertRowInColumn(this.node.id, 1);
2253
+ this.designerState.insertRowInColumn(this.getScopedNodeId(this.node.id), 1);
1983
2254
  }
1984
2255
  wrapWidgetInRow() {
1985
- this.designerState.wrapWidgetInRow(this.node.id);
2256
+ this.designerState.wrapWidgetInRow(this.getScopedNodeId(this.node.id));
1986
2257
  }
1987
2258
  getNodeTypeLabel() {
1988
2259
  if (this.node.type === 'widget') {
@@ -2039,7 +2310,7 @@ class LayoutNodeComponent {
2039
2310
  this.resizeStartHeight = isNaN(currentHeight) ? 50 : currentHeight;
2040
2311
  document.addEventListener('mousemove', this.resizeMoveListener);
2041
2312
  document.addEventListener('mouseup', this.resizeUpListener);
2042
- this.designerState.selectNode(node.id);
2313
+ this.designerState.selectNode(this.getScopedNodeId(node.id));
2043
2314
  }
2044
2315
  // Column Width Resize
2045
2316
  onColResizeStart(e, node) {
@@ -2056,7 +2327,7 @@ class LayoutNodeComponent {
2056
2327
  this.resizeStartSpan = activeSpan;
2057
2328
  document.addEventListener('mousemove', this.resizeMoveListener);
2058
2329
  document.addEventListener('mouseup', this.resizeUpListener);
2059
- this.designerState.selectNode(node.id);
2330
+ this.designerState.selectNode(this.getScopedNodeId(node.id));
2060
2331
  }
2061
2332
  onResizeMove(e) {
2062
2333
  // Handle Height Resize
@@ -2191,6 +2462,9 @@ class LayoutNodeComponent {
2191
2462
  return;
2192
2463
  this.widgetComponentRef.setInput('config', config);
2193
2464
  this.widgetComponentRef.setInput('engine', this.getEngineForRender());
2465
+ if (config.type === 'repeatable-group') {
2466
+ this.widgetComponentRef.setInput('scopePath', this.scopePath);
2467
+ }
2194
2468
  const control = this.getWidgetControl(field);
2195
2469
  if (control) {
2196
2470
  this.widgetComponentRef.setInput('control', control);
@@ -2213,7 +2487,7 @@ class LayoutNodeComponent {
2213
2487
  createWidgetInjector(widgetNode) {
2214
2488
  const context = {
2215
2489
  isDesignMode: () => this.designMode,
2216
- isSelected: () => this.designMode && this.designerState.selectedNodeId() === widgetNode.id,
2490
+ isSelected: () => this.designMode && this.designerState.selectedNodeId() === this.getScopedNodeId(widgetNode.id),
2217
2491
  updateField: (fieldId, updates) => {
2218
2492
  if (!this.designMode)
2219
2493
  return;
@@ -2243,6 +2517,21 @@ class LayoutNodeComponent {
2243
2517
  trackById(index, node) {
2244
2518
  return node.id;
2245
2519
  }
2520
+ getScopedNodeId(nodeId) {
2521
+ return this.designerState.composeScopedNodeId(this.scopePath, nodeId);
2522
+ }
2523
+ hasActiveNestedSelection() {
2524
+ if (this.node.type !== 'widget')
2525
+ return false;
2526
+ const widgetNode = this.node;
2527
+ if (!widgetNode.refId)
2528
+ return false;
2529
+ const field = this.fields.find(candidate => candidate.id === widgetNode.refId)
2530
+ ?? this.engine?.getSchema()?.fields?.find(candidate => candidate.id === widgetNode.refId);
2531
+ if (!field || field.type !== 'repeatable-group')
2532
+ return false;
2533
+ return this.designerState.isSelectionInScope([...this.scopePath, field.id]);
2534
+ }
2246
2535
  asRow(node) { return node; }
2247
2536
  asCol(node) { return node; }
2248
2537
  getColClasses(node) {
@@ -2349,7 +2638,7 @@ class LayoutNodeComponent {
2349
2638
  return !this.engine.isFieldVisible(field.id);
2350
2639
  }
2351
2640
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.17", ngImport: i0, type: LayoutNodeComponent, deps: [{ token: WIDGET_DEFINITIONS }, { token: DesignerStateService }, { token: i0.ChangeDetectorRef }, { token: i0.ElementRef }], target: i0.ɵɵFactoryTarget.Component });
2352
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "19.2.17", type: LayoutNodeComponent, isStandalone: true, selector: "app-layout-node", inputs: { node: "node", engine: "engine", fields: "fields", designMode: "designMode", device: "device", breakpoint: "breakpoint", connectedDropLists: "connectedDropLists" }, outputs: { nodeDrop: "nodeDrop", nodeSelect: "nodeSelect" }, viewQueries: [{ propertyName: "widgetContainer", first: true, predicate: ["widgetContainer"], descendants: true, read: ViewContainerRef }], usesOnChanges: true, ngImport: i0, template: `
2641
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "19.2.17", type: LayoutNodeComponent, isStandalone: true, selector: "app-layout-node", inputs: { node: "node", engine: "engine", fields: "fields", designMode: "designMode", scopePath: "scopePath", device: "device", breakpoint: "breakpoint", connectedDropLists: "connectedDropLists" }, outputs: { nodeDrop: "nodeDrop", nodeSelect: "nodeSelect" }, viewQueries: [{ propertyName: "widgetContainer", first: true, predicate: ["widgetContainer"], descendants: true, read: ViewContainerRef }], usesOnChanges: true, ngImport: i0, template: `
2353
2642
  <ng-container [ngSwitch]="node.type">
2354
2643
 
2355
2644
  <!-- ROW -->
@@ -2397,6 +2686,7 @@ class LayoutNodeComponent {
2397
2686
  [engine]="engine"
2398
2687
  [fields]="fields"
2399
2688
  [designMode]="designMode"
2689
+ [scopePath]="scopePath"
2400
2690
  [device]="device"
2401
2691
  [breakpoint]="breakpoint"
2402
2692
  [connectedDropLists]="connectedDropLists"
@@ -2423,7 +2713,7 @@ class LayoutNodeComponent {
2423
2713
  (contextmenu)="onColumnContextMenu($event)"
2424
2714
  cdkDropList
2425
2715
  [cdkDropListData]="node"
2426
- [id]="node.id"
2716
+ [id]="getScopedNodeId(node.id)"
2427
2717
  [cdkDropListConnectedTo]="connectedDropLists"
2428
2718
  (cdkDropListDropped)="onDrop($event)"
2429
2719
  [class.outline-dashed]="designMode"
@@ -2477,6 +2767,7 @@ class LayoutNodeComponent {
2477
2767
  [engine]="engine"
2478
2768
  [fields]="fields"
2479
2769
  [designMode]="designMode"
2770
+ [scopePath]="scopePath"
2480
2771
  [device]="device"
2481
2772
  [breakpoint]="breakpoint"
2482
2773
  [connectedDropLists]="connectedDropLists"
@@ -2552,7 +2843,7 @@ class LayoutNodeComponent {
2552
2843
  </div>
2553
2844
 
2554
2845
  <!-- Overlay for selection in design mode - only show on unselected widgets to allow interaction with selected ones -->
2555
- <div *ngIf="designMode && !isSelected" class="absolute inset-0 cursor-pointer z-10 hover:bg-blue-500/5" (click)="onWidgetClick($event)"></div>
2846
+ <div *ngIf="designMode && !isSelected && !hasActiveNestedSelection()" class="absolute inset-0 cursor-pointer z-10 hover:bg-blue-500/5" (click)="onWidgetClick($event)"></div>
2556
2847
  <div *ngIf="designMode && isSelected"
2557
2848
  class="absolute bottom-0 left-0 right-0 h-3 cursor-row-resize bg-transparent hover:bg-blue-400/20 transition-colors z-50 group-hover:bg-blue-100/30"
2558
2849
  (mousedown)="onResizeStart($event, node)"
@@ -2562,7 +2853,7 @@ class LayoutNodeComponent {
2562
2853
  </ng-container>
2563
2854
 
2564
2855
  </ng-container>
2565
- `, isInline: true, dependencies: [{ kind: "component", type: LayoutNodeComponent, selector: "app-layout-node", inputs: ["node", "engine", "fields", "designMode", "device", "breakpoint", "connectedDropLists"], outputs: ["nodeDrop", "nodeSelect"] }, { kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i1.NgForOf, selector: "[ngFor][ngForOf]", inputs: ["ngForOf", "ngForTrackBy", "ngForTemplate"] }, { kind: "directive", type: i1.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "directive", type: i1.NgSwitch, selector: "[ngSwitch]", inputs: ["ngSwitch"] }, { kind: "directive", type: i1.NgSwitchCase, selector: "[ngSwitchCase]", inputs: ["ngSwitchCase"] }, { kind: "ngmodule", type: DragDropModule }, { kind: "directive", type: i3.CdkDropList, selector: "[cdkDropList], cdk-drop-list", inputs: ["cdkDropListConnectedTo", "cdkDropListData", "cdkDropListOrientation", "id", "cdkDropListLockAxis", "cdkDropListDisabled", "cdkDropListSortingDisabled", "cdkDropListEnterPredicate", "cdkDropListSortPredicate", "cdkDropListAutoScrollDisabled", "cdkDropListAutoScrollStep", "cdkDropListElementContainer"], outputs: ["cdkDropListDropped", "cdkDropListEntered", "cdkDropListExited", "cdkDropListSorted"], exportAs: ["cdkDropList"] }, { kind: "directive", type: i3.CdkDrag, selector: "[cdkDrag]", inputs: ["cdkDragData", "cdkDragLockAxis", "cdkDragRootElement", "cdkDragBoundary", "cdkDragStartDelay", "cdkDragFreeDragPosition", "cdkDragDisabled", "cdkDragConstrainPosition", "cdkDragPreviewClass", "cdkDragPreviewContainer", "cdkDragScale"], outputs: ["cdkDragStarted", "cdkDragReleased", "cdkDragEnded", "cdkDragEntered", "cdkDragExited", "cdkDragDropped", "cdkDragMoved"], exportAs: ["cdkDrag"] }, { kind: "directive", type: i3.CdkDragHandle, selector: "[cdkDragHandle]", inputs: ["cdkDragHandleDisabled"] }, { kind: "directive", type: i3.CdkDragPlaceholder, selector: "ng-template[cdkDragPlaceholder]", inputs: ["data"] }, { kind: "ngmodule", type: UiIconModule }, { kind: "component", type: i2.LucideAngularComponent, selector: "lucide-angular, lucide-icon, i-lucide, span-lucide", inputs: ["class", "name", "img", "color", "absoluteStrokeWidth", "size", "strokeWidth"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
2856
+ `, isInline: true, dependencies: [{ kind: "component", type: LayoutNodeComponent, selector: "app-layout-node", inputs: ["node", "engine", "fields", "designMode", "scopePath", "device", "breakpoint", "connectedDropLists"], outputs: ["nodeDrop", "nodeSelect"] }, { kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i1.NgForOf, selector: "[ngFor][ngForOf]", inputs: ["ngForOf", "ngForTrackBy", "ngForTemplate"] }, { kind: "directive", type: i1.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "directive", type: i1.NgSwitch, selector: "[ngSwitch]", inputs: ["ngSwitch"] }, { kind: "directive", type: i1.NgSwitchCase, selector: "[ngSwitchCase]", inputs: ["ngSwitchCase"] }, { kind: "ngmodule", type: DragDropModule }, { kind: "directive", type: i3.CdkDropList, selector: "[cdkDropList], cdk-drop-list", inputs: ["cdkDropListConnectedTo", "cdkDropListData", "cdkDropListOrientation", "id", "cdkDropListLockAxis", "cdkDropListDisabled", "cdkDropListSortingDisabled", "cdkDropListEnterPredicate", "cdkDropListSortPredicate", "cdkDropListAutoScrollDisabled", "cdkDropListAutoScrollStep", "cdkDropListElementContainer"], outputs: ["cdkDropListDropped", "cdkDropListEntered", "cdkDropListExited", "cdkDropListSorted"], exportAs: ["cdkDropList"] }, { kind: "directive", type: i3.CdkDrag, selector: "[cdkDrag]", inputs: ["cdkDragData", "cdkDragLockAxis", "cdkDragRootElement", "cdkDragBoundary", "cdkDragStartDelay", "cdkDragFreeDragPosition", "cdkDragDisabled", "cdkDragConstrainPosition", "cdkDragPreviewClass", "cdkDragPreviewContainer", "cdkDragScale"], outputs: ["cdkDragStarted", "cdkDragReleased", "cdkDragEnded", "cdkDragEntered", "cdkDragExited", "cdkDragDropped", "cdkDragMoved"], exportAs: ["cdkDrag"] }, { kind: "directive", type: i3.CdkDragHandle, selector: "[cdkDragHandle]", inputs: ["cdkDragHandleDisabled"] }, { kind: "directive", type: i3.CdkDragPlaceholder, selector: "ng-template[cdkDragPlaceholder]", inputs: ["data"] }, { kind: "ngmodule", type: UiIconModule }, { kind: "component", type: i2.LucideAngularComponent, selector: "lucide-angular, lucide-icon, i-lucide, span-lucide", inputs: ["class", "name", "img", "color", "absoluteStrokeWidth", "size", "strokeWidth"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
2566
2857
  }
2567
2858
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.17", ngImport: i0, type: LayoutNodeComponent, decorators: [{
2568
2859
  type: Component,
@@ -2619,6 +2910,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.17", ngImpo
2619
2910
  [engine]="engine"
2620
2911
  [fields]="fields"
2621
2912
  [designMode]="designMode"
2913
+ [scopePath]="scopePath"
2622
2914
  [device]="device"
2623
2915
  [breakpoint]="breakpoint"
2624
2916
  [connectedDropLists]="connectedDropLists"
@@ -2645,7 +2937,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.17", ngImpo
2645
2937
  (contextmenu)="onColumnContextMenu($event)"
2646
2938
  cdkDropList
2647
2939
  [cdkDropListData]="node"
2648
- [id]="node.id"
2940
+ [id]="getScopedNodeId(node.id)"
2649
2941
  [cdkDropListConnectedTo]="connectedDropLists"
2650
2942
  (cdkDropListDropped)="onDrop($event)"
2651
2943
  [class.outline-dashed]="designMode"
@@ -2699,6 +2991,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.17", ngImpo
2699
2991
  [engine]="engine"
2700
2992
  [fields]="fields"
2701
2993
  [designMode]="designMode"
2994
+ [scopePath]="scopePath"
2702
2995
  [device]="device"
2703
2996
  [breakpoint]="breakpoint"
2704
2997
  [connectedDropLists]="connectedDropLists"
@@ -2774,7 +3067,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.17", ngImpo
2774
3067
  </div>
2775
3068
 
2776
3069
  <!-- Overlay for selection in design mode - only show on unselected widgets to allow interaction with selected ones -->
2777
- <div *ngIf="designMode && !isSelected" class="absolute inset-0 cursor-pointer z-10 hover:bg-blue-500/5" (click)="onWidgetClick($event)"></div>
3070
+ <div *ngIf="designMode && !isSelected && !hasActiveNestedSelection()" class="absolute inset-0 cursor-pointer z-10 hover:bg-blue-500/5" (click)="onWidgetClick($event)"></div>
2778
3071
  <div *ngIf="designMode && isSelected"
2779
3072
  class="absolute bottom-0 left-0 right-0 h-3 cursor-row-resize bg-transparent hover:bg-blue-400/20 transition-colors z-50 group-hover:bg-blue-100/30"
2780
3073
  (mousedown)="onResizeStart($event, node)"
@@ -2797,6 +3090,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.17", ngImpo
2797
3090
  type: Input
2798
3091
  }], designMode: [{
2799
3092
  type: Input
3093
+ }], scopePath: [{
3094
+ type: Input
2800
3095
  }], device: [{
2801
3096
  type: Input
2802
3097
  }], breakpoint: [{
@@ -3073,32 +3368,73 @@ class JsonFormRendererComponent {
3073
3368
  return {};
3074
3369
  const uploaded = {};
3075
3370
  const schema = this.engine.getSchema();
3371
+ const values = this.engine.getValues();
3372
+ const changedTopLevelFields = new Set();
3373
+ await this.uploadPendingFilesInSchema({
3374
+ schema,
3375
+ valueScope: values,
3376
+ uploaded,
3377
+ markTopLevelChanged: (fieldName) => changedTopLevelFields.add(fieldName)
3378
+ });
3379
+ for (const fieldName of changedTopLevelFields) {
3380
+ this.engine.setValue(fieldName, values[fieldName]);
3381
+ }
3382
+ return uploaded;
3383
+ }
3384
+ async uploadPendingFilesInSchema(params) {
3385
+ const { schema, valueScope, uploaded, markTopLevelChanged, pathPrefix = '', topLevelFieldName } = params;
3076
3386
  for (const field of schema.fields) {
3077
- if (field.type !== 'file')
3387
+ if (!field?.name)
3078
3388
  continue;
3079
- const pendingFiles = this.getPendingFiles(this.engine.getValue(field.name));
3080
- if (pendingFiles.length === 0)
3081
- continue;
3082
- const endpoint = field.uploadUrl?.trim() || DEFAULT_UPLOAD_ENDPOINT;
3083
- try {
3084
- const refs = await this.uploadClient.upload(pendingFiles, {
3085
- endpoint,
3086
- fieldId: field.id,
3087
- fieldName: field.name,
3088
- fieldKey: field.uploadFieldName || undefined
3089
- });
3090
- if (refs.length > 0) {
3091
- const storedValue = field.multiple ? refs : (refs[0] ?? null);
3092
- field.defaultValue = storedValue;
3093
- this.engine.setValue(field.name, storedValue);
3094
- uploaded[field.id] = refs;
3389
+ if (field.type === 'file') {
3390
+ const pendingFiles = this.getPendingFiles(valueScope[field.name]);
3391
+ if (pendingFiles.length === 0)
3392
+ continue;
3393
+ const endpoint = field.uploadUrl?.trim() || DEFAULT_UPLOAD_ENDPOINT;
3394
+ try {
3395
+ const refs = await this.uploadClient.upload(pendingFiles, {
3396
+ endpoint,
3397
+ fieldId: field.id,
3398
+ fieldName: field.name,
3399
+ fieldKey: field.uploadFieldName || undefined
3400
+ });
3401
+ if (refs.length > 0) {
3402
+ const storedValue = field.multiple ? refs : (refs[0] ?? null);
3403
+ valueScope[field.name] = storedValue;
3404
+ const uploadedKey = pathPrefix ? `${pathPrefix}.${field.id}` : field.id;
3405
+ uploaded[uploadedKey] = refs;
3406
+ markTopLevelChanged(topLevelFieldName ?? field.name);
3407
+ }
3408
+ }
3409
+ catch (error) {
3410
+ console.warn('[ngx-form-designer] File upload failed', { fieldId: field.id, error });
3095
3411
  }
3412
+ continue;
3096
3413
  }
3097
- catch (error) {
3098
- console.warn('[ngx-form-designer] File upload failed', { fieldId: field.id, error });
3414
+ if (field.type !== 'repeatable-group')
3415
+ continue;
3416
+ const itemSchema = field.repeatable?.itemSchema;
3417
+ if (!itemSchema || !Array.isArray(itemSchema.fields) || !itemSchema.layout)
3418
+ continue;
3419
+ const rawRows = valueScope[field.name];
3420
+ if (!Array.isArray(rawRows))
3421
+ continue;
3422
+ const nestedTopLevel = topLevelFieldName ?? field.name;
3423
+ for (let rowIndex = 0; rowIndex < rawRows.length; rowIndex += 1) {
3424
+ const rowValue = rawRows[rowIndex];
3425
+ if (!this.isObjectRecord(rowValue))
3426
+ continue;
3427
+ const rowPath = pathPrefix ? `${pathPrefix}.${field.id}[${rowIndex}]` : `${field.id}[${rowIndex}]`;
3428
+ await this.uploadPendingFilesInSchema({
3429
+ schema: itemSchema,
3430
+ valueScope: rowValue,
3431
+ uploaded,
3432
+ markTopLevelChanged,
3433
+ pathPrefix: rowPath,
3434
+ topLevelFieldName: nestedTopLevel
3435
+ });
3099
3436
  }
3100
3437
  }
3101
- return uploaded;
3102
3438
  }
3103
3439
  getPendingFiles(value) {
3104
3440
  if (this.isFile(value))
@@ -3117,16 +3453,42 @@ class JsonFormRendererComponent {
3117
3453
  isFileList(value) {
3118
3454
  return typeof FileList !== 'undefined' && value instanceof FileList;
3119
3455
  }
3456
+ isObjectRecord(value) {
3457
+ return !!value && typeof value === 'object' && !Array.isArray(value);
3458
+ }
3120
3459
  buildFieldValueMap(values) {
3121
3460
  const schema = this.engine?.getSchema();
3122
3461
  if (!schema)
3123
3462
  return {};
3124
- const fieldByName = new Map(schema.fields.map(field => [field.name, field.id]));
3463
+ return this.buildFieldValueMapForSchema(schema, values);
3464
+ }
3465
+ buildFieldValueMapForSchema(schema, valuesScope) {
3125
3466
  const mapped = {};
3126
- for (const [name, value] of Object.entries(values)) {
3127
- mapped[name] = {
3128
- fieldId: fieldByName.get(name) ?? name,
3129
- value
3467
+ for (const field of schema.fields) {
3468
+ if (!field?.id || !field?.name)
3469
+ continue;
3470
+ const rawValue = valuesScope[field.name];
3471
+ if (field.type === 'repeatable-group') {
3472
+ const itemSchema = field.repeatable?.itemSchema;
3473
+ const rows = [];
3474
+ if (itemSchema && Array.isArray(rawValue)) {
3475
+ for (const row of rawValue) {
3476
+ if (!this.isObjectRecord(row)) {
3477
+ rows.push({});
3478
+ continue;
3479
+ }
3480
+ rows.push(this.buildFieldValueMapForSchema(itemSchema, row));
3481
+ }
3482
+ }
3483
+ mapped[field.id] = {
3484
+ fieldName: field.name,
3485
+ value: rows
3486
+ };
3487
+ continue;
3488
+ }
3489
+ mapped[field.id] = {
3490
+ fieldName: field.name,
3491
+ value: rawValue
3130
3492
  };
3131
3493
  }
3132
3494
  return mapped;
@@ -3143,9 +3505,9 @@ class JsonFormRendererComponent {
3143
3505
  if (!grouped[groupKey]) {
3144
3506
  grouped[groupKey] = {};
3145
3507
  }
3146
- const fieldValue = values[field.name];
3508
+ const fieldValue = values[field.id];
3147
3509
  if (fieldValue !== undefined) {
3148
- grouped[groupKey][field.name] = fieldValue;
3510
+ grouped[groupKey][field.id] = fieldValue;
3149
3511
  }
3150
3512
  }
3151
3513
  return grouped;
@@ -3159,14 +3521,14 @@ class JsonFormRendererComponent {
3159
3521
  const groupKey = field.groupKey?.trim();
3160
3522
  if (!groupKey)
3161
3523
  continue;
3162
- const fieldValue = values[field.name];
3524
+ const fieldValue = values[field.id];
3163
3525
  if (!fieldValue)
3164
3526
  continue;
3165
3527
  const currentGroup = combined[groupKey];
3166
3528
  const groupValues = this.isGroupValue(currentGroup) ? currentGroup : {};
3167
- groupValues[field.name] = fieldValue;
3529
+ groupValues[field.id] = fieldValue;
3168
3530
  combined[groupKey] = groupValues;
3169
- delete combined[field.name];
3531
+ delete combined[field.id];
3170
3532
  }
3171
3533
  return combined;
3172
3534
  }
@@ -3174,7 +3536,8 @@ class JsonFormRendererComponent {
3174
3536
  if (!value || typeof value !== 'object' || Array.isArray(value))
3175
3537
  return false;
3176
3538
  const record = value;
3177
- return typeof record['fieldId'] === 'string' && Object.hasOwn(record, 'value');
3539
+ return typeof record['fieldName'] === 'string'
3540
+ && Object.hasOwn(record, 'value');
3178
3541
  }
3179
3542
  isGroupValue(value) {
3180
3543
  if (!value || typeof value !== 'object' || Array.isArray(value))
@@ -3202,7 +3565,7 @@ class JsonFormRendererComponent {
3202
3565
  </ng-container>
3203
3566
  <ng-template #loading>Loading form...</ng-template>
3204
3567
  </div>
3205
- `, isInline: true, styles: [".form-renderer-container{width:100%;height:100%;overflow-y:auto}.form-renderer-container.is-design{padding-top:2.25rem;height:auto;overflow:visible}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i1.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "component", type: LayoutNodeComponent, selector: "app-layout-node", inputs: ["node", "engine", "fields", "designMode", "device", "breakpoint", "connectedDropLists"], outputs: ["nodeDrop", "nodeSelect"] }] });
3568
+ `, isInline: true, styles: [".form-renderer-container{width:100%;height:100%;overflow-y:auto}.form-renderer-container.is-design{padding-top:2.25rem;height:auto;overflow:visible}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i1.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "component", type: LayoutNodeComponent, selector: "app-layout-node", inputs: ["node", "engine", "fields", "designMode", "scopePath", "device", "breakpoint", "connectedDropLists"], outputs: ["nodeDrop", "nodeSelect"] }] });
3206
3569
  }
3207
3570
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.17", ngImport: i0, type: JsonFormRendererComponent, decorators: [{
3208
3571
  type: Component,
@@ -3975,6 +4338,7 @@ class FieldPaletteComponent {
3975
4338
  getDragData(widget) {
3976
4339
  return {
3977
4340
  type: 'new-widget',
4341
+ widgetId: widget.id,
3978
4342
  widgetType: widget.type,
3979
4343
  widgetKind: widget.kind
3980
4344
  };
@@ -6554,7 +6918,7 @@ class DynamicPropertiesComponent {
6554
6918
  return sections;
6555
6919
  }
6556
6920
  getFieldReferenceCandidates(field) {
6557
- const all = this.designerCtx.getFields();
6921
+ const all = this.getAllFields();
6558
6922
  const allowed = field.allowedFieldTypes;
6559
6923
  const includeSelf = field.includeSelf === true;
6560
6924
  const currentId = this.config?.id;
@@ -12932,7 +13296,7 @@ class WidgetInspectorComponent {
12932
13296
  <div class="flex flex-col min-h-0 bg-white">
12933
13297
  <app-dynamic-properties
12934
13298
  [config]="field()"
12935
- [allFields]="stateService.schema().fields"
13299
+ [allFields]="stateService.getSelectedScopeFields()"
12936
13300
  [excludeSections]="['Layout', 'Spacing', 'Size', 'Typography', 'Appearance', 'Box Model', 'Position']"
12937
13301
  (configChange)="fieldChange.emit($event)">
12938
13302
  </app-dynamic-properties>
@@ -12944,7 +13308,7 @@ class WidgetInspectorComponent {
12944
13308
  <div class="flex flex-col min-h-0 bg-white p-1">
12945
13309
  <app-data-panel
12946
13310
  [config]="field()"
12947
- [allFields]="stateService.schema().fields"
13311
+ [allFields]="stateService.getSelectedScopeFields()"
12948
13312
  [dataConsumer]="dataConsumer()"
12949
13313
  [bindingShape]="bindingShape()"
12950
13314
  [widgetType]="field().type"
@@ -12958,7 +13322,7 @@ class WidgetInspectorComponent {
12958
13322
  <div class="flex flex-col min-h-0 bg-white">
12959
13323
  <app-rules-panel
12960
13324
  [rules]="field().rules || []"
12961
- [allFields]="stateService.schema().fields"
13325
+ [allFields]="stateService.getSelectedScopeFields()"
12962
13326
  (rulesChange)="rulesChange.emit($event)">
12963
13327
  </app-rules-panel>
12964
13328
  </div>
@@ -12969,7 +13333,7 @@ class WidgetInspectorComponent {
12969
13333
  <div class="flex flex-col min-h-0 bg-white">
12970
13334
  <app-events-panel
12971
13335
  [config]="field()"
12972
- [allFields]="stateService.schema().fields"
13336
+ [allFields]="stateService.getSelectedScopeFields()"
12973
13337
  (configChange)="fieldChange.emit($event)">
12974
13338
  </app-events-panel>
12975
13339
  </div>
@@ -13155,7 +13519,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.17", ngImpo
13155
13519
  <div class="flex flex-col min-h-0 bg-white">
13156
13520
  <app-dynamic-properties
13157
13521
  [config]="field()"
13158
- [allFields]="stateService.schema().fields"
13522
+ [allFields]="stateService.getSelectedScopeFields()"
13159
13523
  [excludeSections]="['Layout', 'Spacing', 'Size', 'Typography', 'Appearance', 'Box Model', 'Position']"
13160
13524
  (configChange)="fieldChange.emit($event)">
13161
13525
  </app-dynamic-properties>
@@ -13167,7 +13531,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.17", ngImpo
13167
13531
  <div class="flex flex-col min-h-0 bg-white p-1">
13168
13532
  <app-data-panel
13169
13533
  [config]="field()"
13170
- [allFields]="stateService.schema().fields"
13534
+ [allFields]="stateService.getSelectedScopeFields()"
13171
13535
  [dataConsumer]="dataConsumer()"
13172
13536
  [bindingShape]="bindingShape()"
13173
13537
  [widgetType]="field().type"
@@ -13181,7 +13545,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.17", ngImpo
13181
13545
  <div class="flex flex-col min-h-0 bg-white">
13182
13546
  <app-rules-panel
13183
13547
  [rules]="field().rules || []"
13184
- [allFields]="stateService.schema().fields"
13548
+ [allFields]="stateService.getSelectedScopeFields()"
13185
13549
  (rulesChange)="rulesChange.emit($event)">
13186
13550
  </app-rules-panel>
13187
13551
  </div>
@@ -13192,7 +13556,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.17", ngImpo
13192
13556
  <div class="flex flex-col min-h-0 bg-white">
13193
13557
  <app-events-panel
13194
13558
  [config]="field()"
13195
- [allFields]="stateService.schema().fields"
13559
+ [allFields]="stateService.getSelectedScopeFields()"
13196
13560
  (configChange)="fieldChange.emit($event)">
13197
13561
  </app-events-panel>
13198
13562
  </div>
@@ -13735,7 +14099,8 @@ class PropertiesPanelComponent {
13735
14099
  return;
13736
14100
  const currentStyle = { ...(node.style || {}) };
13737
14101
  currentStyle[key] = value;
13738
- this.state.updateNodeStyle(nodeId, currentStyle);
14102
+ const scopedNodeId = this.state.selectedNodeId() ?? nodeId;
14103
+ this.state.updateNodeStyle(scopedNodeId, currentStyle);
13739
14104
  }
13740
14105
  /**
13741
14106
  * Handle style changes from the widget inspector.
@@ -13748,7 +14113,8 @@ class PropertiesPanelComponent {
13748
14113
  // For now, we only support container styling (node.style)
13749
14114
  // Widget-specific styling would require extending the FieldSchema
13750
14115
  if (event.target === 'container') {
13751
- this.state.updateNodeStyle(node.id, event.style);
14116
+ const scopedNodeId = this.state.selectedNodeId() ?? node.id;
14117
+ this.state.updateNodeStyle(scopedNodeId, event.style);
13752
14118
  }
13753
14119
  }
13754
14120
  /**
@@ -13767,7 +14133,8 @@ class PropertiesPanelComponent {
13767
14133
  else {
13768
14134
  currentResponsive[breakpoint] = Number(value);
13769
14135
  }
13770
- this.state.updateNodeResponsive(nodeId, currentResponsive);
14136
+ const scopedNodeId = this.state.selectedNodeId() ?? nodeId;
14137
+ this.state.updateNodeResponsive(scopedNodeId, currentResponsive);
13771
14138
  }
13772
14139
  /**
13773
14140
  * Legacy method for backward compatibility with dynamic properties.
@@ -13832,7 +14199,7 @@ class PropertiesPanelComponent {
13832
14199
  // Fallback to Data-Driven Properties
13833
14200
  const ref = this.inspectorContainer.createComponent(DynamicPropertiesComponent);
13834
14201
  ref.setInput('config', field);
13835
- ref.setInput('allFields', this.state.schema().fields);
14202
+ ref.setInput('allFields', this.state.getSelectedScopeFields());
13836
14203
  ref.setInput('readOnly', this.state.isReadOnly());
13837
14204
  if (this.isBrickWidget(node)) {
13838
14205
  ref.setInput('excludeSections', ['Brick']);
@@ -13898,13 +14265,13 @@ class PropertiesPanelComponent {
13898
14265
  return def?.dataBinding || null;
13899
14266
  }
13900
14267
  applyPreset(rowId, preset) {
13901
- this.state.setPresetLayout(rowId, preset);
14268
+ this.state.setPresetLayout(this.state.selectedNodeId() ?? rowId, preset);
13902
14269
  }
13903
14270
  addColumn(rowId) {
13904
- this.state.addColumn(rowId);
14271
+ this.state.addColumn(this.state.selectedNodeId() ?? rowId);
13905
14272
  }
13906
14273
  removeColumn(colId) {
13907
- this.state.removeColumn(colId);
14274
+ this.state.removeColumn(this.state.selectedNodeId() ?? colId);
13908
14275
  // Deselect because it's gone
13909
14276
  this.state.selectNode(null);
13910
14277
  }
@@ -23415,6 +23782,484 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.17", ngImpo
23415
23782
  type: Input
23416
23783
  }] } });
23417
23784
 
23785
+ class RepeatableGroupWidgetComponent {
23786
+ config;
23787
+ engine;
23788
+ control = new FormControl();
23789
+ scopePath = [];
23790
+ rowStates = [];
23791
+ itemSchema = createEmptySchema('form', { title: 'Repeatable Item' });
23792
+ designPreviewEngine;
23793
+ parentValueSub;
23794
+ parentSubmitSub;
23795
+ rowIdCounter = 0;
23796
+ isWritingToParent = false;
23797
+ lastCommittedRowsRef;
23798
+ editorContext = inject(WIDGET_EDITOR_CONTEXT, { optional: true });
23799
+ designerState = inject(DesignerStateService);
23800
+ get groupId() {
23801
+ return `field-${this.config.id}`;
23802
+ }
23803
+ get isDesignMode() {
23804
+ return this.editorContext?.isDesignMode() ?? false;
23805
+ }
23806
+ get isSelected() {
23807
+ return this.editorContext?.isSelected() ?? false;
23808
+ }
23809
+ get nestedScopePath() {
23810
+ return [...this.scopePath, this.config.id];
23811
+ }
23812
+ get addButtonLabel() {
23813
+ const label = this.config.repeatable?.addButtonLabel?.trim();
23814
+ return label || 'Add item';
23815
+ }
23816
+ get removeButtonLabel() {
23817
+ const label = this.config.repeatable?.removeButtonLabel?.trim();
23818
+ return label || 'Remove';
23819
+ }
23820
+ get itemLabel() {
23821
+ const label = this.config.repeatable?.itemLabel?.trim();
23822
+ return label || 'Item';
23823
+ }
23824
+ get minItems() {
23825
+ const raw = Number(this.config.repeatable?.minItems ?? 0);
23826
+ if (!Number.isFinite(raw) || raw < 0)
23827
+ return 0;
23828
+ return Math.floor(raw);
23829
+ }
23830
+ get maxItems() {
23831
+ const raw = this.config.repeatable?.maxItems;
23832
+ if (raw === undefined || raw === null)
23833
+ return Number.POSITIVE_INFINITY;
23834
+ const parsed = Number(raw);
23835
+ if (!Number.isFinite(parsed) || parsed < 0)
23836
+ return Number.POSITIVE_INFINITY;
23837
+ return Math.max(this.minItems, Math.floor(parsed));
23838
+ }
23839
+ get canAddItem() {
23840
+ if (!this.enabled)
23841
+ return false;
23842
+ return this.rowStates.length < this.maxItems;
23843
+ }
23844
+ get canRemoveItem() {
23845
+ if (!this.enabled)
23846
+ return false;
23847
+ return this.rowStates.length > this.minItems;
23848
+ }
23849
+ ngOnInit() {
23850
+ this.initialize();
23851
+ }
23852
+ ngOnChanges(changes) {
23853
+ if (changes['config'] || changes['engine']) {
23854
+ this.initialize();
23855
+ }
23856
+ }
23857
+ ngOnDestroy() {
23858
+ this.disposeParentSubscriptions();
23859
+ this.disposeRowStates();
23860
+ }
23861
+ addItem() {
23862
+ if (!this.canAddItem)
23863
+ return;
23864
+ const row = this.createDefaultRow(this.itemSchema);
23865
+ this.rowStates.push(this.createRowState(row));
23866
+ this.commitRowsToParent();
23867
+ }
23868
+ removeItem(index) {
23869
+ if (!this.canRemoveItem)
23870
+ return;
23871
+ const row = this.rowStates[index];
23872
+ if (!row)
23873
+ return;
23874
+ row.valueSub.unsubscribe();
23875
+ this.rowStates.splice(index, 1);
23876
+ this.commitRowsToParent();
23877
+ }
23878
+ trackByRowId(index, row) {
23879
+ return row.id;
23880
+ }
23881
+ getWrapperStyles() {
23882
+ if (!this.config?.style)
23883
+ return {};
23884
+ return mergeAndNormalize({
23885
+ width: '100%'
23886
+ }, this.config.style);
23887
+ }
23888
+ get visible() {
23889
+ return this.engine ? this.engine.isFieldVisible(this.config.id) : true;
23890
+ }
23891
+ get enabled() {
23892
+ if (this.config?.disabled)
23893
+ return false;
23894
+ return this.engine ? this.engine.isFieldEnabled(this.config.id) : true;
23895
+ }
23896
+ get required() {
23897
+ return this.engine ? this.engine.isFieldRequired(this.config.id) : !!this.config?.html5?.required;
23898
+ }
23899
+ onNestedNodeSelect(nodeId) {
23900
+ if (!this.isDesignMode)
23901
+ return;
23902
+ this.designerState.selectNode(nodeId);
23903
+ }
23904
+ initialize() {
23905
+ this.itemSchema = this.resolveItemSchema();
23906
+ this.buildDesignPreviewEngine();
23907
+ if (this.isDesignMode) {
23908
+ this.disposeParentSubscriptions();
23909
+ this.disposeRowStates();
23910
+ return;
23911
+ }
23912
+ this.rebuildRows(this.readRowsFromParent(), true);
23913
+ this.bindParentSubscriptions();
23914
+ }
23915
+ bindParentSubscriptions() {
23916
+ this.disposeParentSubscriptions();
23917
+ if (!this.engine)
23918
+ return;
23919
+ this.parentValueSub = this.engine.valueChanges$.subscribe(values => {
23920
+ if (!this.config?.name)
23921
+ return;
23922
+ if (this.isWritingToParent)
23923
+ return;
23924
+ const incoming = values[this.config.name];
23925
+ if (incoming === this.lastCommittedRowsRef)
23926
+ return;
23927
+ this.rebuildRows(this.normalizeRows(incoming), true);
23928
+ });
23929
+ this.parentSubmitSub = this.engine.submitted$.subscribe(() => {
23930
+ for (const row of this.rowStates) {
23931
+ row.engine.validate();
23932
+ row.engine.submit();
23933
+ }
23934
+ });
23935
+ }
23936
+ disposeParentSubscriptions() {
23937
+ this.parentValueSub?.unsubscribe();
23938
+ this.parentSubmitSub?.unsubscribe();
23939
+ this.parentValueSub = undefined;
23940
+ this.parentSubmitSub = undefined;
23941
+ }
23942
+ rebuildRows(sourceRows, enforceBounds) {
23943
+ this.disposeRowStates();
23944
+ const boundedRows = enforceBounds ? this.applyBounds(sourceRows) : sourceRows;
23945
+ this.rowStates = boundedRows.map(row => this.createRowState(row));
23946
+ if (!this.engine || !this.config?.name)
23947
+ return;
23948
+ const current = this.engine.getValue(this.config.name);
23949
+ if (current === boundedRows) {
23950
+ this.lastCommittedRowsRef = current;
23951
+ return;
23952
+ }
23953
+ if (!this.isSameLength(current, boundedRows)) {
23954
+ this.commitRowsToParent();
23955
+ return;
23956
+ }
23957
+ this.lastCommittedRowsRef = current;
23958
+ }
23959
+ createRowState(rowValue) {
23960
+ const rowEngine = createFormEngine(this.itemSchema, rowValue);
23961
+ const valueSub = rowEngine.valueChanges$.pipe(skip(1)).subscribe(() => {
23962
+ this.commitRowsToParent();
23963
+ });
23964
+ this.rowIdCounter += 1;
23965
+ return {
23966
+ id: `repeatable-row-${this.config.id}-${this.rowIdCounter}`,
23967
+ engine: rowEngine,
23968
+ valueSub
23969
+ };
23970
+ }
23971
+ disposeRowStates() {
23972
+ for (const row of this.rowStates) {
23973
+ row.valueSub.unsubscribe();
23974
+ }
23975
+ this.rowStates = [];
23976
+ }
23977
+ commitRowsToParent() {
23978
+ if (!this.engine || !this.config?.name)
23979
+ return;
23980
+ const nextRows = this.rowStates.map(row => row.engine.getValues());
23981
+ this.lastCommittedRowsRef = nextRows;
23982
+ this.isWritingToParent = true;
23983
+ this.engine.setValue(this.config.name, nextRows);
23984
+ this.isWritingToParent = false;
23985
+ }
23986
+ readRowsFromParent() {
23987
+ if (!this.engine || !this.config?.name) {
23988
+ return this.applyBounds([]);
23989
+ }
23990
+ const value = this.engine.getValue(this.config.name);
23991
+ return this.applyBounds(this.normalizeRows(value));
23992
+ }
23993
+ normalizeRows(value) {
23994
+ if (!Array.isArray(value))
23995
+ return [];
23996
+ return value.map(item => this.normalizeRow(item));
23997
+ }
23998
+ normalizeRow(value) {
23999
+ if (!value || typeof value !== 'object' || Array.isArray(value))
24000
+ return {};
24001
+ return { ...value };
24002
+ }
24003
+ applyBounds(rows) {
24004
+ const bounded = [...rows];
24005
+ if (bounded.length > this.maxItems) {
24006
+ bounded.length = this.maxItems;
24007
+ }
24008
+ while (bounded.length < this.minItems) {
24009
+ bounded.push(this.createDefaultRow(this.itemSchema));
24010
+ }
24011
+ return bounded;
24012
+ }
24013
+ createDefaultRow(schema) {
24014
+ const row = {};
24015
+ for (const field of schema.fields) {
24016
+ if (!field.name)
24017
+ continue;
24018
+ if (field.defaultValue !== undefined) {
24019
+ row[field.name] = this.cloneValue(field.defaultValue);
24020
+ }
24021
+ else {
24022
+ row[field.name] = null;
24023
+ }
24024
+ }
24025
+ return row;
24026
+ }
24027
+ resolveItemSchema() {
24028
+ const schema = this.config?.repeatable?.itemSchema;
24029
+ if (schema && this.isSchemaLike(schema)) {
24030
+ return schema;
24031
+ }
24032
+ return createEmptySchema('form', { title: 'Repeatable Item' });
24033
+ }
24034
+ isSchemaLike(value) {
24035
+ if (!value || typeof value !== 'object')
24036
+ return false;
24037
+ const candidate = value;
24038
+ return Array.isArray(candidate.fields) && !!candidate.layout;
24039
+ }
24040
+ isSameLength(left, right) {
24041
+ return Array.isArray(left) && left.length === right.length;
24042
+ }
24043
+ cloneValue(value) {
24044
+ if (typeof structuredClone === 'function') {
24045
+ return structuredClone(value);
24046
+ }
24047
+ try {
24048
+ return JSON.parse(JSON.stringify(value));
24049
+ }
24050
+ catch {
24051
+ return value;
24052
+ }
24053
+ }
24054
+ buildDesignPreviewEngine() {
24055
+ if (!this.itemSchema?.layout) {
24056
+ this.designPreviewEngine = undefined;
24057
+ return;
24058
+ }
24059
+ const previewRow = this.createDefaultRow(this.itemSchema);
24060
+ this.designPreviewEngine = createFormEngine(this.itemSchema, previewRow);
24061
+ }
24062
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.17", ngImport: i0, type: RepeatableGroupWidgetComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
24063
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "19.2.17", type: RepeatableGroupWidgetComponent, isStandalone: true, selector: "app-repeatable-group-widget", inputs: { config: "config", engine: "engine", control: "control", scopePath: "scopePath" }, usesOnChanges: true, ngImport: i0, template: `
24064
+ <div class="w-full pb-4 font-sans" [class.hidden]="!visible" [ngStyle]="getWrapperStyles()">
24065
+ <div class="flex items-start justify-between gap-3 mb-2">
24066
+ <div class="min-w-0">
24067
+ <label [attr.for]="groupId" class="block text-sm font-medium text-gray-700">
24068
+ {{ config.label }} <span *ngIf="required" class="text-red-500" aria-label="required">*</span>
24069
+ </label>
24070
+ <p *ngIf="config.helpText" class="mt-1 text-xs text-gray-500">{{ config.helpText }}</p>
24071
+ </div>
24072
+ <div class="flex shrink-0 items-center gap-2">
24073
+ <button
24074
+ *ngIf="!isDesignMode"
24075
+ type="button"
24076
+ class="h-9 rounded-md border border-blue-200 px-3 text-xs font-semibold text-blue-700 transition-colors hover:bg-blue-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-40"
24077
+ [disabled]="!canAddItem"
24078
+ (click)="addItem()">
24079
+ {{ addButtonLabel }}
24080
+ </button>
24081
+ </div>
24082
+ </div>
24083
+
24084
+ <ng-container *ngIf="!isDesignMode; else designModeView">
24085
+ <div [id]="groupId" class="flex flex-col gap-3">
24086
+ <div *ngFor="let row of rowStates; let rowIndex = index; trackBy: trackByRowId"
24087
+ class="rounded-lg border border-gray-200 bg-white p-3">
24088
+ <div class="mb-3 flex items-center justify-between">
24089
+ <span class="text-xs font-semibold uppercase tracking-wide text-gray-500">
24090
+ {{ itemLabel }} {{ rowIndex + 1 }}
24091
+ </span>
24092
+ <button
24093
+ type="button"
24094
+ class="h-8 rounded-md border border-red-200 px-2.5 text-xs font-medium text-red-700 transition-colors hover:bg-red-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-40"
24095
+ [disabled]="!canRemoveItem"
24096
+ (click)="removeItem(rowIndex)">
24097
+ {{ removeButtonLabel }}
24098
+ </button>
24099
+ </div>
24100
+
24101
+ <app-layout-node
24102
+ *ngIf="itemSchema.layout"
24103
+ [node]="itemSchema.layout"
24104
+ [engine]="row.engine"
24105
+ [fields]="itemSchema.fields"
24106
+ [designMode]="false"
24107
+ [device]="'desktop'"
24108
+ [breakpoint]="'xl'">
24109
+ </app-layout-node>
24110
+ </div>
24111
+ </div>
24112
+
24113
+ <p *ngIf="rowStates.length === 0" class="rounded-md border border-dashed border-gray-300 bg-gray-50 px-3 py-4 text-xs text-gray-500">
24114
+ No items yet.
24115
+ </p>
24116
+ </ng-container>
24117
+
24118
+ <ng-template #designModeView>
24119
+ <div [id]="groupId" class="flex flex-col gap-3">
24120
+ <div class="rounded-lg border border-dashed border-gray-300 bg-gray-50 p-3">
24121
+ <div class="mb-2 flex items-center justify-between gap-2">
24122
+ <p class="text-xs font-semibold uppercase tracking-wide text-gray-500">Item Template</p>
24123
+ <span class="rounded bg-gray-200 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-gray-600">
24124
+ {{ itemSchema.fields.length }} fields
24125
+ </span>
24126
+ </div>
24127
+ <p class="mb-2 rounded-md border border-gray-200 bg-white px-3 py-2 text-xs text-gray-600">
24128
+ Select this group, then add fields from the main palette. New fields are added inside this group scope.
24129
+ </p>
24130
+ <p *ngIf="itemSchema.fields.length === 0" class="rounded-md border border-dashed border-gray-300 bg-white px-3 py-3 text-xs text-gray-500">
24131
+ No nested fields yet. You can still add rows/columns now.
24132
+ </p>
24133
+ <div class="rounded-md border border-gray-200 bg-white p-2"
24134
+ (click)="$event.stopPropagation()"
24135
+ (mousedown)="$event.stopPropagation()"
24136
+ (contextmenu)="$event.stopPropagation()">
24137
+ <app-layout-node
24138
+ *ngIf="itemSchema.layout && designPreviewEngine"
24139
+ [node]="itemSchema.layout"
24140
+ [engine]="designPreviewEngine"
24141
+ [fields]="itemSchema.fields"
24142
+ [designMode]="true"
24143
+ [scopePath]="nestedScopePath"
24144
+ [device]="'desktop'"
24145
+ [breakpoint]="'xl'"
24146
+ (nodeSelect)="onNestedNodeSelect($event)">
24147
+ </app-layout-node>
24148
+ </div>
24149
+ </div>
24150
+ </div>
24151
+ </ng-template>
24152
+ </div>
24153
+ `, isInline: true, dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i1.NgForOf, selector: "[ngFor][ngForOf]", inputs: ["ngForOf", "ngForTrackBy", "ngForTemplate"] }, { kind: "directive", type: i1.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "directive", type: i1.NgStyle, selector: "[ngStyle]", inputs: ["ngStyle"] }, { kind: "component", type: LayoutNodeComponent, selector: "app-layout-node", inputs: ["node", "engine", "fields", "designMode", "scopePath", "device", "breakpoint", "connectedDropLists"], outputs: ["nodeDrop", "nodeSelect"] }] });
24154
+ }
24155
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.17", ngImport: i0, type: RepeatableGroupWidgetComponent, decorators: [{
24156
+ type: Component,
24157
+ args: [{
24158
+ selector: 'app-repeatable-group-widget',
24159
+ standalone: true,
24160
+ imports: [CommonModule, LayoutNodeComponent],
24161
+ template: `
24162
+ <div class="w-full pb-4 font-sans" [class.hidden]="!visible" [ngStyle]="getWrapperStyles()">
24163
+ <div class="flex items-start justify-between gap-3 mb-2">
24164
+ <div class="min-w-0">
24165
+ <label [attr.for]="groupId" class="block text-sm font-medium text-gray-700">
24166
+ {{ config.label }} <span *ngIf="required" class="text-red-500" aria-label="required">*</span>
24167
+ </label>
24168
+ <p *ngIf="config.helpText" class="mt-1 text-xs text-gray-500">{{ config.helpText }}</p>
24169
+ </div>
24170
+ <div class="flex shrink-0 items-center gap-2">
24171
+ <button
24172
+ *ngIf="!isDesignMode"
24173
+ type="button"
24174
+ class="h-9 rounded-md border border-blue-200 px-3 text-xs font-semibold text-blue-700 transition-colors hover:bg-blue-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-40"
24175
+ [disabled]="!canAddItem"
24176
+ (click)="addItem()">
24177
+ {{ addButtonLabel }}
24178
+ </button>
24179
+ </div>
24180
+ </div>
24181
+
24182
+ <ng-container *ngIf="!isDesignMode; else designModeView">
24183
+ <div [id]="groupId" class="flex flex-col gap-3">
24184
+ <div *ngFor="let row of rowStates; let rowIndex = index; trackBy: trackByRowId"
24185
+ class="rounded-lg border border-gray-200 bg-white p-3">
24186
+ <div class="mb-3 flex items-center justify-between">
24187
+ <span class="text-xs font-semibold uppercase tracking-wide text-gray-500">
24188
+ {{ itemLabel }} {{ rowIndex + 1 }}
24189
+ </span>
24190
+ <button
24191
+ type="button"
24192
+ class="h-8 rounded-md border border-red-200 px-2.5 text-xs font-medium text-red-700 transition-colors hover:bg-red-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-40"
24193
+ [disabled]="!canRemoveItem"
24194
+ (click)="removeItem(rowIndex)">
24195
+ {{ removeButtonLabel }}
24196
+ </button>
24197
+ </div>
24198
+
24199
+ <app-layout-node
24200
+ *ngIf="itemSchema.layout"
24201
+ [node]="itemSchema.layout"
24202
+ [engine]="row.engine"
24203
+ [fields]="itemSchema.fields"
24204
+ [designMode]="false"
24205
+ [device]="'desktop'"
24206
+ [breakpoint]="'xl'">
24207
+ </app-layout-node>
24208
+ </div>
24209
+ </div>
24210
+
24211
+ <p *ngIf="rowStates.length === 0" class="rounded-md border border-dashed border-gray-300 bg-gray-50 px-3 py-4 text-xs text-gray-500">
24212
+ No items yet.
24213
+ </p>
24214
+ </ng-container>
24215
+
24216
+ <ng-template #designModeView>
24217
+ <div [id]="groupId" class="flex flex-col gap-3">
24218
+ <div class="rounded-lg border border-dashed border-gray-300 bg-gray-50 p-3">
24219
+ <div class="mb-2 flex items-center justify-between gap-2">
24220
+ <p class="text-xs font-semibold uppercase tracking-wide text-gray-500">Item Template</p>
24221
+ <span class="rounded bg-gray-200 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-gray-600">
24222
+ {{ itemSchema.fields.length }} fields
24223
+ </span>
24224
+ </div>
24225
+ <p class="mb-2 rounded-md border border-gray-200 bg-white px-3 py-2 text-xs text-gray-600">
24226
+ Select this group, then add fields from the main palette. New fields are added inside this group scope.
24227
+ </p>
24228
+ <p *ngIf="itemSchema.fields.length === 0" class="rounded-md border border-dashed border-gray-300 bg-white px-3 py-3 text-xs text-gray-500">
24229
+ No nested fields yet. You can still add rows/columns now.
24230
+ </p>
24231
+ <div class="rounded-md border border-gray-200 bg-white p-2"
24232
+ (click)="$event.stopPropagation()"
24233
+ (mousedown)="$event.stopPropagation()"
24234
+ (contextmenu)="$event.stopPropagation()">
24235
+ <app-layout-node
24236
+ *ngIf="itemSchema.layout && designPreviewEngine"
24237
+ [node]="itemSchema.layout"
24238
+ [engine]="designPreviewEngine"
24239
+ [fields]="itemSchema.fields"
24240
+ [designMode]="true"
24241
+ [scopePath]="nestedScopePath"
24242
+ [device]="'desktop'"
24243
+ [breakpoint]="'xl'"
24244
+ (nodeSelect)="onNestedNodeSelect($event)">
24245
+ </app-layout-node>
24246
+ </div>
24247
+ </div>
24248
+ </div>
24249
+ </ng-template>
24250
+ </div>
24251
+ `
24252
+ }]
24253
+ }], propDecorators: { config: [{
24254
+ type: Input
24255
+ }], engine: [{
24256
+ type: Input
24257
+ }], control: [{
24258
+ type: Input
24259
+ }], scopePath: [{
24260
+ type: Input
24261
+ }] } });
24262
+
23418
24263
  class ButtonWidgetComponent {
23419
24264
  router = inject(Router, { optional: true });
23420
24265
  pageLinks = inject(WIDGET_PAGE_LINKS, { optional: true });
@@ -24164,6 +25009,36 @@ const FILE_WIDGET_PROPERTIES = [
24164
25009
  { label: 'Appearance', fields: COMMON_APPEARANCE_FIELDS },
24165
25010
  ...COMMON_STYLE_SECTIONS
24166
25011
  ];
25012
+ const REPEATABLE_WIDGET_PROPERTIES = [
25013
+ {
25014
+ label: 'Basic',
25015
+ fields: [
25016
+ { key: 'name', type: 'text', label: 'Field Key (Name)' },
25017
+ { key: 'label', type: 'text', label: 'Label' },
25018
+ { key: 'helpText', type: 'text', label: 'Help Text' },
25019
+ { key: 'tooltip', type: 'text', label: 'Tooltip' },
25020
+ ...COMMON_GROUP_FIELDS
25021
+ ]
25022
+ },
25023
+ {
25024
+ label: 'Repeatable',
25025
+ fields: [
25026
+ { key: 'repeatable.minItems', type: 'number', label: 'Minimum Items', min: 0 },
25027
+ { key: 'repeatable.maxItems', type: 'number', label: 'Maximum Items', min: 1 },
25028
+ { key: 'repeatable.itemLabel', type: 'text', label: 'Item Label', placeholder: 'Item' },
25029
+ { key: 'repeatable.addButtonLabel', type: 'text', label: 'Add Button Label', placeholder: 'Add item' },
25030
+ { key: 'repeatable.removeButtonLabel', type: 'text', label: 'Remove Button Label', placeholder: 'Remove' }
25031
+ ]
25032
+ },
25033
+ {
25034
+ label: 'Validation',
25035
+ fields: [
25036
+ BASE_REQUIRED_FIELD
25037
+ ]
25038
+ },
25039
+ { label: 'Appearance', fields: COMMON_APPEARANCE_FIELDS },
25040
+ ...COMMON_STYLE_SECTIONS
25041
+ ];
24167
25042
  const pluginId$2 = 'core.form';
24168
25043
  const FIELD_WIDGETS = [
24169
25044
  defineWidget(pluginId$2, {
@@ -24470,6 +25345,33 @@ const FIELD_WIDGETS = [
24470
25345
  dataConsumer: 'none',
24471
25346
  properties: FILE_WIDGET_PROPERTIES
24472
25347
  }),
25348
+ defineWidget(pluginId$2, {
25349
+ kind: 'field',
25350
+ flavor: 'form',
25351
+ type: 'repeatable-group',
25352
+ icon: 'list-plus',
25353
+ label: 'Repeatable Group',
25354
+ createConfig: () => ({
25355
+ id: generateId$2(),
25356
+ name: 'group_' + Date.now(),
25357
+ type: 'repeatable-group',
25358
+ label: 'Repeatable Group',
25359
+ helpText: '',
25360
+ defaultValue: [],
25361
+ repeatable: {
25362
+ minItems: 1,
25363
+ maxItems: 5,
25364
+ itemLabel: 'Item',
25365
+ addButtonLabel: 'Add item',
25366
+ removeButtonLabel: 'Remove',
25367
+ itemSchema: createEmptySchema('form', { title: 'Repeatable Item' })
25368
+ },
25369
+ style: { width: '100%' }
25370
+ }),
25371
+ renderer: RepeatableGroupWidgetComponent,
25372
+ dataConsumer: 'none',
25373
+ properties: REPEATABLE_WIDGET_PROPERTIES
25374
+ }),
24473
25375
  defineWidget(pluginId$2, {
24474
25376
  kind: 'field',
24475
25377
  flavor: 'form',