flexi_admin 0.0.5 → 0.0.6

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 (74) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +254 -18
  3. data/lib/flexi_admin/components/helpers/action_helper.rb +21 -0
  4. data/lib/flexi_admin/components/helpers/frontend_helper.rb +23 -0
  5. data/lib/flexi_admin/components/helpers/link_helper.rb +35 -2
  6. data/lib/flexi_admin/components/helpers/resource_helper.rb +79 -14
  7. data/lib/flexi_admin/components/helpers/url_helper.rb +15 -0
  8. data/lib/flexi_admin/components/nav/breadcrumbs_component.html.slim +13 -0
  9. data/lib/flexi_admin/components/nav/breadcrumbs_component.rb +86 -0
  10. data/lib/flexi_admin/components/resource/autocomplete_component.html.slim +19 -7
  11. data/lib/flexi_admin/components/resource/autocomplete_component.rb +41 -11
  12. data/lib/flexi_admin/components/resource/button_select_component.html.slim +2 -2
  13. data/lib/flexi_admin/components/resource/button_select_component.rb +8 -1
  14. data/lib/flexi_admin/components/resource/filter_component.html.slim +47 -0
  15. data/lib/flexi_admin/components/resource/filter_component.rb +51 -0
  16. data/lib/flexi_admin/components/resource/form_component.rb +16 -1
  17. data/lib/flexi_admin/components/resource/form_element_component.rb +1 -1
  18. data/lib/flexi_admin/components/resource/form_mixin.rb +186 -42
  19. data/lib/flexi_admin/components/resource/link_action_component.html.slim +2 -2
  20. data/lib/flexi_admin/components/resource/link_action_component.rb +3 -2
  21. data/lib/flexi_admin/components/resource/view_component.html.slim +14 -4
  22. data/lib/flexi_admin/components/resource/view_component.rb +11 -2
  23. data/lib/flexi_admin/components/resources/bulk_action/button_component.rb +8 -1
  24. data/lib/flexi_admin/components/resources/bulk_action/modal_component.html.slim +21 -15
  25. data/lib/flexi_admin/components/resources/bulk_action/modal_component.rb +16 -8
  26. data/lib/flexi_admin/components/resources/bulk_action/row_action_button_component.html.slim +16 -0
  27. data/lib/flexi_admin/components/resources/bulk_action/row_action_button_component.rb +78 -0
  28. data/lib/flexi_admin/components/resources/bulk_action/row_actions_dropdown_component.html.slim +15 -0
  29. data/lib/flexi_admin/components/resources/bulk_action/row_actions_dropdown_component.rb +33 -0
  30. data/lib/flexi_admin/components/resources/grid_view/card_component.html.slim +4 -1
  31. data/lib/flexi_admin/components/resources/grid_view/card_component.rb +4 -1
  32. data/lib/flexi_admin/components/resources/grid_view/grid_component.html.slim +3 -1
  33. data/lib/flexi_admin/components/resources/grid_view/grid_component.rb +4 -2
  34. data/lib/flexi_admin/components/resources/grid_view_component.rb +7 -2
  35. data/lib/flexi_admin/components/resources/index_page_component.html.slim +5 -4
  36. data/lib/flexi_admin/components/resources/index_page_component.rb +1 -0
  37. data/lib/flexi_admin/components/resources/list_view/table_component.html.slim +1 -1
  38. data/lib/flexi_admin/components/resources/list_view_component.rb +2 -1
  39. data/lib/flexi_admin/components/resources/pagination_component.html.slim +51 -39
  40. data/lib/flexi_admin/components/resources/pagination_component.rb +52 -47
  41. data/lib/flexi_admin/components/resources/switch_view_component.html.slim +2 -2
  42. data/lib/flexi_admin/components/resources/view_component.html.slim +15 -6
  43. data/lib/flexi_admin/components/shared/medium_component.html.slim +1 -1
  44. data/lib/flexi_admin/components/shared/table/header_item_component.html.slim +1 -1
  45. data/lib/flexi_admin/components/shared/table/header_item_component.rb +59 -61
  46. data/lib/flexi_admin/components/shared/tabs_component.html.slim +26 -0
  47. data/lib/flexi_admin/components/shared/tabs_component.rb +68 -0
  48. data/lib/flexi_admin/components.rb +11 -3
  49. data/lib/flexi_admin/config.rb +21 -3
  50. data/lib/flexi_admin/controllers/modals_controller.rb +4 -2
  51. data/lib/flexi_admin/controllers/resources_controller.rb +246 -51
  52. data/lib/flexi_admin/engine.rb +4 -0
  53. data/lib/flexi_admin/helpers/application_helper.rb +3 -0
  54. data/lib/flexi_admin/javascript/controllers/add_row_controller.js +118 -0
  55. data/lib/flexi_admin/javascript/controllers/autocomplete_controller.js +17 -1
  56. data/lib/flexi_admin/javascript/controllers/bulk_action_controller.js +192 -33
  57. data/lib/flexi_admin/javascript/controllers/copy_to_clipboard_controller.js +16 -0
  58. data/lib/flexi_admin/javascript/controllers/datalist_controller.js +1 -1
  59. data/lib/flexi_admin/javascript/controllers/delete_controller.js +40 -0
  60. data/lib/flexi_admin/javascript/controllers/expandable_field_controller.js +15 -0
  61. data/lib/flexi_admin/javascript/controllers/filter_auto_submit_controller.js +93 -0
  62. data/lib/flexi_admin/javascript/controllers/index.js +18 -0
  63. data/lib/flexi_admin/javascript/controllers/pagination_controller.js +8 -0
  64. data/lib/flexi_admin/javascript/controllers/tabs_controller.js +24 -0
  65. data/lib/flexi_admin/models/concerns/application_resource.rb +6 -0
  66. data/lib/flexi_admin/models/concerns/parentable.rb +3 -0
  67. data/lib/flexi_admin/models/context_params.rb +35 -10
  68. data/lib/flexi_admin/models/resources/context.rb +4 -0
  69. data/lib/flexi_admin/routes.rb +7 -1
  70. data/lib/flexi_admin/services/create_resource.rb +6 -6
  71. data/lib/flexi_admin/version.rb +1 -1
  72. data/lib/flexi_admin/views/shared/_reload_frame.slim +19 -0
  73. data/lib/flexi_admin.rb +0 -3
  74. metadata +171 -10
