Node grouping moves upon saving


This is my diagram, and as soon as the page loads, the console displays the message “change not within a transaction.” Then, when I try to save, the issue shown in the second image occurs.

the code I have related to the groups is as follows:

onMounted(async () => {
  diagram = new Diagram("myDiagramDiv", {
    scrollMode: ScrollMode.Infinite,
    LinkDrawn: showLinkLabel,
    LinkRelinked: showLinkLabel,
    'commandHandler.archetypeGroupData': { key: "Group", isGroup: true },
    "undoManager.isEnabled": true,
  });

  diagram.groupTemplate.ungroupable = true

  await getQuestionTypes();
  await getRules();
  await getDataSelect();
  await getGroupList();
  diagramRef.value = diagram;
  $flowchart(diagram);
  const dataModel = [
    {
      category: "Start",
      text: "Start",
      question_type_id: "Start",
    },
    {
      category: "Question",
      text: "Question",
      question_type_id: "",
    },
    {
      category: "Option",
      text: "Option",
      question_type_id: "Option",
    },
    {
      category: "Message",
      text: "Message",
      question_type_id: "Message",
    },
    {
      category: "Group",
      text: "Group",
      question_type_id: "Group",
    },
    {
      category: "Validation",
      text: "Validation",
      question_type_id: "Validation",
    },
    {
      category: "Process",
      text: "Process",
      question_type_id: "Process",
    },
    {
      category: "Variable",
      text: "Variable",
      question_type_id: "Variable",
      color: "#FFF",
    },
    {
      category: "Loop",
      text: "Loop",
      question_type_id: "Loop",
    },
    {
      category: "End",
      text: "End",
      question_type_id: "End",
    },
  ];

  new Palette("myPaletteDiv", {
    nodeTemplateMap: diagram.nodeTemplateMap,
    model: new GraphLinksModel(dataModel),
  });

  const inspector = new Inspector("myInspectorDiv", diagram, {
    multipleSelection: true,
    showSize: 4,
    showAllProperties: true,
    includesOwnProperties: false,
    properties: {
      key: { name: "Key (*)", type: "text" },
      text: {
        name: "Label (*)",
        show: (nodeData: { category: string }) =>
          ["Question", "Message", "Option", "Variable", "Process", "Validation", 'Group'].includes(nodeData.category),
      },
      question_type_id: {
        name: "Type of Question (*)",
        show: (nodeData: { category: string }) => nodeData.category === "Question",
        type: "select",
        choices: questionTypes.value.map((item) => item.type),
      },
      order: {
        name: "Order",
        show: (nodeData: { category: string }) => nodeData.category === "Option",
        type: "input",
      },
      value: {
        name: "Value",
        show: (nodeData: { category: string }) => nodeData.category === "Variable",
        type: "input",
      },
      translate: {
        name: "Translate (*)",
        show: (nodeData: { category: string }) =>
          ["Question", "Message", "Option", 'Group'].includes(nodeData.category),
        type: "input",
      },
      bgMedia: {
        name: "Background Media",
        show: (nodeData: { category: string }) =>
          ["Question", "Message", 'Group'].includes(nodeData.category),
        type: "input",
      },
      bgMediaES: {
        name: "Background Media Translate",
        show: (nodeData: { category: string }) =>
          ["Question", "Message", 'Group'].includes(nodeData.category),
        type: "input",
      },
      dataSource: {
        name: "Data Source",
        show: (nodeData: any) => {
          const data = nodeData.hasOwnProperty("si") ? { ...nodeData.si } : nodeData;
          return data.question_type_id === "List";
        },
        type: "select",
        choices: dataSource.value.map((item) => item.dataselect_id),
      },
      length: {
        name: "Length",
        show: (nodeData: any) => {
          const data = nodeData.hasOwnProperty("si") ? { ...nodeData.si } : nodeData;
          return ["Number", "Text", "Textarea"].includes(data.question_type_id);
        },
        type: "input",
      },
      rules: {
        name: "Rules",
        show: (nodeData: any) => {
          const data = nodeData.hasOwnProperty("si") ? { ...nodeData.si } : nodeData;
          return ["Datepicker", "Text", "Textarea"].includes(data.question_type_id);
        },
        type: "select",
        choices: (nodeData: any) => {
          const data = nodeData.hasOwnProperty("si") ? { ...nodeData.si } : nodeData;
          const questionTypeId = data.question_type_id
            ? data.question_type_id.toLowerCase()
            : "";

          return rules.value
            .filter((e) => e.question_type_id.toLowerCase() === questionTypeId)
            .map((item) => item.field_rule_id);
        },
      },
      required: {
        name: "Required",
        show: (nodeData: { category: string }) =>
          ["Question"].includes(nodeData.category),
        type: "checkbox",
      },
      color: {
        show: (nodeData: { category: string }) => nodeData.category === "Variable",
        type: "color",
        defaultValue: "#FFF",
      },
      list_group: {
        name: "Type of group (*)",
        show: (nodeData: { category: string }) => nodeData.category === "Group",
        type: "select",
        choices: groupList.value.map((item) => item.value),
      },
    },
  });

  const inspector2 = new Inspector("myInspectorDiv2", diagram, {
    inspectSelection: true,
    includesOwnProperties: false,
    properties: {
      helptext: {
        type: "input",
        name: "Help Text",
        show: (nodeData: { category: string }) => nodeData.category === "Message",
      },
      helptextES: {
        type: "input",
        name: "Help Text Translate",
        show: (nodeData: { category: string }) => nodeData.category === "Message",
      },
      placeholder: {
        type: "input",
        name: "Placeholder",
        show: (nodeData: { category: string }) => nodeData.category === "Message",
      },
      placeholderES: {
        type: "input",
        name: "Placeholder Translate",
        show: (nodeData: { category: string }) => nodeData.category === "Message",
      },
      errorText: {
        type: "input",
        name: "Error Text",
        show: (nodeData: { category: string }) => nodeData.category === "Message",
      },
      errorTextES: {
        type: "input",
        name: "Error Text Translate",
        show: (nodeData: { category: string }) => nodeData.category === "Message",
      },
    },
  });

  document.getElementById("myInspectorDiv2")!.style.display = "none";

  const optionTypes = ['Multiple Option', 'Single Option', 'Group', 'List', 'Datepicker'];
  const textTypes = ['Text', 'Textarea'];

  const propertiesText = ['helptext', 'helptextES', 'placeholder', 'placeholderES', 'errorText', 'errorTextES'];
  const propertiesOption = ['helptext', 'helptextES'];
  const PropertiesNumber = ['helptext', 'helptextES', 'placeholder', 'placeholderES'];

  diagram.addDiagramListener("ChangedSelection", e => {
    const selectedNode = e.subject.first();
    const category = selectedNode ? selectedNode.data.category : null;

    // Mostrar u ocultar el segundo inspector basado en la categoría del nodo seleccionado
    if (category === 'Message' || (category === 'Question' && textTypes.includes(selectedNode?.data.question_type_id))) {
      showInspector2(propertiesText);
    } else if (category === 'Question' && optionTypes.includes(selectedNode?.data.question_type_id) || category === 'Group') {
      showInspector2(propertiesOption);
    } else if (category === 'Question' && selectedNode?.data.question_type_id === 'Number') {
      showInspector2(PropertiesNumber);
    } else {
      hideInspector2();
    }

    // Verificar si los nodos Option tienen un padre de la categoría Question
    e.subject.each(function (part: go.Node) {
      if (part instanceof go.Node && part.data.category === "Option") {
        const parent = part.findTreeParentNode();
        if (parent && parent.data.category !== "Question") {
          removeNodeAndLinks(part);
          alert("The 'Option' nodes can only be children of 'Question' nodes");
        } else if (parent && parent.data.category === "Question" &&
          (parent.data.question_type_id !== 'Multiple Option' && parent.data.question_type_id !== 'Single Option')) {
          removeNodeAndLinks(part);
          alert("The 'Option' nodes can only be children of 'Question' nodes with type 'Multiple Option' or 'Single Option'");
        }
      }
    });

    // Verificar si hay más de un nodo de la categoría Start
    let startNodeCount = 0;
    e.diagram.nodes.each(function (node: go.Node) {
      if (node.data.category === "Start") {
        startNodeCount++;
      }
    });

    e.subject.each(function (part: Node) {
      if (part instanceof go.Node && part.data.category === "Start") {
        if (startNodeCount > 1) {
          e.diagram.model.removeNodeData(part.data);
          alert("There can only be one node of the 'Start' category");
          startNodeCount--;
        }
      }
    });

    // Inspeccionar el nodo seleccionado con el inspector principal
    if (inspector && inspector.inspectObject !== null) {
      inspector.inspectObject(selectedNode ? selectedNode.data : null);
      const selectElement = document.querySelector('select[tabindex="2"]');
      selectElement?.addEventListener('change', handleInspectorChange);
    }

    function showInspector2(propertiesToShow: string[]) {
      document.getElementById('myInspectorDiv2')!.style.display = 'block';
      propertiesToShow.forEach(property => {
        inspector2.properties[property].show = true;
      });
      inspector2.inspectObject(selectedNode ? selectedNode.data : null);
    }

    function hideInspector2() {
      document.getElementById('myInspectorDiv2')!.style.display = 'none';
    }

    function handleInspectorChange(event: Event) {
      switch (selectedNode?.data.question_type_id) {
        case 'Text':
        case 'Textarea':
          showInspector2(propertiesText);
          break;
        case 'Single Option':
        case 'List':
        case 'Multiple Option':
        case 'Group':
        case 'Datepicker':
          showInspector2(propertiesOption);
          break;
        case 'Number':
          showInspector2(PropertiesNumber);
          break;
        default:
          hideInspector2();
          break;
      }
    }
  });

  inspector.inspectObject(diagram);
  await renderFlow();

  diagram.model = new GraphLinksModel(state.nodeDataArray, state.linkDataArray, {
    linkFromPortIdProperty: "formPort",
    linkToPortIdProperty: "toPort",
  });

  // diagram.select(diagram.nodes.first());
});

