rails_admin_next 1.0.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 (184) hide show
  1. checksums.yaml +7 -0
  2. data/Gemfile +46 -0
  3. data/LICENSE.md +21 -0
  4. data/README.md +317 -0
  5. data/Rakefile +25 -0
  6. data/app/assets/javascripts/rails_admin/custom/ui.js +1 -0
  7. data/app/assets/stylesheets/rails_admin/framework.css +2375 -0
  8. data/app/assets/stylesheets/rails_admin/skin.css +472 -0
  9. data/app/assets/stylesheets/rails_admin/tokens.css +281 -0
  10. data/app/assets/stylesheets/rails_admin.css +47 -0
  11. data/app/controllers/rails_admin_next/application_controller.rb +98 -0
  12. data/app/controllers/rails_admin_next/main_controller.rb +185 -0
  13. data/app/helpers/rails_admin_next/application_helper.rb +254 -0
  14. data/app/helpers/rails_admin_next/form_builder.rb +173 -0
  15. data/app/helpers/rails_admin_next/main_helper.rb +63 -0
  16. data/app/views/layouts/rails_admin_next/_head.html.erb +29 -0
  17. data/app/views/layouts/rails_admin_next/_navigation.html.erb +18 -0
  18. data/app/views/layouts/rails_admin_next/_secondary_navigation.html.erb +27 -0
  19. data/app/views/layouts/rails_admin_next/_sidebar_navigation.html.erb +6 -0
  20. data/app/views/layouts/rails_admin_next/application.html.erb +42 -0
  21. data/app/views/layouts/rails_admin_next/content.html.erb +22 -0
  22. data/app/views/layouts/rails_admin_next/modal.js.erb +7 -0
  23. data/app/views/rails_admin_next/main/_dashboard_history.html.erb +46 -0
  24. data/app/views/rails_admin_next/main/_delete_notice.html.erb +36 -0
  25. data/app/views/rails_admin_next/main/_form_action_text.html.erb +2 -0
  26. data/app/views/rails_admin_next/main/_form_boolean.html.erb +15 -0
  27. data/app/views/rails_admin_next/main/_form_colorpicker.html.erb +6 -0
  28. data/app/views/rails_admin_next/main/_form_datetime.html.erb +14 -0
  29. data/app/views/rails_admin_next/main/_form_enumeration.html.erb +26 -0
  30. data/app/views/rails_admin_next/main/_form_field.html.erb +2 -0
  31. data/app/views/rails_admin_next/main/_form_file_upload.html.erb +18 -0
  32. data/app/views/rails_admin_next/main/_form_filtering_multiselect.html.erb +20 -0
  33. data/app/views/rails_admin_next/main/_form_filtering_select.html.erb +22 -0
  34. data/app/views/rails_admin_next/main/_form_multiple_file_upload.html.erb +21 -0
  35. data/app/views/rails_admin_next/main/_form_nested_many.html.erb +20 -0
  36. data/app/views/rails_admin_next/main/_form_nested_one.html.erb +19 -0
  37. data/app/views/rails_admin_next/main/_form_polymorphic_association.html.erb +24 -0
  38. data/app/views/rails_admin_next/main/_form_text.html.erb +2 -0
  39. data/app/views/rails_admin_next/main/_pagination.html.erb +54 -0
  40. data/app/views/rails_admin_next/main/_submit_buttons.html.erb +26 -0
  41. data/app/views/rails_admin_next/main/bulk_delete.html.erb +19 -0
  42. data/app/views/rails_admin_next/main/dashboard.html.erb +64 -0
  43. data/app/views/rails_admin_next/main/delete.html.erb +21 -0
  44. data/app/views/rails_admin_next/main/edit.html.erb +3 -0
  45. data/app/views/rails_admin_next/main/export.html.erb +149 -0
  46. data/app/views/rails_admin_next/main/history.html.erb +80 -0
  47. data/app/views/rails_admin_next/main/index.html.erb +186 -0
  48. data/app/views/rails_admin_next/main/new.html.erb +3 -0
  49. data/app/views/rails_admin_next/main/show.html.erb +30 -0
  50. data/config/brakeman.ignore +145 -0
  51. data/config/importmap.rails_admin.rb +29 -0
  52. data/config/initializers/active_record_extensions.rb +21 -0
  53. data/config/locales/rails_admin_next.en.yml +158 -0
  54. data/config/routes.rb +14 -0
  55. data/lib/generators/rails_admin_next/install_generator.rb +39 -0
  56. data/lib/generators/rails_admin_next/templates/initializer.erb +40 -0
  57. data/lib/generators/rails_admin_next/utils.rb +21 -0
  58. data/lib/rails_admin_next/abstract_model.rb +266 -0
  59. data/lib/rails_admin_next/adapters/active_record/association.rb +130 -0
  60. data/lib/rails_admin_next/adapters/active_record/object_extension.rb +13 -0
  61. data/lib/rails_admin_next/adapters/active_record/property.rb +58 -0
  62. data/lib/rails_admin_next/adapters/active_record.rb +371 -0
  63. data/lib/rails_admin_next/config/actions/base.rb +160 -0
  64. data/lib/rails_admin_next/config/actions/bulk_delete.rb +67 -0
  65. data/lib/rails_admin_next/config/actions/dashboard.rb +62 -0
  66. data/lib/rails_admin_next/config/actions/delete.rb +58 -0
  67. data/lib/rails_admin_next/config/actions/edit.rb +56 -0
  68. data/lib/rails_admin_next/config/actions/export.rb +47 -0
  69. data/lib/rails_admin_next/config/actions/history_index.rb +40 -0
  70. data/lib/rails_admin_next/config/actions/history_show.rb +40 -0
  71. data/lib/rails_admin_next/config/actions/index.rb +108 -0
  72. data/lib/rails_admin_next/config/actions/new.rb +69 -0
  73. data/lib/rails_admin_next/config/actions/show.rb +36 -0
  74. data/lib/rails_admin_next/config/actions/show_in_app.rb +37 -0
  75. data/lib/rails_admin_next/config/actions.rb +122 -0
  76. data/lib/rails_admin_next/config/configurable.rb +107 -0
  77. data/lib/rails_admin_next/config/const_load_suppressor.rb +78 -0
  78. data/lib/rails_admin_next/config/fields/association.rb +169 -0
  79. data/lib/rails_admin_next/config/fields/base.rb +391 -0
  80. data/lib/rails_admin_next/config/fields/collection_association.rb +90 -0
  81. data/lib/rails_admin_next/config/fields/factories/action_text.rb +14 -0
  82. data/lib/rails_admin_next/config/fields/factories/active_storage.rb +34 -0
  83. data/lib/rails_admin_next/config/fields/factories/association.rb +32 -0
  84. data/lib/rails_admin_next/config/fields/factories/encrypted_password.rb +28 -0
  85. data/lib/rails_admin_next/config/fields/factories/enum.rb +23 -0
  86. data/lib/rails_admin_next/config/fields/factories/password.rb +19 -0
  87. data/lib/rails_admin_next/config/fields/group.rb +77 -0
  88. data/lib/rails_admin_next/config/fields/singular_association.rb +62 -0
  89. data/lib/rails_admin_next/config/fields/types/action_text.rb +20 -0
  90. data/lib/rails_admin_next/config/fields/types/active_record_enum.rb +62 -0
  91. data/lib/rails_admin_next/config/fields/types/active_storage.rb +70 -0
  92. data/lib/rails_admin_next/config/fields/types/all.rb +32 -0
  93. data/lib/rails_admin_next/config/fields/types/belongs_to_association.rb +48 -0
  94. data/lib/rails_admin_next/config/fields/types/boolean.rb +70 -0
  95. data/lib/rails_admin_next/config/fields/types/citext.rb +15 -0
  96. data/lib/rails_admin_next/config/fields/types/color.rb +43 -0
  97. data/lib/rails_admin_next/config/fields/types/date.rb +38 -0
  98. data/lib/rails_admin_next/config/fields/types/datetime.rb +79 -0
  99. data/lib/rails_admin_next/config/fields/types/decimal.rb +23 -0
  100. data/lib/rails_admin_next/config/fields/types/enum.rb +52 -0
  101. data/lib/rails_admin_next/config/fields/types/file_upload.rb +87 -0
  102. data/lib/rails_admin_next/config/fields/types/float.rb +23 -0
  103. data/lib/rails_admin_next/config/fields/types/has_and_belongs_to_many_association.rb +16 -0
  104. data/lib/rails_admin_next/config/fields/types/has_many_association.rb +16 -0
  105. data/lib/rails_admin_next/config/fields/types/has_one_association.rb +35 -0
  106. data/lib/rails_admin_next/config/fields/types/hidden.rb +31 -0
  107. data/lib/rails_admin_next/config/fields/types/inet.rb +15 -0
  108. data/lib/rails_admin_next/config/fields/types/integer.rb +20 -0
  109. data/lib/rails_admin_next/config/fields/types/json.rb +37 -0
  110. data/lib/rails_admin_next/config/fields/types/multiple_active_storage.rb +82 -0
  111. data/lib/rails_admin_next/config/fields/types/multiple_file_upload.rb +134 -0
  112. data/lib/rails_admin_next/config/fields/types/numeric.rb +24 -0
  113. data/lib/rails_admin_next/config/fields/types/password.rb +45 -0
  114. data/lib/rails_admin_next/config/fields/types/polymorphic_association.rb +118 -0
  115. data/lib/rails_admin_next/config/fields/types/serialized.rb +28 -0
  116. data/lib/rails_admin_next/config/fields/types/string.rb +50 -0
  117. data/lib/rails_admin_next/config/fields/types/string_like.rb +25 -0
  118. data/lib/rails_admin_next/config/fields/types/text.rb +28 -0
  119. data/lib/rails_admin_next/config/fields/types/time.rb +39 -0
  120. data/lib/rails_admin_next/config/fields/types/timestamp.rb +16 -0
  121. data/lib/rails_admin_next/config/fields/types/uuid.rb +15 -0
  122. data/lib/rails_admin_next/config/fields/types.rb +29 -0
  123. data/lib/rails_admin_next/config/fields.rb +88 -0
  124. data/lib/rails_admin_next/config/groupable.rb +20 -0
  125. data/lib/rails_admin_next/config/has_description.rb +14 -0
  126. data/lib/rails_admin_next/config/has_fields.rb +144 -0
  127. data/lib/rails_admin_next/config/has_groups.rb +27 -0
  128. data/lib/rails_admin_next/config/hideable.rb +30 -0
  129. data/lib/rails_admin_next/config/inspectable.rb +39 -0
  130. data/lib/rails_admin_next/config/lazy_model.rb +74 -0
  131. data/lib/rails_admin_next/config/model.rb +126 -0
  132. data/lib/rails_admin_next/config/proxyable/proxy.rb +42 -0
  133. data/lib/rails_admin_next/config/proxyable.rb +26 -0
  134. data/lib/rails_admin_next/config/sections/base.rb +36 -0
  135. data/lib/rails_admin_next/config/sections/create.rb +13 -0
  136. data/lib/rails_admin_next/config/sections/edit.rb +13 -0
  137. data/lib/rails_admin_next/config/sections/export.rb +13 -0
  138. data/lib/rails_admin_next/config/sections/list.rb +63 -0
  139. data/lib/rails_admin_next/config/sections/modal.rb +12 -0
  140. data/lib/rails_admin_next/config/sections/nested.rb +12 -0
  141. data/lib/rails_admin_next/config/sections/show.rb +12 -0
  142. data/lib/rails_admin_next/config/sections/update.rb +12 -0
  143. data/lib/rails_admin_next/config/sections.rb +39 -0
  144. data/lib/rails_admin_next/config.rb +436 -0
  145. data/lib/rails_admin_next/engine.rb +91 -0
  146. data/lib/rails_admin_next/extension.rb +35 -0
  147. data/lib/rails_admin_next/extensions/cancancan/authorization_adapter.rb +90 -0
  148. data/lib/rails_admin_next/extensions/cancancan.rb +5 -0
  149. data/lib/rails_admin_next/extensions/controller_extension.rb +8 -0
  150. data/lib/rails_admin_next/extensions/paper_trail/auditing_adapter.rb +192 -0
  151. data/lib/rails_admin_next/extensions/paper_trail.rb +5 -0
  152. data/lib/rails_admin_next/extensions/pundit/authorization_adapter.rb +72 -0
  153. data/lib/rails_admin_next/extensions/pundit.rb +5 -0
  154. data/lib/rails_admin_next/extensions/url_for_extension.rb +15 -0
  155. data/lib/rails_admin_next/icons.rb +143 -0
  156. data/lib/rails_admin_next/support/composite_keys_serializer.rb +15 -0
  157. data/lib/rails_admin_next/support/csv_converter.rb +100 -0
  158. data/lib/rails_admin_next/support/hash_helper.rb +31 -0
  159. data/lib/rails_admin_next/support/paginated_collection.rb +79 -0
  160. data/lib/rails_admin_next/version.rb +17 -0
  161. data/lib/rails_admin_next.rb +72 -0
  162. data/lib/tasks/rails_admin.rake +40 -0
  163. data/src/rails_admin/application.js +15 -0
  164. data/src/rails_admin/base.js +12 -0
  165. data/src/rails_admin/controllers/alert_controller.js +28 -0
  166. data/src/rails_admin/controllers/collapse_controller.js +16 -0
  167. data/src/rails_admin/controllers/dropdown_controller.js +110 -0
  168. data/src/rails_admin/controllers/feedback_controller.js +73 -0
  169. data/src/rails_admin/controllers/filter_box_controller.js +346 -0
  170. data/src/rails_admin/controllers/filtering_multiselect_controller.js +386 -0
  171. data/src/rails_admin/controllers/filtering_select_controller.js +509 -0
  172. data/src/rails_admin/controllers/index.js +31 -0
  173. data/src/rails_admin/controllers/modal_controller.js +142 -0
  174. data/src/rails_admin/controllers/nested_form_controller.js +117 -0
  175. data/src/rails_admin/controllers/polymorphic_controller.js +26 -0
  176. data/src/rails_admin/controllers/remote_form_controller.js +173 -0
  177. data/src/rails_admin/controllers/sidescroll_controller.js +23 -0
  178. data/src/rails_admin/dom-ready.js +20 -0
  179. data/src/rails_admin/dom.js +19 -0
  180. data/src/rails_admin/i18n.js +20 -0
  181. data/src/rails_admin/icons.js +64 -0
  182. data/src/rails_admin/interactions.js +221 -0
  183. data/src/rails_admin/select_query.js +65 -0
  184. metadata +346 -0
