@saltcorn/builder 1.6.0-beta.9 → 1.6.0-rc.2

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.
@@ -0,0 +1,67 @@
1
+ /*!
2
+ Copyright (c) 2018 Jed Watson.
3
+ Licensed under the MIT License (MIT), see
4
+ http://jedwatson.github.io/classnames
5
+ */
6
+
7
+ /*!
8
+ * Font Awesome Free 5.15.2 by @fontawesome - https://fontawesome.com
9
+ * License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
10
+ */
11
+
12
+ /*! mobile-drag-drop 2.3.0-rc.1 | Copyright (c) 2019 Tim Ruffles | MIT License */
13
+
14
+ /*! regenerator-runtime -- Copyright (c) 2014-present, Facebook, Inc. -- license (MIT): https://github.com/babel/babel/blob/main/packages/babel-helpers/LICENSE */
15
+
16
+ /**
17
+ * @license
18
+ * Lodash <https://lodash.com/>
19
+ * Copyright OpenJS Foundation and other contributors <https://openjsf.org/>
20
+ * Released under MIT license <https://lodash.com/license>
21
+ * Based on Underscore.js 1.8.3 <http://underscorejs.org/LICENSE>
22
+ * Copyright Jeremy Ashkenas, DocumentCloud and Investigative Reporters & Editors
23
+ */
24
+
25
+ /**
26
+ * @license Copyright (c) 2003-2021, CKSource - Frederico Knabben. All rights reserved.
27
+ * For licensing, see LICENSE.md.
28
+ */
29
+
30
+ /**
31
+ * @license React
32
+ * react-dom.production.min.js
33
+ *
34
+ * Copyright (c) Facebook, Inc. and its affiliates.
35
+ *
36
+ * This source code is licensed under the MIT license found in the
37
+ * LICENSE file in the root directory of this source tree.
38
+ */
39
+
40
+ /**
41
+ * @license React
42
+ * react.production.min.js
43
+ *
44
+ * Copyright (c) Facebook, Inc. and its affiliates.
45
+ *
46
+ * This source code is licensed under the MIT license found in the
47
+ * LICENSE file in the root directory of this source tree.
48
+ */
49
+
50
+ /**
51
+ * @license React
52
+ * scheduler.production.min.js
53
+ *
54
+ * Copyright (c) Facebook, Inc. and its affiliates.
55
+ *
56
+ * This source code is licensed under the MIT license found in the
57
+ * LICENSE file in the root directory of this source tree.
58
+ */
59
+
60
+ /** @license React v16.13.1
61
+ * react-is.production.min.js
62
+ *
63
+ * Copyright (c) Facebook, Inc. and its affiliates.
64
+ *
65
+ * This source code is licensed under the MIT license found in the
66
+ * LICENSE file in the root directory of this source tree.
67
+ */
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@saltcorn/builder",
3
- "version": "1.6.0-beta.9",
3
+ "version": "1.6.0-rc.2",
4
4
  "description": "Drag and drop view builder for Saltcorn, open-source no-code platform",
5
5
  "main": "index.js",
6
6
  "homepage": "https://saltcorn.com",
7
7
  "scripts": {
8
8
  "build": "webpack --mode production",
9
- "builddev": "webpack --mode none",
9
+ "builddev": "webpack --mode development",
10
10
  "watch": "webpack --watch --mode development",
11
11
  "test": "jest tests --runInBand",
12
12
  "tsc": "echo \"Error: no TypeScript support yet\"",
@@ -30,7 +30,7 @@
30
30
  "@fortawesome/free-solid-svg-icons": "5.15.2",
31
31
  "@fortawesome/react-fontawesome": "0.1.14",
32
32
  "@monaco-editor/react": "4.7.0",
33
- "@saltcorn/common-code": "1.6.0-beta.9",
33
+ "@saltcorn/common-code": "1.6.0-rc.2",
34
34
  "@tippyjs/react": "4.2.6",
35
35
  "babel-jest": "^29.7.0",
36
36
  "babel-loader": "9.2.1",
@@ -282,18 +282,6 @@ const SettingsPanel = ({ isEnlarged, setIsEnlarged }) => {
282
282
  selectedNodes.forEach((nodeId) => actions.delete(nodeId));
283
283
  }
284
284
  }