How can I prevent this behavior?

const saveDiagram = async () => {
  const diagram = diagramRef.value as go.Diagram;
  if (diagram) {

    cleanOrphanLinks()

    let hasStartNode = false;
    diagram.nodes.each(function (node: go.Node) {
      if (node.data.category === "Start") {
        hasStartNode = true;
      }
    });

    if (!hasStartNode) {
      alert("The diagram must have at least one start node in order to save it");
      return;
    }

    if (await invalidFigure(diagram)) {
      alert("All nodes must have a 'key'");
      return;
    }

    const { isValid, errorMessage } = await invalidQuestions(diagram);
    if (!isValid) {
      alert("Some 'Question' nodes are invalid:\n\n" + errorMessage);
      return;
    }

    if (!await validateLabels(diagram)) {
      return;
    }

    if (await invalidGroups(diagram)) {
      alert("All 'Group' nodes must have a 'Type of Group' selected");
      return;
    }

    if (!await validateNoOrphanNodes(diagram)) {
      alert("You cannot save the diagram due to orphaned nodes. Please connect or delete them before saving");
      return;
    }

    if (!await validateDiagramCompletion(diagram)) {
      alert("All paths in the diagram must end with an 'End' node");
      return;
    }

    diagram.model.nodeDataArray.sort((a, b) => {
      const order: any = { "Start": 1, "Question": 2, "Option": 3, "End": 5 };
      const aOrder = order[a.category] !== undefined ? order[a.category] : 4;
      const bOrder = order[b.category] !== undefined ? order[b.category] : 4;

      return aOrder - bOrder;
    });

    const json = diagram.model.toJson().replace(/\\"/, '');
    if (!json) return alert("Diagram is empty");
    const jsonData = JSON.parse(json);
    try {
      await $flowchartApi.saveQuestionFlow(jsonData, question_section_id.value);
      alert("Diagram saved successfully");
    } catch (error) {
      console.error("Error saving diagram:", error);
    }
  }
};

The first warning was about replacing the link template. What’s going on when you load?

Why are you replacing strings in the result of calling Model.toJson?

Hi Walter, I use replace in diagram.model.toJson(); because question-type shapes have large texts with line breaks and spaces and I didn’t want those blank spaces or line breaks to affect the saving process in the database.
However, I have removed it. Regarding the movement of the groups when saving, I noticed that one of the validations is what causes the movement.

   if (!await validateDiagramCompletion(diagram)) {
      alert("All paths in the diagram must end with an 'End' node");
      return;
    }

here the complete code:

async function validateDiagramCompletion(diagram: go.Diagram): Promise<boolean> {
  const nodes = diagram.model.nodeDataArray as Array<{ key: string | number; category: string }>;
  const startNodes = nodes.filter(node => node.category === "Start");

  // Almacenar nodos que ya se han confirmado que llegan a un nodo 'End'
  const completedNodes = new Set<string | number>();

  // Función recursiva para verificar si un camino llega a un nodo End
  function isPathComplete(nodeKey: string | number, visited = new Set<string | number>(), path: Array<go.Node | go.Link> = []): boolean {

    const nodeData = nodes.find(n => n.key === nodeKey);
    if (nodeData?.category === "Loop") {
      // No se requiere que los nodos "Loop" lleguen a un "End", se excluyen de la validación
      highlightPath(diagram, path, 'yellow');
      return true;
    }

    if (completedNodes.has(nodeKey)) {
      highlightPath(diagram, path, 'green');
      return true; // Nodo ya verificado que llega a un 'End'
    }
    if (visited.has(nodeKey)) {
      highlightPath(diagram, path, 'red');
      return false; // Evitar ciclos
    }

    visited.add(nodeKey);

    const node = diagram.findNodeForKey(nodeKey);
    if (node) path.push(node);

    const outgoingLinks = diagram.findLinksByExample({ from: nodeKey });
    if (!outgoingLinks.count) {
      // Verificar si el nodo actual es un nodo 'End'
      const nodeData = nodes.find(n => n.key === nodeKey);
      if (nodeData && nodeData.category === "End") {
        completedNodes.add(nodeKey);
        highlightPath(diagram, path, 'green');
        return true;
      } else {
        highlightPath(diagram, path, 'red');
        return false;
      }
    }

    for (const link of outgoingLinks) {
      path.push(link);
      const toNodeKey = link.data.to;
      if (isPathComplete(toNodeKey, new Set(visited), [...path])) {
        completedNodes.add(toNodeKey); // Marcar el nodo destino como completado si llega a un 'End'
      } else {
        return false;
      }
    }

    completedNodes.add(nodeKey); // Marcar el nodo actual como completado si todos los caminos llegan a un 'End'
    return true;
  }

  // Verificar todos los nodos de inicio
  for (const startNode of startNodes) {
    if (!isPathComplete(startNode.key)) {
      return false;
    }
  }

  return true;
}

It is possible that the highlightPath function, which changes the color of the links, or some other modification, is affecting the nodes or groups, causing an unintended change in position.

// Función para resaltar nodos y enlaces en el diagrama
function highlightPath(diagram: go.Diagram, path: Array<go.Node | go.Link>, color: string) {
  diagram.startTransaction('highlight');
  path.forEach(part => {
    if (part instanceof go.Node) {
      const shape = part.findObject("SHAPE") as go.Shape;
      if (shape) {
        shape.stroke = color;
        shape.strokeWidth = 3;
      }
    } else if (part instanceof go.Link) {
      const shape = part.path as go.Shape;
      if (shape) {
        shape.stroke = color;
        shape.strokeWidth = 3;
      }
    }
  });
  diagram.commitTransaction('highlight');
}

I suppose changing the Shape.strokeWidth of some Links will change their actualBounds, which if they are members of Groups with Placeholders will change the size of those Groups, which will invalidate the layout(s) responsible for arranging those Groups, which might cause them or other nodes and links to be moved.

If that is the case, you could set Group.computesBoundsIncludingLinks to false.

onMounted(async () => {
  diagram = new Diagram("myDiagramDiv", {
    scrollMode: ScrollMode.Infinite,
    LinkDrawn: showLinkLabel,
    LinkRelinked: showLinkLabel,
    'commandHandler.archetypeGroupData': { key: "Group", isGroup: true, computesBoundsIncludingLinks: false},
    "undoManager.isEnabled": true,
  });

Including the adjustments you mentioned has improved the behavior, but there’s still a slight movement. You might want to check if the function is modifying the layout or triggering a layout recalculation. It could help to explicitly preserve the positions of the nodes or groups before and after the highlightPath function is called, ensuring their positions remain fixed during the process.

Ah, depending on what your Node template is, you are still changing their sizes when you highlight or unhighlight them. Maybe you could change your node template so that you change the opacity of a second Shape that is always present but normally not seen because its opacity is initially zero. You could do the same for Links too. Take a look at the Distances sample: Distances & Paths | GoJS Diagramming Library

Based on the example, I noticed the HIGHLIGHT figure in the linkTemplate and modified the highlightPath function to change the color specifically for the HIGHLIGHT figure. While the group no longer moves, the issue is that its size decreases. I am attaching images to explain the situation:
Before save:


After save:

my new function:

function highlightPath(diagram: go.Diagram, path: Array<go.Node | go.Link>, color: string) {
  diagram.startTransaction('highlight');
  path.forEach(part => {
    if (part instanceof go.Node) {
      const highlight = part.findObject("HIGHLIGHT") as go.Shape;
      if (highlight) {
        highlight.stroke = color;
        highlight.strokeWidth = 3;
      }
    } else if (part instanceof go.Link) {
      const highlight = part.findObject("HIGHLIGHT") as go.Shape;
      if (highlight) {
        highlight.stroke = color;
        highlight.strokeWidth = 3;
      }
    }
  });
  diagram.commitTransaction('highlight');
}

my link template:

 diagram.linkTemplate = $(
          Link,
          {
            routing: Routing.AvoidsNodes,
            curve: Curve.JumpOver,
            corner: 5,
            toShortLength: 4,
            relinkableFrom: true,
            relinkableTo: true,
            reshapable: true,
            resegmentable: true,
            mouseEnter: (e, link: any) =>
              (link.findObject('HIGHLIGHT').stroke = 'rgba(30,144,255,0.2)'),
            mouseLeave: (e, link: any) =>
              (link.findObject('HIGHLIGHT').stroke = 'transparent'),
            selectionAdorned: false
          },
          new Binding('points').makeTwoWay(),
          $(
            Shape, // the highlight shape, normally transparent
            {
              isPanelMain: true,
              strokeWidth: 8,
              stroke: 'transparent',
              name: 'HIGHLIGHT'
            }
          ),
          $(
            Shape, // the link path shape
            { isPanelMain: true, stroke: 'gray', strokeWidth: 2 },
            new Binding('stroke', 'isSelected', (sel) =>
              sel ? 'dodgerblue' : 'gray'
            ).ofObject()
          ),

how to fix it ?

How is the group template defined?

Does that mean that for the groups created with Cmd + G that group nodes, I need to define a template? If so, could you please tell me how it should be? I’m not quite sure how to do it.

For all classes there is at least one default template pre-defined, but for basically all apps one will want to use a custom template for Nodes and for Groups.

There are several pages in the Introduction dealing with Groups:

And there are many samples that make use of Groups:
Samples Index | GoJS

Based on the group template example, I added the following code, which modified the group design, so I notice that the group template is applied:

      diagram.groupTemplate =
          new go.Group("Vertical")
            .add(
              new go.Panel("Auto")
                .add(
                  new go.Shape("RoundedRectangle", {  // surrounds the Placeholder
                    parameter1: 14,
                    fill: "rgba(128,128,128,0.33)"
                  }),
                  new go.Placeholder(    // represents the area of all member parts,
                    { padding: 5 })  // with some extra padding around them
                ),
              new go.TextBlock({         // group title
                alignment: go.Spot.Right, font: "Bold 12pt Sans-Serif"
              })
                .bind("text")
          );

However, when I save, the size of the group continues to decrease

That’s fine. I don’t understand what’s going on. My guess is that the member nodes when loaded no longer remember which group they are members of. Check what data you have when loading – does each member node properly refer to its containing group?

In reality, each node does know which group it belongs to because when the screen is reloaded, it brings the nodeData and linkData from the database, and the diagram looks perfect. The issue only occurs when saving, related to the function for highlighting lines. If I comment out this function, the group does not move when clicking save

Just because a node is positioned to be inside of a group doesn’t mean that that node is a member of that group. That’s why you need to check the model data objects that are saved in the database.

{ "class": "_GraphLinksModel",
  "linkFromPortIdProperty": "formPort",
  "linkToPortIdProperty": "toPort",
  "nodeDataArray": [
{"category":"End","text":"End","question_type_id":"End","key":"-13","loc":"-47.28515625 389.1640625","required":true,"order":0},
{"category":"End","text":"End","question_type_id":"End","key":"-15","loc":"133.6796875 -147.95703125","required":true,"order":0},
{"category":"Group","text":"Group","question_type_id":"Group","key":"Grup","loc":"-26.10546875 -33.47265625","translate":"Gt","required":true,"list_group":"Direction","order":0,"group":"L_Direction"},
{"category":"Loop","text":"Loop","question_type_id":"Loop","key":"LOP","loc":"153.9375 -41.73046875","required":true,"order":0,"group":"L_Direction"},
{"category":"Message","text":"MessageGrupo","question_type_id":"Message","key":"MsgG","loc":"31.31640625 -418.3046875","translate":"mg","required":true,"order":0,"group":"L_Direction"},
{"category":"Option","text":"No","question_type_id":"Option","key":"LOpcNo","loc":"-47.3828125 143.17578125","translate":"No","required":true,"order":2},
{"category":"Option","text":"yes","question_type_id":"Option","key":"LOpcSi","loc":"138.3125 142.859375","translate":"si","required":true,"order":1},
{"category":"Option","text":"No","question_type_id":"Option","key":"OpcNo","loc":"93.671875 -237.34765625","translate":"no","required":true,"order":2,"group":"L_Direction"},
{"category":"Option","text":"yes","question_type_id":"Option","key":"OpcSi","loc":"-26.12890625 -238.07421875","translate":"si","required":true,"order":1,"group":"L_Direction"},
{"category":"Question","text":"Question1","question_type_id":"Single Option","key":"Q1","loc":"31.125 -323.0390625","translate":"QT","required":false,"order":0,"group":"L_Direction"},
{"category":"Question","text":"Loop","question_type_id":"Single Option","key":"Q3L","loc":"51.125 59.9609375","translate":"LopT","required":false,"order":0},
{"category":"Start","text":"Start","question_type_id":"Start","key":"Start","loc":"-117.89453125 -418.42578125","required":true,"order":0},
{"category":"Question","text":"Question","question_type_id":"Text","key":"Q2","loc":"-26.171875 -136.76953125","translate":"QT2","required":false,"length":0,"order":0,"group":"L_Direction"},
{"category":"Question","text":"Question","question_type_id":"Text","key":"Q5","loc":"-46.89453125 247.25","translate":"QT5","required":false,"length":0,"order":0},
{"key":"L_Direction","isGroup":true}
],

Here you can see the member nodes of the group from the example I shared in the images

Sorry, I missed this reply.

When I use your group template and then load your model, the group appears to cover the whole area occupied by its member nodes, as I believe you are expecting.