smriti 0.5.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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +168 -0
- data/Rakefile +15 -0
- data/app/assets/images/smriti/android-chrome-192x192.png +0 -0
- data/app/assets/images/smriti/android-chrome-512x512.png +0 -0
- data/app/assets/images/smriti/apple-touch-icon.png +0 -0
- data/app/assets/images/smriti/favicon-16x16.png +0 -0
- data/app/assets/images/smriti/favicon-32x32.png +0 -0
- data/app/assets/images/smriti/favicon-48x48.png +0 -0
- data/app/assets/images/smriti/favicon.ico +0 -0
- data/app/assets/images/smriti/favicon.svg +18 -0
- data/app/assets/images/smriti/logo.svg +18 -0
- data/app/assets/images/smriti/mask-icon.svg +5 -0
- data/app/assets/stylesheets/smriti/application.css +1040 -0
- data/app/controllers/smriti/admin/application_controller.rb +135 -0
- data/app/controllers/smriti/admin/dashboard_controller.rb +32 -0
- data/app/controllers/smriti/admin/mat_view_definitions_controller.rb +372 -0
- data/app/controllers/smriti/admin/mat_view_runs_controller.rb +185 -0
- data/app/controllers/smriti/admin/preferences_controller.rb +91 -0
- data/app/helpers/smriti/admin/datatable_helper.rb +249 -0
- data/app/helpers/smriti/admin/localized_digit_helper.rb +70 -0
- data/app/helpers/smriti/admin/ui_helper.rb +539 -0
- data/app/javascript/smriti/application.js +8 -0
- data/app/javascript/smriti/controllers/application.js +10 -0
- data/app/javascript/smriti/controllers/body_setup_controller.js +120 -0
- data/app/javascript/smriti/controllers/datatable_controller.js +351 -0
- data/app/javascript/smriti/controllers/details_controller.js +200 -0
- data/app/javascript/smriti/controllers/drawer_controller.js +470 -0
- data/app/javascript/smriti/controllers/flash_controller.js +112 -0
- data/app/javascript/smriti/controllers/index.js +10 -0
- data/app/javascript/smriti/controllers/mv_confirm_controller.js +435 -0
- data/app/javascript/smriti/controllers/tabs_controller.js +184 -0
- data/app/javascript/smriti/controllers/tooltip_controller.js +525 -0
- data/app/javascript/smriti/controllers/turbo_frame_lifecycle_controller.js +342 -0
- data/app/jobs/smriti/application_job.rb +144 -0
- data/app/jobs/smriti/create_view_job.rb +87 -0
- data/app/jobs/smriti/delete_view_job.rb +89 -0
- data/app/jobs/smriti/refresh_view_job.rb +94 -0
- data/app/models/concerns/smriti_i18n.rb +139 -0
- data/app/models/concerns/smriti_paginate.rb +70 -0
- data/app/models/concerns/smriti_query_helper.rb +36 -0
- data/app/models/smriti/application_record.rb +39 -0
- data/app/models/smriti/mat_view_definition.rb +254 -0
- data/app/models/smriti/mat_view_run.rb +275 -0
- data/app/views/layouts/smriti/_footer.html.erb +47 -0
- data/app/views/layouts/smriti/_header.html.erb +25 -0
- data/app/views/layouts/smriti/admin.html.erb +47 -0
- data/app/views/layouts/smriti/turbo_frame.html.erb +3 -0
- data/app/views/smriti/admin/dashboard/index.html.erb +38 -0
- data/app/views/smriti/admin/mat_view_definitions/_definition_actions.html.erb +94 -0
- data/app/views/smriti/admin/mat_view_definitions/_dt-index-empty-row.html.erb +11 -0
- data/app/views/smriti/admin/mat_view_definitions/_dt-index-row.html.erb +27 -0
- data/app/views/smriti/admin/mat_view_definitions/empty.html.erb +1 -0
- data/app/views/smriti/admin/mat_view_definitions/form.html.erb +79 -0
- data/app/views/smriti/admin/mat_view_definitions/index.html.erb +10 -0
- data/app/views/smriti/admin/mat_view_definitions/show.html.erb +40 -0
- data/app/views/smriti/admin/mat_view_runs/_dt-index-empty-row.html.erb +11 -0
- data/app/views/smriti/admin/mat_view_runs/_dt-index-row.html.erb +41 -0
- data/app/views/smriti/admin/mat_view_runs/index.html.erb +1 -0
- data/app/views/smriti/admin/mat_view_runs/show.html.erb +64 -0
- data/app/views/smriti/admin/preferences/show.html.erb +49 -0
- data/app/views/smriti/admin/ui/_card.html.erb +15 -0
- data/app/views/smriti/admin/ui/_datatable.html.erb +34 -0
- data/app/views/smriti/admin/ui/_datatable_filters.html.erb +45 -0
- data/app/views/smriti/admin/ui/_datatable_tbody.html.erb +11 -0
- data/app/views/smriti/admin/ui/_datatable_tfoot.html.erb +70 -0
- data/app/views/smriti/admin/ui/_datatable_thead.html.erb +105 -0
- data/app/views/smriti/admin/ui/_details.html.erb +10 -0
- data/app/views/smriti/admin/ui/_flash.html.erb +6 -0
- data/app/views/smriti/admin/ui/_table.html.erb +8 -0
- data/config/importmap.rb +9 -0
- data/config/locales/ar.yml +223 -0
- data/config/locales/de.yml +230 -0
- data/config/locales/en-AU-ocker.yml +223 -0
- data/config/locales/en-AU.yml +202 -0
- data/config/locales/en-BORK.yml +225 -0
- data/config/locales/en-CA.yml +223 -0
- data/config/locales/en-GB.yml +223 -0
- data/config/locales/en-LOL.yml +219 -0
- data/config/locales/en-SCOT.yml +223 -0
- data/config/locales/en-SHAKESPEARE.yml +225 -0
- data/config/locales/en-US-pirate.yml +222 -0
- data/config/locales/en-US.yml +225 -0
- data/config/locales/en-YODA.yml +221 -0
- data/config/locales/en.yml +223 -0
- data/config/locales/es.yml +226 -0
- data/config/locales/fa.yml +223 -0
- data/config/locales/fr-CA.yml +227 -0
- data/config/locales/fr.yml +227 -0
- data/config/locales/he.yml +218 -0
- data/config/locales/hi.yml +223 -0
- data/config/locales/it.yml +225 -0
- data/config/locales/ja-JP.yml +215 -0
- data/config/locales/pt.yml +225 -0
- data/config/locales/ru.yml +228 -0
- data/config/locales/ur.yml +225 -0
- data/config/locales/zh-CN.yml +214 -0
- data/config/locales/zh-TW.yml +214 -0
- data/config/routes.rb +36 -0
- data/lib/ext/exception.rb +20 -0
- data/lib/generators/smriti/install/install_generator.rb +86 -0
- data/lib/generators/smriti/install/templates/create_mat_view_definitions.rb +29 -0
- data/lib/generators/smriti/install/templates/create_mat_view_runs.rb +32 -0
- data/lib/generators/smriti/install/templates/smriti_initializer.rb +23 -0
- data/lib/smriti/admin/auth_bridge.rb +93 -0
- data/lib/smriti/admin/default_auth.rb +62 -0
- data/lib/smriti/configuration.rb +58 -0
- data/lib/smriti/engine.rb +82 -0
- data/lib/smriti/helpers/ui_test_ids.rb +49 -0
- data/lib/smriti/jobs/adapter.rb +81 -0
- data/lib/smriti/service_response.rb +75 -0
- data/lib/smriti/services/base_service.rb +471 -0
- data/lib/smriti/services/check_matview_exists.rb +76 -0
- data/lib/smriti/services/concurrent_refresh.rb +94 -0
- data/lib/smriti/services/create_view.rb +173 -0
- data/lib/smriti/services/delete_view.rb +111 -0
- data/lib/smriti/services/regular_refresh.rb +90 -0
- data/lib/smriti/services/swap_refresh.rb +181 -0
- data/lib/smriti/version.rb +21 -0
- data/lib/smriti.rb +64 -0
- data/lib/tasks/helpers.rb +185 -0
- data/lib/tasks/smriti_tasks.rake +151 -0
- metadata +206 -0
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright Codevedas Inc. 2025-present
|
|
3
|
+
*
|
|
4
|
+
* This source code is licensed under the MIT license found in the
|
|
5
|
+
* LICENSE file in the root directory of this source tree.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Stimulus Controller: DatatableController
|
|
10
|
+
* ----------------------------------------
|
|
11
|
+
* Headless datatable controller that manages search, multi-column sorting,
|
|
12
|
+
* filter clauses, pagination, and Turbo Stream refreshes.
|
|
13
|
+
*
|
|
14
|
+
* Responsibilities:
|
|
15
|
+
* - Keep URL query params in sync with datatable state (`dtsearch`, `dtsort`, `dtfilter`, `dtpage`, `dtperpage`).
|
|
16
|
+
* - Debounce search input and trigger refreshes efficiently.
|
|
17
|
+
* - Manage multi-column sort state and annotate sort order on headers.
|
|
18
|
+
* - Build and load the Turbo Frame stream URL for server-rendered table updates.
|
|
19
|
+
*
|
|
20
|
+
* Key Components:
|
|
21
|
+
* - Search: `onSearchInput`, `clearSearchInput`
|
|
22
|
+
* - Sorting: `toggleSort`, `_syncSortParamsFromLocation`, `_serializeSortParams`, `_updateSortIcons`
|
|
23
|
+
* - Filtering: `onFilterChange`
|
|
24
|
+
* - Pagination: `goToPage`, `_placeDefaultPaginationParamsInLocation`
|
|
25
|
+
* - Refresh & URL: `_refresh`, `_replaceLocationParam`, `_buildStreamSrc`
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { Controller } from "@hotwired/stimulus";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @class DatatableController
|
|
32
|
+
* @extends Controller
|
|
33
|
+
*/
|
|
34
|
+
export default class extends Controller {
|
|
35
|
+
/**
|
|
36
|
+
* Stimulus targets used by this controller.
|
|
37
|
+
* - th: header cells that toggle sort (data-key is required)
|
|
38
|
+
* - searchInput: text input for search terms
|
|
39
|
+
* - filterField: <select> elements that emit filter clauses
|
|
40
|
+
*/
|
|
41
|
+
static targets = ["th", "searchInput", "filterField"];
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Stimulus values used by this controller.
|
|
45
|
+
* @property {string} indexUrl - Base index URL used to fetch Turbo Stream updates.
|
|
46
|
+
* @property {number} perpageDefault - Default page size if not present in URL.
|
|
47
|
+
*/
|
|
48
|
+
static values = {
|
|
49
|
+
indexUrl: String,
|
|
50
|
+
perpageDefault: { type: Number, default: 10 },
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** Separator for joining multiple param clauses (e.g., sort/filter). */
|
|
54
|
+
paramsSeparator = ",";
|
|
55
|
+
/** Separator between sort column and direction (e.g., "name:asc"). */
|
|
56
|
+
sortDirSeparator = ":";
|
|
57
|
+
/** Debounce duration (ms) for search input. */
|
|
58
|
+
debounceTimeout = 500;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Lifecycle: connect
|
|
62
|
+
* Binds refresh handler, initializes state from URL, updates icons, and triggers first refresh.
|
|
63
|
+
* @return {void}
|
|
64
|
+
*/
|
|
65
|
+
connect() {
|
|
66
|
+
this._refresh = this._refresh.bind(this);
|
|
67
|
+
document.addEventListener("datatable:refresh", this._refresh);
|
|
68
|
+
this._searchTimeout = null;
|
|
69
|
+
this.dtSortParams = [];
|
|
70
|
+
this._frameListenersAttached = false;
|
|
71
|
+
|
|
72
|
+
this._placeDefaultPaginationParamsInLocation();
|
|
73
|
+
this._syncSortParamsFromLocation();
|
|
74
|
+
this._updateSortIcons();
|
|
75
|
+
this._refresh();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Lifecycle: disconnect
|
|
80
|
+
* Cleans up listeners and timers.
|
|
81
|
+
* @return {void}
|
|
82
|
+
*/
|
|
83
|
+
disconnect() {
|
|
84
|
+
document.removeEventListener("datatable:refresh", this._refresh);
|
|
85
|
+
clearTimeout(this._searchTimeout);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Handles search input with debounce. Updates `dtsearch` and refreshes.
|
|
90
|
+
* @param {InputEvent} event
|
|
91
|
+
* @return {void}
|
|
92
|
+
*/
|
|
93
|
+
onSearchInput(event) {
|
|
94
|
+
const value = this.searchInputTarget.value.trim();
|
|
95
|
+
clearTimeout(this._searchTimeout);
|
|
96
|
+
this._searchTimeout = setTimeout(() => {
|
|
97
|
+
this._replaceLocationParam("dtsearch", value);
|
|
98
|
+
this._refresh();
|
|
99
|
+
}, this.debounceTimeout);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Clears the search input, removes `dtsearch`, and refreshes.
|
|
104
|
+
* @param {MouseEvent} event
|
|
105
|
+
* @return {void}
|
|
106
|
+
*/
|
|
107
|
+
clearSearchInput(event) {
|
|
108
|
+
event.preventDefault();
|
|
109
|
+
this.searchInputTarget.value = "";
|
|
110
|
+
clearTimeout(this._searchTimeout);
|
|
111
|
+
this._replaceLocationParam("dtsearch", null);
|
|
112
|
+
this._refresh();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Cycles sort state for a header (asc → desc → off) and updates `dtsort`.
|
|
117
|
+
* Supports multi-column sort by accumulating clauses.
|
|
118
|
+
* @param {MouseEvent} event
|
|
119
|
+
* @return {void}
|
|
120
|
+
*/
|
|
121
|
+
toggleSort(event) {
|
|
122
|
+
event.preventDefault();
|
|
123
|
+
const th = event.target.closest("th");
|
|
124
|
+
const key = th?.dataset.key;
|
|
125
|
+
if (!key) return;
|
|
126
|
+
|
|
127
|
+
const existing = this.dtSortParams.find((sort) => sort.col === key);
|
|
128
|
+
if (existing) {
|
|
129
|
+
if (existing.dir === "asc") {
|
|
130
|
+
existing.dir = "desc";
|
|
131
|
+
} else {
|
|
132
|
+
this.dtSortParams = this.dtSortParams.filter(
|
|
133
|
+
(sort) => sort.col !== key,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
} else {
|
|
137
|
+
this.dtSortParams.push({ col: key, dir: "asc" });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
this._replaceLocationParam("dtsort", this._serializeSortParams());
|
|
141
|
+
this._updateSortIcons();
|
|
142
|
+
this._refresh();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Builds filter clauses from select targets and updates `dtfilter`.
|
|
147
|
+
* Clause format: "<key>:<value>", joined by `paramsSeparator`.
|
|
148
|
+
* @param {Event} _event
|
|
149
|
+
* @return {void}
|
|
150
|
+
*/
|
|
151
|
+
onFilterChange(_event) {
|
|
152
|
+
const selects = this.filterFieldTargets;
|
|
153
|
+
if (!selects || selects.length === 0) return;
|
|
154
|
+
|
|
155
|
+
const filterClauses = [];
|
|
156
|
+
selects.forEach((select) => {
|
|
157
|
+
const key = select.dataset.key;
|
|
158
|
+
const value = select.value;
|
|
159
|
+
|
|
160
|
+
if (key && value && value != "no_filter") {
|
|
161
|
+
filterClauses.push(`${key}:${value}`);
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
const filterParam =
|
|
166
|
+
filterClauses.length > 0
|
|
167
|
+
? filterClauses.join(this.paramsSeparator)
|
|
168
|
+
: null;
|
|
169
|
+
this._replaceLocationParam("dtfilter", filterParam);
|
|
170
|
+
this._refresh();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Navigates to a specific page and updates `dtpage`.
|
|
175
|
+
* @param {MouseEvent} event
|
|
176
|
+
* @return {void}
|
|
177
|
+
*/
|
|
178
|
+
goToPage(event) {
|
|
179
|
+
event.preventDefault();
|
|
180
|
+
const page = event.currentTarget.dataset.page;
|
|
181
|
+
if (!page) return;
|
|
182
|
+
|
|
183
|
+
const pageNum = parseInt(page);
|
|
184
|
+
if (isNaN(pageNum) || pageNum < 1) return;
|
|
185
|
+
|
|
186
|
+
this._replaceLocationParam("dtpage", `${pageNum}`);
|
|
187
|
+
this._refresh();
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
changePerPage(event) {
|
|
191
|
+
const select = event.target;
|
|
192
|
+
const perpage = select.value;
|
|
193
|
+
if (!perpage) return;
|
|
194
|
+
|
|
195
|
+
const perpageNum = parseInt(perpage);
|
|
196
|
+
if (isNaN(perpageNum) || perpageNum < 1) return;
|
|
197
|
+
|
|
198
|
+
this._replaceLocationParam("dtperpage", `${perpageNum}`);
|
|
199
|
+
this._replaceLocationParam("dtpage", "1");
|
|
200
|
+
this._refresh();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Triggers a Turbo Frame reload with the computed stream URL.
|
|
205
|
+
* @return {void}
|
|
206
|
+
*/
|
|
207
|
+
_refresh() {
|
|
208
|
+
const url = this._buildStreamSrc(this.indexUrlValue);
|
|
209
|
+
this.element.closest("turbo-frame").src = url;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Parses `dtsort` from window location and initializes `dtSortParams`.
|
|
214
|
+
* @return {void}
|
|
215
|
+
*/
|
|
216
|
+
_syncSortParamsFromLocation() {
|
|
217
|
+
const qs = new URLSearchParams(window.location.search);
|
|
218
|
+
const sortParam = qs.get("dtsort");
|
|
219
|
+
if (!sortParam) return;
|
|
220
|
+
|
|
221
|
+
this.dtSortParams = sortParam
|
|
222
|
+
.split(this.paramsSeparator)
|
|
223
|
+
.map((clause) => clause.split(this.sortDirSeparator))
|
|
224
|
+
.filter(([col]) => col)
|
|
225
|
+
.map(([col, dir]) => ({
|
|
226
|
+
col,
|
|
227
|
+
dir: dir?.toLowerCase() === "desc" ? "desc" : "asc",
|
|
228
|
+
}));
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Replaces or removes a query param in the current URL without reloading.
|
|
233
|
+
* If `value` is null/empty, the param is removed.
|
|
234
|
+
* @param {string} key
|
|
235
|
+
* @param {?string} value
|
|
236
|
+
* @return {void}
|
|
237
|
+
*/
|
|
238
|
+
_replaceLocationParam(key, value) {
|
|
239
|
+
const url = new URL(window.location);
|
|
240
|
+
url.searchParams.delete(key);
|
|
241
|
+
if (value && value.length > 0) {
|
|
242
|
+
url.search += `&${key}=${value}`;
|
|
243
|
+
}
|
|
244
|
+
window.history.replaceState({}, "", url);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Serializes `dtSortParams` into the `dtsort` query value.
|
|
249
|
+
* Example: "name:asc,created_at:desc"
|
|
250
|
+
* @return {?string}
|
|
251
|
+
*/
|
|
252
|
+
_serializeSortParams() {
|
|
253
|
+
if (!this.dtSortParams.length) return null;
|
|
254
|
+
return this.dtSortParams
|
|
255
|
+
.map((sort) => `${sort.col}${this.sortDirSeparator}${sort.dir}`)
|
|
256
|
+
.join(this.paramsSeparator);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Updates header sort icons and annotations to reflect `dtSortParams`.
|
|
261
|
+
* Shows numeric annotations when multiple columns are sorted.
|
|
262
|
+
* @return {void}
|
|
263
|
+
*/
|
|
264
|
+
_updateSortIcons() {
|
|
265
|
+
this.thTargets.forEach((th) => {
|
|
266
|
+
const sortNeutral = th.querySelector(".mv-icon.sort-neutral");
|
|
267
|
+
const sortAsc = th.querySelector(".mv-icon.sort-asc");
|
|
268
|
+
const sortDesc = th.querySelector(".mv-icon.sort-desc");
|
|
269
|
+
const annotation = th.querySelector(".mv-annotation");
|
|
270
|
+
sortNeutral?.classList.remove("hidden");
|
|
271
|
+
sortAsc?.classList.add("hidden");
|
|
272
|
+
sortDesc?.classList.add("hidden");
|
|
273
|
+
annotation?.classList.add("hidden");
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
const showAnnotation = this.dtSortParams.length > 1;
|
|
277
|
+
this.dtSortParams.forEach((sort, index) => {
|
|
278
|
+
const th = this.thTargets.find(
|
|
279
|
+
(target) => target.dataset.key === sort.col,
|
|
280
|
+
);
|
|
281
|
+
if (!th) return;
|
|
282
|
+
|
|
283
|
+
const sortNeutral = th.querySelector(".mv-icon.sort-neutral");
|
|
284
|
+
const sortAsc = th.querySelector(".mv-icon.sort-asc");
|
|
285
|
+
const sortDesc = th.querySelector(".mv-icon.sort-desc");
|
|
286
|
+
const annotation = th.querySelector(".mv-annotation");
|
|
287
|
+
|
|
288
|
+
sortNeutral?.classList.add("hidden");
|
|
289
|
+
if (showAnnotation && annotation) {
|
|
290
|
+
annotation.textContent = String(index + 1);
|
|
291
|
+
annotation.classList.remove("hidden");
|
|
292
|
+
} else {
|
|
293
|
+
annotation?.classList.add("hidden");
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (sort.dir === "asc") {
|
|
297
|
+
sortAsc?.classList.remove("hidden");
|
|
298
|
+
sortDesc?.classList.add("hidden");
|
|
299
|
+
} else {
|
|
300
|
+
sortAsc?.classList.add("hidden");
|
|
301
|
+
sortDesc?.classList.remove("hidden");
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Builds the Turbo Stream URL by merging datatable params from the current page.
|
|
308
|
+
* Always appends `stream=true` to force stream rendering server-side.
|
|
309
|
+
* @param {string} baseSrc
|
|
310
|
+
* @return {string}
|
|
311
|
+
*/
|
|
312
|
+
_buildStreamSrc(baseSrc) {
|
|
313
|
+
const url = new URL(baseSrc, window.location.origin);
|
|
314
|
+
const pageParams = new URLSearchParams(window.location.search);
|
|
315
|
+
["dtsearch", "dtsort", "dtfilter", "dtpage", "dtperpage"].forEach(
|
|
316
|
+
(param) => {
|
|
317
|
+
if (pageParams.has(param)) {
|
|
318
|
+
url.searchParams.set(param, pageParams.get(param));
|
|
319
|
+
} else {
|
|
320
|
+
url.searchParams.delete(param);
|
|
321
|
+
}
|
|
322
|
+
},
|
|
323
|
+
);
|
|
324
|
+
url.searchParams.set("stream", "true");
|
|
325
|
+
return url.toString();
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Ensures the URL contains default pagination params (`dtpage`, `dtperpage`).
|
|
330
|
+
* @return {void}
|
|
331
|
+
*/
|
|
332
|
+
_placeDefaultPaginationParamsInLocation() {
|
|
333
|
+
const url = new URL(window.location);
|
|
334
|
+
const params = url.searchParams;
|
|
335
|
+
let updated = false;
|
|
336
|
+
|
|
337
|
+
if (!params.has("dtpage")) {
|
|
338
|
+
params.set("dtpage", "1");
|
|
339
|
+
updated = true;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (!params.has("dtperpage")) {
|
|
343
|
+
params.set("dtperpage", String(this.perpageDefaultValue));
|
|
344
|
+
updated = true;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (updated) {
|
|
348
|
+
window.history.replaceState({}, "", url);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright Codevedas Inc. 2025-present
|
|
3
|
+
*
|
|
4
|
+
* This source code is licensed under the MIT license found in the
|
|
5
|
+
* LICENSE file in the root directory of this source tree.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Stimulus Controller: DetailsDisclosureController
|
|
10
|
+
* -----------------------------------------------
|
|
11
|
+
* Smoothly animates a `<details>` element’s open/close behavior by
|
|
12
|
+
* transitioning the height of an inner content wrapper.
|
|
13
|
+
*
|
|
14
|
+
* Responsibilities:
|
|
15
|
+
* - Normalize initial open/closed state on connect (SSR/serverside toggles).
|
|
16
|
+
* - Animate expand/collapse using CSS transitions on a single `height` property.
|
|
17
|
+
* - Provide interruption handling to cancel/lock ongoing animations cleanly.
|
|
18
|
+
*
|
|
19
|
+
* Key Components:
|
|
20
|
+
* - Public: `toggle`
|
|
21
|
+
* - Internal: `_expand`, `_collapse`, `_cancelAnimation`, `_onTransitionEnd`
|
|
22
|
+
* - Style helpers: `_setTransition`, `_setTransitionNone`, `_setHeight`, `_setHeightAuto`
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { Controller } from "@hotwired/stimulus";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @class DetailsDisclosureController
|
|
29
|
+
* @extends Controller
|
|
30
|
+
*/
|
|
31
|
+
export default class extends Controller {
|
|
32
|
+
/**
|
|
33
|
+
* Targets:
|
|
34
|
+
* - content: wrapper inside <details> whose height is animated.
|
|
35
|
+
*/
|
|
36
|
+
static targets = ["content"];
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Values:
|
|
40
|
+
* - duration: animation duration in milliseconds.
|
|
41
|
+
*/
|
|
42
|
+
static values = { duration: { type: Number, default: 200 } };
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Lifecycle: connect
|
|
46
|
+
* Normalizes initial state and prepares animation flags.
|
|
47
|
+
* @return {void}
|
|
48
|
+
*/
|
|
49
|
+
connect() {
|
|
50
|
+
this.animating = false;
|
|
51
|
+
// Normalize initial state (support SSR or toggled-by-server)
|
|
52
|
+
if (this.element.open) {
|
|
53
|
+
this._setHeightAuto();
|
|
54
|
+
} else {
|
|
55
|
+
this._setHeight(0);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Toggles the disclosure (expand if closed, collapse if open).
|
|
61
|
+
* Cancels any in-flight animation before toggling.
|
|
62
|
+
* @param {Event} event
|
|
63
|
+
* @return {void}
|
|
64
|
+
*/
|
|
65
|
+
toggle(event) {
|
|
66
|
+
event.preventDefault();
|
|
67
|
+
if (this.animating) this._cancelAnimation();
|
|
68
|
+
|
|
69
|
+
if (this.element.open) {
|
|
70
|
+
this._collapse();
|
|
71
|
+
} else {
|
|
72
|
+
this._expand();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ────────────────────────────────────────────────────────────────
|
|
77
|
+
// internal
|
|
78
|
+
// ────────────────────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Animates expansion: set height from 0 → measured content height.
|
|
82
|
+
* Keeps <details> open and resets to `height:auto` after transition.
|
|
83
|
+
* @return {void}
|
|
84
|
+
*/
|
|
85
|
+
_expand() {
|
|
86
|
+
const el = this.contentTarget;
|
|
87
|
+
this.animating = true;
|
|
88
|
+
|
|
89
|
+
// Set starting point
|
|
90
|
+
this._setTransitionNone();
|
|
91
|
+
this._setHeight(0);
|
|
92
|
+
// Make <details> open *before* measuring end height, so children are laid out.
|
|
93
|
+
this.element.open = true;
|
|
94
|
+
|
|
95
|
+
// Next frame: measure, then animate to end height
|
|
96
|
+
requestAnimationFrame(() => {
|
|
97
|
+
const end = el.scrollHeight;
|
|
98
|
+
this._setTransition();
|
|
99
|
+
this._setHeight(end);
|
|
100
|
+
|
|
101
|
+
this._onTransitionEnd(() => {
|
|
102
|
+
this._setHeightAuto(); // allow content to grow/shrink after expand
|
|
103
|
+
this.animating = false;
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Animates collapse: lock current pixel height → 0, then close <details>.
|
|
110
|
+
* @return {void}
|
|
111
|
+
*/
|
|
112
|
+
_collapse() {
|
|
113
|
+
const el = this.contentTarget;
|
|
114
|
+
this.animating = true;
|
|
115
|
+
|
|
116
|
+
// Freeze current height (auto → px) to start a smooth collapse
|
|
117
|
+
this._setTransitionNone();
|
|
118
|
+
const start = el.scrollHeight;
|
|
119
|
+
this._setHeight(start);
|
|
120
|
+
|
|
121
|
+
// Next frame: animate to 0, then close details
|
|
122
|
+
requestAnimationFrame(() => {
|
|
123
|
+
this._setTransition();
|
|
124
|
+
this._setHeight(0);
|
|
125
|
+
|
|
126
|
+
this._onTransitionEnd(() => {
|
|
127
|
+
this.element.open = false;
|
|
128
|
+
this._setTransitionNone();
|
|
129
|
+
this._setHeight(0); // keep at 0 when closed
|
|
130
|
+
this.animating = false;
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Cancels any ongoing transition and locks the current visual height.
|
|
137
|
+
* Useful when rapidly toggling.
|
|
138
|
+
* @return {void}
|
|
139
|
+
*/
|
|
140
|
+
_cancelAnimation() {
|
|
141
|
+
// Interrupt ongoing animation cleanly
|
|
142
|
+
const el = this.contentTarget;
|
|
143
|
+
const computed = parseFloat(getComputedStyle(el).height);
|
|
144
|
+
this._setTransitionNone();
|
|
145
|
+
this._setHeight(computed); // lock current visual height
|
|
146
|
+
this.animating = false;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Invokes callback after the height transition on the content target ends.
|
|
151
|
+
* Filters unrelated transition events.
|
|
152
|
+
* @param {Function} cb - Callback to run after transition end.
|
|
153
|
+
* @return {void}
|
|
154
|
+
*/
|
|
155
|
+
_onTransitionEnd(cb) {
|
|
156
|
+
const el = this.contentTarget;
|
|
157
|
+
const handler = (e) => {
|
|
158
|
+
if (e.target !== el || e.propertyName !== "height") return;
|
|
159
|
+
el.removeEventListener("transitionend", handler);
|
|
160
|
+
el.removeEventListener("transitioncancel", handler);
|
|
161
|
+
cb();
|
|
162
|
+
};
|
|
163
|
+
el.addEventListener("transitionend", handler, { once: false });
|
|
164
|
+
el.addEventListener("transitioncancel", handler, { once: false });
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Applies the height transition using the configured duration.
|
|
169
|
+
* @return {void}
|
|
170
|
+
*/
|
|
171
|
+
_setTransition() {
|
|
172
|
+
this.contentTarget.style.transition = `height ${this.durationValue}ms ease`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Disables the height transition for instantaneous style updates.
|
|
177
|
+
* @return {void}
|
|
178
|
+
*/
|
|
179
|
+
_setTransitionNone() {
|
|
180
|
+
this.contentTarget.style.transition = "none";
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Sets the content wrapper height in pixels.
|
|
185
|
+
* @param {number} px - Height in pixels.
|
|
186
|
+
* @return {void}
|
|
187
|
+
*/
|
|
188
|
+
_setHeight(px) {
|
|
189
|
+
this.contentTarget.style.height = `${px}px`;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Switches the content wrapper to natural height (`auto`).
|
|
194
|
+
* @return {void}
|
|
195
|
+
*/
|
|
196
|
+
_setHeightAuto() {
|
|
197
|
+
this._setTransitionNone();
|
|
198
|
+
this.contentTarget.style.height = "auto";
|
|
199
|
+
}
|
|
200
|
+
}
|