285
- if ((event.ctrlKey || event.metaKey) && event.keyCode == 86) {
286
- navigator.clipboard.readText().then((clipText) => {
287
- const layout = JSON.parse(clipText);
288
- layoutToNodes(
289
- layout,
290
- query,
291
- actions,
292
- selected?.id || "ROOT",
293
- options
294
- );
295
- });
296
- }
297
285
  if ((event.ctrlKey || event.metaKey) && event.keyCode == 90) {
298
286
  // undo
299
287
  actions.history.undo();
@@ -303,6 +291,43 @@ const SettingsPanel = ({ isEnlarged, setIsEnlarged }) => {
303
291
  actions.history.redo();
304
292
  }
305
293
  }
294
+ if ((event.ctrlKey || event.metaKey) && event.keyCode == 86) {
295
+ const inputTags = ["input", "textarea", "select"];
296
+ if (!inputTags.includes(tagName) && !target.isContentEditable) {
297
+ navigator.clipboard.readText().then((clipText) => {
298
+ try {
299
+ const layout = JSON.parse(clipText);
300
+
301
+ let pasteTarget = "ROOT";
302
+ let pasteIndex = false;
303
+ try {
304
+ if (selected?.id && selected.id !== "ROOT") {
305
+ const selNode = query.node(selected.id).get();
306
+ const linkedNodes = selNode?.data?.linkedNodes;
307
+ if (linkedNodes && Object.keys(linkedNodes).length > 0) {
308
+ const firstLinkedId = Object.values(linkedNodes)[0];
309
+ pasteTarget = firstLinkedId;
310
+ pasteIndex = query.node(firstLinkedId).childNodes().length;
311
+ } else if (selNode?.data?.isCanvas) {
312
+ pasteTarget = selected.id;
313
+ pasteIndex = query.node(selected.id).childNodes().length;
314
+ } else {
315
+ const parentId = selNode?.data?.parent;
316
+ if (parentId) {
317
+ pasteTarget = parentId;
318
+ const siblings = query.node(parentId).childNodes();
319
+ const sibIx = siblings.findIndex((sib) => sib === selected.id);
320
+ if (sibIx !== -1) pasteIndex = sibIx + 1;
321
+ }
322
+ }
323
+ }
324
+ } catch (_) {}
325
+ layoutToNodes(layout, query, actions, pasteTarget, options, pasteIndex);
326
+ } catch (e) {
327
+ }
328
+ });
329
+ }
330
+ }
306
331
  if ((tagName === "body" || tagName === "button") &&
307
332
  (event.ctrlKey || event.metaKey) && event.keyCode == 65) {
308
333
  event.preventDefault();
@@ -318,6 +343,7 @@ const SettingsPanel = ({ isEnlarged, setIsEnlarged }) => {
318
343
  window.removeEventListener("keydown", handleUserKeyPress);
319
344
  };
320
345
  }, [handleUserKeyPress]);
346
+
321
347
  const hasChildren =
322
348
  selected && selected.children && selected.children.length > 0;
323
349
 
@@ -726,15 +752,6 @@ const HistoryPanel = () => {
726
752
  return (
727
753
  <div className="d-flex gap-1">
728
754
  <button
729
- className="btn btn-sm btn-secondary redo-builder"
730
- title={t("Redo")}
731
- onClick={() => actions.history.redo()}
732
- disabled={!canRedo}
733
- style={!canRedo ? { opacity: 0.4, pointerEvents: "none" } : {}}
734
- >
735
- <FontAwesomeIcon icon={faRedo} />
736
- </button>
737
- <button
738
755
  className="btn btn-sm btn-secondary undo-builder"
739
756
  title={t("Undo")}
740
757
  onClick={() => actions.history.undo()}
@@ -743,6 +760,15 @@ const HistoryPanel = () => {
743
760
  >
744
761
  <FontAwesomeIcon icon={faUndo} />
745
762
  </button>
763
+ <button
764
+ className="btn btn-sm btn-secondary redo-builder"
765
+ title={t("Redo")}
766
+ onClick={() => actions.history.redo()}
767
+ disabled={!canRedo}
768
+ style={!canRedo ? { opacity: 0.4, pointerEvents: "none" } : {}}
769
+ >
770
+ <FontAwesomeIcon icon={faRedo} />
771
+ </button>
746
772
  </div>
747
773
  );
748
774
  };
@@ -824,6 +850,61 @@ const Builder = ({ options, layout, mode }) => {
824
850
  setBuilderTop(rect.top);
825
851
  });
