django-admin-inline-controls 0.1.0__py3-none-any.whl

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 (33) hide show
  1. django_admin_inline_controls/__init__.py +1 -0
  2. django_admin_inline_controls/actions.py +176 -0
  3. django_admin_inline_controls/apps.py +7 -0
  4. django_admin_inline_controls/checks.py +212 -0
  5. django_admin_inline_controls/contrib/__init__.py +4 -0
  6. django_admin_inline_controls/contrib/filters.py +69 -0
  7. django_admin_inline_controls/contrib/nested.py +77 -0
  8. django_admin_inline_controls/contrib/unfold.py +85 -0
  9. django_admin_inline_controls/controls.py +684 -0
  10. django_admin_inline_controls/locale/es/LC_MESSAGES/django.mo +0 -0
  11. django_admin_inline_controls/locale/es/LC_MESSAGES/django.po +232 -0
  12. django_admin_inline_controls/mixins.py +932 -0
  13. django_admin_inline_controls/py.typed +0 -0
  14. django_admin_inline_controls/row_actions.py +173 -0
  15. django_admin_inline_controls/static/django_admin_inline_controls/css/contrib/unfold.css +111 -0
  16. django_admin_inline_controls/static/django_admin_inline_controls/css/core.css +294 -0
  17. django_admin_inline_controls/static/django_admin_inline_controls/js/actions.js +330 -0
  18. django_admin_inline_controls/static/django_admin_inline_controls/js/contrib/unfold.js +21 -0
  19. django_admin_inline_controls/static/django_admin_inline_controls/js/core.js +570 -0
  20. django_admin_inline_controls/static/django_admin_inline_controls/js/save.js +109 -0
  21. django_admin_inline_controls/templates/django_admin_inline_controls/footer.html +99 -0
  22. django_admin_inline_controls/templates/django_admin_inline_controls/inline.html +34 -0
  23. django_admin_inline_controls/templates/django_admin_inline_controls/inline_response.html +14 -0
  24. django_admin_inline_controls/templates/django_admin_inline_controls/row_actions.html +19 -0
  25. django_admin_inline_controls/templates/django_admin_inline_controls/tfoot.html +36 -0
  26. django_admin_inline_controls/templates/django_admin_inline_controls/toolbar.html +93 -0
  27. django_admin_inline_controls/templatetags/__init__.py +0 -0
  28. django_admin_inline_controls/templatetags/inline_controls.py +72 -0
  29. django_admin_inline_controls/types.py +40 -0
  30. django_admin_inline_controls-0.1.0.dist-info/METADATA +823 -0
  31. django_admin_inline_controls-0.1.0.dist-info/RECORD +33 -0
  32. django_admin_inline_controls-0.1.0.dist-info/WHEEL +4 -0
  33. django_admin_inline_controls-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,570 @@