@@ -0,0 +1,117 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+
3
+ // Drives RailsAdminNext's nested association forms. The server renders subforms
4
+ // with native Rails
5
+ // `fields_for` + `accepts_nested_attributes_for`; this controller owns the
6
+ // add/remove/mark-for-destroy lifecycle:
7
+ //
8
+ // - add: clones a blank subform from the <template>. For a has_many
9
+ // association the template carries a child-index placeholder
10
+ // (e.g. "new_nested_field_tests") which is swapped for a unique value so
11
+ // each new row submits under its own index; has_one/belongs_to subforms
12
+ // have no placeholder. Focus then moves to the first visible field.
13
+ // - remove on an unsaved subform deletes it from the DOM.
14
+ // - remove on a persisted subform sets its `_destroy` hidden field and dims
15
+ // it ("will be removed on save"); the record is destroyed only on submit.
16
+ // Required inputs in a doomed subform are relaxed so they can't block
17
+ // native form validation. A second click un-marks it.
18
+ // - singular (has_one/belongs_to) associations hold at most one subform; the
19
+ // Add control's visibility is not mirrored here — :has() CSS (skin.css)
20
+ // derives it from the subform's presence, including one marked for
21
+ // destruction.
22
+ //
23
+ // A 422 re-render keeps the submitted subforms — Rails re-renders them from the
24
+ // invalid object — and the form-wide feedback controller moves focus to the
25
+ // first invalid field, so there is nothing to restore here.
26
+ export default class extends Controller {
27
+ static targets = ["container", "template"];
28
+ static values = {
29
+ // has_many child-index token to replace on add; empty for singular
30
+ // (has_one/belongs_to) associations, which mark themselves with
31
+ // data-nested-form-singular-value="true" — read only by the skin.css
32
+ // add-control rule, not by this controller.
33
+ placeholder: String,
34
+ };
35
+
36
+ static index = 0;
37
+
38
+ add(event) {
39
+ event?.preventDefault();
40
+ let html = this.templateTarget.innerHTML;
41
+ if (this.placeholderValue) {
42
+ html = html.replaceAll(this.placeholderValue, this.#nextIndex());
43
+ }
44
+ const fragment = document.createRange().createContextualFragment(html);
45
+ const subform = fragment.firstElementChild;
46
+ this.containerTarget.appendChild(fragment);
47
+ // Stimulus widgets in the new subform (date pickers, selects) auto-connect via
48
+ // the MutationObserver; this fires rails_admin.dom_ready so the dom_ready-based
49
+ // initialisers (rich-text editors) pick up the freshly inserted fields too.
50
+ document.dispatchEvent(
51
+ new CustomEvent("rails_admin.dom_ready", { detail: subform })
52
+ );
53
+ this.#focusFirstField(subform);
54
+ }
55
+
56
+ remove(event) {
57
+ event?.preventDefault();
58
+ const subform = event.target.closest("[data-nested-form-target='subform']");
59
+ if (!subform) return;
60
+
61
+ if (this.#persisted(subform)) {
62
+ this.#toggleMarkedForDestruction(subform);
63
+ } else {
64
+ subform.remove();
65
+ }
66
+ }
67
+
68
+ // A persisted record carries a populated `id` hidden field (Rails emits one
69
+ // automatically for existing nested records); a brand-new subform has none.
70
+ #persisted(subform) {
71
+ const id = subform.querySelector("input[name$='[id]']");
72
+ return Boolean(id && id.value);
73
+ }
74
+
75
+ #toggleMarkedForDestruction(subform) {
76
+ const marked = subform.classList.toggle("marked_for_destruction");
77
+ const destroy = subform.querySelector(
78
+ "input[data-nested-form-target='destroy']"
79
+ );
80
+ if (destroy) destroy.value = marked ? "1" : "";
81
+ this.#relaxRequired(subform, marked);
82
+ }
83
+
84
+ // Required inputs in a doomed subform must not block native form submission;
85
+ // relax them while marked and restore them if the user un-marks.
86
+ #relaxRequired(subform, marked) {
87
+ if (marked) {
88
+ subform.querySelectorAll("[required]").forEach((input) => {
89
+ input.dataset.nestedFormWasRequired = "true";
90
+ input.required = false;
91
+ });
92
+ } else {
93
+ subform
94
+ .querySelectorAll("[data-nested-form-was-required]")
95
+ .forEach((input) => {
96
+ input.required = true;
97
+ delete input.dataset.nestedFormWasRequired;
98
+ });
99
+ }
100
+ }
101
+
102
+ #focusFirstField(subform) {
103
+ const fields = subform.querySelectorAll(
104
+ "input:not([type='hidden']):not([type='submit']):not([type='button']), select, textarea"
105
+ );
106
+ // querySelectorAll does not descend into a nested <template>'s inert
107
+ // content, so deeply-nested blank fields are never matched here.
108
+ const target =
109
+ Array.from(fields).find((field) => field.offsetParent !== null) ||
110
+ fields[0];
111
+ target?.focus();
112
+ }
113
+
114
+ #nextIndex() {
115
+ return `${Date.now()}${this.constructor.index++}`;
116
+ }
117
+ }
@@ -0,0 +1,26 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+
3
+ // When the polymorphic type <select> changes, swap the object <select>'s
4
+ // filtering-select source to the chosen type's options, which makes that
5
+ // controller rebuild itself (reload).
6
+ //
7
+ // Wired by data-controller="polymorphic" + data-action="change->polymorphic#change"
8
+ // on the type select; the per-type option blobs live in sibling
9
+ // `#<type>-js-options` divs (data-options), and the object select carries
10
+ // data-controller="filtering-select".
11
+ export default class extends Controller {
12
+ change() {
13
+ const type = this.element.value.toLowerCase().replace(/::/g, "-");
14
+ const row = this.element.closest(".row");
15
+ const optionsEl = row?.querySelector(`#${CSS.escape(type)}-js-options`);
16
+ const objectSelect = row?.querySelector(
17
+ '[data-controller~="filtering-select"]'
18
+ );
19
+ if (!objectSelect || !optionsEl) return;
20
+
21
+ objectSelect.setAttribute(
22
+ "data-filtering-select-options-value",
23
+ optionsEl.dataset.options || "{}"
24
+ );
25
+ }
26
+ }
@@ -0,0 +1,173 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+
3
+ const ACCEPT_FORM = "text/javascript";
4
+ const ACCEPT_SUBMIT = "application/json, text/javascript";
5
+
6
+ // Inline create/edit of associated records. Attached to the .row wrapping an
7
+ // association's hidden <select> and its add/edit links. Clicking "Add a new …"
8
+ // (or "Edit this …", or double-clicking a multiselect option) loads the
9
+ // server-rendered new/edit form into the shared modal; submitting goes through
10
+ // the modal and, on success, injects the saved record straight into the select.
11
+ // Validation errors re-render the form in the modal in place.
12
+ export default class extends Controller {
13
+ static outlets = ["modal"];
14
+
15
+ connect() {
16
+ // The modal renders the same association partials, so its forms also carry
17
+ // this controller — but nested inline-add is not supported, so skip it.
18
+ if (this.element.closest("#modal")) return;
19
+ this.element.addEventListener("click", this.onClick);
20
+ this.element.addEventListener("dblclick", this.onDblclick);
21
+ }
22
+
23
+ disconnect() {
24
+ this.element.removeEventListener("click", this.onClick);
25
+ this.element.removeEventListener("dblclick", this.onDblclick);
26
+ }
27
+
28
+ onClick = (event) => {
29
+ const create = event.target.closest(".create");
30
+ if (create) {
31
+ event.preventDefault();
32
+ this.#open(create.dataset.link);
33
+ return;
34
+ }
35
+ const update = event.target.closest(".update");
36
+ if (update) {
37
+ event.preventDefault();
38
+ if (update.classList.contains("disabled")) return;
39
+ const value = this.#select?.value;
40
+ if (value) this.#open(update.dataset.link.replace("__ID__", value));
41
+ }
42
+ };
43
+
44
+ onDblclick = (event) => {
45
+ const option = event.target.closest("option");
46
+ if (!option || option.disabled || !option.closest(".ra-multiselect"))
47
+ return;
48
+ const editUrl = this.#editUrl;
49
+ if (editUrl) this.#open(editUrl.replace("__ID__", option.value));
50
+ };
51
+
52
+ async #open(url) {
53
+ if (!this.hasModalOutlet) return;
54
+ const modal = this.modalOutlet;
55
+ modal.open();
56
+ const onSave = () => this.#submit();
57
+ modal.onSave = onSave;
58
+ this.loadAbortController?.abort();
59
+ const controller = (this.loadAbortController = new AbortController());
60
+ try {
61
+ const html = await this.#load(url, controller.signal);
62
+ // The modal is shared by every association field. If it was dismissed or
63
+ // reopened for a different field while this request was in flight,
64
+ // `modal.onSave` no longer points at the callback we just set — discard
65
+ // the response instead of rendering a stale form under someone else's
66
+ // Save handler (and injecting into the wrong hidden select).
67
+ if (modal.onSave !== onSave) return;
68
+ modal.renderContent(html);
69
+ } catch (error) {
70
+ if (error.name === "AbortError") return;
71
+ modal.close();
72
+ }
73
+ }
74
+
75
+ async #load(url, signal) {
76
+ const response = await fetch(url, {
77
+ headers: { Accept: ACCEPT_FORM, "X-Requested-With": "XMLHttpRequest" },
78
+ credentials: "same-origin",
79
+ signal,
80
+ });
81
+ return response.text();
82
+ }
83
+
84
+ async #submit() {
85
+ const modal = this.modalOutlet;
86
+ // Same staleness guard as #open: if the shared modal was dismissed or
87
+ // reopened for another field while this request was in flight,
88
+ // `modal.onSave` no longer points at this field's callback and the modal
89
+ // chrome must be left alone. Deliberately no AbortController here — a
90
+ // submit that reached the server saves the record regardless, and the
91
+ // response must still be injected into this field's row.
92
+ const onSave = modal.onSave;
93
+ modal.disableSave();
94
+ try {
95
+ const form = modal.bodyTarget.querySelector("form");
96
+ if (!form) return;
97
+ const response = await fetch(form.action, {
98
+ method: form.method,
99
+ headers: {
100
+ Accept: ACCEPT_SUBMIT,
101
+ "X-Requested-With": "XMLHttpRequest",
102
+ },
103
+ body: new FormData(form),
104
+ credentials: "same-origin",
105
+ });
106
+ if (
107
+ response.ok &&
108
+ response.headers.get("Content-Type")?.includes("json")
109
+ ) {
110
+ // Always inject — #inject targets this.element (the submitting
111
+ // field's own row), not the modal, so the saved record lands in the
112
+ // right select even if the modal has moved on.
113
+ this.#inject(await response.json());
114
+ if (modal.onSave === onSave) modal.close();
115
+ } else if (modal.onSave === onSave) {
116
+ modal.renderContent(await response.text());
117
+ }
118
+ } catch (error) {
119
+ if (modal.onSave === onSave) modal.close();
120
+ } finally {
121
+ if (modal.onSave === onSave) modal.enableSave();
122
+ }
123
+ }
124
+
125
+ // Place the saved record into the field's hidden <select> (the submitted value)
126
+ // and reflect it in the visible widget.
127
+ #inject({ id, label }) {
128
+ const select = this.#select;
129
+ if (!select) return;
130
+ const multiselect = this.element.querySelector(".ra-multiselect");
131
+ if (multiselect) {
132
+ const existing = multiselect.querySelector(`option[value="${id}"]`);
133
+ if (existing) {
134
+ select
135
+ .querySelectorAll(`option[value="${id}"]`)
136
+ .forEach((option) => (option.text = label));
137
+ multiselect
138
+ .querySelectorAll(`option[value="${id}"]`)
139
+ .forEach((option) => (option.text = label));
140
+ } else {
141
+ select.add(new Option(label, id, true, true));
142
+ multiselect
143
+ .querySelector("select.ra-multiselect-selection")
144
+ ?.add(new Option(label, id));
145
+ }
146
+ } else {
147
+ const input = this.element.querySelector(".ra-filtering-select-input");
148
+ if (input) input.value = label;
149
+ if (select.querySelector(`option[value="${id}"]`)) {
150
+ Array.from(select.options).forEach(
151
+ (option) => (option.selected = option.value === String(id))
152
+ );
153
+ } else {
154
+ select.replaceChildren(new Option(label, id, true, true));
155
+ }
156
+ this.element.querySelector(".update")?.classList.remove("disabled");
157
+ }
158
+ }
159
+
160
+ get #select() {
161
+ return this.element.querySelector("select");
162
+ }
163
+
164
+ get #editUrl() {
165
+ const options = this.#select?.dataset.options;
166
+ if (!options) return null;
167
+ try {
168
+ return JSON.parse(options)["edit-url"] || null;
169
+ } catch {
170
+ return null;
171
+ }
172
+ }
173
+ }
@@ -0,0 +1,23 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+
3
+ // Positions the sticky (frozen) columns of a horizontally scrollable list table.
4
+ // Each sticky cell is offset by its distance from the first sticky cell in the
5
+ // row so the frozen columns stack flush against the left edge while the rest of
6
+ // the table scrolls under them.
7
+ //
8
+ // It runs on connect: Turbo Drive re-inserts #sidescroll on each visit, so the
9
+ // controller reconnects and recomputes the offsets automatically without a
10
+ // separate dom_ready listener.
11
+ export default class extends Controller {
12
+ connect() {
13
+ this.element.querySelectorAll("tr").forEach((tr) => {
14
+ let firstPosition;
15
+ tr.querySelectorAll("th.sticky, td.sticky").forEach((cell, index) => {
16
+ if (index === 0) {
17
+ firstPosition = cell.offsetLeft;
18
+ }
19
+ cell.style.left = `${cell.offsetLeft - firstPosition}px`;
20
+ });
21
+ });
22
+ }
23
+ }
@@ -0,0 +1,20 @@
1
+ import I18n from "rails_admin/i18n";
2
+
3
+ // Fires the `rails_admin.dom_ready` event exactly once per navigation:
4
+ // DOMContentLoaded covers the cold page load, turbo:render covers subsequent
5
+ // Turbo Drive visits — the two are mutually exclusive.
6
+ function triggerDomReady() {
7
+ const admin = document.getElementById("admin-js");
8
+ I18n.init(document.documentElement.lang, admin && admin.dataset.i18nOptions);
9
+ document.dispatchEvent(new CustomEvent("rails_admin.dom_ready"));
10
+ }
11
+
12
+ if (document.readyState === "loading") {
13
+ document.addEventListener("DOMContentLoaded", triggerDomReady);
14
+ } else {
15
+ // Already loaded: defer to the next tick so the rest of the module graph (and
16
+ // any host-registered rails_admin.dom_ready listeners imported after us) has
17
+ // finished evaluating before the event fires.
18
+ setTimeout(triggerDomReady, 0);
19
+ }
20
+ document.addEventListener("turbo:render", triggerDomReady);
@@ -0,0 +1,19 @@
1
+ // Minimal DOM-builder shared by the filter/select Stimulus controllers
2
+ // (filter_box, filtering_select, filtering_multiselect). Boolean `true`
3
+ // in `attrs` renders a bare HTML boolean attribute (setAttribute(key, key),
4
+ // e.g. { multiple: true } -> multiple="multiple"); `false`/`null`/`undefined`
5
+ // skip the attribute entirely; anything else is stringified via setAttribute.
6
+ // `null`/`undefined` children are skipped so callers can splice in
7
+ // conditional content inline.
8
+ export function el(tag, attrs = {}, ...children) {
9
+ const node = document.createElement(tag);
10
+ for (const [key, value] of Object.entries(attrs)) {
11
+ if (value === true) node.setAttribute(key, key);
12
+ else if (value != null && value !== false) node.setAttribute(key, value);
13
+ }
14
+ for (const child of children) {
15
+ if (child == null) continue;
16
+ node.append(child);
17
+ }
18
+ return node;
19
+ }
@@ -0,0 +1,20 @@
1
+ var I18n = {
2
+ locale: null,
3
+ translations: null,
4
+ init(locale, translations) {
5
+ this.locale = locale;
6
+ this.translations = translations;
7
+ if (typeof this.translations === "string") {
8
+ this.translations = JSON.parse(this.translations);
9
+ }
10
+ },
11
+ t(key) {
12
+ var humanize;
13
+ humanize = key.charAt(0).toUpperCase() + key.replace(/_/g, " ").slice(1);
14
+ // Stimulus controllers can connect before dom-ready.js calls I18n.init, so
15
+ // tolerate translations not being loaded yet (fall back to the humanized key).
16
+ return (this.translations && this.translations[key]) || humanize;
17
+ },
18
+ };
19
+
20
+ export { I18n as default };
@@ -0,0 +1,64 @@
1
+ // Inline-SVG icons for client-rendered widgets (filter box, filtering select and
2
+ // multiselect). Mirrors the server-side RailsAdminNext::Icons helper
3
+ // (lib/rails_admin_next/icons.rb): the same Font Awesome Free 6.1.1 (solid) glyph
4
+ // data and the same data-icon/class output, so JS- and server-rendered icons match.
5
+ // Only the glyphs drawn at runtime live here; the full set is owned by the Ruby side.
6
+
7
+ const SVG_NS = "http://www.w3.org/2000/svg";
8
+
9
+ const GLYPHS = {
10
+ delete: {
11
+ viewBox: "0 0 320 512",
12
+ path: "M310.6 361.4c12.5 12.5 12.5 32.75 0 45.25C304.4 412.9 296.2 416 288 416s-16.38-3.125-22.62-9.375L160 301.3L54.63 406.6C48.38 412.9 40.19 416 32 416S15.63 412.9 9.375 406.6c-12.5-12.5-12.5-32.75 0-45.25l105.4-105.4L9.375 150.6c-12.5-12.5-12.5-32.75 0-45.25s32.75-12.5 45.25 0L160 210.8l105.4-105.4c12.5-12.5 32.75-12.5 45.25 0s12.5 32.75 0 45.25l-105.4 105.4L310.6 361.4z",
13
+ },
14
+ minus: {
15
+ viewBox: "0 0 448 512",
16
+ path: "M400 288h-352c-17.69 0-32-14.32-32-32.01s14.31-31.99 32-31.99h352c17.69 0 32 14.3 32 31.99S417.7 288 400 288z",
17
+ },
18
+ move_down: {
19
+ viewBox: "0 0 512 512",
20
+ path: "M256 0C114.6 0 0 114.6 0 256c0 141.4 114.6 256 256 256s256-114.6 256-256C512 114.6 397.4 0 256 0zM390.6 246.6l-112 112C272.4 364.9 264.2 368 256 368s-16.38-3.125-22.62-9.375l-112-112c-12.5-12.5-12.5-32.75 0-45.25s32.75-12.5 45.25 0L256 290.8l89.38-89.38c12.5-12.5 32.75-12.5 45.25 0S403.1 234.1 390.6 246.6z",
21
+ },
22
+ move_left: {
23
+ viewBox: "0 0 512 512",
24
+ path: "M256 0C114.6 0 0 114.6 0 256c0 141.4 114.6 256 256 256s256-114.6 256-256C512 114.6 397.4 0 256 0zM310.6 345.4c12.5 12.5 12.5 32.75 0 45.25s-32.75 12.5-45.25 0l-112-112C147.1 272.4 144 264.2 144 256s3.125-16.38 9.375-22.62l112-112c12.5-12.5 32.75-12.5 45.25 0s12.5 32.75 0 45.25L221.3 256L310.6 345.4z",
25
+ },
26
+ move_right: {
27
+ viewBox: "0 0 512 512",
28
+ path: "M256 0C114.6 0 0 114.6 0 256c0 141.4 114.6 256 256 256s256-114.6 256-256C512 114.6 397.4 0 256 0zM358.6 278.6l-112 112c-12.5 12.5-32.75 12.5-45.25 0s-12.5-32.75 0-45.25L290.8 256L201.4 166.6c-12.5-12.5-12.5-32.75 0-45.25s32.75-12.5 45.25 0l112 112C364.9 239.6 368 247.8 368 256S364.9 272.4 358.6 278.6z",
29
+ },
30
+ move_up: {
31
+ viewBox: "0 0 512 512",
32
+ path: "M256 0C114.6 0 0 114.6 0 256c0 141.4 114.6 256 256 256s256-114.6 256-256C512 114.6 397.4 0 256 0zM390.6 310.6c-12.5 12.5-32.75 12.5-45.25 0L256 221.3L166.6 310.6c-12.5 12.5-32.75 12.5-45.25 0s-12.5-32.75 0-45.25l112-112C239.6 147.1 247.8 144 256 144s16.38 3.125 22.62 9.375l112 112C403.1 277.9 403.1 298.1 390.6 310.6z",
33
+ },
34
+ new: {
35
+ viewBox: "0 0 448 512",
36
+ path: "M432 256c0 17.69-14.33 32.01-32 32.01H256v144c0 17.69-14.33 31.99-32 31.99s-32-14.3-32-31.99v-144H48c-17.67 0-32-14.32-32-32.01s14.33-31.99 32-31.99H192v-144c0-17.69 14.33-32.01 32-32.01s32 14.32 32 32.01v144h144C417.7 224 432 238.3 432 256z",
37
+ },
38
+ trash: {
39
+ viewBox: "0 0 448 512",
40
+ path: "M135.2 17.69C140.6 6.848 151.7 0 163.8 0H284.2C296.3 0 307.4 6.848 312.8 17.69L320 32H416C433.7 32 448 46.33 448 64C448 81.67 433.7 96 416 96H32C14.33 96 0 81.67 0 64C0 46.33 14.33 32 32 32H128L135.2 17.69zM394.8 466.1C393.2 492.3 372.3 512 346.9 512H101.1C75.75 512 54.77 492.3 53.19 466.1L31.1 128H416L394.8 466.1z",
41
+ },
42
+ };
43
+
44
+ // Semantic synonyms => canonical glyph name, mirroring the Ruby ALIASES subset.
45
+ const ALIASES = { cancel: "delete", plus: "new", times: "delete" };
46
+
47
+ // Build an inline <svg> element for a logical icon name (see GLYPHS), or null if
48
+ // the name is unknown. Output mirrors the server-side rails_admin_icon helper.
49
+ export function icon(name, { className = "" } = {}) {
50
+ const key = GLYPHS[name] ? name : ALIASES[name];
51
+ const glyph = key && GLYPHS[key];
52
+ if (!glyph) return null;
53
+
54
+ const svg = document.createElementNS(SVG_NS, "svg");
55
+ svg.setAttribute("viewBox", glyph.viewBox);
56
+ svg.setAttribute("aria-hidden", "true");
57
+ svg.setAttribute("focusable", "false");
58
+ svg.setAttribute("data-icon", key);
59
+ svg.setAttribute("class", `rails-admin-icon ${className}`.trim());
60
+ const path = document.createElementNS(SVG_NS, "path");
61
+ path.setAttribute("d", glyph.path);
62
+ svg.appendChild(path);
63
+ return svg;
64
+ }