826
852
 
853
+ // Correct the CraftJS drop indicator position when body CSS zoom is applied.
854
+ // getBoundingClientRect() returns visual (zoomed) coords, but position:fixed
855
+ // top values inside a zoomed body are in layout (pre-zoom) coords, so we
856
+ // divide top/height by zoom. Left/width are already in viewport coords.
857
+ useEffect(() => {
858
+ const getBodyZoom = () => {
859
+ const bw = document.body.offsetWidth;
860
+ if (!bw) return 1;
861
+ return document.body.getBoundingClientRect().width / bw || 1;
862
+ };
863
+
864
+ let isCorrecting = false;
865
+
866
+ const correctIndicator = (el) => {
867
+ if (isCorrecting) return;
868
+ const zoom = getBodyZoom();
869
+ if (Math.abs(zoom - 1) < 0.01) return;
870
+
871
+ isCorrecting = true;
872
+ const top = parseFloat(el.style.top);
873
+ const height = parseFloat(el.style.height);
874
+
875
+ if (!isNaN(top)) el.style.top = `${top / zoom}px`;
876
+ if (!isNaN(height)) el.style.height = `${height / zoom}px`;
877
+
878
+ setTimeout(() => { isCorrecting = false; }, 0);
879
+ };
880
+
881
+ const observer = new MutationObserver((mutations) => {
882
+ for (const mutation of mutations) {
883
+ if (mutation.type === "attributes" && mutation.attributeName === "style") {
884
+ const el = mutation.target;
885
+ if (el.classList && el.classList.contains("builder-drop-indicator")) {
886
+ correctIndicator(el);
887
+ }
888
+ } else if (mutation.type === "childList") {
889
+ mutation.addedNodes.forEach((node) => {
890
+ if (node.nodeType === 1 && node.classList && node.classList.contains("builder-drop-indicator")) {
891
+ correctIndicator(node);
892
+ }
893
+ });
894
+ }
895
+ }
896
+ });
897
+
898
+ observer.observe(document.body, {
899
+ childList: true,
900
+ subtree: true,
901
+ attributes: true,
902
+ attributeFilter: ["style"],
903
+ });
904
+
905
+ return () => observer.disconnect();
906
+ }, []);
907
+
827
908
  const canvasHeight =
828
909
  Math.max(windowHeight - builderTop, builderHeight, 600) - 10;
829
910
 
@@ -99,9 +99,80 @@ const InitNewElement = ({ nodekeys, savingState, setSavingState }) => {
99
99
  });
100
100
  const { t } = useTranslation();
101
101
  const options = useContext(optionsCtx);