1
+ /*
2
+ * django-admin-inline-controls
3
+ *
4
+ * Progressive enhancement for inlines rendered by InlineControlsMixin:
5
+ * filters, sortable headers and page links refresh only that inline
6
+ * (fetch + swap) and infinite mode appends the next page on scroll. The
7
+ * save button and the actions are separate scripts (save.js, actions.js),
8
+ * loaded only by inlines that use them.
9
+ * Plain script, no dependencies; uses django.jQuery only to re-run the
10
+ * admin's own inline/widget initialization after a swap.
11
+ */
12
+ (() => {
13
+ const ROOT_SELECTOR = "[data-inline-controls]";
14
+ const states = new WeakMap();
15
+ const features = [];
16
+ let historyPushed = false;
17
+
18
+ const escapeRegExp = (value) =>
19
+ value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
20
+
21
+ const readConfig = (root) => JSON.parse(root.dataset.inlineControls);
22
+
23
+ /** The configured selectors for `key`, with {placeholders} filled in. */
24
+ function selectorsFor(config, key, values = {}) {
25
+ return []
26
+ .concat(config.selectors?.[key] ?? [])
27
+ .map((selector) =>
28
+ selector.replace(/\{(\w+)\}/g, (_, name) => values[name] ?? ""),
29
+ );
30
+ }
31
+
32
+ /** First element matching any of `key`'s selectors, in their order. */
33
+ function query(scope, config, key, values) {
34
+ for (const selector of selectorsFor(config, key, values)) {
35
+ const element = scope?.querySelector(selector);
36
+ if (element) {
37
+ return element;
38
+ }
39
+ }
40
+ return null;
41
+ }
42
+
43
+ /** Point every `<prefix>-<n>` reference in `el`'s attributes at `index`. */
44
+ function updateElementIndex(el, prefix, index) {
45
+ const pattern = new RegExp(
46
+ `${escapeRegExp(prefix)}-(\\d+|__prefix__)(?=-|\\s|$)`,
47
+ "g",
48
+ );
49
+ for (const attr of ["id", "name", "for", "aria-describedby"]) {
50
+ const value = el.getAttribute(attr);
51
+ if (value) {
52
+ el.setAttribute(
53
+ attr,
54
+ value.replace(pattern, `${prefix}-${index}`),
55
+ );
56
+ }
57
+ }
58
+ }
59
+
60
+ function reindexForm(form, prefix, index) {
61
+ updateElementIndex(form, prefix, index);
62
+ for (const el of form.querySelectorAll("*")) {
63
+ updateElementIndex(el, prefix, index);
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Index of a form row of `prefix`: a number, "empty" for the template
69
+ * of new rows, or undefined if `row` isn't one. Read from its id
70
+ * (`<prefix>-<n>`) or, without one, from its fields' names.
71
+ */
72
+ function rowIndex(row, prefix) {
73
+ const escaped = escapeRegExp(prefix);
74
+ if (row.id) {
75
+ return row.id.match(new RegExp(`^${escaped}-(\\d+|empty)$`))?.[1];
76
+ }
77
+ const pattern = new RegExp(`^${escaped}-(\\d+|__prefix__)-`);
78
+ for (const field of row.querySelectorAll("[name]")) {
79
+ const match = field.name.match(pattern);
80
+ if (match) {
81
+ return match[1] === "__prefix__" ? "empty" : match[1];
82
+ }
83
+ }
84
+ return undefined;
85
+ }
86
+
87
+ /** Form containers (tabular rows / stacked blocks) of one formset. */
88
+ function formRows(group, prefix, config = {}) {
89
+ const selectors = selectorsFor(config, "form_rows", { prefix });
90
+ return [
91
+ ...group.querySelectorAll(selectors.join(", ") || "[id]"),
92
+ ].filter((row) => rowIndex(row, prefix) !== undefined);
93
+ }
94
+
95
+ function formIndex(row, prefix) {
96
+ const index = rowIndex(row, prefix);
97
+ return index === undefined || index === "empty"
98
+ ? null
99
+ : Number.parseInt(index, 10);
100
+ }
101
+
102
+ /** Whether a form row holds a saved object (not a new one). */
103
+ function isSaved(row, config) {
104
+ const selectors = selectorsFor(config, "saved_row");
105
+ return row.matches(selectors.join(", ") || ".has_original");
106
+ }
107
+
108
+ /** URL for the current filter widget values of one inline. */
109
+ function filterUrl(config, clear, base = window.location.href) {
110
+ const url = new URL(base);
111
+ for (const name of config.filterParams) {
112
+ url.searchParams.delete(name);
113
+ }
114
+ url.searchParams.delete(config.pageParam);
115
+ if (!clear) {
116
+ const widgets = document.querySelectorAll(
117
+ `[form="${config.filterFormId}"]`,
118
+ );
119
+ for (const el of widgets) {
120
+ if (!el.name || el.disabled) {
121
+ continue;
122
+ }
123
+ if (
124
+ (el.type === "checkbox" || el.type === "radio") &&
125
+ !el.checked
126
+ ) {
127
+ continue;
128
+ }
129
+ if (el.tagName === "SELECT" && el.multiple) {
130
+ for (const option of el.selectedOptions) {
131
+ url.searchParams.append(el.name, option.value);
132
+ }
133
+ } else if (el.value !== "") {
134
+ url.searchParams.append(el.name, el.value);
135
+ }
136
+ }
137
+ }
138
+ return url.toString();
139
+ }
140
+
141
+ async function fetchDocument(url) {
142
+ const response = await fetch(url, {
143
+ credentials: "same-origin",
144
+ headers: { "X-Requested-With": "XMLHttpRequest" },
145
+ });
146
+ if (!response.ok) {
147
+ throw new Error(`HTTP ${response.status}`);
148
+ }
149
+ const text = await response.text();
150
+ return new DOMParser().parseFromString(text, "text/html");
151
+ }
152
+
153
+ /** Move the toolbar under the inline heading and the footer to its end. */
154
+ function placeControls(root, config = readConfig(root)) {
155
+ const toolbar = root.querySelector(":scope > .inline-controls-toolbar");
156
+ const footer = root.querySelector(":scope > .inline-controls-footer");
157
+ // Listeners can place them themselves and cancel the default.
158
+ const placing = new CustomEvent("inline-controls:place", {
159
+ bubbles: true,
160
+ cancelable: true,
161
+ detail: { toolbar, footer },
162
+ });
163
+ if (!root.dispatchEvent(placing)) {
164
+ return;
165
+ }
166
+ const container = query(root, config, "container");
167
+ if (!container) {
168
+ return;
169
+ }
170
+ const heading = query(container, config, "heading");
171
+ if (toolbar && heading) {
172
+ (heading.closest("summary") ?? heading).after(toolbar);
173
+ }
174
+ if (footer) {
175
+ (query(container, config, "footer_parent") ?? container).append(
176
+ footer,
177
+ );
178
+ }
179
+ }
180
+
181
+ /**
182
+ * Turn tabular column headers into sort links. Returns true when every
183
+ * sortable column got a header, so the toolbar links can be hidden.
184
+ */
185
+ function decorateHeaders(root, config) {
186
+ const thead = query(root, config, "table_head");
187
+ if (!thead || config.ordering.length === 0) {
188
+ return false;
189
+ }
190
+ let all = true;
191
+ for (const column of config.ordering) {
192
+ const th = query(thead, config, "column_header", {
193
+ name: column.name,
194
+ });
195
+ if (!th) {
196
+ all = false;
197
+ continue;
198
+ }
199
+ th.classList.add("inline-controls-sortable");
200
+ if (column.direction) {
201
+ th.classList.add(`inline-controls-${column.direction}`);
202
+ }
203
+ const link = document.createElement("a");
204
+ link.href = column.toggleUrl;
205
+ link.className = "inline-controls-sort-toggle";
206
+ link.title = config.messages.sortToggle;
207
+ link.dataset.inlineControlsNav = "";
208
+ const texts = [...th.childNodes].filter(
209
+ (node) =>
210
+ node.nodeType === Node.TEXT_NODE && node.textContent.trim(),
211
+ );
212
+ for (const node of texts) {
213
+ link.append(node.textContent.trim());
214
+ node.remove();
215
+ }
216
+ if (texts.length === 0) {
217
+ // The label is in child elements (Unfold): link them all.
218
+ link.append(...th.childNodes);
219
+ }
220
+ th.prepend(link);
221
+ if (column.priority) {
222
+ const priority = document.createElement("span");
223
+ priority.className = "inline-controls-sort-priority";
224
+ priority.textContent = ` ${column.priority}`;
225
+ const remove = document.createElement("a");
226
+ remove.href = column.removeUrl;
227
+ remove.className = "inline-controls-sort-remove";
228
+ remove.title = config.messages.sortRemove;
229
+ remove.dataset.inlineControlsNav = "";
230
+ remove.textContent = "×";
231
+ link.after(priority, remove);
232
+ }
233
+ }
234
+ return all;
235
+ }
236
+
237
+ /** Re-run the admin's inline and widget setup on freshly inserted HTML. */
238
+ function reinitAdmin(container, config = readConfig(container)) {
239
+ const $ = window.django?.jQuery;
240
+ const group = container.matches(".js-inline-admin-formset")
241
+ ? container
242
+ : container.querySelector(".js-inline-admin-formset");
243
+ if ($ && group && $.fn.tabularFormset) {
244
+ const data = JSON.parse(group.dataset.inlineFormset);
245
+ const values = { group: `${data.name}-group` };
246
+ if (group.dataset.inlineType === "tabular") {
247
+ const [selector] = selectorsFor(config, "tabular_rows", values);
248
+ $(selector).tabularFormset(selector, data.options);
249
+ } else if (group.dataset.inlineType === "stacked") {
250
+ const [selector] = selectorsFor(config, "stacked_rows", values);
251
+ $(selector).stackedFormset(selector, data.options);
252
+ }
253
+ }
254
+ initWidgets(container);
255
+ }
256
+
257
+ function initWidgets(container) {
258
+ const $ = window.django?.jQuery;
259
+ if ($?.fn.djangoAdminSelect2) {
260
+ $(container)
261
+ .find(".admin-autocomplete")
262
+ .not("[name*=__prefix__]")
263
+ .djangoAdminSelect2();
264
+ }
265
+ if ($ && typeof window.DateTimeShortcuts !== "undefined") {
266
+ $(".datetimeshortcuts").remove();
267
+ window.DateTimeShortcuts.init();
268
+ }
269
+ if (typeof window.SelectFilter !== "undefined") {
270
+ for (const el of container.querySelectorAll(
271
+ ".selectfilter, .selectfilterstacked",
272
+ )) {
273
+ if (!el.name.includes("__prefix__")) {
274
+ window.SelectFilter.init(
275
+ el.id,
276
+ el.dataset.fieldName,
277
+ el.classList.contains("selectfilterstacked"),
278
+ );
279
+ }
280
+ }
281
+ }
282
+ if ($) {
283
+ $(container)
284
+ .find(".related-widget-wrapper select")
285
+ .trigger("change");
286
+ }
287
+ container.dispatchEvent(
288
+ new CustomEvent("inline-controls:updated", { bubbles: true }),
289
+ );
290
+ }
291
+
292
+ /** Replace `root` with a copy of `fresh`, keeping <details> open state. */
293
+ function swapInline(root, fresh) {
294
+ const node = document.importNode(fresh, true);
295
+ const wasOpen = [...root.querySelectorAll("details")].map(
296
+ (details) => details.open,
297
+ );
298
+ node.querySelectorAll("details").forEach((details, index) => {
299
+ if (index < wasOpen.length) {
300
+ details.open = wasOpen[index];
301
+ }
302
+ });
303
+ root.replaceWith(node);
304
+ return node;
305
+ }
306
+
307
+ /** Show a result message in the status element under `scope`. */
308
+ function showStatus(root, status, message, scope) {
309
+ const el = root.querySelector(`${scope} .inline-controls-status`);
310
+ if (el) {
311
+ el.className = `inline-controls-status inline-controls-status-${status}`;
312
+ el.textContent = message;
313
+ }
314
+ }
315
+
316
+ /** Replace one inline with its state at `url` (or reload if unsupported). */
317
+ async function navigate(root, url) {
318
+ const state = states.get(root);
319
+ // A click while this inline is loading would act on stale links.
320
+ if (state.loading) {
321
+ return;
322
+ }
323
+ if (state.dirty && !window.confirm(state.config.messages.unsaved)) {
324
+ return;
325
+ }
326
+ const target = new URL(url, window.location.href).toString();
327
+ if (!state.config.ajax) {
328
+ window.location.assign(target);
329
+ return;
330
+ }
331
+ state.loading = true;
332
+ root.classList.add("inline-controls-loading");
333
+ try {
334
+ const doc = await fetchDocument(target);
335
+ const fresh = doc.getElementById(root.id);
336
+ if (!fresh) {
337
+ throw new Error("Inline not found in response");
338
+ }
339
+ const node = swapInline(root, fresh);
340
+ window.history.pushState({ inlineControls: true }, "", target);
341
+ historyPushed = true;
342
+ setup(node);
343
+ reinitAdmin(node);
344
+ } catch {
345
+ window.location.assign(target);
346
+ }
347
+ }
348
+
349
+ /** Infinite mode: append the next page's saved objects to the formset. */
350
+ async function loadMore(root) {
351
+ const state = states.get(root);
352
+ const { config } = state;
353
+ if (state.loading || !config.nextUrl) {
354
+ return;
355
+ }
356
+ state.loading = true;
357
+ root.classList.add("inline-controls-loading");
358
+ const target = new URL(config.nextUrl, window.location.href).toString();
359
+ try {
360
+ const doc = await fetchDocument(target);
361
+ const fresh = doc.getElementById(root.id);
362
+ const freshGroup = doc.getElementById(`${config.prefix}-group`);
363
+ const group = document.getElementById(`${config.prefix}-group`);
364
+ if (!fresh || !freshGroup || !group) {
365
+ throw new Error("Inline not found in response");
366
+ }
367
+ const { prefix } = config;
368
+ const totalInput = document.getElementById(
369
+ `id_${prefix}-TOTAL_FORMS`,
370
+ );
371
+ const initialInput = document.getElementById(
372
+ `id_${prefix}-INITIAL_FORMS`,
373
+ );
374
+ const initial = Number.parseInt(initialInput.value, 10);
375
+ const loaded = formRows(freshGroup, prefix, config).filter((row) =>
376
+ isSaved(row, config),
377
+ );
378
+ const count = loaded.length;
379
+ const rows = formRows(group, prefix, config);
380
+ // Unsaved rows must keep indexes above every saved one.
381
+ const unsaved = rows.filter((row) => {
382
+ const index = formIndex(row, prefix);
383
+ return index !== null && index >= initial;
384
+ });
385
+ for (const row of unsaved.reverse()) {
386
+ reindexForm(row, prefix, formIndex(row, prefix) + count);
387
+ }
388
+ const anchor = rows.find((row) => !isSaved(row, config));
389
+ const last = rows.at(-1);
390
+ const inserted = loaded.map((row, offset) => {
391
+ const node = document.importNode(row, true);
392
+ reindexForm(node, prefix, initial + offset);
393
+ node.classList.add(`dynamic-${prefix}`);
394
+ if (anchor) {
395
+ anchor.before(node);
396
+ } else if (last) {
397
+ last.after(node);
398
+ }
399
+ return node;
400
+ });
401
+ totalInput.value = Number.parseInt(totalInput.value, 10) + count;
402
+ initialInput.value = initial + count;
403
+
404
+ const freshConfig = readConfig(fresh);
405
+ state.config = {
406
+ ...config,
407
+ nextUrl: freshConfig.nextUrl,
408
+ loadedCount: freshConfig.loadedCount,
409
+ };
410
+ root.dataset.inlineControls = JSON.stringify(state.config);
411
+ const countEl = root.querySelector(".inline-controls-count");
412
+ const freshCount = fresh.querySelector(".inline-controls-count");
413
+ if (countEl && freshCount) {
414
+ countEl.textContent = freshCount.textContent;
415
+ }
416
+ const more = root.querySelector("[data-inline-controls-more]");
417
+ if (more) {
418
+ if (freshConfig.nextUrl) {
419
+ more.href = freshConfig.nextUrl;
420
+ } else {
421
+ state.observer?.disconnect();
422
+ more.remove();
423
+ }
424
+ }
425
+ for (const feature of features) {
426
+ feature.rowsLoaded?.(root, state);
427
+ }
428
+ for (const node of inserted) {
429
+ initWidgets(node);
430
+ }
431
+ } catch {
432
+ window.location.assign(target);
433
+ } finally {
434
+ state.loading = false;
435
+ root.classList.remove("inline-controls-loading");
436
+ }
437
+ }
438
+
439
+ function setup(root) {
440
+ if (states.has(root)) {
441
+ return;
442
+ }
443
+ const config = readConfig(root);
444
+ const state = { config, dirty: false, loading: false, observer: null };
445
+ states.set(root, state);
446
+
447
+ placeControls(root, config);
448
+ for (const feature of features) {
449
+ feature.setup?.(root, state);
450
+ }
451
+ if (decorateHeaders(root, config)) {
452
+ const ordering = root.querySelector(".inline-controls-ordering");
453
+ if (ordering) {
454
+ ordering.hidden = true;
455
+ }
456
+ }
457
+
458
+ const isFilterWidget = (el) =>
459
+ el.getAttribute?.("form") === config.filterFormId;
460
+
461
+ root.addEventListener("click", (event) => {
462
+ const nav = event.target.closest("[data-inline-controls-nav]");
463
+ if (nav) {
464
+ event.preventDefault();
465
+ navigate(root, nav.getAttribute("href"));
466
+ } else if (event.target.closest("[data-inline-controls-apply]")) {
467
+ event.preventDefault();
468
+ navigate(root, filterUrl(state.config, false));
469
+ } else if (event.target.closest("[data-inline-controls-clear]")) {
470
+ event.preventDefault();
471
+ navigate(root, filterUrl(state.config, true));
472
+ } else if (event.target.closest("[data-inline-controls-more]")) {
473
+ event.preventDefault();
474
+ loadMore(root);
475
+ } else if (
476
+ features.some((feature) => feature.click?.(event, root, state))
477
+ ) {
478
+ event.preventDefault();
479
+ }
480
+ });
481
+ root.addEventListener("keydown", (event) => {
482
+ if (event.key === "Enter" && isFilterWidget(event.target)) {
483
+ event.preventDefault();
484
+ navigate(root, filterUrl(state.config, false));
485
+ }
486
+ });
487
+ const markDirty = (event) => {
488
+ // A feature's own widgets (e.g. the action checkboxes) are not
489
+ // edits of the inline.
490
+ if (
491
+ features.some((feature) => feature.edit?.(event, root, state))
492
+ ) {
493
+ return;
494
+ }
495
+ if (!isFilterWidget(event.target)) {
496
+ state.dirty = true;
497
+ }
498
+ };
499
+ root.addEventListener("input", markDirty);
500
+ root.addEventListener("change", markDirty);
501
+
502
+ const more = root.querySelector("[data-inline-controls-more]");
503
+ if (more && typeof window.IntersectionObserver !== "undefined") {
504
+ state.observer = new IntersectionObserver((entries) => {
505
+ if (entries.some((entry) => entry.isIntersecting)) {
506
+ loadMore(root);
507
+ }
508
+ });
509
+ state.observer.observe(more);
510
+ }
511
+ }
512
+
513
+ /**
514
+ * Add a feature (the save button, the actions…), loaded as its own
515
+ * script after this one. Its optional hooks get `(root, state)`:
516
+ * `setup` when an inline is set up, `rowsLoaded` after infinite mode
517
+ * appends rows, and `click` / `edit` also the event first, returning
518
+ * true when they handled it.
519
+ */
520
+ function register(feature) {
521
+ features.push(feature);
522
+ for (const root of document.querySelectorAll(ROOT_SELECTOR)) {
523
+ const state = states.get(root);
524
+ if (state) {
525
+ feature.setup?.(root, state);
526
+ }
527
+ }
528
+ }
529
+
530
+ function init() {
531
+ for (const root of document.querySelectorAll(ROOT_SELECTOR)) {
532
+ setup(root);
533
+ }
534
+ }
535
+
536
+ // Swapped inlines are not cached by the browser: going back or forward
537
+ // through URLs pushed by navigate() needs a real load.
538
+ window.addEventListener("popstate", () => {
539
+ if (historyPushed) {
540
+ window.location.reload();
541
+ }
542
+ });
543
+
544
+ if (document.readyState === "loading") {
545
+ document.addEventListener("DOMContentLoaded", init);
546
+ } else {
547
+ init();
548
+ }
549
+
550
+ globalThis.DjangoAdminInlineControls = {
551
+ decorateHeaders,
552
+ filterUrl,
553
+ formIndex,
554
+ formRows,
555
+ init,
556
+ isSaved,
557
+ loadMore,
558
+ navigate,
559
+ placeControls,
560
+ query,
561
+ register,
562
+ reindexForm,
563
+ reinitAdmin,
564
+ setup,
565
+ showStatus,
566
+ stateOf: (root) => states.get(root),
567
+ swapInline,
568
+ updateElementIndex,
569
+ };
570
+ })();
@@ -0,0 +1,109 @@
1
+ /*
2
+ * django-admin-inline-controls: the save button (inline_save_button).
3
+ * Loaded after core.js, only by inlines that use it.
4
+ */
5
+ (() => {
6
+ const core = globalThis.DjangoAdminInlineControls;
7
+ const SCOPE = ".inline-controls-save";
8
+
9
+ /** The admin form's values for the controls inside one inline group. */
10
+ function inlineFormData(group, form) {
11
+ const data = new FormData();
12
+ const csrf = form.querySelector("[name=csrfmiddlewaretoken]");
13
+ if (csrf) {
14
+ data.append(csrf.name, csrf.value);
15
+ }
16
+ const skipTypes = ["submit", "button", "reset", "image"];
17
+ for (const el of group.querySelectorAll("input, select, textarea")) {
18
+ if (
19
+ !el.name ||
20
+ el.disabled ||
21
+ el.form !== form ||
22
+ el.name.includes("__prefix__") ||
23
+ skipTypes.includes(el.type)
24
+ ) {
25
+ continue;
26
+ }
27
+ if (
28
+ (el.type === "checkbox" || el.type === "radio") &&
29
+ !el.checked
30
+ ) {
31
+ continue;
32
+ }
33
+ if (el.type === "file") {
34
+ for (const file of el.files) {
35
+ data.append(el.name, file);
36
+ }
37
+ } else if (el.tagName === "SELECT" && el.multiple) {
38
+ for (const option of el.selectedOptions) {
39
+ data.append(el.name, option.value);
40
+ }
41
+ } else {
42
+ data.append(el.name, el.value);
43
+ }
44
+ }
45
+ return data;
46
+ }
47
+
48
+ /** POST only this inline's forms and swap in the re-rendered inline. */
49
+ async function saveInline(root) {
50
+ const state = core.stateOf(root);
51
+ const { config } = state;
52
+ const group = document.getElementById(`${config.prefix}-group`);
53
+ const form = group?.closest("form");
54
+ if (state.loading || !config.saveUrl || !form) {
55
+ return;
56
+ }
57
+ state.loading = true;
58
+ root.classList.add("inline-controls-loading");
59
+ const target = new URL(
60
+ config.saveUrl + window.location.search,
61
+ window.location.href,
62
+ ).toString();
63
+ try {
64
+ const response = await fetch(target, {
65
+ method: "POST",
66
+ body: inlineFormData(group, form),
67
+ credentials: "same-origin",
68
+ headers: { "X-Requested-With": "XMLHttpRequest" },
69
+ });
70
+ if (!response.ok) {
71
+ throw new Error(`HTTP ${response.status}`);
72
+ }
73
+ const text = await response.text();
74
+ const doc = new DOMParser().parseFromString(text, "text/html");
75
+ const result = doc.getElementById(
76
+ `${config.prefix}-inline-controls-response`,
77
+ );
78
+ const fresh = doc.getElementById(root.id);
79
+ if (!result || !fresh) {
80
+ throw new Error("Inline not found in response");
81
+ }
82
+ const node = core.swapInline(root, fresh);
83
+ core.setup(node);
84
+ core.reinitAdmin(node);
85
+ core.showStatus(
86
+ node,
87
+ result.dataset.status,
88
+ result.dataset.message,
89
+ SCOPE,
90
+ );
91
+ } catch {
92
+ state.loading = false;
93
+ root.classList.remove("inline-controls-loading");
94
+ core.showStatus(root, "failed", config.messages.saveFailed, SCOPE);
95
+ }
96
+ }
97
+
98
+ core.register({
99
+ click(event, root) {
100
+ if (!event.target.closest("[data-inline-controls-save]")) {
101
+ return false;
102
+ }
103
+ saveInline(root);
104
+ return true;
105
+ },
106
+ });
107
+
108
+ Object.assign(core, { inlineFormData, saveInline });
109
+ })();