@saltcorn/builder 1.7.0-alpha.1 → 1.7.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saltcorn/builder",
3
- "version": "1.7.0-alpha.1",
3
+ "version": "1.7.0-beta.0",
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",
@@ -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.7.0-alpha.1",
33
+ "@saltcorn/common-code": "1.7.0-beta.0",
34
34
  "@tippyjs/react": "4.2.6",
35
35
  "babel-loader": "9.2.1",
36
36
  "ckeditor4-react": "2.0.0",
@@ -40,6 +40,7 @@
40
40
  "jsdom": "26.0.0",
41
41
  "lodash": "4.17.21",
42
42
  "monaco-editor": "^0.52.2",
43
+ "process": "0.11.10",
43
44
  "prop-types": "15.8.1",
44
45
  "react": "^18.2.0",
45
46
  "react-bootstrap-icons": "1.10.3",
@@ -42,13 +42,16 @@ import {
42
42
  ToolboxPage,
43
43
  ToolboxFilter,
44
44
  ToolboxList,
45
+ ToolboxRoom,
45
46
  } from "./Toolbox";
46
- import { craftToSaltcorn, layoutToNodes } from "./storage";
47
+ import { craftToSaltcorn, layoutToNodes, resolveLibraryRefs } from "./storage";
47
48
  import { Card } from "./elements/Card";
48
49
  import { Link } from "./elements/Link";
49
50
  import { View } from "./elements/View";
50
51
  import { Container } from "./elements/Container";
51
52
  import { Column } from "./elements/Column";
53
+ import { LibraryInstance } from "./elements/LibraryInstance";
54
+ import { LibrarySlotInstance } from "./elements/LibrarySlotInstance";
52
55
  import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