@@ -7,16 +7,27 @@ export default class extends Controller {
7
7
  scope: String,
8
8
  };
9
9
 
10
+ static targets = ["counter", "defaultText", "selectionText"];
11
+
10
12
  connect() {
11
- this.selectedIds = [];
13
+ this._loadFromStorage();
12
14
 
13
- document.addEventListener("bulk-action-modal-opened", (event) => {
14
- this._modalOpened(event);
15
- });
15
+ // Bind the handler so we can properly remove it in disconnect
16
+ this._boundModalOpened = this._modalOpened.bind(this);
17
+ document.addEventListener("bulk-action-modal-opened", this._boundModalOpened);
18
+
19
+ // Track shiftKey from click events (change events don't carry modifier keys)
20
+ this._boundTrackShift = (e) => { this._shiftDown = e.shiftKey; };
21
+ this.element.addEventListener("click", this._boundTrackShift, true);
22
+
23
+ // Restore checkbox states based on stored selections
24
+ this._restoreCheckboxStates();
25
+ this._updateSelectionUI();
16
26
  }
17
27
 
18
28
  disconnect() {
19
- document.removeEventListener("bulk-action-modal-opened", this._modalOpened);
29
+ document.removeEventListener("bulk-action-modal-opened", this._boundModalOpened);
30
+ this.element.removeEventListener("click", this._boundTrackShift, true);
20
31
  }
21
32
 
