@saltcorn/builder 1.6.0-alpha.9 → 1.6.0-beta.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,285 @@
1
+ /**
2
+ * @category saltcorn-builder
3
+ * @module components/elements/Prompt
4
+ * @subcategory components / elements
5
+ */
6
+
7
+ import React, { useState, useContext, Fragment } from "react";
8
+ import { useNode, useEditor } from "@craftjs/core";
9
+ import useTranslation from "../../hooks/useTranslation";
10
+ import optionsCtx from "../context";
11
+ import StorageCtx from "../storage_context";
12
+
13
+ const PROMPT_ICONS = {
14
+ container: "fas fa-box",
15
+ view: "fas fa-eye",
16
+ field: "fas fa-i-cursor",
17
+ action: "fas fa-bolt",
18
+ };
19
+
20
+ const PROMPT_LABELS = {
21
+ container: "Container",
22
+ view: "View",
23
+ field: "Field",
24
+ action: "Action",
25
+ };
26
+
27
+ export const Prompt = ({ promptType, promptText }) => {
28
+ const {
29
+ connectors: { connect, drag },
30
+ selected,
31
+ actions: { setProp },
32
+ id,
33
+ parent,
34
+ } = useNode((state) => ({
35
+ selected: state.events.selected,
36
+ parent: state.data.parent,
37
+ }));
38
+
39
+ const { query, actions: editorActions } = useEditor();
40
+ const options = useContext(optionsCtx);
41
+ const { layoutToNodes } = useContext(StorageCtx);
42
+ const { t } = useTranslation();
43
+
44
+ const [generating, setGenerating] = useState(false);
45
+
46
+ const icon = PROMPT_ICONS[promptType] || "fas fa-robot";
47
+
48
+ const handleGenerate = async (e) => {
49
+ e.stopPropagation();
50
+ if (!promptText.trim()) return;
51
+
52
+ setGenerating(true);
53
+ setProp((props) => {
54
+ props.generateError = null;
55
+ });
56
+
57
+ try {
58
+ const combinedPrompt = `[${promptType}]: ${promptText}`;
59
+
60
+ const res = await fetch("/viewedit/copilot-generate-layout", {
61
+ method: "POST",
62
+ headers: {
63
+ "Content-Type": "application/json",
64
+ "CSRF-Token": options.csrfToken,
65
+ "X-Requested-With": "XMLHttpRequest",
66
+ },
67
+ body: JSON.stringify({
68
+ prompt: combinedPrompt,
69
+ mode: options.mode,
70
+ table: options.tableName,
71
+ }),
72
+ });
73
+
74
+ const data = await res.json();
75
+ if (data.error) {
76
+ setProp((props) => {
77
+ props.generateError = data.error;
78
+ });
79
+ } else if (data.layout) {
80
+ editorActions.delete(id);
81
+ layoutToNodes(data.layout, query, editorActions, parent, options);
82
+ }
83
+ } catch (err) {
84
+ setProp((props) => {
85
+ props.generateError = err.message || "Generation failed";
86
+ });
87
+ } finally {
88
+ setGenerating(false);
89
+ }
90
+ };
91
+
92
+ return (
93
+ <div
94
+ ref={(dom) => connect(drag(dom))}
95
+ className={`prompt-placeholder ${selected ? "selected-node" : ""}`}
96
+ style={{
97
+ border: "2px dashed #6c8ebf",
98
+ borderRadius: "8px",
99
+ padding: "12px",
100
+ margin: "4px 0",
101
+ backgroundColor: "#e8f0fe",
102
+ minHeight: "60px",
103
+ }}
104
+ >
105
+ <div
106
+ style={{
107
+ display: "flex",
108
+ alignItems: "center",
109
+ gap: "6px",
110
+ marginBottom: "6px",
111
+ fontWeight: "bold",
112
+ fontSize: "13px",
113
+ color: "#1a73e8",
114
+ }}
115
+ >
116
+ <i className={icon}></i>
117
+ <span>{t("Prompt")}</span>
118
+ </div>
119
+ <textarea
120
+ rows="3"
121
+ className="form-control form-control-sm"
122
+ style={{
123
+ fontSize: "13px",
124
+ backgroundColor: "transparent",
125
+ border: "1px solid #b0c4de",
126
+ resize: "vertical",
127
+ marginBottom: "8px",
128
+ }}
129
+ value={promptText}
130
+ placeholder={t("Describe what you want to generate...")}
131
+ onChange={(e) =>
132
+ setProp((props) => {
133
+ props.promptText = e.target.value;
134
+ })
135
+ }
136
+ onClick={(e) => e.stopPropagation()}
137
+ />
138
+ <button
139
+ className="btn btn-sm btn-success w-100"
140
+ onClick={handleGenerate}
141
+ disabled={generating || !promptText.trim()}
142
+ style={{ fontSize: "12px" }}
143
+ >
144
+ {generating ? (
145
+ <Fragment>
146
+ <span
147
+ className="spinner-border spinner-border-sm me-1"
148
+ role="status"
149
+ ></span>
150
+ {t("Generating...")}
151
+ </Fragment>
152
+ ) : (
153
+ <Fragment>
154
+ <i className="fas fa-robot me-1"></i>
155
+ {t("Generate")}
156
+ </Fragment>
157
+ )}
158
+ </button>
159
+ </div>
160
+ );
161
+ };
162
+
163
+ const PromptSettings = () => {
164
+ const { t } = useTranslation();
165
+ const {
166
+ actions: { setProp },
167
+ promptType,
168
+ promptText,
169
+ generateError,
170
+ id,
171
+ parent,
172
+ } = useNode((node) => ({
173
+ promptType: node.data.props.promptType,
174
+ promptText: node.data.props.promptText,
175
+ generateError: node.data.props.generateError,
176
+ id: node.id,
177
+ parent: node.data.parent,
178
+ }));
179
+
180
+ const { query, actions: editorActions } = useEditor();
181
+ const options = useContext(optionsCtx);
182
+ const { layoutToNodes } = useContext(StorageCtx);
183
+
184
+ const [generating, setGenerating] = useState(false);
185
+
186
+ const handleGenerate = async () => {
187
+ if (!promptText.trim()) return;
188
+
189
+ setGenerating(true);
190
+ setProp((props) => {
191
+ props.generateError = null;
192
+ });
193
+
194
+ try {
195
+ const combinedPrompt = `[${promptType}]: ${promptText}`;
196
+
197
+ const res = await fetch("/viewedit/copilot-generate-layout", {
198
+ method: "POST",
199
+ headers: {
200
+ "Content-Type": "application/json",
201
+ "CSRF-Token": options.csrfToken,
202
+ "X-Requested-With": "XMLHttpRequest",
203
+ },
204
+ body: JSON.stringify({
205
+ prompt: combinedPrompt,
206
+ mode: options.mode,
207
+ table: options.tableName,
208
+ }),
209
+ });
210
+
211
+ const data = await res.json();
212
+ if (data.error) {
213
+ setProp((props) => {
214
+ props.generateError = data.error;
215
+ });
216
+ } else if (data.layout) {
217
+ editorActions.delete(id);
218
+ layoutToNodes(data.layout, query, editorActions, parent, options);
219
+ }
220
+ } catch (err) {
221
+ setProp((props) => {
222
+ props.generateError = err.message || "Generation failed";
223
+ });
224
+ } finally {
225
+ setGenerating(false);
226
+ }
227
+ };
228
+
229
+ return (
230
+ <div>
231
+ <div className="mb-2">
232
+ <label className="form-label">{t("Prompt")}</label>
233
+ <textarea
234
+ rows="4"
235
+ className="form-control"
236
+ value={promptText}
237
+ placeholder={t("Describe what you want to generate...")}
238
+ onChange={(e) =>
239
+ setProp((props) => {
240
+ props.promptText = e.target.value;
241
+ })
242
+ }
243
+ />
244
+ {generateError && (
245
+ <div className="text-danger small mt-1">{generateError}</div>
246
+ )}
247
+ </div>
248
+ <div className="mb-2">
249
+ <button
250
+ className="btn btn-sm btn-success w-100"
251
+ onClick={handleGenerate}
252
+ disabled={generating || !promptText.trim()}
253
+ >
254
+ {generating ? (
255
+ <Fragment>
256
+ <span
257
+ className="spinner-border spinner-border-sm me-1"
258
+ role="status"
259
+ ></span>
260
+ {t("Generating...")}
261
+ </Fragment>
262
+ ) : (
263
+ <Fragment>
264
+ <i className="fas fa-robot me-1"></i>
265
+ {t("Generate")}
266
+ </Fragment>
267
+ )}
268
+ </button>
269
+ </div>
270
+ </div>
271
+ );
272
+ };
273
+
274
+ Prompt.craft = {
275
+ displayName: "Prompt",
276
+ defaultProps: {
277
+ promptType: "container",
278
+ promptText: "",
279
+ },
280
+ related: {
281
+ settings: PromptSettings,
282
+ segment_type: "prompt",
283
+ fields: ["promptType", "promptText"],
284
+ },
285
+ };
@@ -7,7 +7,7 @@
7
7
  import React, { Fragment, useState, useEffect, useContext } from "react";
