@saltcorn/builder 1.6.0-alpha.8 → 1.6.0-beta.1

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,7 +23,7 @@ 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
28
  const {
29
29
  selected,
@@ -31,6 +31,15 @@ const SearchBar = ({ has_dropdown, children, show_badges }) => {
31
31
  } = useNode((node) => ({ selected: node.events.selected }));
32
32
  const [showDropdown, setDropdown] = useState(false);
33
33
  const [dropWidth, setDropWidth] = useState(200);
34
+
35
+ const renderContents = () => {
36
+ const actualChildren = contents || children;
37
+ if (!actualChildren) return null;
38
+ if (React.isValidElement(actualChildren)) return actualChildren;
39
+ if (Array.isArray(actualChildren)) return actualChildren;
40
+ return actualChildren;
41
+ };
42
+
34
43
  return (
35
44
  <div
36
45
  className={`input-group ${selected ? "selected-node" : ""}`}
@@ -73,10 +82,19 @@ const SearchBar = ({ has_dropdown, children, show_badges }) => {
73
82
  }`}
74
83
  style={{ width: dropWidth, left: 0 }}
75
84
  >
76
- <div className="canvas">{children}</div>
85
+ <Element canvas id="searchbar-contents" is={Column}>
86
+ {renderContents()}
87
+ </Element>
77
88
  </div>
78
89
  </Fragment>
79
90
  )}
91
+ {!has_dropdown && (
92
+ <div style={{ display: "none" }}>
93
+ <Element canvas id="searchbar-contents" is={Column}>
94
+ {renderContents()}
95
+ </Element>
96
+ </div>
97
+ )}
80
98
  </div>
81
99
  );
82
100
  };
@@ -146,11 +164,16 @@ SearchBar.craft = {
146
164
  has_dropdown: false,
147
165
  show_badges: false,
148
166
  autofocus: false,
167
+ contents: [],
149
168
  },
150
169
  related: {
151
170
  settings: SearchBarSettings,
152
171
  segment_type: "search_bar",
153
- hasContents: true,
154
- fields: ["has_dropdown", "show_badges", "autofocus"],
172
+ fields: [
173
+ { name: "has_dropdown" },
174
+ { name: "show_badges" },
175
+ { name: "autofocus" },
176
+ { label: "Contents", name: "contents", type: "Nodes", nodeID: "searchbar-contents" },
177
+ ],
155
178
  },
156
179
  };
@@ -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
  /**
@@ -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";
@@ -89,6 +91,8 @@ 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 },
@@ -99,6 +103,19 @@ const Text = ({
99
103
  dragged: state.events.dragged,
100
104
  }));
101
105
  const [editable, setEditable] = useState(false);
106
+ const { previewDevice } = useContext(PreviewCtx);
107
+
108
+ const baseStyle = {
109
+ ...(font ? { fontFamily: font } : {}),
110
+ ...reactifyStyles(style || {}),
111
+ };
112
+ const activeFontSize = getDeviceValue(
113
+ baseStyle.fontSize,
114
+ tabletFontSize,
115
+ mobileFontSize,
116
+ previewDevice
117
+ );
118
+ if (activeFontSize) baseStyle.fontSize = activeFontSize;
102
119
 
103
120
  useEffect(() => {
104
121
  !selected && setEditable(false);
@@ -112,10 +129,7 @@ const Text = ({
112
129
  } ${selected ? "selected-node" : ""}`}
113
130
  ref={(dom) => connect(drag(dom))}
114
131
  onDoubleClick={(e) => selected && setEditable(true)}
115
- style={{
116
- ...(font ? { fontFamily: font } : {}),
117
- ...reactifyStyles(style || {}),
118
- }}
132
+ style={baseStyle}
119
133
  >
120
134
  <DynamicFontAwesomeIcon icon={icon} className="me-1" />
121
135
  {isFormula.text ? (
@@ -158,6 +172,7 @@ export /**
158
172
  */
159
173
  const TextSettings = () => {
160
174
  const { t } = useTranslation();
175
+ const { previewDevice } = useContext(PreviewCtx);
161
176
  const node = useNode((node) => ({
162
177
  id: node.id,
163
178
  text: node.data.props.text,
@@ -170,6 +185,8 @@ const TextSettings = () => {
170
185
  icon: node.data.props.icon,
171
186
  font: node.data.props.font,
172
187
  style: node.data.props.style,
188
+ mobileFontSize: node.data.props.mobileFontSize,
189
+ tabletFontSize: node.data.props.tabletFontSize,
173
190
  }));
174
191
  const {
175
192
  actions: { setProp },
@@ -183,6 +200,8 @@ const TextSettings = () => {
183
200
  font,
184
201
  style,
185
202
  customClass,
203
+ mobileFontSize,
204
+ tabletFontSize,
186
205
  } = node;
187
206
  const { mode, fields, icons } = useContext(optionsCtx);
188
207
  const setAProp = setAPropGen(setProp);
@@ -255,7 +274,7 @@ const TextSettings = () => {
255
274
  className="w-100"
256
275
  value={icon}
257
276
  icons={icons}
258
- onChange={(value) => setProp((prop) => (prop.icon = value))}
277
+ onChange={(value) => { if ((value || "") !== (icon || "")) setProp((prop) => (prop.icon = value), 500); }}
259
278
  isMulti={false}
260
279
  />
261
280
  </td>
@@ -269,16 +288,41 @@ const TextSettings = () => {
269
288
  node={node}
270
289
  setProp={setProp}
271
290
  />
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
- />
291
+ {previewDevice === "desktop" ? (
292
+ <SettingsRow
293
+ field={{
294
+ name: "font-size",
295
+ label: t("Font size"),
296
+ type: "DimUnits",
297
+ }}
298
+ node={node}
299
+ setProp={setProp}
300
+ isStyle={true}
301
+ />
302
+ ) : (
303
+ <SettingsRow
304
+ field={{
305
+ name: "font-size",
306
+ label: `${t("Font size")} (${previewDevice})`,
307
+ type: "DimUnits",
308
+ }}
309
+ node={{
310
+ ...node,
311
+ style: {
312
+ "font-size": previewDevice === "mobile" ? mobileFontSize : tabletFontSize,
313
+ },
314
+ }}
315
+ setProp={(fn) => {
316
+ // Write to mobileFontSize/tabletFontSize instead of style
317
+ const proxy = { style: {} };
318
+ fn(proxy);
319
+ const val = proxy.style["font-size"];
320
+ const propName = previewDevice === "mobile" ? "mobileFontSize" : "tabletFontSize";
321
+ setProp((prop) => { prop[propName] = val; });
322
+ }}
323
+ isStyle={true}
324
+ />
325
+ )}
282
326
  <SettingsRow
283
327
  field={{
284
328
  name: "font-weight",
@@ -162,6 +162,7 @@ const ViewSettings = () => {
162
162
  }
163
163
  if (viewname && viewname.includes(".")) viewname = viewname.split(".")[0];
164
164
 
165
+ let cacheWasPopulated = false;
165
166
  if (
166
167
  finder &&
167
168
  !(relationsCache[tableName] && relationsCache[tableName][viewname])
@@ -178,8 +179,13 @@ const ViewSettings = () => {
178
179
  );
179
180
  relationsCache[tableName] = relationsCache[tableName] || {};
180
181
  relationsCache[tableName][viewname] = { relations, layers };
181
- setRelationsCache({ ...relationsCache });
182
+ cacheWasPopulated = true;
182
183
  }
184
+ useEffect(() => {
185
+ if (cacheWasPopulated) {
186
+ setRelationsCache({ ...relationsCache });
187
+ }
188
+ });
183
189
  const [relationsData, setRelationsData] = finder
184
190
  ? React.useState(relationsCache[tableName][viewname])
185
191
  : [undefined, undefined];
@@ -193,18 +199,23 @@ const ViewSettings = () => {
193
199
  subView.display_type
194
200
  );
195
201
  }
196
- if (
202
+ const needsInitialRelation =
197
203
  options.mode !== "filter" &&
198
204
  subView?.table_id &&
199
205
  !safeRelation &&
200
206
  !hasLegacyRelation &&
201
- relationsData?.relations.length > 0
202
- ) {
207
+ relationsData?.relations.length > 0;
208
+ if (needsInitialRelation) {
203
209
  safeRelation = initialRelation(relationsData.relations);
204
- setProp((prop) => {
205
- prop.relation = safeRelation.relationString;
206
- });
207
210
  }
211
+ useEffect(() => {
212
+ if (needsInitialRelation) {
213
+ const rel = initialRelation(relationsData.relations);
214
+ setProp((prop) => {
215
+ prop.relation = rel.relationString;
216
+ }, 500);
217
+ }
218
+ }, [needsInitialRelation]);
208
219
  const helpContext = { view_name: viewname };
209
220
  if (options.tableName) helpContext.srcTable = options.tableName;
210
221
  const set_view_name = (e) => {
@@ -394,6 +405,7 @@ const ViewSettings = () => {
394
405
  value={extra_state_fml}
395
406
  propKey="extra_state_fml"
396
407
  onChange={setAProp("extra_state_fml")}
408
+ stateExpr
397
409
  />
398
410
  {errorString ? (
399
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">