53
56
  import {
54
57
  faCopy,
@@ -65,12 +68,14 @@ import {
65
68
  } from "@fortawesome/free-regular-svg-icons";
66
69
  import { Accordion, ErrorBoundary } from "./elements/utils";
67
70
  import { Display, Tablet, Phone } from "react-bootstrap-icons";
68
- import { InitNewElement, Library, LibraryElem } from "./Library";
71
+ import { InitNewElement, Library, LibraryElem, LibrarySlotElem, hydratingRef } from "./Library";
69
72
  import { RenderNode } from "./RenderNode";
70
73
  import { ListColumn } from "./elements/ListColumn";
71
74
  import { ListColumns } from "./elements/ListColumns";
72
75
  import { Prompt } from "./elements/Prompt";
73
76
  import { recursivelyCloneToElems } from "./elements/Clone";
77
+ import { MessageList } from "./elements/MessageList";
78
+ import { MessageForm } from "./elements/MessageForm";
74
79
  import { Page } from "./elements/Page";
75
80
  import CustomLayer from "./elements/CustomLayer";
76
81
 
@@ -786,18 +791,43 @@ const NextButton = ({ layout }) => {
786
791
  const options = useContext(optionsCtx);
787
792
 
788
793
  useEffect(() => {
789
- layoutToNodes(layout, query, actions.history.ignore(), "ROOT", options);
794
+ (async () => {
795
+ hydratingRef.current = true;
796
+ try {
797
+ await resolveLibraryRefs(layout, options);
798
+ layoutToNodes(layout, query, actions.history.ignore(), "ROOT", options);
799
+ } finally {
800
+ hydratingRef.current = false;
801
+ }
802
+ })();
790
803
  }, []);
791
804
 
792
805
  /**
793
806
  * @returns {void}
794
807
  */
795
808
  const onClick = () => {
796
- const { columns, layout } = craftToSaltcorn(
809
+ const { columns, layout, libraryUpdates } = craftToSaltcorn(
797
810
  JSON.parse(query.serialize()),
798
811
  "ROOT",
799
812
  options
800
813
  );
814
+ if (libraryUpdates?.length) {
815
+ // fire-and-forget with keepalive - the form submits right after
816
+ fetch(`/library/save-updates`, {
817
+ method: "POST",
818
+ keepalive: true,
819
+ headers: {
820
+ "Content-Type": "application/json",
821
+ "CSRF-Token": options.csrfToken,
822
+ },
823
+ body: JSON.stringify({ libraryUpdates }),
824
+ }).catch(() => {
825
+ window.notifyAlert({
826
+ type: "danger",
827
+ text: "Unable to save shared component changes",
828
+ });
829
+ });
830
+ }
801
831
  document
802
832
  .querySelector("form#scbuildform input[name=columns]")
803
833
  .setAttribute("value", encodeURIComponent(JSON.stringify(columns)));
@@ -949,8 +979,13 @@ const Builder = ({ options, layout, mode }) => {
949
979
  ListColumn,
950
980
  ListColumns,
951
981
  LibraryElem,
982
+ LibraryInstance,
983
+ LibrarySlotElem,
984
+ LibrarySlotInstance,
952
985
  Prompt,
953
- Page
986
+ Page,
987
+ MessageList,
988
+ MessageForm
954
989
  }}
955
990
  >
956
991
  <Provider value={options}>
@@ -991,6 +1026,7 @@ const Builder = ({ options, layout, mode }) => {
991
1026
  edit: <ToolboxEdit expanded={isLeftEnlarged} />,
992
1027
  page: <ToolboxPage expanded={isLeftEnlarged} />,
993
1028
  filter: <ToolboxFilter expanded={isLeftEnlarged} />,
1029
+ room: <ToolboxRoom expanded={isLeftEnlarged} />,
994
1030
  }[mode] || <div>{t("Missing mode")}</div>}
995
1031
  </div>
996
1032
  <div accordiontitle={t("Library")}>
@@ -17,10 +17,18 @@ import { useEditor, useNode } from "@craftjs/core";
17
17
  import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
18
18
  import { faPlus, faTimes } from "@fortawesome/free-solid-svg-icons";
19
19
  import FontIconPicker from "@fonticonpicker/react-fonticonpicker";
20
- import { craftToSaltcorn, layoutToNodes } from "./storage";
20
+ import { craftToSaltcorn, layoutToNodes, resolveLibraryRefs } from "./storage";
21
21
  import optionsCtx from "./context";
22
22
  import { WrapElem } from "./Toolbox";
23
23
  import { isEqual, throttle, chunk } from "lodash";
24
+ import { LibraryInstance } from "./elements/LibraryInstance";
25
+ import { LibrarySlotInstance } from "./elements/LibrarySlotInstance";
26
+ import { ErrorBoundary } from "./elements/utils";
27
+ import { rand_ident } from "./elements/utils";
28
+
29
+ // true while loading a saved layout into the canvas, so nothing gets
30
+ // accidentally auto-selected along the way
31
+ export const hydratingRef = { current: false };
24
32
 
25
33
  const getSelectedNodes = (selected) => {
26
34
  if (!selected) return [];
@@ -64,6 +72,39 @@ LibraryElem.craft = {
64
72
  displayName: "LibraryElem",
65
73
  };
66
74
 
75
+ export /**
76
+ * A draggable placeholder for a new slot inside a shared component -
77
+ * dropped in while building a component, not while placing one.
78
+ * @returns {Fraggment}
79
+ * @category saltcorn-builder
80
+ * @subcategory components
81
+ * @namespace
82
+ */
83
+ const LibrarySlotElem = () => {
84
+ const {
85
+ selected,
86
+ connectors: { connect, drag },
87
+ } = useNode((node) => ({ selected: node.events.selected }));
88
+ return (
89
+ <Fragment>
90
+ <span
91
+ className={selected ? "selected-node" : ""}
92
+ ref={(dom) => connect(drag(dom))}
93
+ >
94
+ Slot
95
+ </span>
96
+ <br />
97
+ </Fragment>
98
+ );
99
+ };
100
+
101
+ /**
102
+ * @type {object}
103
+ */
104
+ LibrarySlotElem.craft = {
105
+ displayName: "LibrarySlotElem",
106
+ };
107
+
67
108
  // https://www.developerway.com/posts/debouncing-in-react
68
109
  const useThrottle = (callback) => {
69
110
  const ref = useRef();
@@ -84,6 +125,8 @@ const useThrottle = (callback) => {
84
125
  };
85
126
 
86
127
  export /**
128
+ * Renders nothing - just wires up autosave and turns a freshly dropped
129
+ * library item into a real linked instance.
87
130
  * @param {object} props
88
131
  * @param {object} props.nodekeys
89
132
  * @returns {object[]}
@@ -92,7 +135,6 @@ export /**
92
135
  * @namespace
93
136
  */
94
137
  const InitNewElement = ({ nodekeys, savingState, setSavingState }) => {
95
- const [saveTimeout, setSaveTimeout] = useState(false);
96
138
  const savedData = useRef(false);
97
139
  const { actions, query, connectors } = useEditor((state, query) => {
98
140
  return {};
@@ -131,6 +173,7 @@ const InitNewElement = ({ nodekeys, savingState, setSavingState }) => {
131
173
 
132
174
  try {
133
175
  rebuildInProgress.current = true;
176
+ hydratingRef.current = true;
134
177
  savedData.current = JSON.stringify(layout);
135
178
  actions.selectNode();
136
179
  query
@@ -139,11 +182,13 @@ const InitNewElement = ({ nodekeys, savingState, setSavingState }) => {
139
182
  .forEach((child) => {
140
183
  actions.delete(child);
141
184
  });
185
+ await resolveLibraryRefs(layout, options);
142
186
  layoutToNodes(layout, query, actions.history.ignore(), "ROOT", options);
143
187
  } catch (e) {
144
188
  console.error("rebuild error", e);
145
189
  } finally {
146
190
  rebuildInProgress.current = false;
191
+ hydratingRef.current = false;
147
192
  }
148
193
  };
149
194
 
@@ -170,6 +215,14 @@ const InitNewElement = ({ nodekeys, savingState, setSavingState }) => {
170
215
  };
171
216
  }, [handlePageShow]);
172
217
 
218
+ // is id (or a child of it) selected? checked via Craft.js, since editing
219
+ // happens in the settings panel, not the canvas DOM
220
+ const isNodeSelected = (id) => {
221
+ const selectedIds = query.getEvent("selected").all();
222
+ const within = query.node(id).descendants(true);
223
+ return selectedIds.some((sid) => within.includes(sid));
224
+ };
225
+
173
226
  const doSave = (query, keepalive) => {
174
227
  if (!query.serialize) return;
175
228
  if (rebuildInProgress.current) return;
@@ -179,16 +232,32 @@ const InitNewElement = ({ nodekeys, savingState, setSavingState }) => {
179
232
  options
180
233
  );
181
234
  const urlroot = options.page_id ? "pageedit" : "viewedit";
235
+ // also compare libraryUpdates - editing a shared component doesn't
236
+ // change this page's own layout, but still needs saving
237
+ const comparable = JSON.stringify({
238
+ layout: data.layout,
239
+ libraryUpdates: data.libraryUpdates,
240
+ });
182
241
  if (savedData.current === false) {
183
242
  //do not save on first call
184
- savedData.current = JSON.stringify(data.layout);
243
+ savedData.current = comparable;
185
244
 
186
245
  return;
187
246
  }
188
- if (isEqual(savedData.current, JSON.stringify(data.layout))) return;
189
- savedData.current = JSON.stringify(data.layout);
247
+ if (isEqual(savedData.current, comparable)) return;
248
+ savedData.current = comparable;
190
249
  setSavingState({ isSaving: true });
191
250
 
251
+ // dedupe by library_id, preferring the selected placement, so an
252
+ // untouched duplicate can't overwrite the one being edited
253
+ const byLibraryId = {};
254
+ (data.libraryUpdates || []).forEach((u) => {
255
+ const existing = byLibraryId[u.library_id];
256
+ if (!existing || (u.node_id && isNodeSelected(u.node_id)))
257
+ byLibraryId[u.library_id] = u;
258
+ });
259
+ const libraryUpdates = Object.values(byLibraryId);
260
+
192
261
  fetch(`/${urlroot}/savebuilder/${options.page_id || options.view_id}`, {
193
262
  method: "POST", // or 'PUT'
194
263
  keepalive, //this is conditional bec body size is limited to 64KB
@@ -196,16 +265,22 @@ const InitNewElement = ({ nodekeys, savingState, setSavingState }) => {
196
265
  "Content-Type": "application/json",
197
266
  "CSRF-Token": options.csrfToken,
198
267
  },
199
- body: JSON.stringify(data),
268
+ body: JSON.stringify({
269
+ layout: data.layout,
270
+ columns: data.columns,
271
+ libraryUpdates,
272
+ }),
200
273
  })
201
274
  .then((response) => {
202
- response.json().then((data) => {
203
- if (typeof data?.error === "string") {
275
+ response.json().then((respData) => {
276
+ if (typeof respData?.error === "string") {
204
277
  // don't log duplicates
205
278
  if (!savingState.error)
206
- window.notifyAlert({ type: "danger", text: data.error });
207
- setSavingState({ isSaving: false, error: data.error });
208
- } else setSavingState({ isSaving: false });
279
+ window.notifyAlert({ type: "danger", text: respData.error });
280
+ setSavingState({ isSaving: false, error: respData.error });
281
+ } else {
282
+ setSavingState({ isSaving: false });
283
+ }
209
284
  });
210
285
  })
211
286
  .catch((e) => {
@@ -245,18 +320,72 @@ const InitNewElement = ({ nodekeys, savingState, setSavingState }) => {
245
320
  const id = newNodeIds[0];
246
321
  const node = nodes[id];
247
322
  if (node.displayName === "LibraryElem") {
248
- const layout = node.props.layout;
249
- layoutToNodes(
250
- layout.layout ? layout.layout : layout,
251
- query,
252
- actions,
253
- node.parent,
254
- options
255
- );
256
- setTimeout(() => {
323
+ const parent = node.parent;
324
+ // fetch fresh rather than trust the sidebar's page-load snapshot -
325
+ // the shared component may have been edited since this page loaded
326
+ fetch(`/library/content/${node.props.id}`, {
327
+ headers: { "CSRF-Token": options.csrfToken },
328
+ })
329
+ .then((r) => r.json())
330
+ .then(async (lib) => {
331
+ if (lib.error) {
332
+ window.notifyAlert({ type: "danger", text: lib.error });
333
+ actions.delete(id);
334
+ return;
335
+ }
336
+ const libLayout = lib.layout?.layout ? lib.layout.layout : lib.layout || {};
337
+ // resolve any library references nested inside this one too,
338
+ // so dropping a library-in-a-library doesn't leave a gap
339
+ await resolveLibraryRefs(libLayout, options, new Set([lib.id]));
340
+ const wrapperNode = query
341
+ .parseReactElement(
342
+ <LibraryInstance
343
+ library_id={lib.id}
344
+ library_name={lib.name}
345
+ />
346
+ )
347
+ .toNodeTree();
348
+ actions.addNodeTree(wrapperNode, parent);
349
+ layoutToNodes(
350
+ libLayout,
351
+ query,
352
+ actions,
353
+ wrapperNode.rootNodeId,
354
+ options
355
+ );
356
+ actions.delete(id);
357
+ });
358
+ } else if (node.displayName === "LibrarySlotElem") {
359
+ const parent = node.parent;
360
+ // a slot only means anything inside a shared component - reject the
361
+ // drop anywhere else instead of leaving an orphaned, unresolvable slot
362
+ const insideLibrary =
363
+ nodes[parent]?.displayName === LibraryInstance.craft.displayName ||
364
+ query
365
+ .node(parent)
366
+ .ancestors(true)
367
+ .some(
368
+ (aid) =>
369
+ nodes[aid]?.displayName === LibraryInstance.craft.displayName
370
+ );
371
+ if (!insideLibrary) {
372
+ window.notifyAlert({
373
+ type: "danger",
374
+ text: t(
375
+ "A slot can only be placed inside a shared component - drop it there instead"
376
+ ),
377
+ });
257
378
  actions.delete(id);
258
- }, 0);
259
- } else if (node.displayName !== "Column") {
379
+ return;
380
+ }
381
+ const wrapperNode = query
382
+ .parseReactElement(
383
+ <LibrarySlotInstance kind="field" slot_name={rand_ident()} />
384
+ )
385
+ .toNodeTree();
386
+ actions.addNodeTree(wrapperNode, parent);
387
+ actions.delete(id);
388
+ } else if (node.displayName !== "Column" && !hydratingRef.current) {
260
389
  actions.selectNode(id);
261
390
  }
262
391
  }
@@ -267,14 +396,14 @@ const InitNewElement = ({ nodekeys, savingState, setSavingState }) => {
267
396
  const nodes = query.getSerializedNodes();
268
397
  nodekeys.current = Object.keys(nodes);
269
398
  actions.setOptions((options) => {
270
- const oldf = options.onNodesChange(
271
- (options.onNodesChange = oldf
272
- ? (q) => {
273
- oldf(q);
274
- onNodesChange(q);
275
- }
276
- : onNodesChange)
277
- );
399
+ // chain onto any existing handler instead of replacing it
400
+ const oldf = options.onNodesChange;
401
+ options.onNodesChange = oldf
402
+ ? (q) => {
403
+ oldf(q);
404
+ onNodesChange(q);
405
+ }
406
+ : onNodesChange;
278
407
  });
279
408
  }, []);
280
409
 
@@ -288,12 +417,16 @@ export /**
288
417
  * @namespace
289
418
  */
290
419
  const Library = ({ expanded }) => {
291
- const { actions, selected, selectedNodes, query, connectors } = useEditor(
292
- (state, query) => ({
420
+ const { actions, selected, selectedNodes, query, connectors, hasLibraryInstance } =
421
+ useEditor((state, query) => ({
293
422
  selected: getSelectedNodes(state.events.selected)[0] || null,
294
423
  selectedNodes: getSelectedNodes(state.events.selected),
295
- })
296
- );
424
+ // a slot only means anything inside a shared component - nothing to
425
+ // drop it into until at least one is placed
426
+ hasLibraryInstance: Object.values(state.nodes).some(
427
+ (n) => n.data.displayName === LibraryInstance.craft.displayName
428
+ ),
429
+ }));
297
430
  const { t } = useTranslation();
298
431
  const options = useContext(optionsCtx);
299
432
  const [adding, setAdding] = useState(false);
@@ -320,65 +453,122 @@ const Library = ({ expanded }) => {
320
453
  "CSRF-Token": options.csrfToken,
321
454
  },
322
455
  body: JSON.stringify(data),
323
- });
456
+ })
457
+ .then((r) => r.json())
458
+ .then(({ id }) => {
459
+ // needed so this item can be linked (fetched by id) without a
460
+ // page reload, same as items already loaded from the server
461
+ setRecent((r) => [...r, { ...data, id }]);
462
+ // replace what was just saved with a linked instance of it, so the
463
+ // spot it was saved from also stays in sync with the library from now on
464
+ const savedNode = query.node(nodeToSave).get();
465
+ const parentId = savedNode.data.parent;
466
+ const siblings = query.node(parentId).get().data.nodes;
467
+ const ix = siblings.indexOf(nodeToSave);
468
+ const wrapperNode = query
469
+ .parseReactElement(
470
+ <LibraryInstance library_id={id} library_name={newName} />
471
+ )
472
+ .toNodeTree();
473
+ actions.addNodeTree(wrapperNode, parentId, ix);
474
+ layoutToNodes(
475
+ layout.layout,
476
+ query,
477
+ actions,
478
+ wrapperNode.rootNodeId,
479
+ options
480
+ );
481
+ actions.delete(nodeToSave);
482
+ });
324
483
  setAdding(false);
325
484
  setIcon();
326
485
  setNewName("");
327
- setRecent([...recent, data]);
328
486
  };
329
487
 
330
488
  const elemRows = chunk(
331
489
  [...(options.library || []), ...recent],
332
490
  expanded ? 3 : 2
333
491
  );
492
+ const canAdd = !!selected || selectedNodes.length > 0;
334
493
  return (
335
494
  <div className="builder-library">
336
- <div className="dropdown">
337
- <button
338
- className="btn btn-sm btn-secondary dropdown-toggle mt-2"
339
- type="button"
340
- id="dropdownMenuButton"
341
- aria-haspopup="true"
342
- aria-expanded="false"
343
- disabled={!selected && selectedNodes.length === 0}
344
- onClick={() => setAdding(!adding)}
345
- >
346
- <FontAwesomeIcon icon={faPlus} className="me-1" />
347
- {t("Add")}
348
- </button>
495
+ <div className="toolbar-row mt-2">
349
496
  <div
350
- className={`dropdown-menu py-3 px-4 ${adding ? "show" : ""}`}
351
- aria-labelledby="dropdownMenuButton"
497
+ className={`dropdown d-inline-flex wrap-builder-elem align-items-center justify-content-center ${
498
+ canAdd ? "" : "text-muted"
499
+ }`}
352
500
  >
353
- <label>{t("Name")}</label>
354
- <input
355
- type="text"
356
- className="form-control"
357
- value={newName}
358
- onChange={(e) => e?.target && setNewName(e.target.value)}
359
- />
360
- <br />
361
- <label>{t("Icon")}</label>
362
- <FontIconPicker
363
- className="w-100"
364
- value={icon}
365
- icons={options.icons}
366
- onChange={setIcon}
367
- isMulti={false}
368
- />
369
- <button className={`btn btn-primary mt-3`} onClick={addSelected}>
370
- <FontAwesomeIcon icon={faPlus} className="me-1" />
371
- {t("Add")}
372
- </button>
373
- <button
374
- className={`btn btn-outline-secondary ms-2 mt-3`}
375
- onClick={() => setAdding(false)}
501
+ {/* the clickable square and the menu are siblings, not
502
+ parent/child - otherwise every click inside the menu bubbles
503
+ up and re-toggles this onClick, closing the menu */}
504
+ <div
505
+ className="d-inline-flex align-items-center justify-content-center w-100 h-100"
506
+ id="library-add-btn"
507
+ role="button"
508
+ aria-haspopup="true"
509
+ aria-expanded={adding}
510
+ onClick={() => canAdd && setAdding(!adding)}
511
+ >
512
+ <div className="inner">
513
+ <FontAwesomeIcon icon={faPlus} className="fa-lg" />
514
+ </div>
515
+ <label>{t("Add")}</label>
516
+ </div>
517
+ <div
518
+ className={`dropdown-menu py-3 px-4 ${adding ? "show" : ""}`}
519
+ aria-labelledby="library-add-btn"
376
520
  >
377
- <FontAwesomeIcon icon={faTimes} />
378
- </button>
521
+ <label>{t("Name")}</label>
522
+ <input
523
+ type="text"
524
+ className="form-control"
525
+ value={newName}
526
+ onChange={(e) => e?.target && setNewName(e.target.value)}
527
+ />
528
+ <br />
529
+ <label>{t("Icon")}</label>
530
+ {/* this picker has a stray internal callback that can throw
531
+ after it closes, unrelated to anything the user did - catch it
532
+ here so it doesn't take down the whole builder */}
533
+ <ErrorBoundary>
534
+ <FontIconPicker
535
+ className="w-100"
536
+ value={icon}
537
+ icons={options.icons}
538
+ onChange={setIcon}
539
+ isMulti={false}
540
+ />
541
+ </ErrorBoundary>
542
+ <button className={`btn btn-primary mt-3`} onClick={addSelected}>
543
+ <FontAwesomeIcon icon={faPlus} className="me-1" />
544
+ {t("Add")}
545
+ </button>
546
+ <button
547
+ className={`btn btn-outline-secondary ms-2 mt-3`}
548
+ onClick={() => setAdding(false)}
549
+ >
550
+ <FontAwesomeIcon icon={faTimes} />
551
+ </button>
552
+ </div>
379
553
  </div>
554
+ <WrapElem
555
+ connectors={connectors}
556
+ icon="fas fa-square"
557
+ label={t("Slot")}
558
+ disable={!hasLibraryInstance}
559
+ title={
560
+ hasLibraryInstance
561
+ ? undefined
562
+ : t("Place a shared component first - a slot goes inside one")
563
+ }
564
+ >
565
+ <LibrarySlotElem />
566
+ </WrapElem>
567
+ </div>
568
+ <div className="text-muted small border-top mt-2 pt-1">
569
+ {t("Saved components")}
380
570
  </div>
381
- <div className="card mt-2">
571
+ <div className="card">
382
572
  {elemRows.map((els, ix) => (
383
573
  <div className="toolbar-row" key={ix}>
384
574
  {els.map((l, ix) => (
@@ -388,7 +578,11 @@ const Library = ({ expanded }) => {
388
578
  icon={l.icon}
389
579
  label={l.name}
390
580
  >
391
- <LibraryElem name={l.name} layout={l.layout}></LibraryElem>
581
+ <LibraryElem
582
+ id={l.id}
583
+ name={l.name}
584
+ layout={l.layout}
585
+ ></LibraryElem>
392
586
  </WrapElem>
393
587
  ))}
394
588
  </div>