8
8
  import useTranslation from "../../hooks/useTranslation";
9
9
  import optionsCtx from "../context";
10
- import { useNode } from "@craftjs/core";
10
+ import { useNode, Element } from "@craftjs/core";
11
11
  import { Column } from "./Column";
12
12
  import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
13
13
  import { faCaretDown } from "@fortawesome/free-solid-svg-icons";
@@ -23,14 +23,24 @@ export /**
23
23
  * @category saltcorn-builder
24
24
  * @subcategory components
25
25
  */
26
- const SearchBar = ({ has_dropdown, children, show_badges }) => {
26
+ const SearchBar = ({ has_dropdown, children, contents, show_badges }) => {
27
27
  const { t } = useTranslation();
28
+ const options = useContext(optionsCtx);
28
29
  const {
29
30
  selected,
30
31
  connectors: { connect, drag },
31
32
  } = useNode((node) => ({ selected: node.events.selected }));
32
33
  const [showDropdown, setDropdown] = useState(false);
33
34
  const [dropWidth, setDropWidth] = useState(200);
35
+
36
+ const renderContents = () => {
37
+ const actualChildren = contents || children;
38
+ if (!actualChildren) return null;
39
+ if (React.isValidElement(actualChildren)) return actualChildren;
40
+ if (Array.isArray(actualChildren)) return actualChildren;
41
+ return actualChildren;
42
+ };
43
+
34
44
  return (
35
45
  <div
36
46
  className={`input-group ${selected ? "selected-node" : ""}`}
@@ -71,12 +81,21 @@ const SearchBar = ({ has_dropdown, children, show_badges }) => {
71
81
  className={`dropdown-menu searchbar-dropdown ${
72
82
  showDropdown ? "show" : ""
73
83
  }`}
74
- style={{ width: dropWidth, left: 0 }}
84
+ style={{ width: dropWidth, ...(options?.isRTL ? { right: 0 } : { left: 0 }) }}
75
85
  >
76
- <div className="canvas">{children}</div>
86
+ <Element canvas id="searchbar-contents" is={Column}>
87
+ {renderContents()}
88
+ </Element>
77
89
  </div>
78
90
  </Fragment>
79
91
  )}
92
+ {!has_dropdown && (
93
+ <div style={{ display: "none" }}>
94
+ <Element canvas id="searchbar-contents" is={Column}>
95
+ {renderContents()}
96
+ </Element>
97
+ </div>
98
+ )}
80
99
  </div>
81
100
  );
82
101
  };
