@saltcorn/builder 0.9.2 → 0.9.3-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,13 +1,13 @@
1
1
  {
2
2
  "name": "@saltcorn/builder",
3
- "version": "0.9.2",
3
+ "version": "0.9.3-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",
7
7
  "scripts": {
8
8
  "build": "webpack --mode production",
9
9
  "builddev": "webpack --mode none",
10
- "test": "echo \"Error: no test specified\"",
10
+ "test": "jest tests --runInBand",
11
11
  "tsc": "echo \"Error: no TypeScript support yet\"",
12
12
  "clean": "echo \"Error: no TypeScript support yet\""
13
13
  },
@@ -26,14 +26,19 @@
26
26
  "@fortawesome/free-regular-svg-icons": "5.15.2",
27
27
  "@fortawesome/free-solid-svg-icons": "5.15.2",
28
28
  "@fortawesome/react-fontawesome": "0.1.14",
29
+ "babel-jest": "^29.7.0",
29
30
  "babel-loader": "8.1.0",
30
31
  "ckeditor4-react": "1.4.2",
31
32
  "classnames": "2.2.6",
33
+ "jest-environment-jsdom": "29.7.0",
34
+ "jest": "^29.7.0",
35
+ "jsdom": "16.4.0",
32
36
  "prop-types": "15.7.2",
33
37
  "react": "16.13.1",
34
38
  "react-bootstrap-icons": "1.5.0",
35
39
  "react-contenteditable": "3.3.5",
36
40
  "react-dom": "16.13.1",
41
+ "react-test-renderer": "16.13.1",
37
42
  "react-transition-group": "4.4.1",
38
43
  "@tippyjs/react": "4.2.6",
39
44
  "webpack": "5.68.0",
@@ -51,5 +56,8 @@
51
56
  "overrides": {
52
57
  "immer": "9.0.6",
53
58
  "glob-parent": "^5.1.2"
59
+ },
60
+ "jest": {
61
+ "testEnvironment": "jsdom"
54
62
  }
55
63
  }
@@ -257,13 +257,9 @@ const ViewElem = ({ connectors, views }) => (
257
257
  icon="fas fa-eye"
258
258
  title="Embed a view"
259
259
  label="View"
260
- disable={views.length === 0}
260
+ disable={views.length < 2}
261
261
  >
262
- <View
263
- name={"not_assigned"}
264
- state={"shared"}
265
- view={views.length > 0 ? views[0].name : "view"}
266
- />
262
+ <View name={"not_assigned"} state={"shared"} view={views[0].name} />
267
263
  </WrapElem>
268
264
  );