102
- const doSave = (query, keepalive) => {
102
+
103
+ const lastReload = useRef(null);
104
+ const rebuildInProgress = useRef(false);
105
+
106
+ const reloadEntityContentFromServer = async () => {
103
107
  if (!query.serialize) return;
108
+ if (lastReload.current && new Date() - lastReload.current < 1000) return;
109
+ lastReload.current = new Date();
110
+
111
+ const urlroot = options.page_id ? "pageedit" : "viewedit";
112
+ const response = await fetch(
113
+ `/${urlroot}/getlayout/${options.page_id || options.view_id}`
114
+ );
115
+ const { layout } = await response.json();
116
+
117
+ if (!layout) return;
118
+
119
+ const data = craftToSaltcorn(
120
+ JSON.parse(query.serialize()),
121
+ "ROOT",
122
+ options
123
+ );
124
+ if (
125
+ isEqual(
126
+ JSON.parse(JSON.stringify(layout)),
127
+ JSON.parse(JSON.stringify(data.layout))
128
+ )
129
+ )
130
+ return;
131
+
132
+ try {
133
+ rebuildInProgress.current = true;
134
+ savedData.current = JSON.stringify(layout);
135
+ actions.selectNode();
136
+ query
137
+ .node("ROOT")
138
+ .childNodes()
139
+ .forEach((child) => {
140
+ actions.delete(child);
141
+ });
142
+ layoutToNodes(layout, query, actions.history.ignore(), "ROOT", options);
143
+ } catch (e) {
144
+ console.error("rebuild error", e);
145
+ } finally {
146
+ rebuildInProgress.current = false;
147
+ }
148
+ };
149
+
150
+ const handleVisibilityChange = () => {
151
+ if (document.hidden === false) reloadEntityContentFromServer();
152
+ };
153
+
154
+ const handlePageShow = (event) => {
155
+ if (event.persisted || window.performance?.navigation.type === 2)
156
+ reloadEntityContentFromServer();
157
+ };
158
+
159
+ useEffect(() => {
160
+ document.addEventListener("visibilitychange", handleVisibilityChange);
161
+ return () => {
162
+ document.removeEventListener("visibilitychange", handleVisibilityChange);
163
+ };
164
+ }, [handleVisibilityChange]);
165
+
166
+ useEffect(() => {
167
+ window.addEventListener("pageshow", handlePageShow);
168
+ return () => {
169
+ document.removeEventListener("pageshow", handlePageShow);
170
+ };
171
+ }, [handlePageShow]);
104
172
 
173
+ const doSave = (query, keepalive) => {
174
+ if (!query.serialize) return;
175
+ if (rebuildInProgress.current) return;
105
176
  const data = craftToSaltcorn(
106
177
  JSON.parse(query.serialize()),
107
178
  "ROOT",
@@ -120,7 +191,7 @@ const InitNewElement = ({ nodekeys, savingState, setSavingState }) => {
120
191
 
121
192
  fetch(`/${urlroot}/savebuilder/${options.page_id || options.view_id}`, {
122
193
  method: "POST", // or 'PUT'
123
- keepalive,//this is conditional bec body size is limited to 64KB
194
+ keepalive, //this is conditional bec body size is limited to 64KB
124
195
  headers: {
125
196
  "Content-Type": "application/json",
126
197
  "CSRF-Token": options.csrfToken,
@@ -217,10 +288,12 @@ export /**
217
288
  * @namespace
218
289
  */
219
290
  const Library = ({ expanded }) => {
220
- const { actions, selected, selectedNodes, query, connectors } = useEditor((state, query) => ({
221
- selected: getSelectedNodes(state.events.selected)[0] || null,
222
- selectedNodes: getSelectedNodes(state.events.selected),
223
- }));
291
+ const { actions, selected, selectedNodes, query, connectors } = useEditor(
292
+ (state, query) => ({
293
+ selected: getSelectedNodes(state.events.selected)[0] || null,
294
+ selectedNodes: getSelectedNodes(state.events.selected),
295
+ })
296
+ );
224
297
  const { t } = useTranslation();
225
298
  const options = useContext(optionsCtx);
226
299
  const [adding, setAdding] = useState(false);
@@ -35,7 +35,7 @@ const RenderNode = ({ render }) => {
35
35
  const { id } = useNode();
36
36
  const options = useContext(optionsCtx);
37
37
  const { actions, query, isActive } = useEditor((state) => ({
38
- isActive: state.nodes[id].events.selected,
38
+ isActive: state.nodes[id]?.events?.selected,
39
39
  }));
40
40
 
41
41
  const {
@@ -58,23 +58,33 @@ const RenderNode = ({ render }) => {
58
58
 
59
59
  const currentRef = useRef();
60
60
 
61
+ const getZoomFactor = useCallback(() => {
62
+ const bw = document.body.offsetWidth;
63
+ if (!bw) return 1;
64
+ const factor = document.body.getBoundingClientRect().width / bw;
65
+ return factor || 1;
66
+ }, []);
67
+
61
68
  const getPos = useCallback((dom) => {
69
+ const zoom = getZoomFactor();
62
70
  const { top, left, bottom, height, width, right } = dom
63
71
  ? dom.getBoundingClientRect()
64
72
  : { top: 0, left: 0, bottom: 0, right: 0, height: 0, width: 0 };
65
- const rightPos = window.innerWidth - right;
73
+ const topAdj = (top > 0 ? top : bottom) / zoom;
74
+ const leftAdj = left / zoom;
75
+ const rightPos = window.innerWidth / zoom - right / zoom;
66
76
  return {
67
- top: `${top > 0 ? top : bottom}px`,
68
- left: `${left}px`,
77
+ top: `${topAdj}px`,
78
+ left: `${leftAdj}px`,
69
79
  right: `${rightPos}px`,
70
- topn: top,
71
- leftn: left,
80
+ topn: topAdj,
81
+ leftn: leftAdj,
72
82
  rightn: rightPos,
73
- height,
74
- width,
75
- bottom,
83
+ height: height / zoom,
84
+ width: width / zoom,
85
+ bottom: bottom / zoom,
76
86
  };
77
- }, []);
87
+ }, [getZoomFactor]);
78
88
 
79
89
  const scroll = useCallback(() => {
80
90
  const { current: currentDOM } = currentRef;
@@ -146,6 +146,7 @@ const ActionSettings = () => {
146
146
  } = node;
147
147
  const options = useContext(optionsCtx);
148
148
  const getCfgFields = (fv) => (options.actionConfigForms || {})[fv];
149
+ const actionDescription = (options.actionDescriptions || {})[name];
149
150
  const cfgFields = getCfgFields(name);
150
151
  const setAProp = setAPropGen(setProp);
151
152
  const use_setting_action_n =
@@ -260,6 +261,13 @@ const ActionSettings = () => {
260
261
  )}
261
262
  </td>
262
263
  </tr>
264
+ {actionDescription ? (
265
+ <tr>
266
+ <td colSpan="2">
267
+ <small className="text-muted">{actionDescription}</small>
268
+ </td>
269
+ </tr>
270
+ ) : null}
263
271
  {name !== "Clear" && options.mode === "filter" ? (
264
272
  <tr>
265
273
  <td>
@@ -165,7 +165,7 @@ const AggregationSettings = () => {
165
165
  <label>
166
166
  {options.mode === "filter"
167
167
  ? t("Field")
168
- : t("Child table field")}
168
+ : t("On field")}
169
169
  </label>
170
170
  </td>
171
171
  <td>
@@ -132,20 +132,20 @@ const Card = ({
132
132
  {bgType === "Image" && bgFileId && imageLocation === "Top" ? (
133
133
  <img src={`/files/serve/${bgFileId}`} className="card-img-top" />
134
134
  ) : null}
135
- {title && title.length > 0 && (
136
- <div className="card-header right-section">
137
- {isFormula?.title ? (
135
+ <div className="card-header right-section">
136
+ {title && title.length > 0 ? (
137
+ isFormula?.title ? (
138
138
  <span className="font-monospace">={title}</span>
139
139
  ) : (
140
140
  title
141
- )}
142
- <div className='title-right'>
141
+ )
142
+ ) : null}
143
+ <div className="title-right">
143
144
  <Element canvas id="titleRight" is={Column}>
144
145
  {titleRight}
145
146
  </Element>
146
147
  </div>
147
- </div>
148
- )}
148
+ </div>
149
149
  <div
150
150
  className={`card-body ${noPadding ? "p-0" : ""}`}
151
151
  style={
@@ -441,9 +441,9 @@ const CardSettings = () => {
441
441
  className="form-control-sm form-select"
442
442
  onChange={setAProp("imageLocation")}
443
443
  >
444
- <option>{t("Card")}</option>
445
- <option>{t("Body")}</option>
446
- <option>{t("Top")}</option>
444
+ <option value="Card">{t("Card")}</option>
445
+ <option value="Body">{t("Body")}</option>
446
+ <option value="Top">{t("Top")}</option>
447
447
  </select>
448
448
  </td>
449
449
  </tr>
@@ -32,7 +32,7 @@ export const recursivelyCloneToElems = (query) => (nodeId, ix) => {
32
32
  const children = (nodes || []).map(recursivelyCloneToElems(query));
33
33
  if (data.displayName === "Columns") {
34
34
  const cols = ntimes(data.props.ncols, (ix) =>
35
- recursivelyCloneToElems(query)(data.linkedNodes["Col" + ix])
35
+ cloneLinkedNodeChildren(query, data.linkedNodes["Col" + ix])
36
36
  );
37
37
  return React.createElement(Columns, {
38
38
  ...newProps,
@@ -42,7 +42,7 @@ export const recursivelyCloneToElems = (query) => (nodeId, ix) => {
42
42
  }
43
43
 
44
44
  if (data.displayName === "ListColumn") {
45
- const col = recursivelyCloneToElems(query)(data.linkedNodes["listcol"]);
45
+ const col = cloneLinkedNodeChildren(query, data.linkedNodes["listcol"]);
46
46
 
47
47
  return React.createElement(ListColumn, {
48
48
  ...newProps,
@@ -1026,7 +1026,7 @@ const ContainerSettings = () => {
1026
1026
  />
1027
1027
  </tbody>
1028
1028
  </table>
1029
- <table className="w-100" accordiontitle={t("Show if...")}>
1029
+ <table className="w-100" accordiontitle={showIfFormula ? t("Show if...") + " *" : t("Show if...")}>
1030
1030
  <tbody>
1031
1031
  {["show", "edit", "filter", "list"].includes(options.mode) && (
1032
1032
  <SettingsSectionHeaderRow title={t("Formula - show if true")} />
@@ -50,7 +50,7 @@ const CustomLayer = ({ children }) => {
50
50
  name = data?.name || name;
51
51
  }
52
52
 
53
- // Rename linked Columns for Tabs and Table
53
+ // Rename linked Columns for Tabs, Table, and Card sections
54
54
  if (name === "Column" && data?.parent) {
55
55
  const parentNode = state.nodes[data.parent];
56
56
  const parentName = parentNode?.data?.displayName || parentNode?.data?.name;
@@ -68,6 +68,10 @@ const CustomLayer = ({ children }) => {
68
68
  if (match) {
69
69
  name = `R${parseInt(match[1], 10) + 1}C${parseInt(match[2], 10) + 1}`;
70
70
  }
71
+ } else if (parentName === "Card") {
72
+ if (key === "titleRight") name = "Title Right";
73
+ else if (key === "cardbody") name = "Body";
74
+ else if (key === "cardfooter") name = "Footer";
71
75
  }
72
76
  }
73
77
  }
@@ -79,14 +83,15 @@ const CustomLayer = ({ children }) => {
79
83
  (nodes && nodes.length > 0) ||
80
84
  (linkedNodes && Object.keys(linkedNodes).length > 0);
81
85
 
82
- // Hide the ROOT Column and linked Columns of Card/Container
86
+ // Hide the ROOT Column and Container's single linked canvas (no ambiguity there).
87
+ // Card's linked canvases are NOT hidden — they show as "Title Right", "Body", "Footer".
83
88
  let shouldHide = false;
84
89
  if (id === "ROOT") {
85
90
  shouldHide = true;
86
91
  } else if ((data?.displayName === "Column" || data?.name === "Column") && data?.parent) {
87
92
  const parentNode = state.nodes[data.parent];
88
93
  const parentName = parentNode?.data?.displayName || parentNode?.data?.name;
89
- if (parentName === "Card" || parentName === "Container") {
94
+ if (parentName === "Container") {
90
95
  const parentLinked = parentNode?.data?.linkedNodes;
91
96
  if (parentLinked && Object.values(parentLinked).includes(id)) {
92
97
  shouldHide = true;
@@ -171,14 +171,14 @@ const LinkSettings = () => {
171
171
  });
172
172
  }}
173
173
  >
174
- <option>{t("URL")}</option>
175
- {(options.pages || []).length > 0 && <option>{t("Page")}</option>}
174
+ <option value="URL">{t("URL")}</option>
175
+ {(options.pages || []).length > 0 && <option value="Page">{t("Page")}</option>}
176
176
  {(options.views || []).length > 0 &&
177
177
  ["page", "filter"].includes(options.mode) && (
178
- <option>{t("View")}</option>
178
+ <option value="View">{t("View")}</option>
179
179
  )}
180
180
  {(options.page_groups || []).length > 0 && (
181
- <option>{t("Page Group")}</option>
181
+ <option value="Page Group">{t("Page Group")}</option>
182
182
  )}
183
183
  </select>
184
184
  </td>
@@ -48,10 +48,8 @@ const ListColumn = ({
48
48
  const { actions, query, isActve } = useEditor((state) => ({}));
49
49
  const options = useContext(optionsCtx);
50
50
 
51
- const {
52
- data: { parent },
53
- } = query.node(id).get();
54
- const siblings = query.node(parent).childNodes();
51
+ const parent = query.node(id).get()?.data?.parent;
52
+ const siblings = parent ? query.node(parent).childNodes() : [];
55
53
  const nChildren = siblings.length;
56
54
  const childIx = siblings.findIndex((sib) => sib === id);
57
55