@saltcorn/builder 1.7.0-alpha.0 → 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/dist/builder_bundle.js +86 -4207
- package/package.json +4 -3
- package/src/components/Builder.js +41 -5
- package/src/components/Library.js +272 -78
- package/src/components/RenderNode.js +53 -13
- package/src/components/Toolbox.js +74 -0
- package/src/components/elements/LibraryInstance.js +83 -0
- package/src/components/elements/LibrarySlotInstance.js +214 -0
- package/src/components/elements/MessageForm.js +101 -0
- package/src/components/elements/MessageList.js +167 -0
- package/src/components/elements/MonacoEditor.js +39 -27
- package/src/components/storage.js +192 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@saltcorn/builder",
|
|
3
|
-
"version": "1.7.0-
|
|
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-
|
|
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",
|
|
@@ -48,7 +49,7 @@
|
|
|
48
49
|
"react-select": "5.8.0",
|
|
49
50
|
"react-test-renderer": "18.2.0",
|
|
50
51
|
"react-transition-group": "4.4.5",
|
|
51
|
-
"webpack": "
|
|
52
|
+
"webpack": "5.97.1",
|
|
52
53
|
"webpack-cli": "6.0.1"
|
|
53
54
|
},
|
|
54
55
|
"publishConfig": {
|
|
@@ -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
|
-
|
|
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 =
|
|
243
|
+
savedData.current = comparable;
|
|
185
244
|
|
|
186
245
|
return;
|
|
187
246
|
}
|
|
188
|
-
if (isEqual(savedData.current,
|
|
189
|
-
savedData.current =
|
|
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(
|
|
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((
|
|
203
|
-
if (typeof
|
|
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:
|
|
207
|
-
setSavingState({ isSaving: false, error:
|
|
208
|
-
} else
|
|
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
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
|
|
259
|
-
|
|
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
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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 } =
|
|
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="
|
|
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-
|
|
351
|
-
|
|
497
|
+
className={`dropdown d-inline-flex wrap-builder-elem align-items-center justify-content-center ${
|
|
498
|
+
canAdd ? "" : "text-muted"
|
|
499
|
+
}`}
|
|
352
500
|
>
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
<
|
|
370
|
-
|
|
371
|
-
|
|
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
|
-
<
|
|
378
|
-
|
|
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
|
|
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
|
|
581
|
+
<LibraryElem
|
|
582
|
+
id={l.id}
|
|
583
|
+
name={l.name}
|
|
584
|
+
layout={l.layout}
|
|
585
|
+
></LibraryElem>
|
|
392
586
|
</WrapElem>
|
|
393
587
|
))}
|
|
394
588
|
</div>
|