sdc_client 0.158.9 → 0.160.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 (57) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/LICENSE +21 -0
  3. package/dist/index.js +23 -13
  4. package/dist/ugly.index.js +2 -1
  5. package/dist/ugly.index.js.LICENSE.txt +1 -8
  6. package/gulp/gulp.jsx +11 -36
  7. package/package.json +23 -12
  8. package/src/index.js +31 -7
  9. package/src/simpleDomControl/AbstractSDC.js +15 -11
  10. package/src/simpleDomControl/sdc_controller.js +3 -3
  11. package/src/simpleDomControl/sdc_dom_events.js +5 -10
  12. package/src/simpleDomControl/sdc_events.js +3 -3
  13. package/src/simpleDomControl/sdc_main.js +34 -13
  14. package/src/simpleDomControl/sdc_model.js +225 -92
  15. package/src/simpleDomControl/sdc_params.js +0 -3
  16. package/src/simpleDomControl/sdc_server_call.js +21 -4
  17. package/src/simpleDomControl/sdc_test_utils.js +0 -1
  18. package/src/simpleDomControl/sdc_utils.js +59 -7
  19. package/src/simpleDomControl/sdc_view.js +16 -1
  20. package/.github/workflows/node.js.yml +0 -38
  21. package/.idea/inspectionProfiles/Project_Default.xml +0 -17
  22. package/.idea/jsLibraryMappings.xml +0 -6
  23. package/.idea/misc.xml +0 -6
  24. package/.idea/modules.xml +0 -8
  25. package/.idea/vcs.xml +0 -6
  26. package/.idea/workspace.xml +0 -403
  27. package/.readthedocs.yaml +0 -13
  28. package/.tool-versions +0 -1
  29. package/SimpleDomControlClient.iml +0 -12
  30. package/docs/api-reference.rst +0 -225
  31. package/docs/conf.py +0 -11
  32. package/docs/controllers.rst +0 -228
  33. package/docs/events-and-dom.rst +0 -120
  34. package/docs/getting-started.rst +0 -143
  35. package/docs/index.rst +0 -22
  36. package/docs/models.rst +0 -351
  37. package/docs/overview.rst +0 -66
  38. package/docs/requirements.txt +0 -1
  39. package/eslint.config.js +0 -43
  40. package/gulpfile.cjs +0 -30
  41. package/jest.config.mjs +0 -197
  42. package/test/controller.test.js +0 -93
  43. package/test/dist.test.js +0 -127
  44. package/test/model.test.js +0 -330
  45. package/test/models/Author.js +0 -145
  46. package/test/models/Book.js +0 -112
  47. package/test/models/BookContent.js +0 -114
  48. package/test/models/SdcUser.js +0 -75
  49. package/test/models/src.js +0 -8
  50. package/test/sdc_model_dates.ai.test.js +0 -57
  51. package/test/sdc_server_call.ai.test.js +0 -267
  52. package/test/utils.js +0 -88
  53. package/test/view.test.js +0 -159
  54. package/test-setup.js +0 -4
  55. package/webpack.config/webpack.default.config.cjs +0 -14
  56. package/webpack.config/webpack.development.config.cjs +0 -14
  57. package/webpack.config/webpack.production.config.cjs +0 -11
