@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.
@@ -0,0 +1,167 @@
1
+ /**
2
+ * @category saltcorn-builder
3
+ * @module components/elements/MessageList
4
+ * @subcategory components / elements
5
+ */
6
+
7
+ import React, { useContext } from "react";
8
+ import useTranslation from "../../hooks/useTranslation";
9
+ import optionsCtx from "../context";
10
+ import { useNode } from "@craftjs/core";
11
+ import { setAPropGen, Accordion, SettingsRow } from "./utils";
12
+
13
+ export /**
14
+ * The list of messages in a Room view. The messages themselves are rendered by
15
+ * the room's message show view, so this stands in for them on the canvas.
16
+ *
17
+ * @param {object} props
18
+ * @param {string} props.height
19
+ * @returns {div}
20
+ * @namespace
21
+ * @category saltcorn-builder
22
+ * @subcategory components
23
+ */
24
+ const MessageList = ({ height }) => {
25
+ const { t } = useTranslation();
26
+ const {
27
+ selected,
28
+ connectors: { connect, drag },
29
+ } = useNode((node) => ({ selected: node.events.selected }));
30
+
31
+ return (
32
+ <div
33
+ className={`sc-room-msglist-preview ${selected ? "selected-node" : ""}`}
34
+ ref={(dom) => connect(drag(dom))}
35
+ style={{
36
+ border: "1px dashed #adb5bd",
37
+ borderRadius: "0.25rem",
38
+ padding: "0.5rem",
39
+ minHeight: "5rem",
40
+ maxHeight: height || undefined,
41
+ overflowY: height ? "auto" : undefined,
42
+ }}
43
+ >
44
+ <div className="text-muted small mb-1">
45
+ {t("Messages")}
46
+ {height ? ` (${t("scrolls")}: ${height})` : ""}
47
+ </div>
48
+ <div className="mb-1">{t("An earlier message")}</div>
49
+ <div className="mb-1">{t("A reply to it")}</div>
50
+ <div>{t("The most recent message")}</div>
51
+ </div>
52
+ );
53
+ };
54
+
55
+ export /**
56
+ * @returns {div}
57
+ * @namespace
58
+ * @category saltcorn-builder
59
+ * @subcategory components
60
+ */
61
+ const MessageListSettings = () => {
62
+ const { t } = useTranslation();
63
+ const options = useContext(optionsCtx);
64
+ const node = useNode((node) => ({
65
+ height: node.data.props.height,
66
+ currentSettingsTab: node.data.props.currentSettingsTab,
67
+ animateName: node.data.props.animateName,
68
+ animateDelay: node.data.props.animateDelay,
69
+ animateDuration: node.data.props.animateDuration,
70
+ animateInitialHide: node.data.props.animateInitialHide,
71
+ }));
72
+ const {
73
+ actions: { setProp },
74
+ height,
75
+ currentSettingsTab,
76
+ } = node;
77
+ const setAProp = setAPropGen(setProp);
78
+
79
+ return (
80
+ <Accordion
81
+ value={currentSettingsTab}
82
+ onChange={(ix) => setProp((prop) => (prop.currentSettingsTab = ix))}
83
+ >
84
+ <div accordiontitle={t("Messages")} className="w-100">
85
+ <label>{t("Height")}</label>
86
+ <input
87
+ type="text"
88
+ className="form-control"
89
+ value={height || ""}
90
+ spellCheck={false}
91
+ onChange={setAProp("height")}
92
+ />
93
+ <small className="text-muted">
94
+ {t(
95
+ "A CSS length, for example 400px or 60vh. The list scrolls within this height. Leave blank to grow with the page"
96
+ )}
97
+ </small>
98
+ </div>
99
+ <table className="w-100" accordiontitle={t("Animate")}>
100
+ <tbody>
101
+ <SettingsRow
102
+ field={{
103
+ name: "animateName",
104
+ label: t("Animation"),
105
+ type: "select",
106
+ options: ["None", ...(options.keyframes || [])],
107
+ }}
108
+ node={node}
109
+ setProp={setProp}
110
+ />
111
+ <SettingsRow
112
+ field={{
113
+ name: "animateDuration",
114
+ label: t("Duration (s)"),
115
+ type: "Float",
116
+ }}
117
+ node={node}
118
+ setProp={setProp}
119
+ />
120
+ <SettingsRow
121
+ field={{
122
+ name: "animateDelay",
123
+ label: t("Delay (s)"),
124
+ type: "Float",
125
+ }}
126
+ node={node}
127
+ setProp={setProp}
128
+ />
129
+ <SettingsRow
130
+ field={{
131
+ name: "animateInitialHide",
132
+ label: t("Initially hidden"),
133
+ type: "Bool",
134
+ }}
135
+ node={node}
136
+ setProp={setProp}
137
+ />
138
+ </tbody>
139
+ </table>
140
+ </Accordion>
141
+ );
142
+ };
143
+
144
+ /**
145
+ * @type {object}
146
+ */
147
+ MessageList.craft = {
148
+ displayName: "MessageList",
149
+ props: {
150
+ height: "",
151
+ animateName: "None",
152
+ animateDuration: "",
153
+ animateDelay: "",
154
+ animateInitialHide: false,
155
+ },
156
+ related: {
157
+ settings: MessageListSettings,
158
+ segment_type: "message_list",
159
+ fields: [
160
+ { name: "height" },
161
+ { name: "animateName" },
162
+ { name: "animateDuration" },
163
+ { name: "animateDelay" },
164
+ { name: "animateInitialHide" },
165
+ ],
166
+ },
167
+ };
@@ -138,42 +138,49 @@ export const SingleLineEditor = React.forwardRef(
138
138
  : value;
139
139
 
140
140
  return (
141
- <div ref={ref} className="form-control p-0 pt-1" style={{ position: "relative" }}>
141
+ <div ref={ref} className="form-control monaco-input monaco-input-singleline">
142
142
  {isEmpty && !activePrefix && placeholder && (
143
143
  <div style={{
144
144
  position: "absolute",
145
- top: "3px",
146
- left: "14px",
145
+ top: "0.375rem",
146
+ left: "10px",
147
+ right: "10px",
148
+ bottom: "0.375rem",
149
+ display: "flex",
150
+ alignItems: "center",
147
151
  color: "#999",
148
152
  pointerEvents: "none",
149
153
  zIndex: 1,
150
154
  fontSize: "14px",
151
155
  whiteSpace: "nowrap",
156
+ overflow: "hidden",
152
157
  }}>
153
158
  {placeholder}
154
159
  </div>
155
160
  )}
156
- <Editor
157
- className={className || ""}
158
- height="22px"
159
- value={editorValue}
160
- onChange={(fullValue) => {
161
- const userValue = activePrefix
162
- ? (fullValue || "").substring((fullValue || "").indexOf("\n") + 1)
163
- : fullValue;
164
- onChange && onChange(userValue);
165
- setProp &&
166
- propKey &&
167
- setProp((prop) => (prop[propKey] = userValue));
168
- }}
169
- defaultLanguage="typescript"
170
- //onMount={handleEditorDidMount}
171
- //beforeMount={handleEditorWillMount}
172
- options={singleLineEditorOptions}
173
- //theme="myCoolTheme"
174
- beforeMount={handleEditorWillMount}
175
- onMount={handleEditorDidMount}
176
- />
161
+ <div className="monaco-input-editor">
162
+ <Editor
163
+ className={className || ""}
164
+ height="100%"
165
+ value={editorValue}
166
+ onChange={(fullValue) => {
167
+ const userValue = activePrefix
168
+ ? (fullValue || "").substring((fullValue || "").indexOf("\n") + 1)
169
+ : fullValue;
170
+ onChange && onChange(userValue);
171
+ setProp &&
172
+ propKey &&
173
+ setProp((prop) => (prop[propKey] = userValue));
174
+ }}
175
+ defaultLanguage="typescript"
176
+ //onMount={handleEditorDidMount}
177
+ //beforeMount={handleEditorWillMount}
178
+ options={singleLineEditorOptions}
179
+ //theme="myCoolTheme"
180
+ beforeMount={handleEditorWillMount}
181
+ onMount={handleEditorDidMount}
182
+ />
183
+ </div>
177
184
  </div>
178
185
  );
179
186
  }
