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.
Files changed (39) hide show
  1. package/.github/workflows/node.js.yml +2 -2
  2. package/.idea/inspectionProfiles/Project_Default.xml +17 -0
  3. package/.idea/workspace.xml +122 -23
  4. package/.readthedocs.yaml +13 -0
  5. package/dist/index.js +70 -51
  6. package/dist/ugly.index.js +1 -1
  7. package/docs/api-reference.rst +225 -0
  8. package/docs/conf.py +11 -0
  9. package/docs/controllers.rst +228 -0
  10. package/docs/events-and-dom.rst +120 -0
  11. package/docs/getting-started.rst +143 -0
  12. package/docs/index.rst +22 -0
  13. package/docs/models.rst +351 -0
  14. package/docs/overview.rst +66 -0
  15. package/docs/requirements.txt +1 -0
  16. package/eslint.config.js +60 -0
  17. package/gulp/gulp.jsx +15 -13
  18. package/package.json +13 -3
  19. package/src/index.js +44 -11
  20. package/src/simpleDomControl/AbstractSDC.js +287 -286
  21. package/src/simpleDomControl/sdc_controller.js +157 -132
  22. package/src/simpleDomControl/sdc_dom_events.js +99 -88
  23. package/src/simpleDomControl/sdc_events.js +39 -40
  24. package/src/simpleDomControl/sdc_main.js +88 -67
  25. package/src/simpleDomControl/sdc_model.js +1510 -0
  26. package/src/simpleDomControl/sdc_params.js +44 -29
  27. package/src/simpleDomControl/sdc_server_call.js +153 -154
  28. package/src/simpleDomControl/sdc_socket.js +8 -821
  29. package/src/simpleDomControl/sdc_test_utils.js +77 -80
  30. package/src/simpleDomControl/sdc_utils.js +295 -176
  31. package/src/simpleDomControl/sdc_view.js +245 -177
  32. package/test/model.test.js +332 -0
  33. package/test/models/Author.js +145 -0
  34. package/test/models/Book.js +112 -0
  35. package/test/models/BookContent.js +114 -0
  36. package/test/models/SdcUser.js +75 -0
  37. package/test/models/src.js +8 -0
  38. package/test/sdc_model_dates.ai.test.js +57 -0
  39. 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
+ }