@@ -47,7 +47,10 @@ let sdcDomFragment = function (element, props) {
47
47
  if (props) {
48
48
  Object.entries(props).forEach(([k, v]) => {
49
49
  if (k.startsWith("on")) {
50
- $new_elem[0].addEventListener(k.substring(2).toLowerCase(), v);
50
+ const type = k.substring(2).toLowerCase();
51
+ $new_elem[0].addEventListener(type, v);
52
+ // Remembered so reconcile() can move the listeners to a node it keeps.
53
+ ($new_elem[0]._sdcListeners ??= []).push([type, v]);
51
54
  } else {
52
55
  if (PROPERTIES_UPDATE[k.toLowerCase()]) {
53
56
  k = PROPERTIES_UPDATE[k.toLowerCase()];
@@ -75,6 +78,14 @@ window.sdcDom = function (tagName, props, ...children) {
75
78
  return $new_elem;
76
79
  };
77
80
 
81
+ const redirector = (a) => {
82
+ if (a["url-link"]) {
83
+ trigger("onNavLink", a["url-link"]);
84
+ } else {
85
+ window.location.href = a["url"];
86
+ }
87
+ };
88
+
78
89
  export let app = {
79
90
  CSRF_TOKEN: window.CSRF_TOKEN || "",
80
91
  LANGUAGE_CODE: window.LANGUAGE_CODE || "en",
@@ -87,6 +98,7 @@ export let app = {
87
98
  _isInit: false,
88
99
  _origin_trigger: null,
89
100
  _globalControllerClasses: [],
101
+ _createdGlobals: [],
90
102
 
91
103
  init_sdc: () => {
92
104
  if (!app._isInit) {
@@ -119,8 +131,12 @@ export let app = {
119
131
 
120
132
  app.tagNames = tagList();
121
133
 
134
+ // Global controllers are created only once, even if init_sdc() is called again.
135
+ const newGlobals = Global.filter((tagName) => !app._createdGlobals.includes(tagName));
136
+ app._createdGlobals.push(...newGlobals);
137
+
122
138
  const $globalDiv = $("<div></div>");
123
- Global.forEach((tagName) => {
139
+ newGlobals.forEach((tagName) => {
124
140
  const ControllerClass = controllerList[tagName][0];
125
141
  $globalDiv.append(
126
142
  `<${ControllerClass.prototype._tagName}></${ControllerClass.prototype._tagName}>`,
@@ -169,9 +185,12 @@ export let app = {
169
185
  * @param {AbstractSDC} Controller
170
186
  */
171
187
  registerGlobal: (Controller) => {
172
- app.register(Controller);
188
+ const registration = app.register(Controller);
173
189
  let tagName = Controller.prototype._tagName;
174
- Global.push(tagName);
190
+ if (!Global.includes(tagName)) {
191
+ Global.push(tagName);
192
+ }
193
+ return registration;
175
194
  },
176
195
 
177
196
  cleanCache: () => {
@@ -260,14 +279,23 @@ export let app = {
260
279
  .then((a, b, c) => {
261
280
  resolve(a, b, c);
262
281
  if (a.status === "redirect") {
263
- trigger("onNavLink", a["url-link"]);
282
+ redirector(a);
264
283
  } else {
265
284
  p.then(() => {
266
285
  app.refresh(controller.$container);
267
286
  });
268
287
  }
269
288
  })
270
- .catch(reject);
289
+ .catch((a, b, c) => {
290
+ // send_redirect() answers with HTTP 301 and the redirect in the JSON body.
291
+ if (a.status === 301 && a.responseJSON) {
292
+ a = a.responseJSON;
293
+ redirector(a);
294
+ resolve(a, b, c);
295
+ } else {
296
+ reject(a, b, c);
297
+ }
298
+ });
271
299
  });
272
300
 
273
301
  return p;
@@ -275,13 +303,6 @@ export let app = {
275
303
 
276
304
  submitFormAndUpdateView: (controller, form, url, method) => {
277
305
  let formData = new FormData(form);
278
- const redirector = (a) => {
279
- if (a["url-link"]) {
280
- trigger("onNavLink", a["url-link"]);
281
- } else {
282
- window.location.href = a["url"];
283
- }
284
- };
285
306
 
286
307
  const p = new Promise((resolve, reject) => {
287
308
  uploadFileFormData(formData, url || form.action, method || form.method)
@@ -1,4 +1,4 @@
1
- import { getValueFromField, setValueInField, uuidv4 } from "./sdc_utils.js";
1
+ import { formatDate, getValueFromField, setValueInField, toDate, uuidv4 } from "./sdc_utils.js";
2
2
  import { getModel } from "./sdc_socket.js"
3
3
  import { app } from "./sdc_main.js";
4
4
  import { trigger } from "./sdc_events.js";
@@ -6,11 +6,15 @@ import { trigger } from "./sdc_events.js";
6
6
  const MAX_FILE_UPLOAD = 25000;
7
7
  const CONNECTING_REQUEST_ID = "_connecting_process";
8
8
 
9
- const cloneObject = (original) => {
10
- return Object.assign(
11
- Object.create(Object.getPrototypeOf(original)),
12
- structuredClone(original)
13
- );
9
+ /**
10
+ * Copy a model instance for another queryset. A new instance of the same class is
11
+ * created from the serialized field values (structuredClone cannot copy the
12
+ * WeakRef/Proxy members of a model).
13
+ */
14
+ const cloneModel = (original) => {
15
+ const copy = new original.constructor({});
16
+ copy.setValues(original.serialize());
17
+ return copy;
14
18
  }
15
19
 
16
20
  class SdcModelError extends Error {
@@ -82,6 +86,29 @@ function normalizePk(pk) {
82
86
  return Number.isNaN(normalizedPk) ? -1 : normalizedPk;
83
87
  }
84
88
 
89
+ /**
90
+ * Decide whether an argument of `create()` is an options object
91
+ * (`{elem, data}`) or the data itself. It is treated as options if it is
92
+ * a non-empty plain object whose keys are only `elem` and/or `data`, and
93
+ * `elem` (if present) is an SdcModel.
94
+ *
95
+ * @param {*} arg
96
+ * @returns {boolean}
97
+ */
98
+ function isCreateOptions(arg) {
99
+ if (!arg || typeof arg !== "object" || arg instanceof SdcModel) {
100
+ return false;
101
+ }
102
+ const keys = Object.keys(arg);
103
+ if (keys.length === 0 || !keys.every((k) => k === "elem" || k === "data")) {
104
+ return false;
105
+ }
106
+ if ("elem" in arg && arg.elem !== null && !(arg.elem instanceof SdcModel)) {
107
+ return false;
108
+ }
109
+ return !("data" in arg) || arg.data === null || typeof arg.data === "object";
110
+ }
111
+
85
112
  export class SdcQuerySet {
86
113
  /**
87
114
  * Lightweight client-side collection wrapper around the SDC model websocket.
@@ -109,6 +136,9 @@ export class SdcQuerySet {
109
136
  };
110
137
  this.onCreate = () => {
111
138
  };
139
+ // Called with the removed models when rows of this queryset are deleted on the
140
+ // server. If not set, onUpdate is called instead.
141
+ this.onDelete = null;
112
142
 
113
143
  return new Proxy(this, {
114
144
  get(target, prop) {
@@ -150,6 +180,22 @@ export class SdcQuerySet {
150
180
  this.onUpdate = handler;
151
181
  }
152
182
 
183
+ set on_delete(handler) {
184
+ this.onDelete = handler;
185
+ }
186
+
187
+ /**
188
+ * Remove the models with the given ids from the local list.
189
+ *
190
+ * @param {Array<number>} ids
191
+ * @returns {Array<SdcModel>} the removed models
192
+ */
193
+ _removeIds(ids) {
194
+ const removed = this.valuesList.filter((item) => ids.includes(item.id));
195
+ this.valuesList = this.valuesList.filter((item) => !ids.includes(item.id));
196
+ return removed;
197
+ }
198
+
153
199
  /**
154
200
  *
155
201
  *
@@ -169,19 +215,25 @@ export class SdcQuerySet {
169
215
  *
170
216
  *
171
217
  * @param {Array<integr|string>|integer|SdcModel|SdcQuerySet|string} ids
172
- */cloneObject
218
+ */
173
219
  _setIds(ids) {
174
220
 
175
221
  if (ids === null || ids === "" || Array.isArray(ids) && ids.length === 0) {
176
222
  this.valuesList = [];
177
223
  return this.valuesList;
178
- } else if (ids instanceof SdcQuerySet) {
179
- this.valuesList = ids.valuesList.map(cloneObject);
180
- this.valuesList.forEach(value => value._setQuerySet(this, true));
181
- return this.valuesList;
182
- } else if (ids instanceof SdcModel) {
183
- this.valuesList = [cloneObject(ids)];
184
- this.valuesList.forEach(value => value._setQuerySet(this, true));
224
+ } else if (ids instanceof SdcQuerySet || ids instanceof SdcModel) {
225
+ // Models already in this queryset are kept (so references to them stay valid);
226
+ // other models are copied into it.
227
+ const sources = ids instanceof SdcModel ? [ids] : ids.valuesList;
228
+ this.valuesList = sources.map((source) => {
229
+ const existing = source.id !== null ? this.byId(source.id) : null;
230
+ if (existing) {
231
+ return existing;
232
+ }
233
+ const copy = cloneModel(source);
234
+ copy._setQuerySet(this, true);
235
+ return copy;
236
+ });
185
237
  return this.valuesList;
186
238
  }
187
239
 
@@ -277,12 +329,14 @@ export class SdcQuerySet {
277
329
  * Load model instances matching the current query into the queryset cache.
278
330
  *
279
331
  * @param {?object} modelQuery
332
+ * @param {object} [options]
333
+ * @param {?number} [options.limit] load at most this many instances (the server slices the query)
280
334
  * @returns {Promise<SdcQuerySet>}
281
335
  */
282
- load(modelQuery = null) {
336
+ load(modelQuery = null, { limit = null } = {}) {
283
337
  this.valuesList = [];
284
338
  this.modelQuery = modelQuery ?? this.modelQuery;
285
- return this._sendLoad();
339
+ return this._sendLoad(null, limit);
286
340
  }
287
341
 
288
342
  /**
@@ -327,7 +381,10 @@ export class SdcQuerySet {
327
381
  }),
328
382
  );
329
383
 
330
- this.openRequest[event_id] = [resolve, reject];
384
+ this.openRequest[event_id] = [(res) => {
385
+ this._removeIds([normalizePk(pk)]);
386
+ resolve(res);
387
+ }, reject];
331
388
  });
332
389
  });
333
390
  }
@@ -337,19 +394,23 @@ export class SdcQuerySet {
337
394
  *
338
395
  * @returns {Promise<*>}
339
396
  */
340
- _sendLoad(loadQuery = null) {
397
+ _sendLoad(loadQuery = null, limit = null) {
341
398
  return this.isConnected().then(() => {
342
399
  const event_id = uuidv4();
400
+ const args = {
401
+ model_name: this.modelName,
402
+ model_query: loadQuery ?? this.modelQuery,
403
+ };
404
+ if (limit !== null) {
405
+ args.limit = limit;
406
+ }
343
407
  return new Promise((resolve, reject) => {
344
408
  this.socket.send(
345
409
  JSON.stringify({
346
410
  event: "model",
347
411
  event_type: "load",
348
412
  event_id,
349
- args: {
350
- model_name: this.modelName,
351
- model_query: loadQuery ?? this.modelQuery,
352
- },
413
+ args,
353
414
  }),
354
415
  );
355
416
 
@@ -520,7 +581,9 @@ export class SdcQuerySet {
520
581
  )
521
582
  .data("model", modelObj)
522
583
  .data("model_pk", pk)
523
- .data("form_name", formName);
584
+ .data("form_name", formName)
585
+ .attr('SdcModelName', this.modelName)
586
+ .attr('SdcModelPK', pk);
524
587
  modelObj.addForm($form);
525
588
  if ($form.length > 0 && !$form[0].hasAttribute("sdc_submit")) {
526
589
  $form.attr("sdc_submit", "submitModelFormDistributor");
@@ -638,12 +701,24 @@ export class SdcQuerySet {
638
701
  }
639
702
 
640
703
  /**
704
+ * Create a new instance in the DB.
641
705
  *
642
- * @param elem {?SdcModel}
643
- * @param data {?object}
706
+ * @param {{elem?: SdcModel, data?: object} | object} arg
707
+ * Either an options object containing `elem` and/or `data`,
708
+ * or `data` directly.
709
+ *
710
+ * @example
711
+ * create({ elem, data });
712
+ * create({ elem });
713
+ * create(data);
644
714
  * @returns {Promise<unknown>}
645
715
  */
646
- create({ elem = null, data = null } = {}) {
716
+ create(arg = null) {
717
+ let { elem = null, data = null } = isCreateOptions(arg) ? arg : { data: arg };
718
+ if (data && typeof data === "object" && Object.keys(data).length === 0) {
719
+ data = null;
720
+ }
721
+
647
722
  const event_id = uuidv4();
648
723
  if (!elem) {
649
724
  elem = this.new(data);
@@ -758,43 +833,47 @@ export class SdcQuerySet {
758
833
  const value = elem[key];
759
834
  if (value instanceof File) {
760
835
  toSolve.push(
761
- new Promise(async (resolve, reject) => {
836
+ new Promise((resolve, reject) => {
762
837
  const event_id = uuidv4();
763
838
  this.openRequest[event_id] = [resolve, reject];
764
839
 
765
- const buffer = await value.arrayBuffer();
766
- let result = new Uint8Array(buffer);
767
- let numberOfChunks = Math.ceil(result.length / MAX_FILE_UPLOAD);
768
- files[key] = {
769
- id: event_id,
770
- file_name: value.name,
771
- field_name: key,
772
- content_length: value.size,
773
- };
774
- for (let i = 0; i < numberOfChunks; ++i) {
775
- const chunk = Array.from(result.slice(
776
- MAX_FILE_UPLOAD * i,
777
- MAX_FILE_UPLOAD * (i + 1),
778
- ));
779
- this.socket.send(
780
- JSON.stringify({
781
- event: "model",
782
- event_type: "upload",
783
- event_id,
784
- args: {
785
- chunk,
786
- idx: i,
787
- number_of_chunks: numberOfChunks,
788
- file_name: value.name,
789
- field_name: key,
790
- content_length: value.size,
791
- content_type: value.type,
792
- model_name: this.modelName,
793
- model_query: this.modelQuery,
794
- },
795
- }),
796
- );
797
- }
840
+ value.arrayBuffer().then((buffer) => {
841
+ let result = new Uint8Array(buffer);
842
+ let numberOfChunks = Math.ceil(result.length / MAX_FILE_UPLOAD);
843
+ files[key] = {
844
+ id: event_id,
845
+ file_name: value.name,
846
+ field_name: key,
847
+ content_length: value.size,
848
+ };
849
+ for (let i = 0; i < numberOfChunks; ++i) {
850
+ const chunk = Array.from(result.slice(
851
+ MAX_FILE_UPLOAD * i,
852
+ MAX_FILE_UPLOAD * (i + 1),
853
+ ));
854
+ this.socket.send(
855
+ JSON.stringify({
856
+ event: "model",
857
+ event_type: "upload",
858
+ event_id,
859
+ args: {
860
+ chunk,
861
+ idx: i,
862
+ number_of_chunks: numberOfChunks,
863
+ file_name: value.name,
864
+ field_name: key,
865
+ content_length: value.size,
866
+ content_type: value.type,
867
+ model_name: this.modelName,
868
+ model_query: this.modelQuery,
869
+ },
870
+ }),
871
+ );
872
+ }
873
+ }).catch((err) => {
874
+ this._closeOpenRequest(event_id);
875
+ reject(err);
876
+ });
798
877
  }),
799
878
  );
800
879
  }
@@ -814,7 +893,7 @@ export class SdcQuerySet {
814
893
  async _onMessage(e) {
815
894
  let data = JSON.parse(e.data);
816
895
  if (data.is_error) {
817
- if (this.openRequest.hasOwnProperty(data.event_id)) {
896
+ if (Object.hasOwn(this.openRequest, data.event_id)) {
818
897
  this.openRequest[data.event_id][1](new SdcModelError(data));
819
898
  this._closeOpenRequest(data.event_id);
820
899
  }
@@ -838,7 +917,7 @@ export class SdcQuerySet {
838
917
  this._isConnectingProcess = false;
839
918
  this.openRequest[CONNECTING_REQUEST_ID][0](data);
840
919
  this._closeOpenRequest(CONNECTING_REQUEST_ID);
841
- } else if (["load", "named_view", "detail_view"].includes(data.type)) {
920
+ } else if (["load", "list_view", "named_view", "detail_view"].includes(data.type)) {
842
921
  const jsonRes = JSON.parse(data.args.data);
843
922
  data.args.data = await this._parseServerRes(jsonRes);
844
923
  } else if (data.type === "on_update" || data.type === "on_create") {
@@ -855,6 +934,11 @@ export class SdcQuerySet {
855
934
 
856
935
  cb(obj);
857
936
  data.args.data = obj;
937
+ } else if (data.type === "on_delete") {
938
+ const ids = JSON.parse(data.args.data).map((x) => normalizePk(x.id ?? x.pk));
939
+ const removed = this._removeIds(ids);
940
+ (this.onDelete ?? this.onUpdate)(removed);
941
+ data.args.data = removed;
858
942
  }
859
943
 
860
944
  let instance = data.data?.instance;
@@ -862,7 +946,7 @@ export class SdcQuerySet {
862
946
  data.data.instance = JSON.parse(data.data.instance);
863
947
  }
864
948
 
865
- if (this.openRequest.hasOwnProperty(data.event_id)) {
949
+ if (Object.hasOwn(this.openRequest, data.event_id)) {
866
950
  this.openRequest[data.event_id][0](data);
867
951
  this._closeOpenRequest(data.event_id);
868
952
  }
@@ -924,14 +1008,17 @@ export class SdcQuerySet {
924
1008
  `SDC Model (${this.modelName}, ${this.modelId}) Socket closed unexpectedly`,
925
1009
  );
926
1010
  this._isConnected = false;
927
- for (const [_key, value] of Object.entries(this.openRequest)) {
1011
+ for (const value of Object.values(this.openRequest)) {
928
1012
  value[1](e);
929
1013
  }
930
1014
  this.openRequest = {};
931
1015
 
1016
+ this._isConnectingProcess = false;
932
1017
  setTimeout(() => {
933
- if (this._autoReconnect) {
934
- this._connectToServer().then(() => {
1018
+ if (this._autoReconnect && !this._isConnected) {
1019
+ // isConnected() opens the socket and sends the connect handshake, so
1020
+ // live updates work again and later requests reuse this connection.
1021
+ this.isConnected().catch(() => {
935
1022
  });
936
1023
  }
937
1024
  }, 1000);
@@ -1028,11 +1115,12 @@ export default class SdcModel {
1028
1115
  * @returns {SdcQuerySet}
1029
1116
  */
1030
1117
  static querySet(modelQuerySet = null, parent = null) {
1118
+ const { modelName } = new this({});
1031
1119
  if (!parent) {
1032
- return new SdcQuerySet(this.modeName, modelQuerySet);
1120
+ return new SdcQuerySet(modelName, modelQuerySet);
1033
1121
  }
1034
1122
 
1035
- return parent.querySet(this.modeName, modelQuerySet);
1123
+ return parent.querySet(modelName, modelQuerySet);
1036
1124
  }
1037
1125
 
1038
1126
  addForm($form) {
@@ -1054,15 +1142,25 @@ export default class SdcModel {
1054
1142
  _onChange(event) {
1055
1143
  const { name } = event.target;
1056
1144
  if (this.constructor.fields[name]) {
1057
- this[`set${name}`](getValueFromField(event.target));
1145
+ try {
1146
+ this[`set${name}`](getValueFromField(event.target));
1147
+ } catch {
1148
+ // Invalid input (e.g. an empty required field) is kept in the form and
1149
+ // validated by the server on submit.
1150
+ return;
1151
+ }
1152
+ // Show the new value in the other forms of this model, not in the input being edited.
1153
+ this._updateForm(name, event.target);
1058
1154
  }
1059
1155
  }
1060
1156
 
1061
- _updateForm(fieldName) {
1157
+ _updateForm(fieldName, skipElement = null) {
1062
1158
  const self = this;
1063
1159
  this._forms.forEach(($form) => {
1064
1160
  $form.find(`[name="${fieldName}"]`).each(function () {
1065
- setValueInField(this, self[fieldName])
1161
+ if (this !== skipElement) {
1162
+ setValueInField(this, self[fieldName]);
1163
+ }
1066
1164
  });
1067
1165
  });
1068
1166
  }
@@ -1090,7 +1188,15 @@ export default class SdcModel {
1090
1188
  return this._querySet.deref().save({ pk: this.id, formName, data });
1091
1189
  }
1092
1190
 
1093
- create({ data = null } = {}) {
1191
+ /**
1192
+ * Create this instance in the DB.
1193
+ *
1194
+ * @param {{data?: object} | object | null} values
1195
+ * Either `{ data }` or `data` directly.
1196
+ * @returns {Promise<unknown>}
1197
+ */
1198
+ create(values = null) {
1199
+ const data = isCreateOptions(values) ? (values.data ?? null) : values;
1094
1200
  return this._querySet.deref().create({ elem: this, data });
1095
1201
  }
1096
1202
 
@@ -1159,6 +1265,9 @@ export default class SdcModel {
1159
1265
  }
1160
1266
  } else if (val.is_relation && value instanceof SdcModel) {
1161
1267
  acc[key] = value.id ?? null;
1268
+ } else if (value instanceof Date) {
1269
+ // Django's DateField does not accept a full ISO datetime.
1270
+ acc[key] = val.type === "DateField" ? formatDate(value) : value.toISOString();
1162
1271
  } else {
1163
1272
  acc[key] = value;
1164
1273
  }
@@ -1219,15 +1328,16 @@ export default class SdcModel {
1219
1328
  $forms = this._resolveForms($forms);
1220
1329
 
1221
1330
  const self = this;
1222
- const fields = this.constructor.fields;
1331
+ const { fields } = this.constructor;
1223
1332
  $forms.each(function () {
1224
1333
  const pk = normalizePk($(this).data("model_pk"));
1225
- if (self.id !== pk) {
1334
+ // Create forms carry model_pk -1 while the new model's id is still null.
1335
+ if (normalizePk(self.id) !== pk) {
1226
1336
  return;
1227
1337
  }
1228
1338
 
1229
1339
  for (let formItem of this.elements) {
1230
- let name = formItem.name;
1340
+ let { name } = formItem;
1231
1341
  if (name && name !== "" && !!fields[name]) {
1232
1342
  setValueInField(formItem, self[name])
1233
1343
  }
@@ -1247,12 +1357,17 @@ export default class SdcModel {
1247
1357
  $forms = this._resolveForms($forms);
1248
1358
 
1249
1359
  const self = this;
1250
- const fields = this.constructor.fields;
1360
+ const { fields } = this.constructor;
1251
1361
  const returnValue = {}
1252
1362
 
1253
1363
  function setValueInForm(name, value) {
1254
- if (!!fields[name]) {
1255
- self[name] = value;
1364
+ if (fields[name]) {
1365
+ try {
1366
+ self[name] = value;
1367
+ } catch {
1368
+ // Invalid values are still sent to the server, which returns the form
1369
+ // errors; the model keeps its last valid value.
1370
+ }
1256
1371
  }
1257
1372
  returnValue[name] = value;
1258
1373
  }
@@ -1387,14 +1502,21 @@ export default class SdcModel {
1387
1502
 
1388
1503
  switch (type) {
1389
1504
  case "CharField":
1390
- case "TeextField":
1505
+ case "TextField":
1506
+ case "SlugField":
1391
1507
  case "UUIDField":
1392
1508
  case "EmailField":
1393
1509
  return `${value}`;
1394
1510
 
1395
1511
  case "IntegerField":
1396
- case "AutoField":
1512
+ case "SmallIntegerField":
1397
1513
  case "BigIntegerField":
1514
+ case "PositiveIntegerField":
1515
+ case "PositiveSmallIntegerField":
1516
+ case "PositiveBigIntegerField":
1517
+ case "AutoField":
1518
+ case "SmallAutoField":
1519
+ case "BigAutoField":
1398
1520
  return parseInt(value, 10);
1399
1521
 
1400
1522
  case "FloatField":
@@ -1406,7 +1528,7 @@ export default class SdcModel {
1406
1528
 
1407
1529
  case "DateField":
1408
1530
  case "DateTimeField":
1409
- return Date.parse(value);
1531
+ return toDate(value);
1410
1532
 
1411
1533
  case "URLField":
1412
1534
  return new URL(value);
@@ -1429,14 +1551,7 @@ export default class SdcModel {
1429
1551
  }
1430
1552
 
1431
1553
  if (typeof File !== "undefined" && value instanceof File) {
1432
- if (config.max_size && value.size > config.max_size) {
1433
- return `File too large (max ${config.max_size} bytes)`;
1434
- }
1435
-
1436
- if (config.allowed_types && !config.allowed_types.includes(value.type)) {
1437
- return `Invalid file type (${value.type})`;
1438
- }
1439
-
1554
+ // Size and type are checked in validate().
1440
1555
  return value;
1441
1556
  }
1442
1557
  break;
@@ -1518,8 +1633,14 @@ function validateField(value, config) {
1518
1633
  break;
1519
1634
 
1520
1635
  case "IntegerField":
1521
- case "AutoField":
1636
+ case "SmallIntegerField":
1522
1637
  case "BigIntegerField":
1638
+ case "PositiveIntegerField":
1639
+ case "PositiveSmallIntegerField":
1640
+ case "PositiveBigIntegerField":
1641
+ case "AutoField":
1642
+ case "SmallAutoField":
1643
+ case "BigAutoField":
1523
1644
  if (!isFinite(parseInt(value))) {
1524
1645
  return "Must be an integer";
1525
1646
  }
@@ -1566,14 +1687,26 @@ function validateField(value, config) {
1566
1687
  break;
1567
1688
 
1568
1689
  case "JSONField":
1569
- if (typeof value !== "object") {
1570
- return "Must be JSON object";
1690
+ if (typeof value === "string") {
1691
+ try {
1692
+ JSON.parse(value);
1693
+ } catch {
1694
+ return "Must be valid JSON";
1695
+ }
1571
1696
  }
1572
1697
  break;
1573
1698
  case "FileField":
1574
1699
  if (!FileLoaded.isValid(value) && !(value instanceof File) && !(value instanceof FileLoaded)) {
1575
1700
  return "Must be a valid file";
1576
1701
  }
1702
+ if (typeof File !== "undefined" && value instanceof File) {
1703
+ if (config.max_size && value.size > config.max_size) {
1704
+ return `File too large (max ${config.max_size} bytes)`;
1705
+ }
1706
+ if (config.allowed_types && !config.allowed_types.includes(value.type)) {
1707
+ return `Invalid file type (${value.type})`;
1708
+ }
1709
+ }
1577
1710
  break;
1578
1711
  default:
1579
1712
  break;
@@ -21,7 +21,6 @@ export function prepareData(data, controller = null) {
21
21
  {
22
22
  ...fields,
23
23
  id: pk,
24
- id: pk,
25
24
  },
26
25
  ];
27
26
  } catch {
@@ -47,8 +46,6 @@ function getDomTagParamsWithList($element, controller = null) {
47
46
  function reg_runOnInitWithParameter(controller, $element, applyController) {
48
47
  if (!controller) {
49
48
  return false;
50
- } else if (typeof controller.onInit !== "function") {
51
- return false;
52
49
  }
53
50
 
54
51
  controller.params = getDomTagParamsWithList(