22
33
  async requestModal(event) {
@@ -30,21 +41,41 @@ export default class extends Controller {
30
41
  const modal = this._withModal();
31
42
  const form = modal.querySelector("form");
32
43
  form.requestSubmit();
44
+ this._unselectAll();
33
45
  }
34
46
 
35
47
  toggle(event) {
36
- const id = event.target.value;
48
+ const checkbox = event.target;
49
+ const checkboxes = this._allCheckboxes();
37
50
 
38
- if (event.target.checked) {
39
- // Add ID to the array if the checkbox is checked
40
- this.selectedIds.push(id);
51
+ if (this._shiftDown && this._lastClickedCheckbox) {
52
+ const currentIndex = checkboxes.indexOf(checkbox);
53
+ const lastIndex = checkboxes.indexOf(this._lastClickedCheckbox);
54
+
55
+ if (currentIndex !== -1 && lastIndex !== -1) {
56
+ const start = Math.min(currentIndex, lastIndex);
57
+ const end = Math.max(currentIndex, lastIndex);
58
+
59
+ for (let i = start; i <= end; i++) {
60
+ checkboxes[i].checked = true;
61
+ if (!this.selectedIds.includes(checkboxes[i].value)) {
62
+ this.selectedIds.push(checkboxes[i].value);
63
+ }
64
+ }
65
+ }
41
66
  } else {
42
- // Remove ID from the array if the checkbox is unchecked
43
- this.selectedIds = this.selectedIds.filter(
44
- (selectedId) => selectedId !== id
45
- );
67
+ const id = checkbox.value;
68
+ if (checkbox.checked) {
69
+ this.selectedIds.push(id);
70
+ } else {
71
+ this.selectedIds = this.selectedIds.filter(
72
+ (selectedId) => selectedId !== id
73
+ );
74
+ }
46
75
  }
47
76
 
77
+ this._lastClickedCheckbox = checkbox;
78
+
48
79
  if (this.selectedIds.length > 0) {
49
80
  this._enableActions();
50
81
  } else {
@@ -52,7 +83,8 @@ export default class extends Controller {
52
83
  }
53
84
 
54
85
  this._persist();
55
- // console.log(`${this.scopeValue} select one`, this.selectedIds.length);
86
+ this._saveToStorage();
87
+ this._updateSelectionUI();
56
88
  }
57
89
 
58
90
  toggleAll(event) {
@@ -63,6 +95,26 @@ export default class extends Controller {
63
95
  }
64
96
  }
65
97
 
98
+ navigateWithSelectedIds(event) {
99
+ event.preventDefault();
100
+
101
+ if (this.selectedIds.length === 0) {
102
+ return;
103
+ }
104
+
105
+ const target = event.target || event.currentTarget;
106
+ const baseUrl = target.dataset.baseUrl;
107
+ const paramName = target.dataset.paramName || 'selected_ids[]';
108
+ const params = new URLSearchParams();
109
+
110
+ this.selectedIds.forEach(id => {
111
+ params.append(paramName, id);
112
+ });
113
+
114
+ const url = `${baseUrl}?${params.toString()}`;
115
+ window.Turbo.visit(url);
116
+ }
117
+
66
118
  _modalOpened(event) {
67
119
  if (event.detail.scope !== this.scopeValue) {
68
120
  return;
@@ -70,20 +122,50 @@ export default class extends Controller {
70
122
 
71
123
  const modal = this._withModal();
72
124
 
73
- this._populateCountElements(modal);
74
- this._populateIds(modal);
125
+ // If selectedIds are provided in the event (from row action), use them
126
+ // Otherwise, use the controller's selectedIds (from bulk checkbox selection)
127
+ const idsToUse = event.detail.selectedIds && event.detail.selectedIds.length > 0
128
+ ? event.detail.selectedIds
129
+ : this.selectedIds;
130
+
131
+ this._populateCountElements(modal, idsToUse.length);
132
+ this._populateIds(modal, idsToUse);
75
133
  this._addProcessor(modal, event.detail.modalId);
134
+ this._addScope(modal, event.detail.scope);
76
135
  }
77
136
 
78
- _populateCountElements(modal) {
137
+ _populateCountElements(modal, count = null) {
138
+ const countToUse = count !== null ? count : this.selectedIds.length;
79
139
  const countElements = modal.querySelectorAll("span.count");
80
140
  countElements.forEach((countElement) => {
81
- countElement.textContent = this.selectedIds.length;
141
+ countElement.textContent = countToUse;
82
142
  });
83
143
  }
84
144
 
145
+ _addScope(modal, scope) {
146
+ const form = modal.querySelector("form");
147
+
148
+ const existingInput = form.querySelector('input[name="fa_scope"]');
149
+ if (existingInput) {
150
+ existingInput.remove();
151
+ }
152
+
153
+ const hiddenInput = document.createElement("input");
154
+ hiddenInput.type = "hidden";
155
+ hiddenInput.name = "fa_scope";
156
+ hiddenInput.value = scope;
157
+ form.appendChild(hiddenInput);
158
+ }
159
+
85
160
  _addProcessor(modal, processor) {
86
161
  const form = modal.querySelector("form");
162
+
163
+ // Remove any existing processor input to prevent duplicates when called multiple times
164
+ const existingProcessorInput = form.querySelector('input[name="processor"]');
165
+ if (existingProcessorInput) {
166
+ existingProcessorInput.remove();
167
+ }
168
+
87
169
  const hiddenInput = document.createElement("input");
88
170
  hiddenInput.type = "hidden";
89
171
  hiddenInput.name = "processor";
@@ -91,12 +173,20 @@ export default class extends Controller {
91
173
  form.appendChild(hiddenInput);
92
174
  }
93
175
 
94
- _populateIds(modal) {
176
+ _populateIds(modal, ids = null) {
177
+ const idsToUse = ids !== null ? ids : this.selectedIds;
95
178
  const form = modal.querySelector("form");
179
+
180
+ // Remove any existing ids input to prevent duplicates when called multiple times
181
+ const existingIdsInput = form.querySelector('input[name="ids"]');
182
+ if (existingIdsInput) {
183
+ existingIdsInput.remove();
184
+ }
185
+
96
186
  const hiddenInput = document.createElement("input");
97
187
  hiddenInput.type = "hidden";
98
188
  hiddenInput.name = "ids";
99
- hiddenInput.value = JSON.stringify(this.selectedIds);
189
+ hiddenInput.value = JSON.stringify(idsToUse);
100
190
  form.appendChild(hiddenInput);
101
191
  }
102
192
 
@@ -105,39 +195,39 @@ export default class extends Controller {
105
195
  }
106
196
 
107
197
  _selectAll() {
108
- // find all checkboxes with the name of the actionScope
109
- const checkboxes = document.querySelectorAll(
110
- `.bulk-action-checkbox > input[name="${this.scopeValue}"]`
111
- );
198
+ const checkboxes = this._allCheckboxes();
112
199
 
113
- this.selectedIds = Array.from(checkboxes).map((checkbox) => checkbox.value);
200
+ this.selectedIds = checkboxes.map((checkbox) => checkbox.value);
114
201
 
115
- Array.from(checkboxes).forEach((checkbox) => {
202
+ checkboxes.forEach((checkbox) => {
116
203
  checkbox.checked = true;
117
204
  });
118
205
 
119
206
  this._persist();
207
+ this._saveToStorage();
208
+ this._updateSelectionUI();
120
209
  this._enableActions();
121
- // console.log(`${this.scopeValue} select all`, this.selectedIds.length);
122
210
  }
123
211
 
124
212
  _unselectAll() {
125
213
  this.selectedIds = [];
126
- const checkboxes = document.querySelectorAll(
127
- `.bulk-action-checkbox > input[name="${this.scopeValue}"]`
128
- );
129
- Array.from(checkboxes).forEach((checkbox) => {
214
+ this._lastClickedCheckbox = null;
215
+ const checkboxes = this._allCheckboxes();
216
+ checkboxes.forEach((checkbox) => {
130
217
  checkbox.checked = false;
131
218
  });
132
219
 
220
+ const selectAllCheckbox = document.querySelector('#checkbox-all');
221
+ if (selectAllCheckbox) selectAllCheckbox.checked = false;
222
+
133
223
  this._persist();
224
+ this._saveToStorage();
225
+ this._updateSelectionUI();
134
226
  this._disableActions();
135
- // console.log(`${this.scopeValue} unselect all`, this.selectedIds.length);
136
227
  }
137
228
 
138
229
  _persist() {
139
230
  this.element.dataset.ids = JSON.stringify(this.selectedIds);
140
- // console.log("persist", this.element.dataset.ids);
141
231
  }
142
232
 
143
233
  _enableActions() {
@@ -155,4 +245,73 @@ export default class extends Controller {
155
245
  item.classList.add("disabled");
156
246
  });
157
247
  }
248
+
249
+ clearSelection(event) {
250
+ event.preventDefault();
251
+ this._unselectAll();
252
+ }
253
+
254
+ _storageKey() {
255
+ return `bulk_action_selection_${this.scopeValue}`;
256
+ }
257
+
258
+ _loadFromStorage() {
259
+ const stored = sessionStorage.getItem(this._storageKey());
260
+ if (stored) {
261
+ try {
262
+ this.selectedIds = JSON.parse(stored);
263
+ } catch (e) {
264
+ this.selectedIds = [];
265
+ }
266
+ } else {
267
+ this.selectedIds = [];
268
+ }
269
+ }
270
+
271
+ _saveToStorage() {
272
+ sessionStorage.setItem(this._storageKey(), JSON.stringify(this.selectedIds));
273
+ }
274
+
275
+ _restoreCheckboxStates() {
276
+ if (this.selectedIds.length === 0) {
277
+ return;
278
+ }
279
+
280
+ const checkboxes = this._allCheckboxes();
281
+ checkboxes.forEach((checkbox) => {
282
+ if (this.selectedIds.includes(checkbox.value)) {
283
+ checkbox.checked = true;
284
+ }
285
+ });
286
+
287
+ if (this.selectedIds.length > 0) {
288
+ this._enableActions();
289
+ }
290
+ }
291
+
292
+ _allCheckboxes() {
293
+ return Array.from(document.querySelectorAll(
294
+ `.bulk-action-checkbox > input[name="${this.scopeValue}"]:not(#checkbox-all)`
295
+ ));
296
+ }
297
+
298
+ _updateSelectionUI() {
299
+ const count = this.selectedIds.length;
300
+
301
+ // Update counter value
302
+ if (this.hasCounterTarget) {
303
+ this.counterTarget.textContent = count;
304
+ }
305
+
306
+ // Toggle between default text and selection text
307
+ if (this.hasDefaultTextTarget && this.hasSelectionTextTarget) {
308
+ if (count > 0) {
309
+ this.defaultTextTarget.style.display = 'none';
310
+ this.selectionTextTarget.style.display = 'inline';
311
+ } else {
312
+ this.defaultTextTarget.style.display = 'inline';
313
+ this.selectionTextTarget.style.display = 'none';
314
+ }
315
+ }
316
+ }
158
317
  }
@@ -0,0 +1,16 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ copy() {
5
+ const input = this.element.querySelector("input, textarea")
6
+ if (!input) return
7
+
8
+ navigator.clipboard.writeText(input.value).then(() => {
9
+ const icon = this.element.querySelector(".copy-field-btn i")
10
+ if (!icon) return
11
+
12
+ icon.classList.replace("bi-clipboard", "bi-check")
13
+ setTimeout(() => icon.classList.replace("bi-check", "bi-clipboard"), 1500)
14
+ })
15
+ }
16
+ }
@@ -40,7 +40,7 @@ export default class extends Controller {
40
40
 
41
41
  this.timeout = setTimeout(() => {
42
42
  this._search(this.inputTarget.value);
43
- }, 200);
43
+ }, 300);
44
44
  }
45
45
 
46
46
  clear() {
@@ -0,0 +1,40 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+ import { CSRFToken } from "../utils";
3
+
4
+ // Connects to data-controller="delete"
5
+ export default class extends Controller {
6
+ static values = {
7
+ resourcePath: String,
8
+ confirmMessage: String,
9
+ disabled: Boolean,
10
+ };
11
+
12
+ delete(event) {
13
+ event.preventDefault();
14
+
15
+ if (this.disabledValue) {
16
+ return;
17
+ }
18
+
19
+ const confirmMessage = this.confirmMessageValue || "Are you sure you want to delete this item?";
20
+
21
+ if (!confirm(confirmMessage)) {
22
+ return;
23
+ }
24
+
25
+ fetch(this.resourcePathValue, {
26
+ method: "DELETE",
27
+ headers: {
28
+ Accept: "text/vnd.turbo-stream.html",
29
+ "X-CSRF-Token": CSRFToken(),
30
+ },
31
+ })
32
+ .then((response) => response.text())
33
+ .then((html) => {
34
+ Turbo.renderStreamMessage(html);
35
+ })
36
+ .catch((error) => {
37
+ console.error("Error deleting resource:", error);
38
+ });
39
+ }
40
+ }
@@ -0,0 +1,15 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+
3
+ export default class extends Controller {
4
+ connect() {
5
+ this.resize();
6
+ }
7
+
8
+ resize() {
9
+ this.element.style.height = "auto";
10
+ const minHeight =
11
+ parseFloat(getComputedStyle(this.element).lineHeight) || 36;
12
+ this.element.style.height =
13
+ Math.max(this.element.scrollHeight, minHeight) + "px";
14
+ }
15
+ }
@@ -0,0 +1,93 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+
3
+ // Connects to data-controller="filter-auto-submit"
4
+ export default class extends Controller {
5
+ initialize() {
6
+ this.initialValues = new Map();
7
+ this.storageKey = 'filterAutoSubmit_openDropdowns';
8
+ }
9
+
10
+ connect() {
11
+ this.getInputFields().forEach((field) => {
12
+ this.initialValues.set(field, field.value);
13
+ });
14
+
15
+ this.reopenDropdowns();
16
+ this.autofocusSearchField();
17
+ }
18
+
19
+ autofocusSearchField() {
20
+ const field = this.element.querySelector('input[type="text"], input[type="search"]');
21
+ if (field) field.focus();
22
+ }
23
+
24
+ reopenDropdowns() {
25
+ const openDropdownIds = this.getOpenDropdownIds();
26
+ if (openDropdownIds.length > 0) {
27
+ setTimeout(() => {
28
+ openDropdownIds.forEach((dropdownId) => {
29
+ const button = this.element.querySelector(`#${dropdownId}`);
30
+ if (button) {
31
+ const dropdown = new bootstrap.Dropdown(button);
32
+ dropdown.show();
33
+ }
34
+ });
35
+ sessionStorage.removeItem(this.storageKey);
36
+ }, 100);
37
+ }
38
+ }
39
+
40
+ getOpenDropdownIds() {
41
+ try {
42
+ const stored = sessionStorage.getItem(this.storageKey);
43
+ return stored ? JSON.parse(stored) : [];
44
+ } catch (e) {
45
+ return [];
46
+ }
47
+ }
48
+
49
+ storeOpenDropdownIds(ids) {
50
+ try {
51
+ sessionStorage.setItem(this.storageKey, JSON.stringify(ids));
52
+ } catch (e) {
53
+ }
54
+ }
55
+
56
+ handleDropdownChange(event) {
57
+ event.stopPropagation();
58
+
59
+ const openDropdownIds = [];
60
+ this.element.querySelectorAll('.dropdown-menu.show').forEach((menu) => {
61
+ const button = menu.previousElementSibling;
62
+ if (button && button.id) {
63
+ openDropdownIds.push(button.id);
64
+ }
65
+ });
66
+
67
+ this.storeOpenDropdownIds(openDropdownIds);
68
+
69
+ this.element.requestSubmit();
70
+ }
71
+
72
+ submitOnBlur(event) {
73
+ let field = null;
74
+
75
+ if (event.target.matches('input, textarea')) {
76
+ field = event.target;
77
+ } else {
78
+ field = event.target.querySelector('input, textarea');
79
+ }
80
+
81
+ if (!field) return;
82
+
83
+ if (this.initialValues.get(field) !== field.value) {
84
+ this.element.requestSubmit();
85
+ }
86
+ }
87
+
88
+ getInputFields() {
89
+ return Array.from(
90
+ this.element.querySelectorAll('input[type="text"], input[type="search"], textarea')
91
+ );
92
+ }
93
+ }
@@ -16,6 +16,12 @@ application.register("button-select", ButtonSelectController);
16
16
  import DatalistController from "./datalist_controller";
17
17
  application.register("datalist", DatalistController);
18
18
 
19
+ import DeleteController from "./delete_controller";
20
+ application.register("delete", DeleteController);
21
+
22
+ import FilterAutoSubmitController from "./filter_auto_submit_controller";
23
+ application.register("filter-auto-submit", FilterAutoSubmitController);
24
+
19
25
  import FormController from "./form_controller";
20
26
  application.register("form", FormController);
21
27
 
@@ -42,3 +48,15 @@ application.register("sorting", SortingController);
42
48
 
43
49
  import FloatingTocController from "./floating_toc_controller";
44
50
  application.register("floating-toc", FloatingTocController);
51
+
52
+ import AddRowController from "./add_row_controller";
53
+ application.register("add-row", AddRowController);
54
+
55
+ import CopyToClipboardController from "./copy_to_clipboard_controller";
56
+ application.register("copy-to-clipboard", CopyToClipboardController);
57
+
58
+ import ExpandableFieldController from "./expandable_field_controller";
59
+ application.register("expandable-field", ExpandableFieldController);
60
+
61
+ import TabsController from "./tabs_controller";
62
+ application.register("tabs", TabsController);
@@ -10,4 +10,12 @@ export default class extends Controller {
10
10
 
11
11
  fetchTurboContent(event, path);
12
12
  }
13
+
14
+ changePerPage(event) {
15
+ const newPerPage = event.target.value;
16
+ const pathTemplate = event.target.dataset.perPagePath;
17
+ const path = pathTemplate.replace("__PER_PAGE__", newPerPage);
18
+
19
+ fetchTurboContent(event, path);
20
+ }
13
21
  }
@@ -0,0 +1,24 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+
3
+ // Keeps the open tab of a TabsComponent in the URL hash (#<key>), so a reload or a
4
+ // shared link reopens the same tab instead of falling back to the first one.
5
+ //
6
+ // Connects to data-controller="tabs". Each tab button carries
7
+ // data-tabs-key-param="<key>" and data-action="shown.bs.tab->tabs#remember".
8
+ export default class extends Controller {
9
+ connect() {
10
+ const key = decodeURIComponent(window.location.hash.replace("#", ""));
11
+ if (!key) return;
12
+
13
+ const button = Array.from(this.element.querySelectorAll("[data-tabs-key-param]")).find(
14
+ (candidate) => candidate.dataset.tabsKeyParam === key
15
+ );
16
+ // Clicking goes through Bootstrap's own data-api, so the controller needs no
17
+ // reference to the `bootstrap` global.
18
+ if (button && !button.classList.contains("active")) button.click();
19
+ }
20
+
21
+ remember({ params: { key } }) {
22
+ history.replaceState(history.state, "", `#${key}`);
23
+ }
24
+ }
@@ -4,6 +4,12 @@ module FlexiAdmin::Models::Concerns::ApplicationResource
4
4
  extend ActiveSupport::Concern
5
5
  include FlexiAdmin::Models::Concerns::Parentable
6
6
 
7
+ class_methods do
8
+ def fa_allowed_scopes
9
+ []
10
+ end
11
+ end
12
+
7
13
  def identifier
8
14
  "#{self.class.name.underscore}_#{id}"
9
15
  end
@@ -6,6 +6,9 @@ module FlexiAdmin::Models::Concerns::Parentable
6
6
  included do
7
7
  scope :with_parent, lambda { |parent_instance|
8
8
  return all if parent_instance.blank?
9
+ if !parent_instance.class.ancestors.include?(ActiveRecord::Base)
10
+ raise ArgumentError, "Parent instance must be a valid model instance, was #{parent_instance.class.name}"
11
+ end
9
12
 
10
13
  plural_name = parent_instance.class.model_name.plural
11
14
 
@@ -14,7 +14,10 @@ module FlexiAdmin::Models
14
14
  view: 'fa_view',
15
15
  ac_action: 'ac_action', # autocomplete action
16
16
  ac_fields: { 'ac_fields': [] }, # autocomplete result fields
17
- ac_path: 'ac_path' # autocomplete path for opening resource in new window
17
+ ac_path: 'ac_path', # autocomplete path for opening resource in new window
18
+ reload_frame: 'fa_reload_frame', # turbo-frame ID to reload after bulk actions
19
+ reload: 'fa_reload', # :page forces full page reload after bulk actions
20
+ custom_scope: 'fa_custom_scope', # named model scope for autocomplete filtering
18
21
  }
19
22
 
20
23
  def self.permitted_params_keys
@@ -31,12 +34,19 @@ module FlexiAdmin::Models
31
34
  end
32
35
  end
33
36
 
34
- attr_reader :params
37
+ attr_reader :params, :request_params
35
38
 
36
39
  def initialize(params)
37
- @params = params.to_h.with_indifferent_access.each_with_object({}) do |(key, value), acc|
38
- acc[self.class.params_map.invert[key.to_s]] = value
40
+ params_hash = params.to_h.with_indifferent_access
41
+
42
+ @params = params_hash.each_with_object({}) do |(key, value), acc|
43
+ if self.class.params_map.invert[key.to_s]
44
+ acc[self.class.params_map.invert[key.to_s]] = value
45
+ end
39
46
  end.with_indifferent_access
47
+
48
+ excluded_keys = FlexiAdmin::Controllers::ResourcesController::DEFAULT_CONTROLLER_PARAMS_KEYS
49
+ @request_params = params_hash.except(*self.class.params_map.values, *excluded_keys).with_indifferent_access
40
50
  end
41
51
 
42
52
  def [](key)
@@ -54,7 +64,9 @@ module FlexiAdmin::Models
54
64
 
55
65
  new_params = new_params.transform_keys { |key| self.class.params_map[key.to_sym] || key.to_sym }
56
66
 
57
- self.class.new(new_params)
67
+ combined_params = new_params.merge(@request_params)
68
+
69
+ self.class.new(combined_params)
58
70
  end
59
71
 
60
72
  def with_parent(parent_instance)
@@ -71,12 +83,17 @@ module FlexiAdmin::Models
71
83
  def except(*keys)
72
84
  new_params = @params.dup.except(*keys)
73
85
  new_params = new_params.transform_keys { |key| self.class.params_map.invert[key.to_s] }
74
- self.class.new(new_params)
86
+
87
+ combined_params = new_params.merge(@request_params)
88
+
89
+ self.class.new(combined_params)
75
90
  end
76
91
 
77
92
  def to_params
78
- self.class.params_map.map { |k, v| [v, params[k]] }
93
+ context_params = self.class.params_map.map { |k, v| [v, params[k]] }
79
94
  .reject { |_k, v| v.blank? }.to_h
95
+
96
+ context_params.merge(@request_params)
80
97
  end
81
98
 
82
99
  def to_path(path)
@@ -88,7 +105,7 @@ module FlexiAdmin::Models
88
105
  end
89
106
 
90
107
  def per_page
91
- params[:per_page] || 13
108
+ params[:per_page]
92
109
  end
93
110
 
94
111
  def page
@@ -113,7 +130,7 @@ module FlexiAdmin::Models
113
130
  end
114
131
 
115
132
  def pagination
116
- { page:, per_page: }
133
+ { page: page, per_page: per_page }
117
134
  end
118
135
 
119
136
  def sort
@@ -123,5 +140,13 @@ module FlexiAdmin::Models
123
140
  def order
124
141
  params[:order]
125
142
  end
143
+
144
+ def reload_frame
145
+ params[:reload_frame]
146
+ end
147
+
148
+ def reload
149
+ params[:reload]&.to_sym
150
+ end
126
151
  end
127
- end
152
+ end