sdc_client 0.58.5 → 0.158.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/.github/workflows/node.js.yml +2 -2
- package/.idea/inspectionProfiles/Project_Default.xml +17 -0
- package/.idea/workspace.xml +122 -23
- package/.readthedocs.yaml +13 -0
- package/dist/index.js +70 -51
- package/dist/ugly.index.js +1 -1
- package/docs/api-reference.rst +225 -0
- package/docs/conf.py +11 -0
- package/docs/controllers.rst +228 -0
- package/docs/events-and-dom.rst +120 -0
- package/docs/getting-started.rst +143 -0
- package/docs/index.rst +22 -0
- package/docs/models.rst +351 -0
- package/docs/overview.rst +66 -0
- package/docs/requirements.txt +1 -0
- package/eslint.config.js +60 -0
- package/gulp/gulp.jsx +15 -13
- package/package.json +13 -3
- package/src/index.js +44 -11
- package/src/simpleDomControl/AbstractSDC.js +287 -286
- package/src/simpleDomControl/sdc_controller.js +157 -132
- package/src/simpleDomControl/sdc_dom_events.js +99 -88
- package/src/simpleDomControl/sdc_events.js +39 -40
- package/src/simpleDomControl/sdc_main.js +88 -67
- package/src/simpleDomControl/sdc_model.js +1510 -0
- package/src/simpleDomControl/sdc_params.js +44 -29
- package/src/simpleDomControl/sdc_server_call.js +153 -154
- package/src/simpleDomControl/sdc_socket.js +8 -821
- package/src/simpleDomControl/sdc_test_utils.js +77 -80
- package/src/simpleDomControl/sdc_utils.js +295 -176
- package/src/simpleDomControl/sdc_view.js +245 -177
- package/test/model.test.js +332 -0
- package/test/models/Author.js +145 -0
- package/test/models/Book.js +112 -0
- package/test/models/BookContent.js +114 -0
- package/test/models/SdcUser.js +75 -0
- package/test/models/src.js +8 -0
- package/test/sdc_model_dates.ai.test.js +57 -0
- package/test/sdc_server_call.ai.test.js +267 -0
|
@@ -0,0 +1,1510 @@
|
|
|
1
|
+
import {getValueFromField, setValueInField, uuidv4} from "./sdc_utils.js";
|
|
2
|
+
import {getModel} from "./sdc_socket.js"
|
|
3
|
+
import {app} from "./sdc_main.js";
|
|
4
|
+
import {trigger} from "./sdc_events.js";
|
|
5
|
+
|
|
6
|
+
const MAX_FILE_UPLOAD = 25000;
|
|
7
|
+
const CONNECTING_REQUEST_ID = "_connecting_process";
|
|
8
|
+
|
|
9
|
+
class SdcModelError extends Error {
|
|
10
|
+
constructor(props) {
|
|
11
|
+
if (typeof props === 'string') {
|
|
12
|
+
props = {
|
|
13
|
+
msg: props,
|
|
14
|
+
is_error: true,
|
|
15
|
+
header: 'Error'
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
super(props.msg)
|
|
19
|
+
this.msg = props.msg;
|
|
20
|
+
this.header = props.header;
|
|
21
|
+
this.is_error = props.is_error;
|
|
22
|
+
this.html = props.html;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
class FileLoaded {
|
|
27
|
+
constructor({name, url}) {
|
|
28
|
+
this.name = name;
|
|
29
|
+
this.url = url;
|
|
30
|
+
this.content = null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async load(force = false) {
|
|
34
|
+
if (force || !this.content) {
|
|
35
|
+
this.content = await new Promise((resolve, reject) => {
|
|
36
|
+
$.get(this.url).then(resolve).catch(
|
|
37
|
+
() => {
|
|
38
|
+
resolve('');
|
|
39
|
+
}
|
|
40
|
+
);
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return this.content;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async text() {
|
|
48
|
+
return this.load();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
static isValid(value) {
|
|
52
|
+
return (
|
|
53
|
+
value !== null &&
|
|
54
|
+
typeof value === "object" &&
|
|
55
|
+
!Array.isArray(value) &&
|
|
56
|
+
!(value instanceof File) &&
|
|
57
|
+
typeof value.url === "string" &&
|
|
58
|
+
typeof value.name === "string"
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Normalize an incoming primary key value.
|
|
67
|
+
*
|
|
68
|
+
* `-1` is used throughout this module as the sentinel for "new object / no pk".
|
|
69
|
+
*
|
|
70
|
+
* @param {*} pk
|
|
71
|
+
* @returns {number}
|
|
72
|
+
*/
|
|
73
|
+
function normalizePk(pk) {
|
|
74
|
+
const normalizedPk = parseInt(pk ?? -1, 10);
|
|
75
|
+
return Number.isNaN(normalizedPk) ? -1 : normalizedPk;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export class SdcQuerySet {
|
|
79
|
+
/**
|
|
80
|
+
* Lightweight client-side collection wrapper around the SDC model websocket.
|
|
81
|
+
*
|
|
82
|
+
* The queryset exposes array-like access through a proxy while also keeping
|
|
83
|
+
* track of socket state and outstanding requests for that model.
|
|
84
|
+
*
|
|
85
|
+
* @param {string} modelName
|
|
86
|
+
* @param {object} modelQuery
|
|
87
|
+
*/
|
|
88
|
+
constructor(modelName, modelQuery = {}) {
|
|
89
|
+
this.valuesList = [];
|
|
90
|
+
this.loaded = false;
|
|
91
|
+
this.modelName = modelName;
|
|
92
|
+
this.modelQuery = modelQuery ?? {};
|
|
93
|
+
this._onNoOpenRequests = [];
|
|
94
|
+
this._isConnected = false;
|
|
95
|
+
this._isConnectingProcess = false;
|
|
96
|
+
this._autoReconnect = true;
|
|
97
|
+
this.socket = null;
|
|
98
|
+
// Request resolvers are stored by websocket event id until the server responds.
|
|
99
|
+
this.openRequest = {};
|
|
100
|
+
this.modelId = 0;
|
|
101
|
+
this.onUpdate = () => {
|
|
102
|
+
};
|
|
103
|
+
this.onCreate = () => {
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
return new Proxy(this, {
|
|
107
|
+
get(target, prop) {
|
|
108
|
+
if (typeof prop !== "symbol" && !isNaN(prop)) {
|
|
109
|
+
return target.valuesList[prop];
|
|
110
|
+
}
|
|
111
|
+
return target[prop];
|
|
112
|
+
},
|
|
113
|
+
|
|
114
|
+
set(target, prop, value) {
|
|
115
|
+
if (!isNaN(prop)) {
|
|
116
|
+
target.valuesList[prop] = value;
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
target[prop] = value;
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
[Symbol.iterator]() {
|
|
126
|
+
let idx = -1;
|
|
127
|
+
return {
|
|
128
|
+
next: () => {
|
|
129
|
+
++idx;
|
|
130
|
+
if (idx < this.valuesList.length) {
|
|
131
|
+
return {value: this.valuesList[idx], done: false};
|
|
132
|
+
}
|
|
133
|
+
return {value: null, done: true};
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
set on_create(handler) {
|
|
139
|
+
this.onCreate = handler;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
set on_update(handler) {
|
|
143
|
+
this.onUpdate = handler;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
*
|
|
148
|
+
*
|
|
149
|
+
* @param {Array<integr|string>|integer|SdcModel|SdcQuerySet|string} ids
|
|
150
|
+
*/
|
|
151
|
+
setIds(ids) {
|
|
152
|
+
|
|
153
|
+
if (ids === null || Array.isArray(ids) && ids.length === 0) {
|
|
154
|
+
this.valuesList = [];
|
|
155
|
+
return this.valuesList;
|
|
156
|
+
} else if (ids instanceof SdcQuerySet) {
|
|
157
|
+
this.valuesList = structuredClone(ids.valuesList);
|
|
158
|
+
this.valuesList.forEach(value => value._setQuerySet(this, true));
|
|
159
|
+
return this.valuesList;
|
|
160
|
+
} else if (ids instanceof SdcModel) {
|
|
161
|
+
this.valuesList = [structuredClone(ids)];
|
|
162
|
+
this.valuesList.forEach(value => value._setQuerySet(this, true));
|
|
163
|
+
return this.valuesList;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
let numList, numId = Number.NaN;
|
|
167
|
+
if (Number.isInteger(ids)) {
|
|
168
|
+
numId = ids;
|
|
169
|
+
} else if (ids instanceof Array) {
|
|
170
|
+
const tempNumList = ids.map((x) => parseInt(x));
|
|
171
|
+
if (!tempNumList.some(Number.isNaN)) {
|
|
172
|
+
numList = tempNumList;
|
|
173
|
+
}
|
|
174
|
+
} else if (ids instanceof String) {
|
|
175
|
+
const tempNumList = ids.split(',').map((x) => parseInt(x));
|
|
176
|
+
if (!tempNumList.some(Number.isNaN)) {
|
|
177
|
+
numList = tempNumList
|
|
178
|
+
} else {
|
|
179
|
+
numId = parseInt(ids);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (!Number.isNaN(numId)) {
|
|
184
|
+
const newModel = this.new();
|
|
185
|
+
newModel.id = numId;
|
|
186
|
+
} else if (numList) {
|
|
187
|
+
this.valuesList = this.valuesList.filter((item) => numList.includes(item.id));
|
|
188
|
+
const valueIds = this.getIds();
|
|
189
|
+
numList.filter(x => !valueIds.includes(x)).forEach((id) => {
|
|
190
|
+
this.valuesList.push(new (getModel(this.modelName))({id}));
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
return this.valuesList;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
getIds() {
|
|
197
|
+
return this.valuesList.map(x => x.id);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Number of model instances currently present in the queryset cache.
|
|
202
|
+
*
|
|
203
|
+
* @returns {number}
|
|
204
|
+
*/
|
|
205
|
+
get length() {
|
|
206
|
+
return this.valuesList.length;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Resolve a model instance by id, loading the queryset first if needed.
|
|
211
|
+
*
|
|
212
|
+
* @param {*} id
|
|
213
|
+
* @returns {SdcModel|null}
|
|
214
|
+
*/
|
|
215
|
+
byId(id) {
|
|
216
|
+
if (id !== null) {
|
|
217
|
+
const normalizedId = normalizePk(id);
|
|
218
|
+
return this.valuesList.find((elm) => elm.id === normalizedId) ?? null;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Merge additional query constraints into the current queryset.
|
|
226
|
+
*
|
|
227
|
+
* @param {object} modelQuery
|
|
228
|
+
* @returns {SdcQuerySet}
|
|
229
|
+
*/
|
|
230
|
+
setFilter(modelQuery) {
|
|
231
|
+
this.modelQuery = modelQuery;
|
|
232
|
+
return this;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
addFilter(modelQuery) {
|
|
236
|
+
this.modelQuery = Object.assign({}, this.modelQuery, modelQuery);
|
|
237
|
+
return this;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* @param {?object} values
|
|
242
|
+
* @returns {SdcModel}
|
|
243
|
+
*/
|
|
244
|
+
new(values = {}) {
|
|
245
|
+
const newModel = new (getModel(this.modelName))(values);
|
|
246
|
+
newModel._setQuerySet(this, false);
|
|
247
|
+
this.valuesList.push(newModel);
|
|
248
|
+
return newModel;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Load model instances matching the current query into the queryset cache.
|
|
253
|
+
*
|
|
254
|
+
* @param {?object} modelQuery
|
|
255
|
+
* @returns {Promise<SdcQuerySet>}
|
|
256
|
+
*/
|
|
257
|
+
load(modelQuery = null) {
|
|
258
|
+
this.valuesList = [];
|
|
259
|
+
this.modelQuery = modelQuery ?? this.modelQuery;
|
|
260
|
+
return this._sendLoad();
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Load model instances matching the current query into the queryset cache.
|
|
265
|
+
*
|
|
266
|
+
* @param {?object} modelQuery
|
|
267
|
+
* @param {?SdcModel} item
|
|
268
|
+
* @returns {Promise<SdcQuerySet>}
|
|
269
|
+
*/
|
|
270
|
+
update({modelQuery = null, item = null}) {
|
|
271
|
+
this.modelQuery = modelQuery ?? this.modelQuery;
|
|
272
|
+
let loadQuery = item ? {pk: item.id} : this.modelQuery;
|
|
273
|
+
return this._sendLoad(loadQuery);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
*
|
|
278
|
+
* @param pk {integr}
|
|
279
|
+
* @param elem {SdcModel}
|
|
280
|
+
* @returns {Promise<unknown>}
|
|
281
|
+
*/
|
|
282
|
+
delete({pk = null, elem = null}) {
|
|
283
|
+
pk = !elem ? pk : elem.id;
|
|
284
|
+
if (pk === null) {
|
|
285
|
+
throw new Error("pk or elem must be set");
|
|
286
|
+
}
|
|
287
|
+
const id = uuidv4();
|
|
288
|
+
return this.isConnected().then(() => {
|
|
289
|
+
return new Promise((resolve, reject) => {
|
|
290
|
+
this.socket.send(
|
|
291
|
+
JSON.stringify({
|
|
292
|
+
event: "model",
|
|
293
|
+
event_type: "delete",
|
|
294
|
+
event_id: id,
|
|
295
|
+
args: {
|
|
296
|
+
model_name: this.modelName,
|
|
297
|
+
model_query: this.modelQuery,
|
|
298
|
+
pk,
|
|
299
|
+
},
|
|
300
|
+
}),
|
|
301
|
+
);
|
|
302
|
+
|
|
303
|
+
this.openRequest[id] = [resolve, reject];
|
|
304
|
+
});
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Request the server-side queryset data over the model websocket.
|
|
310
|
+
*
|
|
311
|
+
* @returns {Promise<*>}
|
|
312
|
+
*/
|
|
313
|
+
_sendLoad(loadQuery = null) {
|
|
314
|
+
return this.isConnected().then(() => {
|
|
315
|
+
const id = uuidv4();
|
|
316
|
+
return new Promise((resolve, reject) => {
|
|
317
|
+
this.socket.send(
|
|
318
|
+
JSON.stringify({
|
|
319
|
+
event: "model",
|
|
320
|
+
event_type: "load",
|
|
321
|
+
event_id: id,
|
|
322
|
+
args: {
|
|
323
|
+
model_name: this.modelName,
|
|
324
|
+
model_query: loadQuery ?? this.modelQuery,
|
|
325
|
+
},
|
|
326
|
+
}),
|
|
327
|
+
);
|
|
328
|
+
|
|
329
|
+
this.openRequest[id] = [(data)=> {
|
|
330
|
+
this.loaded = true;
|
|
331
|
+
resolve(data);
|
|
332
|
+
}, reject];
|
|
333
|
+
});
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Render the list-view endpoint for the current model.
|
|
339
|
+
*
|
|
340
|
+
* @param {object} options
|
|
341
|
+
* @returns {*}
|
|
342
|
+
*/
|
|
343
|
+
_sendListView({
|
|
344
|
+
modelQuery = {},
|
|
345
|
+
cbResolve = null,
|
|
346
|
+
cbReject = null,
|
|
347
|
+
templateContext = {},
|
|
348
|
+
}) {
|
|
349
|
+
return this.view({
|
|
350
|
+
modelQuery,
|
|
351
|
+
cbResolve,
|
|
352
|
+
cbReject,
|
|
353
|
+
templateContext,
|
|
354
|
+
eventType: "list_view",
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Render a named model view and append its HTML to a container element.
|
|
360
|
+
*
|
|
361
|
+
* @param {object} options
|
|
362
|
+
* @returns {*}
|
|
363
|
+
*/
|
|
364
|
+
view({
|
|
365
|
+
viewName = "html_list_template",
|
|
366
|
+
modelQuery = {},
|
|
367
|
+
cbResolve = null,
|
|
368
|
+
cbReject = null,
|
|
369
|
+
templateContext = {},
|
|
370
|
+
eventType = "named_view",
|
|
371
|
+
}) {
|
|
372
|
+
let $divList = $('<div class="container-fluid">');
|
|
373
|
+
this.isConnected().then(() => {
|
|
374
|
+
const id = uuidv4();
|
|
375
|
+
this.socket.send(
|
|
376
|
+
JSON.stringify({
|
|
377
|
+
event: "model",
|
|
378
|
+
event_type: eventType,
|
|
379
|
+
event_id: id,
|
|
380
|
+
args: {
|
|
381
|
+
view_name: viewName,
|
|
382
|
+
model_name: this.modelName,
|
|
383
|
+
model_query: modelQuery,
|
|
384
|
+
template_context: templateContext,
|
|
385
|
+
},
|
|
386
|
+
}),
|
|
387
|
+
);
|
|
388
|
+
|
|
389
|
+
this.openRequest[id] = [
|
|
390
|
+
(data) => {
|
|
391
|
+
$divList.append(data.html);
|
|
392
|
+
app.refresh($divList);
|
|
393
|
+
cbResolve && cbResolve(data);
|
|
394
|
+
},
|
|
395
|
+
(res) => {
|
|
396
|
+
cbReject && cbReject(res);
|
|
397
|
+
},
|
|
398
|
+
];
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
return $divList;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Render the detail-view endpoint for a single model instance.
|
|
406
|
+
*
|
|
407
|
+
* @param {object} options
|
|
408
|
+
* @returns {*}
|
|
409
|
+
*/
|
|
410
|
+
_sendDetailView({
|
|
411
|
+
pk = null,
|
|
412
|
+
cbResolve = null,
|
|
413
|
+
cbReject = null,
|
|
414
|
+
templateContext = {},
|
|
415
|
+
}) {
|
|
416
|
+
pk = normalizePk(pk);
|
|
417
|
+
let $divList = $('<div class="container-fluid">');
|
|
418
|
+
|
|
419
|
+
this.isConnected().then(() => {
|
|
420
|
+
const id = uuidv4();
|
|
421
|
+
this.socket.send(
|
|
422
|
+
JSON.stringify({
|
|
423
|
+
event: "model",
|
|
424
|
+
event_type: "detail_view",
|
|
425
|
+
event_id: id,
|
|
426
|
+
args: {
|
|
427
|
+
model_name: this.modelName,
|
|
428
|
+
model_query: this.modelQuery,
|
|
429
|
+
pk,
|
|
430
|
+
template_context: templateContext,
|
|
431
|
+
},
|
|
432
|
+
}),
|
|
433
|
+
);
|
|
434
|
+
|
|
435
|
+
this.openRequest[id] = [
|
|
436
|
+
(data) => {
|
|
437
|
+
$divList.append(data.html);
|
|
438
|
+
app.refresh($divList);
|
|
439
|
+
cbResolve && cbResolve(data);
|
|
440
|
+
},
|
|
441
|
+
(res) => {
|
|
442
|
+
cbReject && cbReject(res);
|
|
443
|
+
},
|
|
444
|
+
];
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
return $divList;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Fetch a model form fragment and attach the SDC form metadata expected by
|
|
452
|
+
* the rest of the client.
|
|
453
|
+
*
|
|
454
|
+
* @param {string} eventType
|
|
455
|
+
* @param {SdcModel} modelObj
|
|
456
|
+
*/
|
|
457
|
+
getForm({modelObj, eventType, formName, $divForm, cbResolve, cbReject, formId}) {
|
|
458
|
+
const id = uuidv4();
|
|
459
|
+
const pk = modelObj.id ?? -1;
|
|
460
|
+
this.isConnected().then(() => {
|
|
461
|
+
this.socket.send(
|
|
462
|
+
JSON.stringify({
|
|
463
|
+
event: "model",
|
|
464
|
+
event_type: eventType,
|
|
465
|
+
event_id: id,
|
|
466
|
+
args: {
|
|
467
|
+
model_name: this.modelName,
|
|
468
|
+
pk,
|
|
469
|
+
form_name: formName,
|
|
470
|
+
},
|
|
471
|
+
}),
|
|
472
|
+
);
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
const className = pk === null || pk === -1 ? "create" : "edit";
|
|
476
|
+
|
|
477
|
+
this.openRequest[id] = [
|
|
478
|
+
(data) => {
|
|
479
|
+
$divForm.append(data.html);
|
|
480
|
+
let $form = $divForm
|
|
481
|
+
.closest("form")
|
|
482
|
+
.addClass(
|
|
483
|
+
`sdc-model-${className}-form sdc-model-form ${formId}`,
|
|
484
|
+
)
|
|
485
|
+
.data("model", modelObj)
|
|
486
|
+
.data("model_pk", pk)
|
|
487
|
+
.data("form_name", formName);
|
|
488
|
+
modelObj.addForm($form);
|
|
489
|
+
if ($form.length > 0 && !$form[0].hasAttribute("sdc_submit")) {
|
|
490
|
+
$form.attr("sdc_submit", "submitModelFormDistributor");
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
app.refresh($divForm).then(() => null);
|
|
494
|
+
cbResolve && cbResolve(data);
|
|
495
|
+
},
|
|
496
|
+
(res) => {
|
|
497
|
+
cbReject && cbReject(res);
|
|
498
|
+
},
|
|
499
|
+
];
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Fetch a single model instance matching the current query.
|
|
504
|
+
*
|
|
505
|
+
* @param {?object} modelQuery
|
|
506
|
+
* @param {?boolean} doNotLoad true if modelQuery contains id or pk and object does not need to be loaded.
|
|
507
|
+
* @returns {Promise<SdcModel>}
|
|
508
|
+
*/
|
|
509
|
+
async get(modelQuery = null, doNotLoad = false) {
|
|
510
|
+
if (doNotLoad) {
|
|
511
|
+
const id = modelQuery?.id ?? modelQuery?.pk;
|
|
512
|
+
return this.byId(id) ?? this.new(modelQuery || this.modelQuery);
|
|
513
|
+
}
|
|
514
|
+
await this.load(modelQuery);
|
|
515
|
+
if (this.length !== 1) {
|
|
516
|
+
throw new Error(`model query returns ${this.length} but only 1 expected.`);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
return this.valuesList[0];
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
detailView({pk, cbResolve = null, cbReject = null, templateContext = {}}) {
|
|
523
|
+
return this._sendDetailView({
|
|
524
|
+
pk,
|
|
525
|
+
cbResolve,
|
|
526
|
+
cbReject,
|
|
527
|
+
templateContext,
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
listView({modelQuery = {}, searchValues = {}, cbResolve = null, cbReject = null, templateContext = {}}) {
|
|
532
|
+
return this._sendListView({
|
|
533
|
+
modelQuery: Object.assign({}, this.modelQuery, modelQuery, { '__search_values': searchValues }),
|
|
534
|
+
cbResolve,
|
|
535
|
+
cbReject,
|
|
536
|
+
templateContext,
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
save({pk = null, formName = "edit_form", data = null} = {}) {
|
|
541
|
+
const normPk = normalizePk(pk);
|
|
542
|
+
return this.isConnected().then(() => {
|
|
543
|
+
let elemList;
|
|
544
|
+
if (normPk > -1) {
|
|
545
|
+
const elem = this.byId(normPk);
|
|
546
|
+
if (!elem) {
|
|
547
|
+
return Promise.reject(new SdcModelError(`Element not found with ID: ${normPk}`));
|
|
548
|
+
}
|
|
549
|
+
elemList = [elem];
|
|
550
|
+
} else {
|
|
551
|
+
elemList = this.valuesList;
|
|
552
|
+
}
|
|
553
|
+
let pList = [];
|
|
554
|
+
elemList.forEach((elem) => {
|
|
555
|
+
const id = uuidv4();
|
|
556
|
+
pList.push(
|
|
557
|
+
new Promise((resolve, reject) => {
|
|
558
|
+
this._readFiles(elem).then((files) => {
|
|
559
|
+
const sendData = data ? {...data} : elem.serialize();
|
|
560
|
+
sendData.pk = elem.id;
|
|
561
|
+
this.socket.send(
|
|
562
|
+
JSON.stringify({
|
|
563
|
+
event: "model",
|
|
564
|
+
event_type: "save",
|
|
565
|
+
event_id: id,
|
|
566
|
+
args: {
|
|
567
|
+
form_name: formName,
|
|
568
|
+
model_name: this.modelName,
|
|
569
|
+
model_query: this.modelQuery,
|
|
570
|
+
data: sendData,
|
|
571
|
+
pk: sendData.pk,
|
|
572
|
+
files: files,
|
|
573
|
+
},
|
|
574
|
+
}),
|
|
575
|
+
);
|
|
576
|
+
|
|
577
|
+
this.openRequest[id] = [
|
|
578
|
+
(res) => {
|
|
579
|
+
let data =
|
|
580
|
+
typeof res.data.instance === "string"
|
|
581
|
+
? JSON.parse(res.data.instance)
|
|
582
|
+
: res.data.instance;
|
|
583
|
+
res.data.instance = this._parseServerRes(data);
|
|
584
|
+
resolve(res);
|
|
585
|
+
},
|
|
586
|
+
reject,
|
|
587
|
+
];
|
|
588
|
+
});
|
|
589
|
+
}),
|
|
590
|
+
);
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
return Promise.all(pList);
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
*
|
|
599
|
+
* @param elem {?SdcModel}
|
|
600
|
+
* @param data {?object}
|
|
601
|
+
* @returns {Promise<unknown>}
|
|
602
|
+
*/
|
|
603
|
+
create({elem = null, data = null} = {}) {
|
|
604
|
+
const id = uuidv4();
|
|
605
|
+
if (!elem) {
|
|
606
|
+
elem = this.new(data);
|
|
607
|
+
}
|
|
608
|
+
return this.isConnected().then(() => {
|
|
609
|
+
return new Promise((resolve, reject) => {
|
|
610
|
+
this._readFiles(elem).then((files) => {
|
|
611
|
+
this.socket.send(
|
|
612
|
+
JSON.stringify({
|
|
613
|
+
event: "model",
|
|
614
|
+
event_type: "create",
|
|
615
|
+
event_id: id,
|
|
616
|
+
args: {
|
|
617
|
+
model_name: this.modelName,
|
|
618
|
+
model_query: this.modelQuery,
|
|
619
|
+
data: data ?? elem?.serialize() ?? {},
|
|
620
|
+
files: files,
|
|
621
|
+
},
|
|
622
|
+
}),
|
|
623
|
+
);
|
|
624
|
+
|
|
625
|
+
this.openRequest[id] = [
|
|
626
|
+
(res) => {
|
|
627
|
+
let data =
|
|
628
|
+
typeof res.data.instance === "string"
|
|
629
|
+
? JSON.parse(res.data.instance)
|
|
630
|
+
: res.data.instance;
|
|
631
|
+
if (elem) {
|
|
632
|
+
elem.id = data[0]?.pk || data[0]?.id;
|
|
633
|
+
}
|
|
634
|
+
res.data.instance = this._parseServerRes(data)[0];
|
|
635
|
+
resolve(res);
|
|
636
|
+
},
|
|
637
|
+
reject,
|
|
638
|
+
];
|
|
639
|
+
});
|
|
640
|
+
});
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* Ensure the websocket is open and has completed the server-side connect
|
|
646
|
+
* handshake before issuing model requests.
|
|
647
|
+
*
|
|
648
|
+
* Multiple callers can arrive while a connection attempt is already in
|
|
649
|
+
* progress. In that case they are queued behind the same synthetic request id.
|
|
650
|
+
*
|
|
651
|
+
* @returns {Promise<void>}
|
|
652
|
+
*/
|
|
653
|
+
isConnected() {
|
|
654
|
+
return new Promise((resolve, reject) => {
|
|
655
|
+
if (this._isConnected) {
|
|
656
|
+
resolve();
|
|
657
|
+
} else if (
|
|
658
|
+
!this._isConnectingProcess ||
|
|
659
|
+
!this.openRequest[CONNECTING_REQUEST_ID]
|
|
660
|
+
) {
|
|
661
|
+
this._isConnectingProcess = true;
|
|
662
|
+
this.openRequest[CONNECTING_REQUEST_ID] = [() => {
|
|
663
|
+
}, () => {
|
|
664
|
+
}];
|
|
665
|
+
this._connectToServer().then(() => {
|
|
666
|
+
resolve(this._checkConnection());
|
|
667
|
+
});
|
|
668
|
+
} else {
|
|
669
|
+
const [resolveOrigin, rejectOrigin] =
|
|
670
|
+
this.openRequest[CONNECTING_REQUEST_ID];
|
|
671
|
+
this.openRequest[CONNECTING_REQUEST_ID] = [
|
|
672
|
+
() => {
|
|
673
|
+
resolveOrigin();
|
|
674
|
+
resolve();
|
|
675
|
+
},
|
|
676
|
+
() => {
|
|
677
|
+
rejectOrigin();
|
|
678
|
+
reject();
|
|
679
|
+
},
|
|
680
|
+
];
|
|
681
|
+
}
|
|
682
|
+
});
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* Close the websocket and disable automatic reconnect for this queryset.
|
|
687
|
+
*/
|
|
688
|
+
close() {
|
|
689
|
+
if (this.socket) {
|
|
690
|
+
this._autoReconnect = false;
|
|
691
|
+
this.socket.onclose = () => {
|
|
692
|
+
};
|
|
693
|
+
this.socket.close();
|
|
694
|
+
delete this["socket"];
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
this.valuesList.forEach((elem) => {
|
|
698
|
+
elem._onClose()
|
|
699
|
+
});
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* Upload attached `File` values in fixed-size chunks before save/create calls.
|
|
704
|
+
*
|
|
705
|
+
* @param {SdcModel} elem
|
|
706
|
+
* @returns {Promise<object>}
|
|
707
|
+
*/
|
|
708
|
+
_readFiles(elem) {
|
|
709
|
+
let toSolve = [];
|
|
710
|
+
let files = {};
|
|
711
|
+
if (!elem) {
|
|
712
|
+
return Promise.resolve(files);
|
|
713
|
+
}
|
|
714
|
+
Object.keys(elem.constructor.fields).forEach((key) => {
|
|
715
|
+
const value = elem[key];
|
|
716
|
+
if (value instanceof File) {
|
|
717
|
+
toSolve.push(
|
|
718
|
+
new Promise(async (resolve, reject) => {
|
|
719
|
+
const id = uuidv4();
|
|
720
|
+
this.openRequest[id] = [resolve, reject];
|
|
721
|
+
|
|
722
|
+
const buffer = await value.arrayBuffer();
|
|
723
|
+
let result = new Uint8Array(buffer);
|
|
724
|
+
let numberOfChunks = Math.ceil(result.length / MAX_FILE_UPLOAD);
|
|
725
|
+
files[key] = {
|
|
726
|
+
id: id,
|
|
727
|
+
file_name: value.name,
|
|
728
|
+
field_name: key,
|
|
729
|
+
content_length: value.size,
|
|
730
|
+
};
|
|
731
|
+
for (let i = 0; i < numberOfChunks; ++i) {
|
|
732
|
+
const chunk = Array.from(result.slice(
|
|
733
|
+
MAX_FILE_UPLOAD * i,
|
|
734
|
+
MAX_FILE_UPLOAD * (i + 1),
|
|
735
|
+
));
|
|
736
|
+
this.socket.send(
|
|
737
|
+
JSON.stringify({
|
|
738
|
+
event: "model",
|
|
739
|
+
event_type: "upload",
|
|
740
|
+
event_id: id,
|
|
741
|
+
args: {
|
|
742
|
+
chunk,
|
|
743
|
+
idx: i,
|
|
744
|
+
number_of_chunks: numberOfChunks,
|
|
745
|
+
file_name: value.name,
|
|
746
|
+
field_name: key,
|
|
747
|
+
content_length: value.size,
|
|
748
|
+
content_type: value.type,
|
|
749
|
+
model_name: this.modelName,
|
|
750
|
+
model_query: this.modelQuery,
|
|
751
|
+
},
|
|
752
|
+
}),
|
|
753
|
+
);
|
|
754
|
+
}
|
|
755
|
+
}),
|
|
756
|
+
);
|
|
757
|
+
}
|
|
758
|
+
});
|
|
759
|
+
|
|
760
|
+
return Promise.all(toSolve).then(() => {
|
|
761
|
+
return files;
|
|
762
|
+
});
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
/**
|
|
766
|
+
* Route websocket responses to the matching pending request and update local
|
|
767
|
+
* state when the server sends model payloads.
|
|
768
|
+
*
|
|
769
|
+
* @param {MessageEvent} e
|
|
770
|
+
*/
|
|
771
|
+
async _onMessage(e) {
|
|
772
|
+
let data = JSON.parse(e.data);
|
|
773
|
+
if (data.is_error) {
|
|
774
|
+
if (this.openRequest.hasOwnProperty(data.event_id)) {
|
|
775
|
+
this.openRequest[data.event_id][1](new SdcModelError(data));
|
|
776
|
+
this._closeOpenRequest(data.event_id);
|
|
777
|
+
}
|
|
778
|
+
if (data.msg || data.header) {
|
|
779
|
+
trigger("pushErrorMsg", data.header || "", data.msg || "");
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
if (data.type === "connect") {
|
|
783
|
+
this.openRequest[CONNECTING_REQUEST_ID][1](new SdcModelError(data));
|
|
784
|
+
this._closeOpenRequest(CONNECTING_REQUEST_ID);
|
|
785
|
+
this._autoReconnect = false;
|
|
786
|
+
this.socket.close();
|
|
787
|
+
}
|
|
788
|
+
} else {
|
|
789
|
+
if (data.msg || data.header) {
|
|
790
|
+
trigger("pushMsg", data.header || "", data.msg || "");
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
if (data.type === "connect") {
|
|
794
|
+
this._isConnected = true;
|
|
795
|
+
this._isConnectingProcess = false;
|
|
796
|
+
this.openRequest[CONNECTING_REQUEST_ID][0](data);
|
|
797
|
+
this._closeOpenRequest(CONNECTING_REQUEST_ID);
|
|
798
|
+
} else if (["load", "named_view", "detail_view"].includes(data.type)) {
|
|
799
|
+
const jsonRes = JSON.parse(data.args.data);
|
|
800
|
+
data.args.data = await this._parseServerRes(jsonRes);
|
|
801
|
+
} else if (data.type === "on_update" || data.type === "on_create") {
|
|
802
|
+
const jsonRes = JSON.parse(data.args.data);
|
|
803
|
+
|
|
804
|
+
let obj = await this._parseServerRes(jsonRes);
|
|
805
|
+
let cb;
|
|
806
|
+
|
|
807
|
+
if (data.type === "on_create") {
|
|
808
|
+
cb = this.onCreate;
|
|
809
|
+
} else {
|
|
810
|
+
cb = this.onUpdate;
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
cb(obj);
|
|
814
|
+
data.args.data = obj;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
let instance = data.data?.instance;
|
|
818
|
+
if (instance) {
|
|
819
|
+
data.data.instance = JSON.parse(data.data.instance);
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
if (this.openRequest.hasOwnProperty(data.event_id)) {
|
|
823
|
+
this.openRequest[data.event_id][0](data);
|
|
824
|
+
this._closeOpenRequest(data.event_id);
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
/**
|
|
830
|
+
* Wait until all in-flight websocket requests for this queryset are resolved.
|
|
831
|
+
*
|
|
832
|
+
* @returns {Promise<void>}
|
|
833
|
+
*/
|
|
834
|
+
noOpenRequests() {
|
|
835
|
+
return new Promise((resolve) => {
|
|
836
|
+
if (Object.keys(this.openRequest).length === 0) {
|
|
837
|
+
return resolve();
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
this._onNoOpenRequests.push(resolve);
|
|
841
|
+
});
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
/**
|
|
845
|
+
* Remove a completed request and wake any `noOpenRequests()` waiters when the
|
|
846
|
+
* request map becomes empty.
|
|
847
|
+
*
|
|
848
|
+
* @param {string} eventId
|
|
849
|
+
*/
|
|
850
|
+
_closeOpenRequest(eventId) {
|
|
851
|
+
delete this.openRequest[eventId];
|
|
852
|
+
if (Object.keys(this.openRequest).length === 0) {
|
|
853
|
+
this._onNoOpenRequests.forEach((x) => x());
|
|
854
|
+
this._onNoOpenRequests = [];
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
/**
|
|
859
|
+
* Establish the raw websocket connection for this queryset.
|
|
860
|
+
*
|
|
861
|
+
* @returns {Promise<void>}
|
|
862
|
+
*/
|
|
863
|
+
_connectToServer() {
|
|
864
|
+
return new Promise((resolve) => {
|
|
865
|
+
const modelIdentifier =
|
|
866
|
+
`${this.modelName}` + (this.modelId > 0 ? `/${this.modelId}` : "");
|
|
867
|
+
if (window.location.protocol === "https:") {
|
|
868
|
+
this.socket = new WebSocket(
|
|
869
|
+
`wss://${window.location.host}/sdc_ws/model/${modelIdentifier}`,
|
|
870
|
+
);
|
|
871
|
+
} else {
|
|
872
|
+
this.socket = new WebSocket(
|
|
873
|
+
`ws://${window.location.host}/sdc_ws/model/${modelIdentifier}`,
|
|
874
|
+
);
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
this.socket.onmessage = this._onMessage.bind(this);
|
|
878
|
+
|
|
879
|
+
this.socket.onclose = (e) => {
|
|
880
|
+
console.error(
|
|
881
|
+
`SDC Model (${this.modelName}, ${this.modelId}) Socket closed unexpectedly`,
|
|
882
|
+
);
|
|
883
|
+
this._isConnected = false;
|
|
884
|
+
for (const [_key, value] of Object.entries(this.openRequest)) {
|
|
885
|
+
value[1](e);
|
|
886
|
+
}
|
|
887
|
+
this.openRequest = {};
|
|
888
|
+
|
|
889
|
+
setTimeout(() => {
|
|
890
|
+
if (this._autoReconnect) {
|
|
891
|
+
this._connectToServer().then(() => {
|
|
892
|
+
});
|
|
893
|
+
}
|
|
894
|
+
}, 1000);
|
|
895
|
+
};
|
|
896
|
+
|
|
897
|
+
this.socket.onerror = (err) => {
|
|
898
|
+
console.error(`Model Socket encountered error: ${err} Closing socket`);
|
|
899
|
+
if (this._isConnected) {
|
|
900
|
+
try {
|
|
901
|
+
this.socket.close();
|
|
902
|
+
} catch (e) {
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
};
|
|
906
|
+
|
|
907
|
+
this.socket.onopen = () => {
|
|
908
|
+
resolve();
|
|
909
|
+
};
|
|
910
|
+
});
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
/**
|
|
914
|
+
* Perform the application-level connect handshake once the websocket opens.
|
|
915
|
+
*
|
|
916
|
+
* @returns {Promise<*>}
|
|
917
|
+
*/
|
|
918
|
+
_checkConnection() {
|
|
919
|
+
const id = uuidv4();
|
|
920
|
+
return new Promise((resolve, reject) => {
|
|
921
|
+
this.socket.send(
|
|
922
|
+
JSON.stringify({
|
|
923
|
+
event: "model",
|
|
924
|
+
event_type: "connect",
|
|
925
|
+
event_id: id,
|
|
926
|
+
args: {
|
|
927
|
+
model_name: this.modelName,
|
|
928
|
+
model_query: this.modelQuery,
|
|
929
|
+
},
|
|
930
|
+
}),
|
|
931
|
+
);
|
|
932
|
+
|
|
933
|
+
this.openRequest[id] = [resolve, reject];
|
|
934
|
+
});
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
/**
|
|
938
|
+
* Convert the backend model payload into locally tracked model objects.
|
|
939
|
+
*
|
|
940
|
+
* @param {Array<object>} results
|
|
941
|
+
*/
|
|
942
|
+
_parseServerRes(results) {
|
|
943
|
+
const newModels = []
|
|
944
|
+
for (const x of results) {
|
|
945
|
+
const ModelClass = getModel(this.modelName);
|
|
946
|
+
const newModel = new ModelClass({'id': x.pk ?? x.id, ...x.fields});
|
|
947
|
+
const currentModel = this.byId(newModel.id);
|
|
948
|
+
if (currentModel) {
|
|
949
|
+
currentModel.setValues(newModel);
|
|
950
|
+
newModels.push(currentModel);
|
|
951
|
+
} else {
|
|
952
|
+
this.valuesList.push(newModel);
|
|
953
|
+
newModel._setQuerySet(this, true);
|
|
954
|
+
newModels.push(newModel);
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
this.valuesList.sort((a, b) => a._id - b._id);
|
|
958
|
+
return newModels;
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
export default class SdcModel {
|
|
963
|
+
static fields = {};
|
|
964
|
+
|
|
965
|
+
/**
|
|
966
|
+
* Base model wrapper used by SDC model registrations.
|
|
967
|
+
*
|
|
968
|
+
* @param {string} modelName
|
|
969
|
+
*/
|
|
970
|
+
constructor(modelName) {
|
|
971
|
+
this._id = null;
|
|
972
|
+
this._forms = [];
|
|
973
|
+
this.loaded = false;
|
|
974
|
+
this.formId = uuidv4();
|
|
975
|
+
this.modelName = modelName;
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
/**
|
|
979
|
+
*
|
|
980
|
+
* @param {object?} modelQuerySet
|
|
981
|
+
* @param {AbstractSDC} parent
|
|
982
|
+
* @returns {SdcQuerySet}
|
|
983
|
+
*/
|
|
984
|
+
static querySet(modelQuerySet = null, parent = null) {
|
|
985
|
+
if (!parent) {
|
|
986
|
+
return new SdcQuerySet(this.modeName, modelQuerySet);
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
return parent.querySet(this.modeName, modelQuerySet);
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
addForm($form) {
|
|
993
|
+
// Remove old listeners if form already set
|
|
994
|
+
const onChange = this._onChange.bind(this);
|
|
995
|
+
|
|
996
|
+
this._forms.push($form);
|
|
997
|
+
|
|
998
|
+
if ($form) {
|
|
999
|
+
// Attach delegated event handler to all inputs
|
|
1000
|
+
$form.on(
|
|
1001
|
+
"input.formWatcher change.formWatcher",
|
|
1002
|
+
"input, select, textarea",
|
|
1003
|
+
onChange
|
|
1004
|
+
);
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
_onChange(event) {
|
|
1009
|
+
const {name} = event.target;
|
|
1010
|
+
if (this.constructor.fields[name]) {
|
|
1011
|
+
this[`set${name}`](getValueFromField(event.target));
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
_updateForm(fieldName) {
|
|
1016
|
+
const self = this;
|
|
1017
|
+
this._forms.forEach(($form) => {
|
|
1018
|
+
$form.find(`[name="${fieldName}"]`).each(function () {
|
|
1019
|
+
setValueInField(this, self[fieldName])
|
|
1020
|
+
});
|
|
1021
|
+
});
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
setValues(data = {}) {
|
|
1025
|
+
throw new Error("setValues() must be implemented by subclass");
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
/**
|
|
1029
|
+
* Attach the queryset that owns this model instance.
|
|
1030
|
+
*
|
|
1031
|
+
* @param {SdcQuerySet} querySet
|
|
1032
|
+
* @param {boolean} isLoaded
|
|
1033
|
+
*/
|
|
1034
|
+
_setQuerySet(querySet, isLoaded) {
|
|
1035
|
+
this._querySet = new WeakRef(querySet);
|
|
1036
|
+
this.loaded = isLoaded;
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
save({formName = "edit_form", data = null} = {}) {
|
|
1040
|
+
return this._querySet.deref().save({pk: this.id, formName, data});
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
create({data = null} = {}) {
|
|
1044
|
+
return this._querySet.deref().create({elem: this, data});
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
delete() {
|
|
1048
|
+
return this._querySet.deref().delete({elem: this});
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
load() {
|
|
1052
|
+
return this._querySet.deref().update({item: this});
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
get id() {
|
|
1056
|
+
return this._id;
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
set pk(data) {
|
|
1060
|
+
this._id = data;
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
get pk() {
|
|
1064
|
+
return this._id;
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
close() {
|
|
1068
|
+
return this._querySet.deref().close();
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
_onClose() {
|
|
1072
|
+
Object.keys(this.constructor.fields)
|
|
1073
|
+
.filter((x) => this[`_${x}`] instanceof SdcQuerySet)
|
|
1074
|
+
.forEach((x) => this[`_${x}`].close());
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
/**
|
|
1078
|
+
* Request the server-rendered detail view for this model instance.
|
|
1079
|
+
*
|
|
1080
|
+
* @param {object} options
|
|
1081
|
+
* @returns {*}
|
|
1082
|
+
*/
|
|
1083
|
+
detailView({cbResolve = null, cbReject = null, templateContext = {}}) {
|
|
1084
|
+
return this._querySet.deref().detailView({pk: this.id, cbResolve, cbReject, templateContext});
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
serialize() {
|
|
1088
|
+
return Object.entries(this.constructor.fields).reduce((acc, [key, val]) => {
|
|
1089
|
+
const value = this[key];
|
|
1090
|
+
if (value instanceof SdcQuerySet) {
|
|
1091
|
+
if (val.many_to_many || val.one_to_many) {
|
|
1092
|
+
acc[key] = value.getIds();
|
|
1093
|
+
} else {
|
|
1094
|
+
const allIds = value.getIds();
|
|
1095
|
+
if (allIds.length > 0) {
|
|
1096
|
+
acc[key] = allIds[0];
|
|
1097
|
+
} else {
|
|
1098
|
+
acc[key] = null;
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
}
|
|
1102
|
+
} else if (val.is_relation && value instanceof SdcModel) {
|
|
1103
|
+
acc[key] = value.id ?? null;
|
|
1104
|
+
} else {
|
|
1105
|
+
acc[key] = value;
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
return acc
|
|
1109
|
+
}, {});
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
toJson() {
|
|
1113
|
+
const res = {}
|
|
1114
|
+
for (let key in this.constructor.fields) {
|
|
1115
|
+
const value = this[key];
|
|
1116
|
+
|
|
1117
|
+
if (value instanceof File) {
|
|
1118
|
+
res[key] = value.name;
|
|
1119
|
+
} else if (value instanceof SdcModel) {
|
|
1120
|
+
res[key] = value.id;
|
|
1121
|
+
} else if (value instanceof SdcQuerySet) {
|
|
1122
|
+
res[key] = value.getIds();
|
|
1123
|
+
} else {
|
|
1124
|
+
res[key] = value;
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
return res;
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
/**
|
|
1132
|
+
* Backwards-compatible alias for syncing values from a form into the model.
|
|
1133
|
+
*
|
|
1134
|
+
* @param {*} $forms
|
|
1135
|
+
* @returns {*}
|
|
1136
|
+
*/
|
|
1137
|
+
syncFormToModel($forms) {
|
|
1138
|
+
return this.syncForm($forms);
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
/**
|
|
1142
|
+
* Resolve the form collection associated with this model instance.
|
|
1143
|
+
*
|
|
1144
|
+
* @param {*} $forms
|
|
1145
|
+
* @returns {*}
|
|
1146
|
+
*/
|
|
1147
|
+
_resolveForms($forms) {
|
|
1148
|
+
if (!$forms || !$forms.hasClass(this.formId)) {
|
|
1149
|
+
return $(`.${this.formId}`);
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
return $forms;
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
/**
|
|
1156
|
+
* Copy the current model state into matching form fields.
|
|
1157
|
+
*
|
|
1158
|
+
* @param {*} $forms
|
|
1159
|
+
*/
|
|
1160
|
+
syncModelToForm($forms) {
|
|
1161
|
+
$forms = this._resolveForms($forms);
|
|
1162
|
+
|
|
1163
|
+
const self = this;
|
|
1164
|
+
const fields = this.constructor.fields;
|
|
1165
|
+
$forms.each(function () {
|
|
1166
|
+
const pk = normalizePk($(this).data("model_pk"));
|
|
1167
|
+
if (self.id !== pk) {
|
|
1168
|
+
return;
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
for (let formItem of this.elements) {
|
|
1172
|
+
let name = formItem.name;
|
|
1173
|
+
if (name && name !== "" && !!fields[name]) {
|
|
1174
|
+
setValueInField(formItem, self[name])
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
});
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
/**
|
|
1181
|
+
* Copy matching form values back onto the current model instance.
|
|
1182
|
+
*
|
|
1183
|
+
* Hidden inputs are parsed to their original primitive type when possible.
|
|
1184
|
+
*
|
|
1185
|
+
* @param {*} $forms
|
|
1186
|
+
* @returns {*}
|
|
1187
|
+
*/
|
|
1188
|
+
syncForm($forms) {
|
|
1189
|
+
$forms = this._resolveForms($forms);
|
|
1190
|
+
|
|
1191
|
+
const self = this;
|
|
1192
|
+
const fields = this.constructor.fields;
|
|
1193
|
+
const returnValue = {}
|
|
1194
|
+
|
|
1195
|
+
function setValueInForm(name, value) {
|
|
1196
|
+
if (!!fields[name]) {
|
|
1197
|
+
self[name] = value;
|
|
1198
|
+
}
|
|
1199
|
+
returnValue[name] = value;
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
$forms.each(function () {
|
|
1203
|
+
const pk = normalizePk($(this).data("model_pk"));
|
|
1204
|
+
if (self.id !== pk && (self.id !== null || pk !== -1)) {
|
|
1205
|
+
return;
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
for (let formItem of this.elements) {
|
|
1209
|
+
setValueInForm(formItem.name, getValueFromField(formItem));
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
return self;
|
|
1213
|
+
});
|
|
1214
|
+
|
|
1215
|
+
return returnValue;
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
/**
|
|
1219
|
+
* Render the correct server-side form for this model state.
|
|
1220
|
+
*
|
|
1221
|
+
* @param {object} options
|
|
1222
|
+
* @returns {*}
|
|
1223
|
+
*/
|
|
1224
|
+
form({cbResolve = null, cbReject = null}) {
|
|
1225
|
+
if (this.id === null || this.id === -1) {
|
|
1226
|
+
return this._createForm({cbReject, cbResolve});
|
|
1227
|
+
}
|
|
1228
|
+
return this._editForm({cbReject, cbResolve});
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
/**
|
|
1232
|
+
* Render the create form for a new model instance.
|
|
1233
|
+
*
|
|
1234
|
+
* @param {object} options
|
|
1235
|
+
* @returns {*}
|
|
1236
|
+
*/
|
|
1237
|
+
_createForm({cbResolve = null, cbReject = null}) {
|
|
1238
|
+
let $divForm = $("<div>");
|
|
1239
|
+
this._querySet.deref().getForm({
|
|
1240
|
+
modelObj: this,
|
|
1241
|
+
eventType: "create_form",
|
|
1242
|
+
formName: null,
|
|
1243
|
+
$divForm,
|
|
1244
|
+
cbResolve,
|
|
1245
|
+
cbReject,
|
|
1246
|
+
formId: this.formId,
|
|
1247
|
+
});
|
|
1248
|
+
|
|
1249
|
+
return $divForm;
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
/**
|
|
1253
|
+
* Render the edit form for an existing model instance.
|
|
1254
|
+
*
|
|
1255
|
+
* @param {object} options
|
|
1256
|
+
* @returns {*}
|
|
1257
|
+
*/
|
|
1258
|
+
_editForm({cbResolve = null, cbReject = null}) {
|
|
1259
|
+
let $divForm = $("<div>");
|
|
1260
|
+
|
|
1261
|
+
this._querySet.deref().getForm({
|
|
1262
|
+
modelObj: this,
|
|
1263
|
+
eventType: "edit_form",
|
|
1264
|
+
formName: null,
|
|
1265
|
+
$divForm,
|
|
1266
|
+
cbResolve,
|
|
1267
|
+
cbReject,
|
|
1268
|
+
formId: this.formId,
|
|
1269
|
+
});
|
|
1270
|
+
|
|
1271
|
+
return $divForm;
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
/**
|
|
1275
|
+
* Render a named server-side form for this model.
|
|
1276
|
+
*
|
|
1277
|
+
* @param {object} options
|
|
1278
|
+
* @returns {*}
|
|
1279
|
+
*/
|
|
1280
|
+
namedForm({formName, cbResolve = null, cbReject = null}) {
|
|
1281
|
+
let $divForm = $('<div class="container-fluid">');
|
|
1282
|
+
|
|
1283
|
+
this._querySet.deref().getForm({
|
|
1284
|
+
modelObj: this,
|
|
1285
|
+
eventType: "named_form",
|
|
1286
|
+
formName,
|
|
1287
|
+
$divForm,
|
|
1288
|
+
cbResolve,
|
|
1289
|
+
cbReject,
|
|
1290
|
+
formId: this.formId,
|
|
1291
|
+
});
|
|
1292
|
+
|
|
1293
|
+
return $divForm;
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
/**
|
|
1297
|
+
* Validate a field value using the supplied field config.
|
|
1298
|
+
*
|
|
1299
|
+
* @param {*} value
|
|
1300
|
+
* @param {object} config
|
|
1301
|
+
*/
|
|
1302
|
+
validate(value, config) {
|
|
1303
|
+
const err = validateField(value, config);
|
|
1304
|
+
if (err) throw new Error(err);
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
/**
|
|
1308
|
+
* Convert a raw input value into the shape expected by the field config.
|
|
1309
|
+
*
|
|
1310
|
+
* @param {*} value
|
|
1311
|
+
* @param {object} config
|
|
1312
|
+
* @returns {*}
|
|
1313
|
+
*/
|
|
1314
|
+
parseValue(value, config) {
|
|
1315
|
+
const {
|
|
1316
|
+
type
|
|
1317
|
+
} = config;
|
|
1318
|
+
|
|
1319
|
+
switch (type) {
|
|
1320
|
+
case "CharField":
|
|
1321
|
+
case "TeextField":
|
|
1322
|
+
case "UUIDField":
|
|
1323
|
+
case "EmailField":
|
|
1324
|
+
return `${value}`;
|
|
1325
|
+
|
|
1326
|
+
case "IntegerField":
|
|
1327
|
+
case "AutoField":
|
|
1328
|
+
case "BigIntegerField":
|
|
1329
|
+
return parseInt(value, 10);
|
|
1330
|
+
|
|
1331
|
+
case "FloatField":
|
|
1332
|
+
case "DecimalField":
|
|
1333
|
+
if (typeof value !== "number") {
|
|
1334
|
+
return "Must be a number";
|
|
1335
|
+
}
|
|
1336
|
+
break;
|
|
1337
|
+
|
|
1338
|
+
case "BooleanField":
|
|
1339
|
+
return !!value;
|
|
1340
|
+
|
|
1341
|
+
case "DateField":
|
|
1342
|
+
case "DateTimeField":
|
|
1343
|
+
return Date.parse(value);
|
|
1344
|
+
|
|
1345
|
+
case "URLField":
|
|
1346
|
+
return new URL(value);
|
|
1347
|
+
|
|
1348
|
+
case "FileField":
|
|
1349
|
+
if (value == null) {
|
|
1350
|
+
return null;
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
if (typeof value === "string") {
|
|
1354
|
+
return null;
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
if (FileLoaded.isValid(value)) {
|
|
1358
|
+
return new FileLoaded(value);
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
if (value instanceof FileLoaded) {
|
|
1362
|
+
return new FileLoaded(value);
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
if (typeof File !== "undefined" && value instanceof File) {
|
|
1366
|
+
if (config.max_size && value.size > config.max_size) {
|
|
1367
|
+
return `File too large (max ${config.max_size} bytes)`;
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
if (config.allowed_types && !config.allowed_types.includes(value.type)) {
|
|
1371
|
+
return `Invalid file type (${value.type})`;
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
return value;
|
|
1375
|
+
}
|
|
1376
|
+
break;
|
|
1377
|
+
|
|
1378
|
+
case "JSONField":
|
|
1379
|
+
if (typeof value === "object") {
|
|
1380
|
+
return value;
|
|
1381
|
+
}
|
|
1382
|
+
if (typeof value === "string") {
|
|
1383
|
+
return JSON.parse(value);
|
|
1384
|
+
}
|
|
1385
|
+
break;
|
|
1386
|
+
|
|
1387
|
+
default:
|
|
1388
|
+
break;
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
return value;
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
/**
|
|
1396
|
+
* Validate a field value against the field metadata received from the backend.
|
|
1397
|
+
*
|
|
1398
|
+
* @param {*} value
|
|
1399
|
+
* @param {object} config
|
|
1400
|
+
* @returns {?string}
|
|
1401
|
+
*/
|
|
1402
|
+
function validateField(value, config) {
|
|
1403
|
+
const {
|
|
1404
|
+
type,
|
|
1405
|
+
required,
|
|
1406
|
+
max_length: maxLength,
|
|
1407
|
+
is_relation: isRelation,
|
|
1408
|
+
many_to_many: manyToMany,
|
|
1409
|
+
one_to_many: oneToMany,
|
|
1410
|
+
many_to_one: manyToOne,
|
|
1411
|
+
one_to_one: oneToOne,
|
|
1412
|
+
related_model: relatedModel,
|
|
1413
|
+
} = config;
|
|
1414
|
+
|
|
1415
|
+
void isRelation;
|
|
1416
|
+
|
|
1417
|
+
if (required) {
|
|
1418
|
+
if (value === null || value === undefined || value === "") {
|
|
1419
|
+
return "This field is required";
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
if (value === null || value === undefined) {
|
|
1424
|
+
return null;
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
if (manyToMany || oneToMany || manyToOne || oneToOne) {
|
|
1428
|
+
if (!(value instanceof SdcQuerySet) || value.modelName !== relatedModel) {
|
|
1429
|
+
if (typeof value !== "object" && Number.isNaN(parseInt(value))) {
|
|
1430
|
+
return "Must be object or ID";
|
|
1431
|
+
}
|
|
1432
|
+
return null;
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
switch (type) {
|
|
1437
|
+
case "CharField":
|
|
1438
|
+
case "TextField":
|
|
1439
|
+
if (typeof value !== "string") {
|
|
1440
|
+
return "Must be a string";
|
|
1441
|
+
}
|
|
1442
|
+
if (maxLength && value.length > maxLength) {
|
|
1443
|
+
return `Max length is ${maxLength}`;
|
|
1444
|
+
}
|
|
1445
|
+
break;
|
|
1446
|
+
|
|
1447
|
+
case "IntegerField":
|
|
1448
|
+
case "AutoField":
|
|
1449
|
+
case "BigIntegerField":
|
|
1450
|
+
if (!Number.isInteger(value)) {
|
|
1451
|
+
return "Must be an integer";
|
|
1452
|
+
}
|
|
1453
|
+
break;
|
|
1454
|
+
|
|
1455
|
+
case "FloatField":
|
|
1456
|
+
case "DecimalField":
|
|
1457
|
+
if (typeof value !== "number") {
|
|
1458
|
+
return "Must be a number";
|
|
1459
|
+
}
|
|
1460
|
+
break;
|
|
1461
|
+
|
|
1462
|
+
case "BooleanField":
|
|
1463
|
+
if (typeof value !== "boolean") {
|
|
1464
|
+
return "Must be a boolean";
|
|
1465
|
+
}
|
|
1466
|
+
break;
|
|
1467
|
+
|
|
1468
|
+
case "DateField":
|
|
1469
|
+
case "DateTimeField":
|
|
1470
|
+
if (isNaN(Date.parse(value))) {
|
|
1471
|
+
return "Must be a valid date";
|
|
1472
|
+
}
|
|
1473
|
+
break;
|
|
1474
|
+
|
|
1475
|
+
case "EmailField":
|
|
1476
|
+
if (typeof value !== "string" || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
|
|
1477
|
+
return "Invalid email";
|
|
1478
|
+
}
|
|
1479
|
+
break;
|
|
1480
|
+
|
|
1481
|
+
case "URLField":
|
|
1482
|
+
try {
|
|
1483
|
+
new URL(value);
|
|
1484
|
+
} catch {
|
|
1485
|
+
return "Invalid URL";
|
|
1486
|
+
}
|
|
1487
|
+
break;
|
|
1488
|
+
|
|
1489
|
+
case "UUIDField":
|
|
1490
|
+
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(value)) {
|
|
1491
|
+
return "Invalid UUID";
|
|
1492
|
+
}
|
|
1493
|
+
break;
|
|
1494
|
+
|
|
1495
|
+
case "JSONField":
|
|
1496
|
+
if (typeof value !== "object") {
|
|
1497
|
+
return "Must be JSON object";
|
|
1498
|
+
}
|
|
1499
|
+
break;
|
|
1500
|
+
case "FileField":
|
|
1501
|
+
if (!FileLoaded.isValid(value) && !(value instanceof File) && !(value instanceof FileLoaded)) {
|
|
1502
|
+
return "Must be a valid file";
|
|
1503
|
+
}
|
|
1504
|
+
break;
|
|
1505
|
+
default:
|
|
1506
|
+
break;
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
return null;
|
|
1510
|
+
}
|