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,509 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+ import I18n from "rails_admin/i18n";
3
+ import { icon } from "rails_admin/icons";
4
+ import { el } from "rails_admin/dom";
5
+ import {
6
+ createScopedQuery,
7
+ toQueryString,
8
+ escapeRegex,
9
+ fetchJson,
10
+ } from "rails_admin/select_query";
11
+
12
+ const SEARCH_DELAY = 200;
13
+
14
+ let sequence = 0;
15
+
16
+ // Accessible combobox over a hidden <select> (the real form field), following
17
+ // the WAI-ARIA APG combobox + listbox pattern: role=combobox input with
18
+ // aria-expanded / aria-activedescendant driving a role=listbox of role=option
19
+ // items, arrow/Enter/Escape/Home/End keys, and aria-live regions for the
20
+ // empty and error states.
21
+ //
22
+ // Wired by data-controller="filtering-select" on the <select>, with
23
+ // data-filtering-select-options-value = { xhr, remoteSource, scopeBy }.
24
+ //
25
+ // The rendered DOM keeps the class hooks the inline-add flow (remote-form.js)
26
+ // and the existing CSS depend on: `.filtering-select[data-input-for]`,
27
+ // `.ra-filtering-select-input`, `.dropdown-toggle`.
28
+ export default class extends Controller {
29
+ static values = { options: { type: Object, default: {} } };
30
+
31
+ connect() {
32
+ this.select = this.element;
33
+ this.activeIndex = -1;
34
+ this.items = [];
35
+ this.requestId = 0;
36
+ this.#build();
37
+ }
38
+
39
+ disconnect() {
40
+ // Remove the generated UI and restore the <select> so a Turbo snapshot
41
+ // caches the original markup — this is what prevents a duplicate combobox
42
+ // from being restored on browser back/forward.
43
+ clearTimeout(this.searchTimer);
44
+ this.#teardown();
45
+ this.group?.remove();
46
+ this.select.style.display = "";
47
+ this.select.removeAttribute("data-ra-filtering-select-built");
48
+ }
49
+
50
+ // Re-initialise against a fresh option set / remote source (used by the
51
+ // polymorphic type switch): tears down and fully rebuilds the field.
52
+ reload() {
53
+ this.#teardown();
54
+ this.group?.remove();
55
+ this.select.innerHTML = '<option value="" selected="selected"></option>';
56
+ this.#build();
57
+ }
58
+
59
+ // Remove the document-level outside-click listener bound in #bind(). Without
60
+ // this, every reload() (each polymorphic type switch) would add a new listener
61
+ // on top of the previous one, leaking a permanent handler closing over
62
+ // removed DOM.
63
+ #teardown() {
64
+ if (this.outsideHandler)
65
+ document.removeEventListener("click", this.outsideHandler);
66
+ }
67
+
68
+ #build() {
69
+ const id = this.select.id;
70
+
71
+ // Reuse an orphaned group from a bfcache snapshot rather than duplicating it.
72
+ this.element.parentNode
73
+ .querySelectorAll(`.filtering-select[data-input-for="${CSS.escape(id)}"]`)
74
+ .forEach((node) => node.remove());
75
+
76
+ // Capture the select's own style BEFORE hiding it — and after clearing any
77
+ // display we set on a previous build (reload) — so we never copy display:none
78
+ // onto the visible combobox input.
79
+ this.select.style.display = "";
80
+ const selectStyle = this.select.getAttribute("style");
81
+ this.select.style.display = "none";
82
+ this.select.setAttribute("data-ra-filtering-select-built", "true");
83
+
84
+ const selected = this.select.options[this.select.selectedIndex];
85
+ const value = selected && selected.value ? selected.text : "";
86
+
87
+ this.listId = `ra-fs-list-${(sequence += 1)}`;
88
+ this.statusId = `ra-fs-status-${sequence}`;
89
+
90
+ this.group = el("div", {
91
+ class: "input-group filtering-select",
92
+ "data-input-for": id,
93
+ });
94
+
95
+ this.input = el("input", {
96
+ type: "text",
97
+ class: "form-control ra-filtering-select-input",
98
+ role: "combobox",
99
+ "aria-autocomplete": "list",
100
+ "aria-expanded": "false",
101
+ "aria-controls": this.listId,
102
+ "aria-describedby": this.statusId,
103
+ autocomplete: "off",
104
+ });
105
+ this.input.value = value;
106
+ if (selectStyle) {
107
+ this.input.setAttribute("style", selectStyle);
108
+ }
109
+ if (this.select.getAttribute("placeholder")) {
110
+ this.input.setAttribute(
111
+ "placeholder",
112
+ this.select.getAttribute("placeholder")
113
+ );
114
+ }
115
+ // Move `required` off the hidden <select> (it can't be validated while hidden).
116
+ if (this.select.hasAttribute("required")) {
117
+ this.required = true;
118
+ this.input.setAttribute("required", "required");
119
+ this.select.removeAttribute("required");
120
+ }
121
+
122
+ this.button = el("label", {
123
+ class: "btn btn-info dropdown-toggle",
124
+ role: "button",
125
+ title: "Show All Items",
126
+ "aria-label": "Show All Items",
127
+ });
128
+ this.buttonWrap = el("span", { class: "input-group-btn" }, this.button);
129
+
130
+ this.menu = el("ul", {
131
+ class: "ra-filtering-select-menu",
132
+ id: this.listId,
133
+ role: "listbox",
134
+ hidden: true,
135
+ });
136
+ Object.assign(this.menu.style, {
137
+ position: "absolute",
138
+ zIndex: "1000",
139
+ top: "100%",
140
+ left: "0",
141
+ right: "0",
142
+ margin: "0",
143
+ padding: "0",
144
+ listStyle: "none",
145
+ background: "#fff",
146
+ border: "1px solid rgba(0,0,0,.15)",
147
+ maxHeight: "16rem",
148
+ overflowY: "auto",
149
+ });
150
+
151
+ this.status = el("div", {
152
+ id: this.statusId,
153
+ class: "ra-filtering-select-status visually-hidden",
154
+ role: "status",
155
+ "aria-live": "polite",
156
+ });
157
+
158
+ this.error = el("div", {
159
+ class: "ra-filtering-select-error text-danger",
160
+ role: "alert",
161
+ "aria-live": "assertive",
162
+ hidden: true,
163
+ });
164
+
165
+ this.group.append(
166
+ this.input,
167
+ this.buttonWrap,
168
+ this.menu,
169
+ this.status,
170
+ this.error
171
+ );
172
+ this.group.style.position = "relative";
173
+ this.select.after(this.group);
174
+
175
+ this.#bind();
176
+ }
177
+
178
+ #bind() {
179
+ this.input.addEventListener("input", () => {
180
+ // Emptying the box clears the current selection.
181
+ if (this.input.value.length === 0) this.#clear();
182
+ this.#scheduleSearch(this.input.value);
183
+ });
184
+ this.input.addEventListener("keydown", (e) => this.#onKeydown(e));
185
+ this.input.addEventListener("blur", () => this.#onBlur());
186
+ this.button.addEventListener("click", (e) => {
187
+ e.preventDefault();
188
+ this.#onToggle();
189
+ });
190
+ this.outsideHandler = (e) => {
191
+ if (this.group && !this.group.contains(e.target)) this.#close();
192
+ };
193
+ document.addEventListener("click", this.outsideHandler);
194
+ }
195
+
196
+ #scheduleSearch(term) {
197
+ clearTimeout(this.searchTimer);
198
+ this.searchTimer = setTimeout(() => this.#search(term), SEARCH_DELAY);
199
+ }
200
+
201
+ #onToggle() {
202
+ if (!this.menu.hidden) {
203
+ this.#close();
204
+ return;
205
+ }
206
+ this.#search("");
207
+ this.input.focus();
208
+ }
209
+
210
+ #search(term) {
211
+ this.#query(term, (results, isRemote) =>
212
+ this.#renderMenu(this.#resultSet(term, results, isRemote))
213
+ );
214
+ }
215
+
216
+ // Resolve the source: local (the <select>'s own options) or remote (xhr).
217
+ #query(term, done) {
218
+ if (this.optionsValue.xhr) {
219
+ this.#remoteQuery(term, done);
220
+ } else {
221
+ const source = Array.from(this.select.options)
222
+ .filter((o) => o.value !== "")
223
+ .map((o) => ({ label: o.text, value: o.value }));
224
+ const matcher = new RegExp(escapeRegex(term), "i");
225
+ done(
226
+ source.filter((el) => matcher.test(el.label)),
227
+ false
228
+ );
229
+ }
230
+ }
231
+
232
+ #remoteQuery(term, done) {
233
+ const id = ++this.requestId;
234
+ this.controller?.abort();
235
+ this.controller = new AbortController();
236
+ this.#setBusy(true);
237
+ this.error.hidden = true;
238
+
239
+ const query = createScopedQuery(this.optionsValue.scopeBy, term);
240
+ const url = `${this.optionsValue.remote_source}&${toQueryString(query)}`;
241
+
242
+ fetchJson(url, { signal: this.controller.signal })
243
+ .then((data) => {
244
+ if (id !== this.requestId) return;
245
+ this.#setBusy(false);
246
+ done(data, true);
247
+ })
248
+ .catch((error) => {
249
+ if (error.name === "AbortError" || id !== this.requestId) return;
250
+ this.#setBusy(false);
251
+ this.#renderError();
252
+ });
253
+ }
254
+
255
+ // Build the list of menu entries: a leading "Clear" entry on an empty,
256
+ // optional field; a disabled "no objects" entry when nothing matches;
257
+ // otherwise the (highlighted) matches.
258
+ #resultSet(term, data, isRemote) {
259
+ const matcher = new RegExp(escapeRegex(term), "i");
260
+ const matches = data
261
+ .map((el) => {
262
+ const id = el.id || el.value;
263
+ const label = el.label || el.id;
264
+ if (id && (isRemote || matcher.test(el.label))) {
265
+ return { id, value: label, term };
266
+ }
267
+ return null;
268
+ })
269
+ .filter(Boolean);
270
+
271
+ if (term.length === 0 && !this.required) {
272
+ return [{ clear: true }, ...matches];
273
+ }
274
+ if (matches.length === 0) {
275
+ return [{ empty: true }];
276
+ }
277
+ return matches;
278
+ }
279
+
280
+ #renderMenu(entries) {
281
+ this.menu.replaceChildren();
282
+ this.items = [];
283
+ this.activeIndex = -1;
284
+
285
+ entries.forEach((entry) => {
286
+ const li = document.createElement("li");
287
+ li.id = `${this.listId}-opt-${this.items.length}`;
288
+ li.setAttribute("role", "option");
289
+ li.setAttribute("aria-selected", "false");
290
+ li.className = "ra-filtering-select-option";
291
+ Object.assign(li.style, { padding: ".25rem .75rem", cursor: "pointer" });
292
+
293
+ if (entry.empty) {
294
+ li.setAttribute("aria-disabled", "true");
295
+ li.classList.add("ra-filtering-select-empty");
296
+ li.textContent = I18n.t("no_objects");
297
+ li.style.cursor = "default";
298
+ this.#announce(I18n.t("no_objects"));
299
+ } else if (entry.clear) {
300
+ li.dataset.clear = "true";
301
+ li.append(icon("cancel"), ` ${I18n.t("clear")}`);
302
+ li.addEventListener("mousedown", (e) => e.preventDefault());
303
+ li.addEventListener("click", () => this.#choose(li));
304
+ this.items.push({ el: li, id: null, value: null });
305
+ } else {
306
+ li.dataset.value = entry.id;
307
+ li.dataset.label = entry.value;
308
+ li.append(...this.#highlight(entry.value, entry.term));
309
+ li.addEventListener("mousedown", (e) => e.preventDefault());
310
+ li.addEventListener("click", () => this.#choose(li));
311
+ this.items.push({ el: li, id: entry.id, value: entry.value });
312
+ }
313
+ this.menu.appendChild(li);
314
+ });
315
+
316
+ this.#open();
317
+ }
318
+
319
+ #renderError() {
320
+ this.menu.replaceChildren();
321
+ this.items = [];
322
+ this.error.replaceChildren();
323
+ const message = document.createElement("span");
324
+ message.textContent = I18n.t("no_objects");
325
+ const retry = document.createElement("button");
326
+ retry.type = "button";
327
+ retry.className = "btn btn-link btn-sm ra-filtering-select-retry";
328
+ retry.textContent = I18n.t("retry");
329
+ retry.addEventListener("click", (e) => {
330
+ e.preventDefault();
331
+ this.#search(this.input.value);
332
+ });
333
+ this.error.append(message, " ", retry);
334
+ this.error.hidden = false;
335
+ this.#close();
336
+ }
337
+
338
+ // Split `label` on `term` and wrap the matched term in <strong>, building DOM
339
+ // text nodes (never innerHTML) so server-supplied labels can't inject markup.
340
+ #highlight(label, term) {
341
+ if (!term || !term.length) return [document.createTextNode(label)];
342
+ const matcher = new RegExp(escapeRegex(term), "i");
343
+ const nodes = [];
344
+ let rest = label;
345
+ let match;
346
+ while ((match = rest.match(matcher)) && match[0].length) {
347
+ const index = match.index;
348
+ if (index > 0) nodes.push(document.createTextNode(rest.slice(0, index)));
349
+ const strong = document.createElement("strong");
350
+ strong.textContent = rest.slice(index, index + match[0].length);
351
+ nodes.push(strong);
352
+ rest = rest.slice(index + match[0].length);
353
+ }
354
+ if (rest.length) nodes.push(document.createTextNode(rest));
355
+ return nodes;
356
+ }
357
+
358
+ #choose(li) {
359
+ if (li.dataset.clear) {
360
+ this.#clear();
361
+ this.#close();
362
+ return;
363
+ }
364
+ const id = li.dataset.value;
365
+ const label = li.dataset.label;
366
+
367
+ let option = Array.from(this.select.options).find((o) => o.value === id);
368
+ this.select
369
+ .querySelectorAll("option[selected]")
370
+ .forEach((o) => (o.selected = false));
371
+ if (option) {
372
+ option.selected = true;
373
+ } else {
374
+ option = document.createElement("option");
375
+ option.value = id;
376
+ option.selected = true;
377
+ option.text = label;
378
+ this.select.appendChild(option);
379
+ }
380
+ this.input.value = label;
381
+ this.select.dispatchEvent(new Event("change", { bubbles: true }));
382
+ this.#enableInlineEdit(true);
383
+ this.#close();
384
+ this.input.focus();
385
+ }
386
+
387
+ // Deselect without destroying the option list, so the candidate set survives a
388
+ // browser back/forward restore.
389
+ #clear() {
390
+ this.input.value = "";
391
+ this.select
392
+ .querySelectorAll("option[selected]")
393
+ .forEach((o) => (o.selected = false));
394
+ let blank = Array.from(this.select.options).find((o) => o.value === "");
395
+ if (!blank) {
396
+ blank = document.createElement("option");
397
+ blank.value = "";
398
+ this.select.insertBefore(blank, this.select.firstChild);
399
+ }
400
+ blank.selected = true;
401
+ this.select.value = "";
402
+ this.select.dispatchEvent(new Event("change", { bubbles: true }));
403
+ this.#enableInlineEdit(false);
404
+ }
405
+
406
+ #onKeydown(event) {
407
+ switch (event.key) {
408
+ case "ArrowDown":
409
+ event.preventDefault();
410
+ if (this.menu.hidden) this.#search(this.input.value);
411
+ else this.#move(1);
412
+ break;
413
+ case "ArrowUp":
414
+ event.preventDefault();
415
+ this.#move(-1);
416
+ break;
417
+ case "Home":
418
+ if (!this.menu.hidden) {
419
+ event.preventDefault();
420
+ this.#activate(0);
421
+ }
422
+ break;
423
+ case "End":
424
+ if (!this.menu.hidden) {
425
+ event.preventDefault();
426
+ this.#activate(this.items.length - 1);
427
+ }
428
+ break;
429
+ case "Enter":
430
+ if (!this.menu.hidden && this.activeIndex >= 0) {
431
+ event.preventDefault();
432
+ this.#choose(this.items[this.activeIndex].el);
433
+ }
434
+ break;
435
+ case "Escape":
436
+ if (!this.menu.hidden) {
437
+ event.preventDefault();
438
+ this.#close();
439
+ }
440
+ break;
441
+ default:
442
+ break;
443
+ }
444
+ }
445
+
446
+ #onBlur() {
447
+ // Revert a half-typed, uncommitted query back to the committed selection's
448
+ // label so the input never shows a value the <select> doesn't hold.
449
+ setTimeout(() => {
450
+ if (this.group && this.group.contains(document.activeElement)) return;
451
+ const selected = this.select.options[this.select.selectedIndex];
452
+ const label = selected && selected.value ? selected.text : "";
453
+ if (this.input.value !== label) this.input.value = label;
454
+ this.#close();
455
+ }, 150);
456
+ }
457
+
458
+ #move(direction) {
459
+ if (this.items.length === 0) return;
460
+ let index = this.activeIndex + direction;
461
+ if (index < 0) index = this.items.length - 1;
462
+ if (index >= this.items.length) index = 0;
463
+ this.#activate(index);
464
+ }
465
+
466
+ #activate(index) {
467
+ this.items.forEach(({ el }) => {
468
+ el.classList.remove("active");
469
+ el.setAttribute("aria-selected", "false");
470
+ });
471
+ this.activeIndex = index;
472
+ const { el } = this.items[index];
473
+ el.classList.add("active");
474
+ el.setAttribute("aria-selected", "true");
475
+ el.scrollIntoView({ block: "nearest" });
476
+ this.input.setAttribute("aria-activedescendant", el.id);
477
+ }
478
+
479
+ #open() {
480
+ this.menu.hidden = false;
481
+ this.input.setAttribute("aria-expanded", "true");
482
+ }
483
+
484
+ #close() {
485
+ this.menu.hidden = true;
486
+ this.input.setAttribute("aria-expanded", "false");
487
+ this.input.removeAttribute("aria-activedescendant");
488
+ this.activeIndex = -1;
489
+ }
490
+
491
+ #setBusy(busy) {
492
+ this.input.setAttribute("aria-busy", busy ? "true" : "false");
493
+ this.group.classList.toggle("ra-filtering-select-busy", busy);
494
+ }
495
+
496
+ #announce(message) {
497
+ this.status.textContent = message;
498
+ }
499
+
500
+ #enableInlineEdit(enabled) {
501
+ const controls = this.select.closest(".controls");
502
+ controls?.querySelector(".update")?.classList.toggle("disabled", !enabled);
503
+ }
504
+
505
+ optionsValueChanged() {
506
+ // The polymorphic controller swaps this in; rebuild against the new source.
507
+ if (this.group) this.reload();
508
+ }
509
+ }
@@ -0,0 +1,31 @@
1
+ import { application } from "rails_admin/application";
2
+
3
+ import AlertController from "rails_admin/controllers/alert_controller";
4
+ import CollapseController from "rails_admin/controllers/collapse_controller";
5
+ import DropdownController from "rails_admin/controllers/dropdown_controller";
6
+ import FeedbackController from "rails_admin/controllers/feedback_controller";
7
+ import FilterBoxController from "rails_admin/controllers/filter_box_controller";
8
+ import FilteringMultiselectController from "rails_admin/controllers/filtering_multiselect_controller";
9
+ import FilteringSelectController from "rails_admin/controllers/filtering_select_controller";
10
+ import ModalController from "rails_admin/controllers/modal_controller";
11
+ import NestedFormController from "rails_admin/controllers/nested_form_controller";
12
+ import PolymorphicController from "rails_admin/controllers/polymorphic_controller";
13
+ import RemoteFormController from "rails_admin/controllers/remote_form_controller";
14
+ import SidescrollController from "rails_admin/controllers/sidescroll_controller";
15
+
16
+ // Explicit manifest (one import + register per controller). Preferred over
17
+ // stimulus-loading's eager/lazy autoloading because the engine ships a small,
18
+ // known set of controllers and must stay self-contained — it can't scan the
19
+ // host's controllers/ path under importmap+Propshaft.
20
+ application.register("alert", AlertController);
21
+ application.register("collapse", CollapseController);
22
+ application.register("dropdown", DropdownController);
23
+ application.register("feedback", FeedbackController);
24
+ application.register("filter-box", FilterBoxController);
25
+ application.register("filtering-multiselect", FilteringMultiselectController);
26
+ application.register("filtering-select", FilteringSelectController);
27
+ application.register("modal", ModalController);
28
+ application.register("nested-form", NestedFormController);
29
+ application.register("polymorphic", PolymorphicController);
30
+ application.register("remote-form", RemoteFormController);
31
+ application.register("sidescroll", SidescrollController);
@@ -0,0 +1,142 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+
3
+ const FOCUSABLE = [
4
+ "a[href]",
5
+ "button:not([disabled])",
6
+ 'input:not([disabled]):not([type="hidden"])',
7
+ "select:not([disabled])",
8
+ "textarea:not([disabled])",
9
+ '[tabindex]:not([tabindex="-1"])',
10
+ ].join(", ");
11
+
12
+ // Inline-edit dialog on a native <dialog closedby="any">: the platform owns the
13
+ // top layer, focus trap, Escape, backdrop light-dismissal, return focus and
14
+ // ARIA modality. This controller only fills it with a server-rendered form via
15
+ // renderContent() and wires the primary action through the `onSave` callback
16
+ // set by RemoteFormController.
17
+ //
18
+ // Every dismissal — cancel button, Escape, backdrop click, Turbo teardown —
19
+ // funnels through the native `close` event, so the reset there (nulling
20
+ // `onSave` invalidates in-flight loads, see RemoteFormController#open) cannot
21
+ // be bypassed.
22
+ export default class extends Controller {
23
+ static targets = ["title", "body", "saveButton", "cancelButton"];
24
+
25
+ connect() {
26
+ this.onSave = null;
27
+ // The markup seeds the title with the I18n loading string; keep it to
28
+ // re-seed the loading state on every close.
29
+ this.loadingTitle = this.titleTarget.textContent;
30
+ this.onClose = () => this.#reset();
31
+ this.element.addEventListener("close", this.onClose);
32
+ // Turbo snapshots the DOM as-is, [open] included — but a restored [open]
33
+ // dialog would sit inline, outside the top layer. Close before the
34
+ // snapshot is taken, and on connect in case a stray one got cached anyway.
35
+ this.onBeforeCache = () => this.#dismiss();
36
+ document.addEventListener("turbo:before-cache", this.onBeforeCache);
37
+ this.#dismiss();
38
+ }
39
+
40
+ disconnect() {
41
+ this.element.removeEventListener("close", this.onClose);
42
+ document.removeEventListener("turbo:before-cache", this.onBeforeCache);
43
+ }
44
+
45
+ open() {
46
+ // showModal() throws if the dialog is already open, so reopening (the
47
+ // shared dialog grabbed for another field) goes through a close first.
48
+ // Caveat for future callers: the `close` event is a QUEUED task, so its
49
+ // reset would run after a caller's synchronous post-open() assignments
50
+ // (nulling a just-set onSave). Unreachable today — an open modal dialog
51
+ // makes the rest of the document inert, so nothing can re-enter open()
52
+ // while it is open — but do not rely on this path without fixing that.
53
+ this.#dismiss();
54
+ this.enableSave();
55
+ this.element.showModal();
56
+ }
57
+
58
+ // Swap in a server-rendered form fragment (trusted engine HTML, like a Turbo
59
+ // frame load), copy its title and submit/cancel labels into the chrome, drop
60
+ // the in-form action bar (replaced by the footer) and any nested inline-add
61
+ // controls, then move focus to the first field — autofocus resolved back
62
+ // when showModal() ran, before this async content existed.
63
+ renderContent(html) {
64
+ // A response landing after the dialog was dismissed (e.g. Escape during a
65
+ // slow 422 submit) must not repopulate the reset body — the stale form
66
+ // would resurface, populated, on the next open().
67
+ if (!this.element.open) return;
68
+ this.bodyTarget.innerHTML = html;
69
+ this.bodyTarget.removeAttribute("aria-busy");
70
+ const form = this.bodyTarget.querySelector("form");
71
+ if (form) {
72
+ this.titleTarget.textContent = form.dataset.title || "";
73
+ const save = form.querySelector('[name="_save"]');
74
+ const cancel = form.querySelector('[name="_continue"]');
75
+ if (save) this.saveButtonTarget.innerHTML = save.innerHTML;
76
+ if (cancel) this.cancelButtonTarget.innerHTML = cancel.innerHTML;
77
+ form.querySelectorAll(".form-actions").forEach((node) => node.remove());
78
+ }
79
+ this.bodyTarget
80
+ .querySelectorAll(".modal-actions")
81
+ .forEach((node) => node.remove());
82
+ this.#focusFirst();
83
+ }
84
+
85
+ save(event) {
86
+ event?.preventDefault();
87
+ if (this.saveButtonTarget.classList.contains("disabled")) return;
88
+ this.onSave?.();
89
+ }
90
+
91
+ close(event) {
92
+ event?.preventDefault();
93
+ this.#dismiss();
94
+ }
95
+
96
+ // The Save anchor has no native `disabled` — RemoteFormController toggles
97
+ // this around its fetch so a second click (or Enter) while a submit is in
98
+ // flight is a no-op instead of a second POST.
99
+ disableSave() {
100
+ this.saveButtonTarget.classList.add("disabled");
101
+ this.saveButtonTarget.setAttribute("aria-disabled", "true");
102
+ this.element.setAttribute("aria-busy", "true");
103
+ }
104
+
105
+ enableSave() {
106
+ this.saveButtonTarget.classList.remove("disabled");
107
+ this.saveButtonTarget.removeAttribute("aria-disabled");
108
+ this.element.removeAttribute("aria-busy");
109
+ }
110
+
111
+ #dismiss() {
112
+ if (this.element.open) this.element.close();
113
+ }
114
+
115
+ // Runs on the native `close` event, whatever triggered it. Nulling `onSave`
116
+ // is the stale-load guard's reset; the rest returns the chrome to the seeded
117
+ // loading state (busy empty body, loading title, interactive Save) so the
118
+ // next open() starts clean even after a mid-submit dismissal.
119
+ #reset() {
120
+ this.onSave = null;
121
+ this.enableSave();
122
+ this.bodyTarget.replaceChildren();
123
+ this.bodyTarget.setAttribute("aria-busy", "true");
124
+ this.titleTarget.textContent = this.loadingTitle;
125
+ }
126
+
127
+ #focusable() {
128
+ return Array.from(this.bodyTarget.querySelectorAll(FOCUSABLE)).filter(
129
+ (el) => el.offsetParent !== null || el === document.activeElement
130
+ );
131
+ }
132
+
133
+ // First errored field on a 422 re-render (its wrapper is marked
134
+ // .control-group.error server-side), first field otherwise. With nothing
135
+ // focusable in the body, the focus showModal() placed on the chrome stands.
136
+ #focusFirst() {
137
+ const fields = this.#focusable();
138
+ const target =
139
+ fields.find((el) => el.closest(".control-group.error")) || fields[0];
140
+ target?.focus();
141
+ }
142
+ }