@saltcorn/server 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/locales/en.json +2 -1
- package/markup/admin.js +1 -1
- package/package.json +8 -8
- package/public/relation_helpers.js +348 -0
- package/public/saltcorn-common.js +22 -9
- package/public/saltcorn.js +16 -5
- package/routes/homepage.js +6 -1
- package/routes/plugins.js +1 -1
- package/tests/clientjs.test.js +23 -0
- package/tests/sync.test.js +4 -4
- package/tests/table.test.js +2 -2
- package/tests/view.test.js +58 -16
- package/wrapper.js +1 -0
package/locales/en.json
CHANGED
|
@@ -1285,5 +1285,6 @@
|
|
|
1285
1285
|
"Available themes": "Available themes",
|
|
1286
1286
|
"Install more themes »": "Install more themes »",
|
|
1287
1287
|
"Configure action": "Configure action",
|
|
1288
|
-
"No changes detected, snapshot skipped": "No changes detected, snapshot skipped"
|
|
1288
|
+
"No changes detected, snapshot skipped": "No changes detected, snapshot skipped",
|
|
1289
|
+
"Cannot remove module: views %s depend on it": "Cannot remove module: views %s depend on it"
|
|
1289
1290
|
}
|
package/markup/admin.js
CHANGED
|
@@ -52,7 +52,7 @@ const restore_backup = (csrf, inner, action = `/admin/restore`) =>
|
|
|
52
52
|
class: "d-none",
|
|
53
53
|
name: "file",
|
|
54
54
|
type: "file",
|
|
55
|
-
accept: "application/zip,.zip",
|
|
55
|
+
accept: "application/zip,.zip,.sczip",
|
|
56
56
|
onchange: "notifyAlert('Restoring backup...', true);this.form.submit();",
|
|
57
57
|
})
|
|
58
58
|
);
|
package/package.json
CHANGED
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@saltcorn/server",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.3-beta.0",
|
|
4
4
|
"description": "Server app for Saltcorn, open-source no-code platform",
|
|
5
5
|
"homepage": "https://saltcorn.com",
|
|
6
6
|
"main": "index.js",
|
|
7
7
|
"license": "MIT",
|
|
8
8
|
"dependencies": {
|
|
9
9
|
"@aws-sdk/client-s3": "^3.451.0",
|
|
10
|
-
"@saltcorn/base-plugin": "0.9.
|
|
11
|
-
"@saltcorn/builder": "0.9.
|
|
12
|
-
"@saltcorn/data": "0.9.
|
|
13
|
-
"@saltcorn/admin-models": "0.9.
|
|
14
|
-
"@saltcorn/filemanager": "0.9.
|
|
15
|
-
"@saltcorn/markup": "0.9.
|
|
16
|
-
"@saltcorn/sbadmin2": "0.9.
|
|
10
|
+
"@saltcorn/base-plugin": "0.9.3-beta.0",
|
|
11
|
+
"@saltcorn/builder": "0.9.3-beta.0",
|
|
12
|
+
"@saltcorn/data": "0.9.3-beta.0",
|
|
13
|
+
"@saltcorn/admin-models": "0.9.3-beta.0",
|
|
14
|
+
"@saltcorn/filemanager": "0.9.3-beta.0",
|
|
15
|
+
"@saltcorn/markup": "0.9.3-beta.0",
|
|
16
|
+
"@saltcorn/sbadmin2": "0.9.3-beta.0",
|
|
17
17
|
"@socket.io/cluster-adapter": "^0.2.1",
|
|
18
18
|
"@socket.io/sticky": "^1.0.1",
|
|
19
19
|
"adm-zip": "0.5.10",
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
var relationHelpers = (() => {
|
|
2
|
+
// internal helper to build an object, structured for the picker
|
|
3
|
+
const buildLayers = (path, pathArr, result) => {
|
|
4
|
+
let currentLevel = result;
|
|
5
|
+
for (const relation of pathArr) {
|
|
6
|
+
if (relation.type === "Inbound") {
|
|
7
|
+
const existing = currentLevel.inboundKeys.find(
|
|
8
|
+
(key) => key.name === relation.key && key.table === relation.table
|
|
9
|
+
);
|
|
10
|
+
if (existing) {
|
|
11
|
+
currentLevel = existing;
|
|
12
|
+
} else {
|
|
13
|
+
const nextLevel = {
|
|
14
|
+
name: relation.key,
|
|
15
|
+
table: relation.table,
|
|
16
|
+
inboundKeys: [],
|
|
17
|
+
fkeys: [],
|
|
18
|
+
};
|
|
19
|
+
currentLevel.inboundKeys.push(nextLevel);
|
|
20
|
+
currentLevel = nextLevel;
|
|
21
|
+
}
|
|
22
|
+
} else if (relation.type === "Foreign") {
|
|
23
|
+
const existing = currentLevel.fkeys.find(
|
|
24
|
+
(key) => key.name === relation.key
|
|
25
|
+
);
|
|
26
|
+
if (existing) {
|
|
27
|
+
currentLevel = existing;
|
|
28
|
+
} else {
|
|
29
|
+
const nextLevel = {
|
|
30
|
+
name: relation.key,
|
|
31
|
+
table: relation.table,
|
|
32
|
+
inboundKeys: [],
|
|
33
|
+
fkeys: [],
|
|
34
|
+
};
|
|
35
|
+
currentLevel.fkeys.push(nextLevel);
|
|
36
|
+
currentLevel = nextLevel;
|
|
37
|
+
}
|
|
38
|
+
} else if (relation.type === "Independent") {
|
|
39
|
+
currentLevel.fkeys.push({
|
|
40
|
+
name: "None (no relation)",
|
|
41
|
+
table: relation.table,
|
|
42
|
+
inboundKeys: [],
|
|
43
|
+
fkeys: [],
|
|
44
|
+
relPath: path,
|
|
45
|
+
});
|
|
46
|
+
} else if (relation.type === "Own") {
|
|
47
|
+
currentLevel.fkeys.push({
|
|
48
|
+
name: "Same table",
|
|
49
|
+
table: "",
|
|
50
|
+
inboundKeys: [],
|
|
51
|
+
fkeys: [],
|
|
52
|
+
relPath: path,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
currentLevel.relPath = path;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* build an array of relation objects from a path string
|
|
61
|
+
* '.' stands for no relation
|
|
62
|
+
* '.table' stands for same table
|
|
63
|
+
* @param {*} path relation path string separated by '.', the first token is the source table
|
|
64
|
+
* @param {*} tableNameCache an object with table name as key and table object as value
|
|
65
|
+
* @returns
|
|
66
|
+
*/
|
|
67
|
+
const parseRelationPath = (path, tableNameCache) => {
|
|
68
|
+
if (path === ".")
|
|
69
|
+
return [{ type: "Independent", table: "None (no relation)" }];
|
|
70
|
+
const tokens = path.split(".");
|
|
71
|
+
if (tokens.length === 2)
|
|
72
|
+
return [{ type: "Own", table: `${tokens[1]} (same table)` }];
|
|
73
|
+
else if (tokens.length >= 3) {
|
|
74
|
+
const result = [];
|
|
75
|
+
let currentTbl = tokens[1];
|
|
76
|
+
for (const relation of tokens.slice(2)) {
|
|
77
|
+
if (relation.indexOf("$") > 0) {
|
|
78
|
+
const [inboundTbl, inboundKey] = relation.split("$");
|
|
79
|
+
result.push({ type: "Inbound", table: inboundTbl, key: inboundKey });
|
|
80
|
+
currentTbl = inboundTbl;
|
|
81
|
+
} else {
|
|
82
|
+
const srcTbl = tableNameCache[currentTbl];
|
|
83
|
+
const fk = srcTbl.foreign_keys.find((fk) => fk.name === relation);
|
|
84
|
+
if (fk) {
|
|
85
|
+
const targetTbl = tableNameCache[fk.reftable_name];
|
|
86
|
+
result.push({
|
|
87
|
+
type: "Foreign",
|
|
88
|
+
table: targetTbl.name,
|
|
89
|
+
key: relation,
|
|
90
|
+
});
|
|
91
|
+
currentTbl = targetTbl.name;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return result;
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* build an array of relation objects from a legacy relation
|
|
101
|
+
* @param {string} type relation type (ChildList, Independent, Own, OneToOneShow, ParentShow)
|
|
102
|
+
* @param {string} rest rest of the legaccy relation
|
|
103
|
+
* @param {string} parentTbl source table
|
|
104
|
+
* @returns
|
|
105
|
+
*/
|
|
106
|
+
const parseLegacyRelation = (type, rest, parentTbl) => {
|
|
107
|
+
switch (type) {
|
|
108
|
+
case "ChildList": {
|
|
109
|
+
const path = rest ? rest.split(".") : [];
|
|
110
|
+
if (path.length === 3) {
|
|
111
|
+
const [viewName, table, key] = path;
|
|
112
|
+
return [
|
|
113
|
+
{
|
|
114
|
+
type: "Inbound",
|
|
115
|
+
table,
|
|
116
|
+
key,
|
|
117
|
+
},
|
|
118
|
+
];
|
|
119
|
+
} else if (path.length === 5) {
|
|
120
|
+
const [viewName, thrTbl, thrTblFkey, fromTbl, fromTblFkey] = path;
|
|
121
|
+
return [
|
|
122
|
+
{
|
|
123
|
+
type: "Inbound",
|
|
124
|
+
table: thrTbl,
|
|
125
|
+
key: thrTblFkey,
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
type: "Inbound",
|
|
129
|
+
table: fromTbl,
|
|
130
|
+
key: fromTblFkey,
|
|
131
|
+
},
|
|
132
|
+
];
|
|
133
|
+
}
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
case "Independent": {
|
|
137
|
+
return [{ type: "Independent", table: "None (no relation)" }];
|
|
138
|
+
}
|
|
139
|
+
case "Own": {
|
|
140
|
+
return [{ type: "Own", table: `${parentTbl} (same table)` }];
|
|
141
|
+
}
|
|
142
|
+
case "OneToOneShow": {
|
|
143
|
+
const tokens = rest ? rest.split(".") : [];
|
|
144
|
+
if (tokens.length !== 3) break;
|
|
145
|
+
const [viewname, relatedTbl, fkey] = tokens;
|
|
146
|
+
return [{ type: "Inbound", table: relatedTbl, key: fkey }];
|
|
147
|
+
}
|
|
148
|
+
case "ParentShow": {
|
|
149
|
+
const tokens = rest ? rest.split(".") : [];
|
|
150
|
+
if (tokens.length !== 3) break;
|
|
151
|
+
const [viewname, parentTbl, fkey] = tokens;
|
|
152
|
+
return [{ type: "Foreign", table: parentTbl, key: fkey }];
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return [];
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const ViewDisplayType = {
|
|
159
|
+
ROW_REQUIRED: "ROW_REQUIRED",
|
|
160
|
+
NO_ROW_LIMIT: "NO_ROW_LIMIT",
|
|
161
|
+
INVALID: "INVALID",
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* prepare the relations finder
|
|
166
|
+
* @param {object} tablesCache
|
|
167
|
+
* @param {object} allViews
|
|
168
|
+
* @param {number} maxDepth
|
|
169
|
+
*/
|
|
170
|
+
const RelationsFinder = function (tablesCache, allViews, maxDepth) {
|
|
171
|
+
this.maxDepth = +maxDepth;
|
|
172
|
+
this.allViews = allViews;
|
|
173
|
+
const { tableIdCache, tableNameCache, fieldCache } = tablesCache;
|
|
174
|
+
this.tableIdCache = tableIdCache;
|
|
175
|
+
this.tableNameCache = tableNameCache;
|
|
176
|
+
this.fieldCache = fieldCache;
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* find relations between a source table and a subview
|
|
181
|
+
* @param {string} sourceTblName
|
|
182
|
+
* @param {string} subView
|
|
183
|
+
* @param {string[]} excluded
|
|
184
|
+
* @returns {object} {paths: string[], layers: object}
|
|
185
|
+
*/
|
|
186
|
+
RelationsFinder.prototype.findRelations = function (
|
|
187
|
+
sourceTblName,
|
|
188
|
+
subView,
|
|
189
|
+
excluded
|
|
190
|
+
) {
|
|
191
|
+
let paths = [];
|
|
192
|
+
const layers = { table: sourceTblName, inboundKeys: [], fkeys: [] };
|
|
193
|
+
try {
|
|
194
|
+
const view = this.allViews.find((v) => v.name === subView);
|
|
195
|
+
if (!view) throw new Error(`The view ${subView} does not exist`);
|
|
196
|
+
if (excluded?.find((e) => e === view.viewtemplate)) {
|
|
197
|
+
console.log(`view ${subView} is excluded`);
|
|
198
|
+
return { paths, layers };
|
|
199
|
+
}
|
|
200
|
+
switch (view.display_type) {
|
|
201
|
+
case ViewDisplayType.ROW_REQUIRED:
|
|
202
|
+
paths = this.singleRelationPaths(sourceTblName, subView, excluded);
|
|
203
|
+
break;
|
|
204
|
+
case ViewDisplayType.NO_ROW_LIMIT:
|
|
205
|
+
paths = this.multiRelationPaths(sourceTblName, subView, excluded);
|
|
206
|
+
break;
|
|
207
|
+
default:
|
|
208
|
+
throw new Error(
|
|
209
|
+
`view ${subView}: The displayType (${view.display_type}) is not valid`
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
for (const path of paths)
|
|
213
|
+
buildLayers(path, parseRelationPath(path, this.tableNameCache), layers);
|
|
214
|
+
} catch (error) {
|
|
215
|
+
console.log(error);
|
|
216
|
+
} finally {
|
|
217
|
+
return { paths, layers };
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* find relations between a source table and a subview with single row display (e.g. show)
|
|
223
|
+
* @param {string} sourceTblName
|
|
224
|
+
* @param {string} subView
|
|
225
|
+
* @param {string[]} excluded
|
|
226
|
+
* @returns
|
|
227
|
+
*/
|
|
228
|
+
RelationsFinder.prototype.singleRelationPaths = function (
|
|
229
|
+
sourceTblName,
|
|
230
|
+
subView,
|
|
231
|
+
excluded
|
|
232
|
+
) {
|
|
233
|
+
const result = [];
|
|
234
|
+
const subViewObj = this.allViews.find((v) => v.name === subView);
|
|
235
|
+
if (!subViewObj) throw new Error(`The view ${subView} does not exist`);
|
|
236
|
+
if (excluded?.find((e) => e === subViewObj.viewtemplate)) {
|
|
237
|
+
console.log(`view ${subView} is excluded`);
|
|
238
|
+
return result;
|
|
239
|
+
}
|
|
240
|
+
const sourceTbl = this.tableNameCache[sourceTblName];
|
|
241
|
+
if (!sourceTbl)
|
|
242
|
+
throw new Error(`The table ${sourceTblName} does not exist`);
|
|
243
|
+
// 1. parent relations
|
|
244
|
+
const parentRelations = sourceTbl.foreign_keys;
|
|
245
|
+
if (sourceTbl.id === subViewObj.table_id) result.push(`.${sourceTblName}`);
|
|
246
|
+
for (const relation of parentRelations) {
|
|
247
|
+
const targetTbl = this.tableNameCache[relation.reftable_name];
|
|
248
|
+
if (!targetTbl)
|
|
249
|
+
throw new Error(`The table ${relation.reftable_name} does not exist`);
|
|
250
|
+
if (targetTbl.id === subViewObj.table_id)
|
|
251
|
+
result.push(`.${sourceTblName}.${relation.name}`);
|
|
252
|
+
}
|
|
253
|
+
// 2. OneToOneShow
|
|
254
|
+
const uniqueFksToSrc = (this.fieldCache[sourceTblName] || []).filter(
|
|
255
|
+
(f) => f.is_unique
|
|
256
|
+
);
|
|
257
|
+
for (const relation of uniqueFksToSrc) {
|
|
258
|
+
const targetTbl = this.tableIdCache[relation.table_id];
|
|
259
|
+
if (!targetTbl)
|
|
260
|
+
throw new Error(`The table ${relation.table_id} does not exist`);
|
|
261
|
+
if (targetTbl.id === subViewObj.table_id)
|
|
262
|
+
result.push(`.${sourceTblName}.${targetTbl.name}$${relation.name}`);
|
|
263
|
+
}
|
|
264
|
+
// 3. inbound_self_relations
|
|
265
|
+
const srcFks = sourceTbl.foreign_keys;
|
|
266
|
+
for (const fkToSrc of uniqueFksToSrc) {
|
|
267
|
+
const refTable = this.tableIdCache[fkToSrc.table_id];
|
|
268
|
+
if (!refTable)
|
|
269
|
+
throw new Error(`The table ${fkToSrc.table_id} does not exist`);
|
|
270
|
+
const fromSrcToRef = srcFks.filter(
|
|
271
|
+
(field) => field.reftable_name === refTable.name
|
|
272
|
+
);
|
|
273
|
+
for (const toRef of fromSrcToRef) {
|
|
274
|
+
if (fkToSrc.reftable_name === sourceTblName)
|
|
275
|
+
result.push(`.${sourceTblName}.${toRef.name}.${fkToSrc.name}`);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return result;
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* find relations between a source table and a subview with multiple rows display (e.g. list)
|
|
283
|
+
* @param {string} sourceTblName
|
|
284
|
+
* @param {string} subView
|
|
285
|
+
* @param {string[]} excluded
|
|
286
|
+
* @returns
|
|
287
|
+
*/
|
|
288
|
+
RelationsFinder.prototype.multiRelationPaths = function (
|
|
289
|
+
sourceTblName,
|
|
290
|
+
subView,
|
|
291
|
+
excluded
|
|
292
|
+
) {
|
|
293
|
+
const result = ["."]; // none no relation
|
|
294
|
+
const subViewObj = this.allViews.find((v) => v.name === subView);
|
|
295
|
+
if (!subViewObj) throw new Error(`The view ${subView} does not exist`);
|
|
296
|
+
if (excluded?.find((e) => e === subViewObj.viewtemplate)) {
|
|
297
|
+
console.log(`view ${subView} is excluded`);
|
|
298
|
+
return result;
|
|
299
|
+
}
|
|
300
|
+
const sourceTbl = this.tableNameCache[sourceTblName];
|
|
301
|
+
if (!sourceTbl)
|
|
302
|
+
throw new Error(`The table ${sourceTblName} does not exist`);
|
|
303
|
+
const searcher = (current, path, level, visited) => {
|
|
304
|
+
if (level > this.maxDepth) return;
|
|
305
|
+
const visitedFkCopy = new Set(visited);
|
|
306
|
+
const fks = current.foreign_keys.filter((f) => !visitedFkCopy.has(f.id));
|
|
307
|
+
for (const fk of fks) {
|
|
308
|
+
visitedFkCopy.add(fk.id);
|
|
309
|
+
const target = this.tableNameCache[fk.reftable_name];
|
|
310
|
+
if (!target)
|
|
311
|
+
throw new Error(`The table ${fk.reftable_name} does not exist`);
|
|
312
|
+
const newPath = `${path}.${fk.name}`;
|
|
313
|
+
if (target.id === subViewObj.table_id) result.push(newPath);
|
|
314
|
+
searcher(target, newPath, level + 1, visitedFkCopy);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const visitedInboundCopy = new Set(visited);
|
|
318
|
+
const inbounds = (this.fieldCache[current.name] || []).filter(
|
|
319
|
+
(f) => !visitedInboundCopy.has(f.id)
|
|
320
|
+
);
|
|
321
|
+
for (const inbound of inbounds) {
|
|
322
|
+
visitedInboundCopy.add(inbound.id);
|
|
323
|
+
const target = this.tableIdCache[inbound.table_id];
|
|
324
|
+
if (!target)
|
|
325
|
+
throw new Error(`The table ${inbound.table_id} does not exist`);
|
|
326
|
+
const newPath = `${path}.${target.name}$${inbound.name}`;
|
|
327
|
+
if (target.id === subViewObj.table_id) result.push(newPath);
|
|
328
|
+
searcher(target, newPath, level + 1, visitedInboundCopy);
|
|
329
|
+
}
|
|
330
|
+
};
|
|
331
|
+
const path = `.${sourceTblName}`;
|
|
332
|
+
const visited = new Set();
|
|
333
|
+
searcher(sourceTbl, path, 0, visited);
|
|
334
|
+
return result;
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
return {
|
|
338
|
+
RelationsFinder: RelationsFinder,
|
|
339
|
+
ViewDisplayType: ViewDisplayType,
|
|
340
|
+
parseRelationPath: parseRelationPath,
|
|
341
|
+
parseLegacyRelation: parseLegacyRelation,
|
|
342
|
+
};
|
|
343
|
+
})();
|
|
344
|
+
|
|
345
|
+
// make the module available for jest with react
|
|
346
|
+
if (typeof process !== "undefined" && process.env?.NODE_ENV === "test") {
|
|
347
|
+
module.exports = relationHelpers;
|
|
348
|
+
}
|
|
@@ -1101,6 +1101,10 @@ async function common_done(res, viewname, isWeb = true) {
|
|
|
1101
1101
|
const f = new Function(`viewname, row, {${res.field_names}}`, s);
|
|
1102
1102
|
const evalres = await f(viewname, res.row, res.row);
|
|
1103
1103
|
if (evalres) await common_done(evalres, viewname, isWeb);
|
|
1104
|
+
} else if (res.row) {
|
|
1105
|
+
const f = new Function(`viewname, row`, s);
|
|
1106
|
+
const evalres = await f(viewname, res.row);
|
|
1107
|
+
if (evalres) await common_done(evalres, viewname, isWeb);
|
|
1104
1108
|
} else {
|
|
1105
1109
|
const f = new Function(`viewname`, s);
|
|
1106
1110
|
const evalres = await f(viewname);
|
|
@@ -1138,16 +1142,25 @@ async function common_done(res, viewname, isWeb = true) {
|
|
|
1138
1142
|
});
|
|
1139
1143
|
}
|
|
1140
1144
|
if (res.set_fields && viewname) {
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
+
const form = $(`form[data-viewname=${viewname}]`);
|
|
1146
|
+
if (form.length === 0 && set_state_fields) {
|
|
1147
|
+
// assume this is a filter
|
|
1148
|
+
set_state_fields(
|
|
1149
|
+
res.set_fields,
|
|
1150
|
+
false
|
|
1151
|
+
// $(`[data-sc-embed-viewname="${viewname}"]`)
|
|
1145
1152
|
);
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1153
|
+
} else {
|
|
1154
|
+
Object.keys(res.set_fields).forEach((k) => {
|
|
1155
|
+
const input = form.find(
|
|
1156
|
+
`input[name=${k}], textarea[name=${k}], select[name=${k}]`
|
|
1157
|
+
);
|
|
1158
|
+
if (input.attr("type") === "checkbox")
|
|
1159
|
+
input.prop("checked", res.set_fields[k]);
|
|
1160
|
+
else input.val(res.set_fields[k]);
|
|
1161
|
+
input.trigger("set_form_field");
|
|
1162
|
+
});
|
|
1163
|
+
}
|
|
1151
1164
|
}
|
|
1152
1165
|
if (res.goto && !isWeb)
|
|
1153
1166
|
// TODO ch
|
package/public/saltcorn.js
CHANGED
|
@@ -37,12 +37,23 @@ function updateQueryStringParameter(uri1, key, value) {
|
|
|
37
37
|
var re = new RegExp("([?&])" + key + "=.*?(&|$)", "i");
|
|
38
38
|
var separator = uri.indexOf("?") !== -1 ? "&" : "?";
|
|
39
39
|
if (uri.match(re)) {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
40
|
+
if (Array.isArray(value)) {
|
|
41
|
+
var rmuri = removeQueryStringParameter(uri, key);
|
|
42
|
+
return updateQueryStringParameter(rmuri, key, value);
|
|
43
|
+
} else
|
|
44
|
+
return (
|
|
45
|
+
uri.replace(re, "$1" + key + "=" + encodeURIComponent(value) + "$2") +
|
|
46
|
+
hash
|
|
47
|
+
);
|
|
44
48
|
} else {
|
|
45
|
-
|
|
49
|
+
if (Array.isArray(value))
|
|
50
|
+
return (
|
|
51
|
+
uri +
|
|
52
|
+
separator +
|
|
53
|
+
value.map((val) => key + "=" + encodeURIComponent(val)).join("&") +
|
|
54
|
+
hash
|
|
55
|
+
);
|
|
56
|
+
else return uri + separator + key + "=" + encodeURIComponent(value) + hash;
|
|
46
57
|
}
|
|
47
58
|
}
|
|
48
59
|
|
package/routes/homepage.js
CHANGED
|
@@ -87,7 +87,12 @@ const viewTable = (views, req) =>
|
|
|
87
87
|
{
|
|
88
88
|
label: req.__("Edit"),
|
|
89
89
|
key: (r) =>
|
|
90
|
-
|
|
90
|
+
r.singleton
|
|
91
|
+
? ""
|
|
92
|
+
: link(
|
|
93
|
+
`/viewedit/edit/${encodeURIComponent(r.name)}`,
|
|
94
|
+
req.__("Edit")
|
|
95
|
+
),
|
|
91
96
|
},
|
|
92
97
|
],
|
|
93
98
|
views
|
package/routes/plugins.js
CHANGED
|
@@ -1243,6 +1243,7 @@ router.post(
|
|
|
1243
1243
|
}
|
|
1244
1244
|
await load_plugins.loadAndSaveNewPlugin(plugin, forceReInstall);
|
|
1245
1245
|
const plugin_module = getState().plugins[name];
|
|
1246
|
+
await sleep(1000); // Allow other workers to load this plugin
|
|
1246
1247
|
await getState().refresh_views();
|
|
1247
1248
|
|
|
1248
1249
|
if (plugin_module && plugin_module.configuration_workflow) {
|
|
@@ -1254,7 +1255,6 @@ router.post(
|
|
|
1254
1255
|
plugin_db.name
|
|
1255
1256
|
)
|
|
1256
1257
|
);
|
|
1257
|
-
await sleep(1000); // Allow other workers to load this plugin
|
|
1258
1258
|
res.redirect(`/plugins/configure/${plugin_db.name}`);
|
|
1259
1259
|
} else {
|
|
1260
1260
|
req.flash("success", req.__(`Module %s installed`, plugin.name));
|
package/tests/clientjs.test.js
CHANGED
|
@@ -47,6 +47,29 @@ test("updateQueryStringParameter", () => {
|
|
|
47
47
|
"AK"
|
|
48
48
|
)
|
|
49
49
|
).toBe("/foo?publisher.publisher->name=AK");
|
|
50
|
+
expect(updateQueryStringParameter("/foo", "_or_field", ["baz", "bar"])).toBe(
|
|
51
|
+
"/foo?_or_field=baz&_or_field=bar"
|
|
52
|
+
);
|
|
53
|
+
expect(
|
|
54
|
+
updateQueryStringParameter("/foo?_or_field=zoo", "_or_field", [
|
|
55
|
+
"baz",
|
|
56
|
+
"bar",
|
|
57
|
+
])
|
|
58
|
+
).toBe("/foo?_or_field=baz&_or_field=bar");
|
|
59
|
+
expect(
|
|
60
|
+
updateQueryStringParameter(
|
|
61
|
+
"/foo?_or_field=baz&_or_field=bar",
|
|
62
|
+
"_or_field",
|
|
63
|
+
["baz"]
|
|
64
|
+
)
|
|
65
|
+
).toBe("/foo?&_or_field=baz"); //or no ampersand
|
|
66
|
+
expect(
|
|
67
|
+
updateQueryStringParameter(
|
|
68
|
+
"/foo?_or_field=baz&_or_field=bar",
|
|
69
|
+
"_or_field",
|
|
70
|
+
[]
|
|
71
|
+
)
|
|
72
|
+
).toBe("/foo?&"); //or no ampersand / question mark
|
|
50
73
|
});
|
|
51
74
|
//publisher.publisher->name
|
|
52
75
|
test("updateQueryStringParameter hash", () => {
|
package/tests/sync.test.js
CHANGED
|
@@ -193,7 +193,7 @@ describe("load remote insert/updates", () => {
|
|
|
193
193
|
.post("/table")
|
|
194
194
|
.set("Cookie", loginCookie)
|
|
195
195
|
.send(`name=${encodeURIComponent("Table with capitals")}`)
|
|
196
|
-
.expect(toRedirect("/table/
|
|
196
|
+
.expect(toRedirect("/table/26"));
|
|
197
197
|
// add a field
|
|
198
198
|
await request(app)
|
|
199
199
|
.post("/field/")
|
|
@@ -202,17 +202,17 @@ describe("load remote insert/updates", () => {
|
|
|
202
202
|
.send("label=StringField")
|
|
203
203
|
.send("type=String")
|
|
204
204
|
.send(
|
|
205
|
-
`contextEnc=${encodeURIComponent(JSON.stringify({ table_id:
|
|
205
|
+
`contextEnc=${encodeURIComponent(JSON.stringify({ table_id: 26 }))}`
|
|
206
206
|
)
|
|
207
207
|
.set("Cookie", loginCookie)
|
|
208
208
|
.expect(toInclude("options"));
|
|
209
209
|
// init sync_info table
|
|
210
210
|
await request(app)
|
|
211
211
|
.post("/table")
|
|
212
|
-
.send("id=
|
|
212
|
+
.send("id=26")
|
|
213
213
|
.send("has_sync_info=on")
|
|
214
214
|
.set("Cookie", loginCookie)
|
|
215
|
-
.expect(toRedirect("/table/
|
|
215
|
+
.expect(toRedirect("/table/26"));
|
|
216
216
|
const dbTime = await db.time();
|
|
217
217
|
|
|
218
218
|
// call load changes
|
package/tests/table.test.js
CHANGED
|
@@ -39,7 +39,7 @@ describe("Table Endpoints", () => {
|
|
|
39
39
|
.post("/table/")
|
|
40
40
|
.send("name=mypostedtable")
|
|
41
41
|
.set("Cookie", loginCookie)
|
|
42
|
-
.expect(toRedirect("/table/
|
|
42
|
+
.expect(toRedirect("/table/26"));
|
|
43
43
|
await request(app)
|
|
44
44
|
.get("/table/10")
|
|
45
45
|
.set("Cookie", loginCookie)
|
|
@@ -155,7 +155,7 @@ Pencil, 0.5,2, t`;
|
|
|
155
155
|
.set("Cookie", loginCookie)
|
|
156
156
|
.field("name", "expenses")
|
|
157
157
|
.attach("file", Buffer.from(csv, "utf-8"))
|
|
158
|
-
.expect(toRedirect("/table/
|
|
158
|
+
.expect(toRedirect("/table/27"));
|
|
159
159
|
});
|
|
160
160
|
it("should upload csv to existing table", async () => {
|
|
161
161
|
const csv = `author,Pages
|
package/tests/view.test.js
CHANGED
|
@@ -17,9 +17,6 @@ const View = require("@saltcorn/data/models/view");
|
|
|
17
17
|
const Table = require("@saltcorn/data/models/table");
|
|
18
18
|
|
|
19
19
|
const { plugin_with_routes } = require("@saltcorn/data/tests/mocks");
|
|
20
|
-
const {
|
|
21
|
-
prepareArtistsAlbumRelation,
|
|
22
|
-
} = require("@saltcorn/data/tests/common_helpers");
|
|
23
20
|
|
|
24
21
|
afterAll(db.close);
|
|
25
22
|
beforeAll(async () => {
|
|
@@ -523,7 +520,7 @@ describe("inbound relations", () => {
|
|
|
523
520
|
|
|
524
521
|
await request(app)
|
|
525
522
|
.get(
|
|
526
|
-
`/view/blog_posts_feed?
|
|
523
|
+
`/view/blog_posts_feed?_relation_path_=${encodeURIComponent(
|
|
527
524
|
JSON.stringify(queryObj)
|
|
528
525
|
)}`
|
|
529
526
|
)
|
|
@@ -535,7 +532,7 @@ describe("inbound relations", () => {
|
|
|
535
532
|
queryObj.srcId = 2;
|
|
536
533
|
await request(app)
|
|
537
534
|
.get(
|
|
538
|
-
`/view/blog_posts_feed?
|
|
535
|
+
`/view/blog_posts_feed?_relation_path_=${encodeURIComponent(
|
|
539
536
|
JSON.stringify(queryObj)
|
|
540
537
|
)}`
|
|
541
538
|
)
|
|
@@ -547,7 +544,7 @@ describe("inbound relations", () => {
|
|
|
547
544
|
queryObj.srcId = 3;
|
|
548
545
|
await request(app)
|
|
549
546
|
.get(
|
|
550
|
-
`/view/blog_posts_feed?
|
|
547
|
+
`/view/blog_posts_feed?_relation_path_=${encodeURIComponent(
|
|
551
548
|
JSON.stringify(queryObj)
|
|
552
549
|
)}`
|
|
553
550
|
)
|
|
@@ -567,7 +564,7 @@ describe("inbound relations", () => {
|
|
|
567
564
|
const loginCookie = await getAdminLoginCookie();
|
|
568
565
|
await request(app)
|
|
569
566
|
.get(
|
|
570
|
-
`/view/blog_posts_feed?
|
|
567
|
+
`/view/blog_posts_feed?_relation_path_=${encodeURIComponent(
|
|
571
568
|
JSON.stringify(queryObj)
|
|
572
569
|
)}`
|
|
573
570
|
)
|
|
@@ -579,7 +576,7 @@ describe("inbound relations", () => {
|
|
|
579
576
|
queryObj.srcId = 2;
|
|
580
577
|
await request(app)
|
|
581
578
|
.get(
|
|
582
|
-
`/view/blog_posts_feed?
|
|
579
|
+
`/view/blog_posts_feed?_relation_path_=${encodeURIComponent(
|
|
583
580
|
JSON.stringify(queryObj)
|
|
584
581
|
)}`
|
|
585
582
|
)
|
|
@@ -591,10 +588,6 @@ describe("inbound relations", () => {
|
|
|
591
588
|
});
|
|
592
589
|
|
|
593
590
|
describe("many to many relations", () => {
|
|
594
|
-
beforeAll(async () => {
|
|
595
|
-
await prepareArtistsAlbumRelation();
|
|
596
|
-
});
|
|
597
|
-
|
|
598
591
|
it("artist_plays_on_album", async () => {
|
|
599
592
|
const app = await getApp({ disableCsrf: true });
|
|
600
593
|
const loginCookie = await getAdminLoginCookie();
|
|
@@ -621,7 +614,7 @@ describe("many to many relations", () => {
|
|
|
621
614
|
};
|
|
622
615
|
await request(app)
|
|
623
616
|
.get(
|
|
624
|
-
`/view/albums_feed?
|
|
617
|
+
`/view/albums_feed?_relation_path_=${encodeURIComponent(
|
|
625
618
|
JSON.stringify(queryObj_1)
|
|
626
619
|
)}`
|
|
627
620
|
)
|
|
@@ -635,7 +628,7 @@ describe("many to many relations", () => {
|
|
|
635
628
|
};
|
|
636
629
|
await request(app)
|
|
637
630
|
.get(
|
|
638
|
-
`/view/albums_feed?
|
|
631
|
+
`/view/albums_feed?_relation_path_=${encodeURIComponent(
|
|
639
632
|
JSON.stringify(queryObj_2)
|
|
640
633
|
)}`
|
|
641
634
|
)
|
|
@@ -655,7 +648,7 @@ describe("many to many relations", () => {
|
|
|
655
648
|
};
|
|
656
649
|
await request(app)
|
|
657
650
|
.get(
|
|
658
|
-
`/view/fan_club_feed?
|
|
651
|
+
`/view/fan_club_feed?_relation_path_=${encodeURIComponent(
|
|
659
652
|
JSON.stringify(queryObj_1)
|
|
660
653
|
)}`
|
|
661
654
|
)
|
|
@@ -672,7 +665,7 @@ describe("many to many relations", () => {
|
|
|
672
665
|
};
|
|
673
666
|
await request(app)
|
|
674
667
|
.get(
|
|
675
|
-
`/view/fan_club_feed?
|
|
668
|
+
`/view/fan_club_feed?_relation_path_=${encodeURIComponent(
|
|
676
669
|
JSON.stringify(queryObj_2)
|
|
677
670
|
)}`
|
|
678
671
|
)
|
|
@@ -683,3 +676,52 @@ describe("many to many relations", () => {
|
|
|
683
676
|
.expect(toInclude("fan club official"));
|
|
684
677
|
});
|
|
685
678
|
});
|
|
679
|
+
|
|
680
|
+
describe("legacy relations with relation path", () => {
|
|
681
|
+
it("Independent feed", async () => {
|
|
682
|
+
const app = await getApp({ disableCsrf: true });
|
|
683
|
+
const loginCookie = await getAdminLoginCookie();
|
|
684
|
+
|
|
685
|
+
const queryObj = {
|
|
686
|
+
relation: ".",
|
|
687
|
+
srcId: 1,
|
|
688
|
+
};
|
|
689
|
+
await request(app)
|
|
690
|
+
.get(
|
|
691
|
+
`/view/fan_club_feed?_relation_path_=${encodeURIComponent(
|
|
692
|
+
JSON.stringify(queryObj)
|
|
693
|
+
)}`
|
|
694
|
+
)
|
|
695
|
+
.set("Cookie", loginCookie)
|
|
696
|
+
.expect(toInclude("crazy fan club"))
|
|
697
|
+
.expect(toInclude("another club"))
|
|
698
|
+
.expect(toInclude("fan club"))
|
|
699
|
+
.expect(toInclude("fan club official"));
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
it("Independent feed as subview", async () => {
|
|
703
|
+
const app = await getApp({ disableCsrf: true });
|
|
704
|
+
const loginCookie = await getAdminLoginCookie();
|
|
705
|
+
await request(app)
|
|
706
|
+
.get("/view/show_pressing_job_with_new_indenpendent_relation_path?id=1")
|
|
707
|
+
.set("Cookie", loginCookie)
|
|
708
|
+
.expect(toInclude("crazy fan club"))
|
|
709
|
+
.expect(toInclude("another club"))
|
|
710
|
+
.expect(toInclude("fan club"))
|
|
711
|
+
.expect(toInclude("fan club official"));
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
it("Own same table subview", async () => {
|
|
715
|
+
const app = await getApp({ disableCsrf: true });
|
|
716
|
+
const loginCookie = await getAdminLoginCookie();
|
|
717
|
+
|
|
718
|
+
await request(app)
|
|
719
|
+
.get("/view/show_album_with_subview_new_relation_path?id=1")
|
|
720
|
+
.set("Cookie", loginCookie)
|
|
721
|
+
.expect(toInclude("album A"));
|
|
722
|
+
await request(app)
|
|
723
|
+
.get("/view/show_album_with_subview_new_relation_path?id=2")
|
|
724
|
+
.set("Cookie", loginCookie)
|
|
725
|
+
.expect(toInclude("album B"));
|
|
726
|
+
});
|
|
727
|
+
});
|
package/wrapper.js
CHANGED
|
@@ -195,6 +195,7 @@ const get_headers = (req, version_tag, description, extras = []) => {
|
|
|
195
195
|
{ script: `/static_assets/${version_tag}/saltcorn-common.js` },
|
|
196
196
|
{ script: `/static_assets/${version_tag}/saltcorn.js` },
|
|
197
197
|
{ script: `/static_assets/${version_tag}/dayjs.min.js` },
|
|
198
|
+
{ script: `/static_assets/${version_tag}/relation_helpers.js` },
|
|
198
199
|
];
|
|
199
200
|
let from_cfg = [];
|
|
200
201
|
if (state.getConfig("page_custom_css", ""))
|