269
265
  /**
@@ -429,12 +425,10 @@ const ViewLinkElem = ({ connectors, options }) => (
429
425
  icons={["fas fa-eye", "fas fa-link"]}
430
426
  title="Link to a view"
431
427
  label="ViewLink"
432
- disable={options.link_view_opts.length === 0}
428
+ disable={options.views.length < 2}
433
429
  >
434
430
  <ViewLink
435
- name={
436
- options.link_view_opts.length > 0 ? options.link_view_opts[0].name : ""
437
- }
431
+ name={options.views.length > 0 ? options.views[0].name : ""}
438
432
  block={false}
439
433
  minRole={100}
440
434
  label={""}
@@ -1,9 +1,5 @@
1
- import React, { Fragment, useContext, useState } from "react";
2
- import {
3
- parseRelationPath,
4
- parseLegacyRelation,
5
- removeWhitespaces,
6
- } from "./utils";
1
+ import React from "react";
2
+ import { removeWhitespaces } from "./utils";
7
3
 
8
4
  const buildBadgeCfgs = (parsed, parentTbl) => {
9
5
  const result = [];
@@ -13,8 +9,8 @@ const buildBadgeCfgs = (parsed, parentTbl) => {
13
9
  if (currentCfg) result.push(currentCfg);
14
10
  currentCfg = { up: key, table };
15
11
  } else {
16
- if (!currentCfg) result.push({ down: key, table: parentTbl });
17
- else {
12
+ if (!currentCfg && key) result.push({ down: key, table: parentTbl });
13
+ else if (currentCfg) {
18
14
  currentCfg.down = key;
19
15
  result.push(currentCfg);
20
16
  }
@@ -63,9 +59,15 @@ const buildBadge = ({ up, table, down }, index) => {
63
59
  );
64
60
  };
65
61
 
66
- export const RelationBadges = ({ view, relation, parentTbl, fk_options }) => {
62
+ export const RelationBadges = ({
63
+ view,
64
+ relation,
65
+ parentTbl,
66
+ tableNameCache,
67
+ }) => {
67
68
  if (relation) {
68
- const parsed = parseRelationPath(relation, fk_options);
69
+ const parsed = relationHelpers.parseRelationPath(relation, tableNameCache);
70
+
69
71
  return (
70
72
  <div className="overflow-scroll">
71
73
  {parsed.length > 0
@@ -74,8 +76,9 @@ export const RelationBadges = ({ view, relation, parentTbl, fk_options }) => {
74
76
  </div>
75
77
  );
76
78
  } else {
79
+ if (!view) return buildBadge({ table: "invalid relation" }, 0);
77
80
  const [prefix, rest] = view.split(":");
78
- const parsed = parseLegacyRelation(prefix, rest, parentTbl);
81
+ const parsed = relationHelpers.parseLegacyRelation(prefix, rest, parentTbl);
79
82
  if (parsed.length === 0)
80
83
  return buildBadge({ table: "invalid relation" }, 0);
81
84
  else if (
@@ -0,0 +1,212 @@
1
+ import React from "react";
2
+ import { removeWhitespaces, rand_ident } from "./utils";
3
+ import ReactDOM from "react-dom";
4
+
5
+ const maxLevelDefault = 10;
6
+
7
+ const keyLabel = (key, type) =>
8
+ type === "fk" ? `${key.name}` : `${key.name} (from ${key.table})`;
9
+
10
+ const toggleLayers = (layer, maxLevel, additionalSelectors = []) => {
11
+ const selectors = [];
12
+ for (let i = layer; i < maxLevel; i++) {
13
+ selectors.push(`.dropdown_level_${i}.show`);
14
+ }
15
+ selectors.push(...additionalSelectors);
16
+ if (selectors.length) $(`${selectors.join(",")}`).dropdown("toggle");
17
+ };
18
+
19
+ const setActiveClasses = (level, maxLevel, itemId) => {
20
+ const classes = [];
21
+ for (let i = level; i < maxLevel; i++) {
22
+ classes.push(`.item_level_${i}.active`);
23
+ }
24
+ if (classes.length > 0) $(classes.join(",")).removeClass("active");
25
+ $(`#${itemId}`).addClass(() => "active");
26
+ };
27
+
28
+ const removeAllActiveClasses = () => {
29
+ $(".dropdown-item.active").removeClass("active");
30
+ };
31
+
32
+ const hasSubLevels = (relation) =>
33
+ relation.fkeys?.length > 0 || relation.inboundKeys?.length > 0;
34
+
35
+ /**
36
+ *
37
+ * @param {*} param0
38
+ * @returns
39
+ */
40
+ const Relation = ({ cfg }) => {
41
+ const { relation, ix, level, type, update, maxLevel, setMaxLevel } = cfg;
42
+
43
+ const setRelation = (key) => {
44
+ update(key.relPath);
45
+ removeAllActiveClasses();
46
+ toggleLayers(0, maxLevel);
47
+ setMaxLevel(maxLevelDefault, maxLevel);
48
+ };
49
+ const identifier = removeWhitespaces(
50
+ `${relation.name}_${relation.table}_${type}_${ix}_${level}_${rand_ident()}`
51
+ );
52
+
53
+ if (hasSubLevels(relation)) {
54
+ const itemId = `${identifier}_sub_key`;
55
+ const toggleId = `${identifier}_toggle`;
56
+ const nextDropId = `${identifier}_next_drop`;
57
+ return (
58
+ <div key={`${identifier}_key_div`}>
59
+ <li
60
+ id={itemId}
61
+ key={itemId}
62
+ className={`dropdown-item item_level_${level} ${
63
+ level < 5 ? "dropstart" : "dropdown"
64
+ } `}
65
+ role="button"
66
+ >
67
+ <div
68
+ key={toggleId}
69
+ id={toggleId}
70
+ className={`dropdown-toggle dropdown_level_${level}`}
71
+ role="button"
72
+ aria-expanded="false"
73
+ onClick={() => {
74
+ const layerCfg = {
75
+ layer: relation,
76
+ level: level + 1,
77
+ update,
78
+ maxLevel,
79
+ setMaxLevel,
80
+ };
81
+ ReactDOM.render(
82
+ <RelationLayer cfg={layerCfg} />,
83
+ document.getElementById(nextDropId),
84
+ () => {
85
+ toggleLayers(level, maxLevel, [`#${toggleId}`]);
86
+ setActiveClasses(level, maxLevel, itemId);
87
+ if (level > maxLevel) setMaxLevel(level);
88
+ }
89
+ );
90
+ }}
91
+ >
92
+ {keyLabel(relation, type)}
93
+ </div>
94
+ <div key={nextDropId} id={nextDropId} className="dropdown-menu"></div>
95
+ </li>
96
+ {/* has the layer a direct link ? */}
97
+ {relation.relPath ? (
98
+ <li
99
+ key={`${identifier}_direct_key`}
100
+ className="dropdown-item"
101
+ role="button"
102
+ onClick={() => {
103
+ setRelation(relation);
104
+ }}
105
+ >
106
+ {keyLabel(relation, type)}
107
+ </li>
108
+ ) : (
109
+ ""
110
+ )}
111
+ </div>
112
+ );
113
+ } else {
114
+ return (
115
+ <li
116
+ key={`${identifier}_key`}
117
+ className="dropdown-item"
118
+ role="button"
119
+ onClick={() => {
120
+ setRelation(relation);
121
+ }}
122
+ >
123
+ {keyLabel(relation, type)}
124
+ </li>
125
+ );
126
+ }
127
+ };
128
+
129
+ /**
130
+ *
131
+ * @param {*} param0
132
+ * @returns
133
+ */
134
+ const RelationLayer = ({ cfg }) => {
135
+ const { layer, level, update, maxLevel, setMaxLevel } = cfg;
136
+ const reactKey = (relation, type, ix) =>
137
+ `_rel_layer_${level}_${type}_${ix}_${relation.name}_`;
138
+ return (
139
+ <div>
140
+ <h5 className="join-table-header text-center">{layer.table}</h5>
141
+ <ul className="ps-0 mb-0">
142
+ {layer.fkeys.map((relation, ix) => (
143
+ <Relation
144
+ key={reactKey(relation, "fk", ix)}
145
+ cfg={{
146
+ relation,
147
+ ix,
148
+ level,
149
+ type: "fk",
150
+ update,
151
+ maxLevel,
152
+ setMaxLevel,
153
+ }}
154
+ />
155
+ ))}
156
+ {layer.inboundKeys.map((relation, ix) => (
157
+ <Relation
158
+ key={reactKey(relation, "inbound", ix)}
159
+ cfg={{
160
+ relation,
161
+ ix,
162
+ level,
163
+ type: "inbound",
164
+ update,
165
+ maxLevel,
166
+ setMaxLevel,
167
+ }}
168
+ />
169
+ ))}
170
+ </ul>
171
+ </div>
172
+ );
173
+ };
174
+
175
+ /**
176
+ *
177
+ * @param {*} param0
178
+ * @returns
179
+ */
180
+ export const RelationOnDemandPicker = ({ relations, update }) => {
181
+ const [maxLevel, setMaxLevel] = React.useState(maxLevelDefault);
182
+ const toggleId = "_relation_picker_toggle_";
183
+ const layerCfg = {
184
+ layer: relations,
185
+ level: 1,
186
+ update,
187
+ maxLevel,
188
+ setMaxLevel,
189
+ };
190
+ return (
191
+ <div>
192
+ <label>Relation</label>
193
+ <div style={{ zIndex: 10000 }} className="dropstart">
194
+ <button
195
+ id={toggleId}
196
+ className="btn btn-outline-primary dropdown-toggle dropdown_level_0 mb-1"
197
+ aria-expanded="false"
198
+ onClick={() => {
199
+ removeAllActiveClasses();
200
+ toggleLayers(0, maxLevel, [`#${toggleId}`]);
201
+ setMaxLevel(maxLevelDefault);
202
+ }}
203
+ >
204
+ Select
205
+ </button>
206
+ <div className="dropdown-menu">
207
+ <RelationLayer cfg={layerCfg} />
208
+ </div>
209
+ </div>
210
+ </div>
211
+ );
212
+ };
@@ -4,7 +4,7 @@
4
4
  * @subcategory components / elements