@@ -146,11 +165,16 @@ SearchBar.craft = {
146
165
  has_dropdown: false,
147
166
  show_badges: false,
148
167
  autofocus: false,
168
+ contents: [],
149
169
  },
150
170
  related: {
151
171
  settings: SearchBarSettings,
152
172
  segment_type: "search_bar",
153
- hasContents: true,
154
- fields: ["has_dropdown", "show_badges", "autofocus"],
173
+ fields: [
174
+ { name: "has_dropdown" },
175
+ { name: "show_badges" },
176
+ { name: "autofocus" },
177
+ { label: "Contents", name: "contents", type: "Nodes", nodeID: "searchbar-contents" },
178
+ ],
155
179
  },
156
180
  };
@@ -124,19 +124,17 @@ const TableSettings = () => {
124
124
  showIf: { bs_style: true },
125
125
  },
126
126
  ];
127
- return (
128
- <SettingsFromFields
129
- fields={fields}
130
- onChange={(fnm, v, setProp) => {
131
- if (fnm === "rows")
132
- setProp((prop) => {
133
- ntimes(v, (i) => {
134
- if (!prop.contents[i]) prop.contents[i] = [];
135
- });
127
+ const Settings = SettingsFromFields(fields, {
128
+ onChange: (fnm, v, setProp) => {
129
+ if (fnm === "rows")
130
+ setProp((prop) => {
131
+ ntimes(v, (i) => {
132
+ if (!prop.contents[i]) prop.contents[i] = [];
136
133
  });
137
- }}
138
- />
139
- );
134
+ });
135
+ },
136
+ });
137
+ return <Settings />;
140
138
  };
141
139
 
142
140
  /**
@@ -4,7 +4,7 @@
4
4
  * @subcategory components / elements
5
5
  */
6
6
 
7
- import React, { useState, useContext, useEffect, Fragment } from "react";
7
+ import React, { useState, useContext, useEffect, useRef, Fragment } from "react";
8
8
  import { useNode } from "@craftjs/core";
9
9
  import {
10
10
  blockProps,
@@ -19,8 +19,10 @@ import {
19
19
  SettingsRow,
20
20
  setAPropGen,
21
21
  } from "./utils";
22
+ import { getDeviceValue } from "../../utils/responsive_utils";
22
23
  import ContentEditable from "react-contenteditable";
23
24
  import optionsCtx from "../context";
25
+ import PreviewCtx from "../preview_context";
24
26
  import { CKEditor } from "ckeditor4-react";
25
27
  import FontIconPicker from "@fonticonpicker/react-fonticonpicker";
26
28
  import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
@@ -80,7 +82,7 @@ export /**
80
82
  * @subcategory components
81
83
  */
82
84
  const Text = ({
83
- text,
85
+ text: propText,
84
86
  block,
85
87
  inline,
86
88
  isFormula,
@@ -89,17 +91,52 @@ const Text = ({
89
91
  font,
90
92
  style,
91
93
  customClass,
94
+ mobileFontSize,
95
+ tabletFontSize,
92
96
  }) => {
93
97
  const {
94
98
  connectors: { connect, drag },
95
99
  selected,
100
+ nodeText,
96
101
  actions: { setProp },
97
102
  } = useNode((state) => ({
98
103
  selected: state.events.selected,
99
104
  dragged: state.events.dragged,
105
+ nodeText: state.data.props.text,
100
106
  }));
107
+ // Use nodeText from store (reacts to undo) with fallback to prop
108
+ const text = nodeText !== undefined ? nodeText : propText;
101
109
  const [editable, setEditable] = useState(false);
110
+ const { previewDevice } = useContext(PreviewCtx);
111
+ const ckInitRef = useRef(true);
112
+ const lastSavedTextRef = useRef(text);
113
+ const skipDestroyRef = useRef(false);
102
114
 
115
+ const baseStyle = {
116
+ ...(font ? { fontFamily: font } : {}),
117
+ ...reactifyStyles(style || {}),
118
+ };
119
+ const activeFontSize = getDeviceValue(
120
+ baseStyle.fontSize,
121
+ tabletFontSize,
122
+ mobileFontSize,
123
+ previewDevice
124
+ );
125
+ if (activeFontSize) baseStyle.fontSize = activeFontSize;
126
+
127
+ useEffect(() => {
128
+ if (editable) {
129
+ ckInitRef.current = true;
130
+ lastSavedTextRef.current = text;
131
+ }
132
+ }, [editable]);
133
+ // Close CKEditor when text changes externally (e.g. undo/redo)
134
+ useEffect(() => {
135
+ if (editable && text !== lastSavedTextRef.current) {
136
+ skipDestroyRef.current = true;
137
+ setEditable(false);
138
+ }
139
+ }, [text]);
103
140
  useEffect(() => {
104
141
  !selected && setEditable(false);
105
142
  }, [selected]);
@@ -112,10 +149,7 @@ const Text = ({
112
149
  } ${selected ? "selected-node" : ""}`}
113
150
  ref={(dom) => connect(drag(dom))}
114
151
  onDoubleClick={(e) => selected && setEditable(true)}
115
- style={{
116
- ...(font ? { fontFamily: font } : {}),
117
- ...reactifyStyles(style || {}),
118
- }}
152
+ style={baseStyle}
119
153
  >
120
154
  <DynamicFontAwesomeIcon icon={icon} className="me-1" />
121
155
  {isFormula.text ? (
@@ -135,9 +169,29 @@ const Text = ({
135
169
  <CKEditor
136
170
  initData={text || ""}
137
171
  style={{ display: "inline" }}
138
- onChange={(e) =>
139
- setProp((props) => (props.text = e.editor.getData()))
140
- }
172
+ onChange={(e) => {
173
+ if (ckInitRef.current) {
174
+ ckInitRef.current = false;
175
+ return;
176
+ }
177
+ if (e?.editor) {
178
+ const newText = e.editor.getData();
179
+ setProp((props) => (props.text = newText), 500);
180
+ lastSavedTextRef.current = newText;
181
+ }
182
+ }}
183
+ onBeforeDestroy={(e) => {
184
+ if (skipDestroyRef.current) {
185
+ skipDestroyRef.current = false;
186
+ return;
187
+ }
188
+ if (e?.editor) {
189
+ const newText = e.editor.getData();
190
+ if (newText !== lastSavedTextRef.current) {
191
+ setProp((props) => (props.text = newText));
192
+ }
193
+ }
194
+ }}
141
195
  config={ckConfig}
142
196
  type="inline"
143
197
  />
@@ -158,6 +212,7 @@ export /**
158
212
  */
159
213
  const TextSettings = () => {
160
214
  const { t } = useTranslation();
215
+ const { previewDevice } = useContext(PreviewCtx);
161
216
  const node = useNode((node) => ({
162
217
  id: node.id,
163
218
  text: node.data.props.text,
@@ -170,6 +225,8 @@ const TextSettings = () => {
170
225
  icon: node.data.props.icon,
171
226
  font: node.data.props.font,
172
227
  style: node.data.props.style,
228
+ mobileFontSize: node.data.props.mobileFontSize,
229
+ tabletFontSize: node.data.props.tabletFontSize,
173
230
  }));
174
231
  const {
175
232
  actions: { setProp },
@@ -183,6 +240,8 @@ const TextSettings = () => {
183
240
  font,
184
241
  style,
185
242
  customClass,
243
+ mobileFontSize,
244
+ tabletFontSize,
186
245
  } = node;
187
246
  const { mode, fields, icons } = useContext(optionsCtx);
188
247
  const setAProp = setAPropGen(setProp);
@@ -255,7 +314,7 @@ const TextSettings = () => {
255
314
  className="w-100"
256
315
  value={icon}
257
316
  icons={icons}
258
- onChange={(value) => setProp((prop) => (prop.icon = value))}
317
+ onChange={(value) => { if ((value || "") !== (icon || "")) setProp((prop) => (prop.icon = value), 500); }}
259
318
  isMulti={false}
260
319
  />
261
320
  </td>
@@ -269,16 +328,41 @@ const TextSettings = () => {
269
328
  node={node}
270
329
  setProp={setProp}
271
330
  />
272
- <SettingsRow
273
- field={{
274
- name: "font-size",
275
- label: t("Font size"),
276
- type: "DimUnits",
277
- }}
278
- node={node}
279
- setProp={setProp}
280
- isStyle={true}
281
- />
331
+ {previewDevice === "desktop" ? (
332
+ <SettingsRow
333
+ field={{
334
+ name: "font-size",
335
+ label: t("Font size"),
336
+ type: "DimUnits",
337
+ }}
338
+ node={node}
339
+ setProp={setProp}
340
+ isStyle={true}
341
+ />
342
+ ) : (
343
+ <SettingsRow
344
+ field={{
345
+ name: "font-size",
346
+ label: `${t("Font size")} (${previewDevice})`,
347
+ type: "DimUnits",
348
+ }}
349
+ node={{
350
+ ...node,
351
+ style: {
352
+ "font-size": previewDevice === "mobile" ? mobileFontSize : tabletFontSize,
353
+ },
354
+ }}
355
+ setProp={(fn) => {
356
+ // Write to mobileFontSize/tabletFontSize instead of style
357
+ const proxy = { style: {} };
358
+ fn(proxy);
359
+ const val = proxy.style["font-size"];
360
+ const propName = previewDevice === "mobile" ? "mobileFontSize" : "tabletFontSize";
361
+ setProp((prop) => { prop[propName] = val; });
362
+ }}
363
+ isStyle={true}
364
+ />
365
+ )}
282
366
  <SettingsRow
283
367
  field={{
284
368
  name: "font-weight",
@@ -213,7 +213,7 @@ const ViewSettings = () => {
213
213
  const rel = initialRelation(relationsData.relations);
214
214
  setProp((prop) => {
215
215
  prop.relation = rel.relationString;
216
- });
216
+ }, 500);
217
217
  }
218
218
  }, [needsInitialRelation]);
219
219
  const helpContext = { view_name: viewname };
@@ -405,6 +405,7 @@ const ViewSettings = () => {
405
405
  value={extra_state_fml}
406
406
  propKey="extra_state_fml"
407
407
  onChange={setAProp("extra_state_fml")}
408
+ stateExpr
408
409
  />
409
410
  {errorString ? (
410
411
  <small className="text-danger font-monospace d-block">
@@ -321,6 +321,7 @@ const ViewLinkSettings = () => {
321
321
  value={extra_state_fml}
322
322
  propKey="extra_state_fml"
323
323
  onChange={setAProp("extra_state_fml")}
324
+ stateExpr
324
325
  />
325
326
  {errorString ? (
326
327
  <small className="text-danger font-monospace d-block">