@@ -203,16 +210,21 @@ export const MultiLineCodeEditor = ({ setProp, value, onChange, isModalEditor =
203
210
  const resolvedPlaceholder = placeholder || "// enter code here";
204
211
 
205
212
  return (
206
- <div className="form-control p-0 pt-2" style={{ position: "relative" }}>
213
+ <div className="form-control monaco-input">
207
214
  {isEmpty && !isModalEditor && (
208
215
  <div style={{
209
216
  position: "absolute",
210
- top: "10px",
211
- left: "14px",
217
+ top: "0.375rem",
218
+ left: "10px",
219
+ right: "10px",
220
+ height: "21px",
221
+ display: "flex",
222
+ alignItems: "center",
212
223
  color: "#999",
213
224
  pointerEvents: "none",
214
225
  zIndex: 1,
215
226
  fontSize: "14px",
227
+ overflow: "hidden",
216
228
  }}>
217
229
  {resolvedPlaceholder}
218
230
  </div>
@@ -31,7 +31,11 @@ import { ToggleFilter } from "./elements/ToggleFilter";
31
31
  import { DropMenu } from "./elements/DropMenu";
32
32
  import { Container } from "./elements/Container";
33
33
  import { Prompt } from "./elements/Prompt";
34
+ import { MessageList } from "./elements/MessageList";
35
+ import { MessageForm } from "./elements/MessageForm";
34
36
  import { rand_ident } from "./elements/utils";
37
+ import { LibraryInstance } from "./elements/LibraryInstance";
38
+ import { LibrarySlotInstance } from "./elements/LibrarySlotInstance";
35
39
 
36
40
  /**
37
41
  * @param {object} segment
@@ -82,9 +86,83 @@ const allElements = [
82
86
  ListColumn,
83
87
  ListColumns,
84
88
  Prompt,
89
+ MessageList,
90
+ MessageForm,
85
91
  ];
86
92
 
93
+ /**
94
+ * Fills each slot in a shared component's layout with its saved field pick or dropped-in content.
95
+ * @param {object} segment
96
+ * @param {object[]} [slots]
97
+ * @returns {void}
98
+ */
99
+ const applySlotFills = (segment, slots) => {
100
+ if (!segment) return;
101
+ if (Array.isArray(segment)) {
102
+ segment.forEach((s) => applySlotFills(s, slots));
103
+ return;
104
+ }
105
+ if (segment.type === "library-slot") {
106
+ const fill = (slots || []).find((s) => s.name === segment.name);
107
+ if (fill) Object.assign(segment, fill);
108
+ return;
109
+ }
110
+ if (segment.above) applySlotFills(segment.above, slots);
111
+ else if (segment.besides) applySlotFills(segment.besides, slots);
112
+ else if (segment.contents) applySlotFills(segment.contents, slots);
113
+ };
114
+
87
115
  export /**
116
+ * Fetches the latest content for every shared component in a layout,
117
+ * including ones nested inside another shared component.
118
+ * @param {object} layout
119
+ * @param {object} options
120
+ * @param {Set<number>} [visitedIds] - library ids seen on this branch, so a
121
+ * library that contains itself resolves to nothing instead of looping forever
122
+ * @returns {Promise<void>}
123
+ * @category saltcorn-builder
124
+ * @subcategory components
125
+ * @namespace
126
+ */
127
+ const resolveLibraryRefs = async (layout, options, visitedIds = new Set()) => {
128
+ const go = async (segment) => {
129
+ if (!segment) return;
130
+ if (Array.isArray(segment)) {
131
+ await Promise.all(segment.map(go));
132
+ return;
133
+ }
134
+ if (segment.type === "library" && segment.library_id) {
135
+ if (visitedIds.has(segment.library_id)) {
136
+ segment.contents = {};
137
+ return;
138
+ }
139
+ const r = await fetch(`/library/content/${segment.library_id}`, {
140
+ headers: { "CSRF-Token": options.csrfToken },
141
+ });
142
+ const lib = await r.json();
143
+ if (lib.error) return;
144
+ segment.library_name = lib.name;
145
+ segment.contents = lib.layout?.layout
146
+ ? lib.layout.layout
147
+ : lib.layout || {};
148
+ applySlotFills(segment.contents, segment.slots);
149
+ await resolveLibraryRefs(
150
+ segment.contents,
151
+ options,
152
+ new Set(visitedIds).add(segment.library_id)
153
+ );
154
+ return;
155
+ }
156
+ if (segment.above) await go(segment.above);
157
+ else if (segment.besides) await go(segment.besides);
158
+ else if (segment.contents) await go(segment.contents);
159
+ };
160
+ await go(layout);
161
+ };
162
+
163
+ export /**
164
+ * Builds real placed nodes from a saved layout tree, the reverse of what
165
+ * craftToSaltcorn does.
88
166
  * @param {object} layout
89
167
  * @param {object} query
90
168
  * @param {object} actions
@@ -103,6 +181,7 @@ const layoutToNodes = (
103
181
  index = false
104
182
  ) => {
105
183
  /**
184
+ * Turns one saved layout segment into its matching Craft element.
106
185
  * @param {object} segment
107
186
  * @param {string} ix
108
187
  * @returns {Element|Text|View|Action|Tabs|Columns}
@@ -235,6 +314,36 @@ const layoutToNodes = (
235
314
  isFormula={segment.isFormula || {}}
236
315
  />
237
316
  );
317
+ } else if (segment.type === "library") {
318
+ // segment.contents/library_name must already be filled in by
319
+ // resolveLibraryRefs before this runs - toTag itself can't be async
320
+ return (
321
+ <Element
322
+ key={ix}
323
+ canvas
324
+ is={LibraryInstance}
325
+ library_id={segment.library_id}
326
+ library_name={segment.library_name}
327
+ custom={segment._custom || {}}
328
+ >
329
+ {toTag(segment.contents)}
330
+ </Element>
331
+ );
332
+ } else if (segment.type === "library-slot") {
333
+ return (
334
+ <Element
335
+ key={ix}
336
+ canvas
337
+ is={LibrarySlotInstance}
338
+ slot_name={segment.name}
339
+ kind={segment.kind || "field"}
340
+ field={segment.field}
341
+ fieldview={segment.fieldview}
342
+ custom={segment._custom || {}}
343
+ >
344
+ {toTag(segment.contents)}
345
+ </Element>
346
+ );
238
347
  } else if (segment.type === "tabs") {
239
348
  let contentsArray = segment.contents.map(toTag);
240
349
  let contents;
@@ -397,6 +506,7 @@ const layoutToNodes = (
397
506
  */
398
507
 
399
508
  export /**
509
+ * Turns the placed canvas into a saved layout.
400
510
  * @param {object[]} nodes
401
511
  * @param {string} [startFrom = "ROOT" ]
402
512
  * @returns {object}
@@ -406,6 +516,9 @@ export /**
406
516
  */
407
517
  const craftToSaltcorn = (nodes, startFrom = "ROOT", options) => {
408
518
  var columns = [];
519
+ // edits to shared components save to their own _sc_library row, not
520
+ // this page/view's layout - collected as go() walks the tree
521
+ var libraryUpdates = [];
409
522
  /**
410
523
  * @param {object} node
411
524
  * @returns {void|object}
@@ -419,15 +532,55 @@ const craftToSaltcorn = (nodes, startFrom = "ROOT", options) => {
419
532
 
420
533
  const get_nodes = (node) => {
421
534
  if (!node.nodes || node.nodes.length == 0) return;
422
- else if (node.nodes.length == 1) return go(nodes[node.nodes[0]]);
423
- else return removeEmpty({ above: node.nodes.map((nm) => go(nodes[nm])) });
535
+ else if (node.nodes.length == 1)
536
+ return go(nodes[node.nodes[0]], node.nodes[0]);
537
+ else
538
+ return removeEmpty({
539
+ above: node.nodes.map((nm) => go(nodes[nm], nm)),
540
+ });
424
541
  };
425
542
 
426
543
  /**
544
+ * Finds each slot's fill in a layout tree, keeps it for this page/view,
545
+ * and clears it from the tree so it isn't saved to the shared row.
546
+ * @param {object} segment - layout tree (or subtree) to scan
547
+ * @param {object[]} [found] - accumulator, used internally for recursion
548
+ * @returns {object[]} the fills collected so far
549
+ */
550
+ const collectAndStripSlots = (segment, found = []) => {
551
+ if (!segment) return found;
552
+ if (Array.isArray(segment)) {
553
+ segment.forEach((s) => collectAndStripSlots(s, found));
554
+ return found;
555
+ }
556
+ if (segment.type === "library-slot") {
557
+ found.push({
558
+ name: segment.name,
559
+ kind: segment.kind,
560
+ field: segment.field,
561
+ fieldview: segment.fieldview,
562
+ contents: segment.contents,
563
+ });
564
+ delete segment.field;
565
+ delete segment.fieldview;
566
+ delete segment.contents;
567
+ return found;
568
+ }
569
+ if (segment.above) collectAndStripSlots(segment.above, found);
570
+ else if (segment.besides) collectAndStripSlots(segment.besides, found);
571
+ else if (segment.contents) collectAndStripSlots(segment.contents, found);
572
+ return found;
573
+ };
574
+
575
+ /**
576
+ * Turns one placed node (and everything under it) back into a plain
577
+ * layout segment, the reverse of what layoutToNodes does.
427
578
  * @param {object} node
579
+ * @param {string} [id] - this node's own id, so a LibraryInstance save can
580
+ * be told apart from a sibling placement (see doSave)
428
581
  * @returns {object}
429
582
  */
430
- const go = (node) => {
583
+ const go = (node, id) => {
431
584
  if (!node) return;
432
585
  let customProps = {};
433
586
  const mergedCustom = { ...(node?.props?.custom || {}), ...(node?.custom || {}) };
@@ -489,6 +642,40 @@ const craftToSaltcorn = (nodes, startFrom = "ROOT", options) => {
489
642
  });
490
643
  return lc;
491
644
  }
645
+ if (node.displayName === LibrarySlotInstance.craft.displayName) {
646
+ // keeps its picked field or dropped content right here - if it's
647
+ // part of a shared component, that gets separated out before saving
648
+ const kind = node.props.kind;
649
+ return {
650
+ type: "library-slot",
651
+ kind,
652
+ name: node.props.slot_name,
653
+ ...(kind === "container"
654
+ ? { contents: get_nodes(node) }
655
+ : { field: node.props.field, fieldview: node.props.fieldview }),
656
+ ...customProps,
657
+ };
658
+ }
659
+ if (node.displayName === LibraryInstance.craft.displayName) {
660
+ // must come before the generic canvas case below, or a reload turns
661
+ // this into a plain copy. skip if emptied out, so we don't blank the shared row
662
+ const childContent = get_nodes(node);
663
+ let slots = [];
664
+ if (childContent !== undefined) {
665
+ slots = collectAndStripSlots(childContent);
666
+ libraryUpdates.push({
667
+ library_id: node.props.library_id,
668
+ layout: childContent,
669
+ node_id: id,
670
+ });
671
+ }
672
+ return {
673
+ type: "library",
674
+ library_id: node.props.library_id,
675
+ ...(slots.length ? { slots } : {}),
676
+ ...customProps,
677
+ };
678
+ }
492
679
  if (node.isCanvas) {
493
680
  return get_nodes(node);
494
681
  }
@@ -671,9 +858,9 @@ const craftToSaltcorn = (nodes, startFrom = "ROOT", options) => {
671
858
  };
672
859
  }
673
860
  };
674
- const layout = go(nodes[startFrom]) || {};
861
+ const layout = go(nodes[startFrom], startFrom) || {};
675
862
  /*console.log("nodes", JSON.stringify(nodes));
676
863
  console.log("cols", JSON.stringify(columns));
677
864
  console.log("layout", JSON.stringify(layout));*/
678
- return { columns, layout };
865
+ return { columns, layout, libraryUpdates };
679
866
  };