5
5
  */
6
6
 
7
- import React, { Fragment, useContext, useEffect } from "react";
7
+ import React, { Fragment, useEffect, useMemo } from "react";
8
8
  import { useNode } from "@craftjs/core";
9
9
  import optionsCtx from "../context";
10
10
  import previewCtx from "../preview_context";
@@ -13,13 +13,13 @@ import {
13
13
  fetchViewPreview,
14
14
  ConfigForm,
15
15
  setAPropGen,
16
- FormulaTooltip,
17
16
  buildOptions,
18
17
  HelpTopicLink,
18
+ prepCacheAndFinder,
19
19
  } from "./utils";
20
20
 
21
- import { RelationPicker } from "./RelationPicker";
22
21
  import { RelationBadges } from "./RelationBadges";
22
+ import { RelationOnDemandPicker } from "./RelationOnDemandPicker";
23
23
 
24
24
  export /**
25
25
  * @param {object} props
@@ -37,12 +37,18 @@ const View = ({ name, view, configuration, state }) => {
37
37
  node_id,
38
38
  connectors: { connect, drag },
39
39
  } = useNode((node) => ({ selected: node.events.selected, node_id: node.id }));
40
- const options = useContext(optionsCtx);
40
+ const options = React.useContext(optionsCtx);
41
41
 
42
- const views = options.views;
43
- const theview = views.find((v) => v.name === view);
42
+ let viewname = view;
43
+ if (viewname && viewname.includes(":")) {
44
+ const [prefix, rest] = viewname.split(":");
45
+ if (rest.startsWith(".")) viewname = prefix;
46
+ else viewname = rest;
47
+ }
48
+
49
+ const theview = options.views.find((v) => v.name === viewname);
44
50
  const label = theview ? theview.label : view;
45
- const { previews, setPreviews } = useContext(previewCtx);
51
+ const { previews, setPreviews } = React.useContext(previewCtx);
46
52
  const myPreview = previews[node_id];
47
53
  useEffect(() => {
48
54
  fetchViewPreview({
@@ -73,14 +79,13 @@ const View = ({ name, view, configuration, state }) => {
73
79
  };
74
80
 
75
81
  export /**
76
- * @returns {div}
82
+ * @returns
77
83
  * @category saltcorn-builder
78
84
  * @subcategory components
79
85
  * @namespace
80
86
  */
81
87
  const ViewSettings = () => {
82
88
  const node = useNode((node) => ({
83
- view_name: node.data.props.view_name,
84
89
  name: node.data.props.name,
85
90
  view: node.data.props.view,
86
91
  relation: node.data.props.relation,
@@ -99,13 +104,15 @@ const ViewSettings = () => {
99
104
  node_id,
100
105
  configuration,
101
106
  extra_state_fml,
102
- view_name,
103
107
  } = node;
104
- const options = useContext(optionsCtx);
105
- const views = options.views;
108
+ const options = React.useContext(optionsCtx);
109
+ const { caches, finder } = useMemo(
110
+ () => prepCacheAndFinder(options),
111
+ [undefined]
112
+ );
106
113
  const fixed_state_fields =
107
114
  options.fixed_state_fields && options.fixed_state_fields[view];
108
- const { setPreviews } = useContext(previewCtx);
115
+ const { setPreviews } = React.useContext(previewCtx);
109
116
 
110
117
  const setAProp = setAPropGen(setProp);
111
118
  let errorString = false;
@@ -115,33 +122,56 @@ const ViewSettings = () => {
115
122
  errorString = error.message;
116
123
  }
117
124
 
118
- let viewname = view_name || view;
125
+ let viewname = view;
126
+ let hasLegacyRelation = false;
119
127
  if (viewname && viewname.includes(":")) {
128
+ hasLegacyRelation = true;
120
129
  const [prefix, rest] = viewname.split(":");
121
130
  if (rest.startsWith(".")) viewname = prefix;
122
131
  else viewname = rest;
123
132
  }
124
133
  if (viewname.includes(".")) viewname = viewname.split(".")[0];
134
+ const [relations, setRelations] = finder
135
+ ? React.useState(
136
+ finder.findRelations(
137
+ options.tableName,
138
+ viewname,
139
+ options.excluded_subview_templates
140
+ )
141
+ )
142
+ : [undefined, undefined];
143
+ let safeRelation = relation;
144
+ if (!safeRelation && !hasLegacyRelation && relations?.paths.length > 0) {
145
+ safeRelation = relations.paths[0];
146
+ setProp((prop) => {
147
+ prop.relation = safeRelation;
148
+ });
149
+ }
125
150
  const helpContext = { view_name: viewname };
126
151
  if (options.tableName) helpContext.srcTable = options.tableName;
127
152
  const set_view_name = (e) => {
128
153
  if (e.target) {
129
154
  const target_value = e.target.value;
130
- setProp((prop) => (prop.view_name = target_value));
131
155
  if (target_value !== viewname) {
132
- setProp((prop) => {
133
- if (options.view_relation_opts[target_value]) {
134
- prop.view = options.view_relation_opts[target_value][0].value;
135
- prop.relation = undefined;
136
- }
137
- });
156
+ const newRelations = finder.findRelations(
157
+ options.tableName,
158
+ target_value,
159
+ options.excluded_subview_templates
160
+ );
161
+ if (newRelations.paths.length > 0) {
162
+ setProp((prop) => {
163
+ prop.view = target_value;
164
+ prop.relation = newRelations.paths[0];
165
+ });
166
+ setRelations(newRelations);
167
+ }
138
168
  }
139
169
  }
140
170
  };
141
171
 
142
172
  return (
143
173
  <div>
144
- {options.view_name_opts ? (
174
+ {relations ? (
145
175
  <Fragment>
146
176
  <div>
147
177
  <label>View to {options.mode === "show" ? "embed" : "show"}</label>
@@ -151,36 +181,39 @@ const ViewSettings = () => {
151
181
  onChange={set_view_name}
152
182
  onBlur={set_view_name}
153
183
  >
154
- {options.view_name_opts.map((f, ix) => (
155
- <option key={ix} value={f.name}>
156
- {f.label}
184
+ {options.views.map((v, ix) => (
185
+ <option key={ix} value={v.name}>
186
+ {v.label}
157
187
  </option>
158
188
  ))}
159
189
  </select>
160
190
  </div>
161
- <RelationPicker
162
- options={options}
163
- viewname={viewname}
164
- update={(relPath) => {
165
- if (relPath.startsWith(".")) {
166
- setProp((prop) => {
167
- prop.view = viewname;
168
- prop.relation = relPath;
169
- });
170
- } else {
171
- setProp((prop) => {
172
- prop.view = relPath;
173
- prop.relation = undefined;
174
- });
175
- }
176
- }}
177
- />
178
- <RelationBadges
179
- view={view}
180
- relation={relation}
181
- parentTbl={options.tableName}
182
- fk_options={options.fk_options}
183
- />
191
+ {
192
+ <div>
193
+ <RelationOnDemandPicker
194
+ relations={relations.layers}
195
+ update={(relPath) => {
196
+ if (relPath.startsWith(".")) {
197
+ setProp((prop) => {
198
+ prop.view = viewname;
199
+ prop.relation = relPath;
200
+ });
201
+ } else {
202
+ setProp((prop) => {
203
+ prop.view = relPath;
204
+ prop.relation = undefined;
205
+ });
206
+ }
207
+ }}
208
+ />
209
+ <RelationBadges
210
+ view={view}
211
+ relation={safeRelation}
212
+ parentTbl={options.tableName}
213
+ tableNameCache={caches.tableNameCache}
214
+ />
215
+ </div>
216
+ }
184
217
  </Fragment>
185
218
  ) : (
186
219
  <div>
@@ -191,7 +224,7 @@ const ViewSettings = () => {
191
224
  onChange={setAProp("view")}
192
225
  onBlur={setAProp("view")}
193
226
  >
194
- {views.map((f, ix) => (
227
+ {options.views.map((f, ix) => (
195
228
  <option key={ix} value={f.name}>
196
229
  {f.label || f.name}
197
230
  </option>