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,435 @@
|
|
|
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: ConfirmPopoverController
|
|
10
|
+
* ---------------------------------------------
|
|
11
|
+
* Replaces Turbo’s default confirm dialog with a lightweight, positioned
|
|
12
|
+
* popover that supports keyboard/overlay dismissal and viewport-aware placement.
|
|
13
|
+
*
|
|
14
|
+
* Responsibilities:
|
|
15
|
+
* - Hook into Turbo’s `config.forms.confirm` and `config.links.confirm`.
|
|
16
|
+
* - Render a reusable popover with Yes/No buttons.
|
|
17
|
+
* - Position the popover around the triggering element with clamping.
|
|
18
|
+
* - Resolve a Promise<boolean> back to Turbo to continue/cancel the action.
|
|
19
|
+
*
|
|
20
|
+
* Key Components:
|
|
21
|
+
* - Turbo hooks: `installConfirmHooks`, `uninstallConfirmHooks`, `confirm`, `hasTurbo`
|
|
22
|
+
* - Lifecycle: `connect`, `disconnect`, `open`, `cleanup`
|
|
23
|
+
* - DOM helpers: `ensurePopover`, `createPopover`, `getParts`, `show`, `hide`
|
|
24
|
+
* - Listeners: `bindGlobalListeners`, `unbindGlobalListeners`
|
|
25
|
+
* - Positioning: `position`, `computePlacement`, `coordsFor`, `fits`, `clampX`, `clampY`, `setPlacementClass`
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { Controller } from "@hotwired/stimulus";
|
|
29
|
+
|
|
30
|
+
export default class extends Controller {
|
|
31
|
+
/**
|
|
32
|
+
* Values:
|
|
33
|
+
* - enabled: when false, do not install Turbo confirm hooks.
|
|
34
|
+
* - gap: pixel gap between trigger and popover.
|
|
35
|
+
* - margin: minimum margin from viewport edges.
|
|
36
|
+
*/
|
|
37
|
+
static values = {
|
|
38
|
+
enabled: { type: Boolean, default: true },
|
|
39
|
+
gap: { type: Number, default: 8 },
|
|
40
|
+
margin: { type: Number, default: 8 },
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Lifecycle: connect
|
|
45
|
+
* Installs Turbo hooks if enabled.
|
|
46
|
+
* @return {void}
|
|
47
|
+
*/
|
|
48
|
+
connect() {
|
|
49
|
+
if (!this.enabledValue) return;
|
|
50
|
+
this.installConfirmHooks();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Lifecycle: disconnect
|
|
55
|
+
* Restores Turbo hooks and resolves any pending confirm cleanly.
|
|
56
|
+
* @return {void}
|
|
57
|
+
*/
|
|
58
|
+
disconnect() {
|
|
59
|
+
this.uninstallConfirmHooks();
|
|
60
|
+
this.cleanup(false);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ──────────────────────────────────────────────
|
|
64
|
+
// Turbo hook wiring
|
|
65
|
+
// ──────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Installs a unified confirm handler for Turbo forms and links.
|
|
69
|
+
* @return {void}
|
|
70
|
+
*/
|
|
71
|
+
installConfirmHooks() {
|
|
72
|
+
if (!this.hasTurbo()) return;
|
|
73
|
+
this.prevFormsConfirm = window.Turbo.config.forms?.confirm;
|
|
74
|
+
this.prevLinksConfirm = window.Turbo.config.links?.confirm;
|
|
75
|
+
|
|
76
|
+
const handler = this.confirm.bind(this);
|
|
77
|
+
if (window.Turbo.config.forms) window.Turbo.config.forms.confirm = handler;
|
|
78
|
+
if (window.Turbo.config.links) window.Turbo.config.links.confirm = handler;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Restores previously installed Turbo confirm handlers.
|
|
83
|
+
* @return {void}
|
|
84
|
+
*/
|
|
85
|
+
uninstallConfirmHooks() {
|
|
86
|
+
if (!this.hasTurbo()) return;
|
|
87
|
+
if (window.Turbo.config.forms)
|
|
88
|
+
window.Turbo.config.forms.confirm = this.prevFormsConfirm;
|
|
89
|
+
if (window.Turbo.config.links)
|
|
90
|
+
window.Turbo.config.links.confirm = this.prevLinksConfirm;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Returns true if Turbo configuration is available.
|
|
95
|
+
* @return {boolean}
|
|
96
|
+
*/
|
|
97
|
+
hasTurbo() {
|
|
98
|
+
return !!window.Turbo?.config;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ──────────────────────────────────────────────
|
|
102
|
+
// Confirm entry point
|
|
103
|
+
// ──────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Turbo confirm entry. Shows a popover and resolves to true/false.
|
|
107
|
+
* Falls back to `window.confirm` if Turbo is unavailable.
|
|
108
|
+
* @param {string} message
|
|
109
|
+
* @param {HTMLElement} element
|
|
110
|
+
* @return {Promise<boolean>}
|
|
111
|
+
*/
|
|
112
|
+
confirm(message, element) {
|
|
113
|
+
// Fallback for Turbo < 8 or if anything is missing
|
|
114
|
+
if (!this.hasTurbo()) return Promise.resolve(window.confirm(message));
|
|
115
|
+
|
|
116
|
+
return new Promise((resolve) => {
|
|
117
|
+
this._resolver = resolve;
|
|
118
|
+
this.open(message, element);
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ──────────────────────────────────────────────
|
|
123
|
+
// Open/close lifecycle
|
|
124
|
+
// ──────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Opens the confirm popover near the triggering element.
|
|
128
|
+
* @param {string} message
|
|
129
|
+
* @param {HTMLElement} element
|
|
130
|
+
* @return {void}
|
|
131
|
+
*/
|
|
132
|
+
open(message, element) {
|
|
133
|
+
this.pop = this.ensurePopover();
|
|
134
|
+
this.triggerEl = element;
|
|
135
|
+
|
|
136
|
+
const { content } = this.getParts(this.pop);
|
|
137
|
+
content.textContent = message;
|
|
138
|
+
|
|
139
|
+
this.position(this.pop, element);
|
|
140
|
+
this.show(this.pop);
|
|
141
|
+
this.bindGlobalListeners();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Hides the popover, unbinds listeners, and resolves the pending Promise.
|
|
146
|
+
* @param {boolean} result
|
|
147
|
+
* @return {void}
|
|
148
|
+
*/
|
|
149
|
+
cleanup(result) {
|
|
150
|
+
if (!this.pop) return;
|
|
151
|
+
this.hide(this.pop);
|
|
152
|
+
this.unbindGlobalListeners();
|
|
153
|
+
const resolve = this._resolver;
|
|
154
|
+
this._resolver = null;
|
|
155
|
+
if (resolve) resolve(!!result);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ──────────────────────────────────────────────
|
|
159
|
+
// DOM helpers
|
|
160
|
+
// ──────────────────────────────────────────────
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Ensures a single reusable popover exists in the DOM.
|
|
164
|
+
* @return {HTMLElement}
|
|
165
|
+
*/
|
|
166
|
+
ensurePopover() {
|
|
167
|
+
let el = document.getElementById("mv-confirm");
|
|
168
|
+
if (el) return el;
|
|
169
|
+
el = this.createPopover();
|
|
170
|
+
document.body.appendChild(el);
|
|
171
|
+
return el;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Creates the popover element structure (content, buttons, arrow).
|
|
176
|
+
* @return {HTMLElement}
|
|
177
|
+
*/
|
|
178
|
+
createPopover() {
|
|
179
|
+
const el = document.createElement("div");
|
|
180
|
+
el.id = "mv-confirm";
|
|
181
|
+
el.className = "mv-confirm";
|
|
182
|
+
Object.assign(el.style, {
|
|
183
|
+
position: "fixed",
|
|
184
|
+
top: "0",
|
|
185
|
+
left: "0",
|
|
186
|
+
transform: "translate(-9999px,-9999px)",
|
|
187
|
+
});
|
|
188
|
+
el.innerHTML = `
|
|
189
|
+
<div class="mv-confirm__content"></div>
|
|
190
|
+
<div class="mv-confirm__buttons">
|
|
191
|
+
<button type="button" class="mv-btn mv-btn--sm mv-btn--negative mv-confirm__yes">Yes</button>
|
|
192
|
+
<button type="button" class="mv-btn mv-btn--sm mv-btn--secondary mv-confirm__no">No</button>
|
|
193
|
+
</div>
|
|
194
|
+
<div class="mv-confirm__arrow"></div>`;
|
|
195
|
+
return el;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Returns references to key elements inside the popover.
|
|
200
|
+
* @param {HTMLElement} pop
|
|
201
|
+
* @return {{content: HTMLElement, yes: HTMLButtonElement, no: HTMLButtonElement}}
|
|
202
|
+
*/
|
|
203
|
+
getParts(pop) {
|
|
204
|
+
return {
|
|
205
|
+
content: pop.querySelector(".mv-confirm__content"),
|
|
206
|
+
yes: pop.querySelector(".mv-confirm__yes"),
|
|
207
|
+
no: pop.querySelector(".mv-confirm__no"),
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Marks the popover visible (CSS-driven).
|
|
213
|
+
* @param {HTMLElement} pop
|
|
214
|
+
* @return {void}
|
|
215
|
+
*/
|
|
216
|
+
show(pop) {
|
|
217
|
+
pop.setAttribute("data-show", "true");
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Hides the popover and moves it off-screen.
|
|
222
|
+
* @param {HTMLElement} pop
|
|
223
|
+
* @return {void}
|
|
224
|
+
*/
|
|
225
|
+
hide(pop) {
|
|
226
|
+
pop.removeAttribute("data-show");
|
|
227
|
+
pop.style.transform = "translate(-9999px,-9999px)";
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ──────────────────────────────────────────────
|
|
231
|
+
// Listeners
|
|
232
|
+
// ──────────────────────────────────────────────
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Binds global listeners (clicks, esc, scroll/resize) and button clicks.
|
|
236
|
+
* @return {void}
|
|
237
|
+
*/
|
|
238
|
+
bindGlobalListeners() {
|
|
239
|
+
const { yes, no } = this.getParts(this.pop);
|
|
240
|
+
|
|
241
|
+
this.onYes = () => this.cleanup(true);
|
|
242
|
+
this.onNo = () => this.cleanup(false);
|
|
243
|
+
this.onEsc = (e) => (e.key === "Escape" ? this.cleanup(false) : null);
|
|
244
|
+
this.onOutside = (e) => {
|
|
245
|
+
if (!this.pop.contains(e.target) && e.target !== this.triggerEl)
|
|
246
|
+
this.cleanup(false);
|
|
247
|
+
};
|
|
248
|
+
this.onScrollOrResize = () => this.cleanup(false);
|
|
249
|
+
|
|
250
|
+
yes.addEventListener("click", this.onYes);
|
|
251
|
+
no.addEventListener("click", this.onNo);
|
|
252
|
+
document.addEventListener("keydown", this.onEsc);
|
|
253
|
+
document.addEventListener("click", this.onOutside, true);
|
|
254
|
+
window.addEventListener("scroll", this.onScrollOrResize, true);
|
|
255
|
+
window.addEventListener("resize", this.onScrollOrResize, true);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Unbinds all previously attached listeners.
|
|
260
|
+
* @return {void}
|
|
261
|
+
*/
|
|
262
|
+
unbindGlobalListeners() {
|
|
263
|
+
if (!this.pop) return;
|
|
264
|
+
const { yes, no } = this.getParts(this.pop);
|
|
265
|
+
|
|
266
|
+
yes.removeEventListener("click", this.onYes);
|
|
267
|
+
no.removeEventListener("click", this.onNo);
|
|
268
|
+
document.removeEventListener("keydown", this.onEsc);
|
|
269
|
+
document.removeEventListener("click", this.onOutside, true);
|
|
270
|
+
window.removeEventListener("scroll", this.onScrollOrResize, true);
|
|
271
|
+
window.removeEventListener("resize", this.onScrollOrResize, true);
|
|
272
|
+
|
|
273
|
+
this.onYes =
|
|
274
|
+
this.onNo =
|
|
275
|
+
this.onEsc =
|
|
276
|
+
this.onOutside =
|
|
277
|
+
this.onScrollOrResize =
|
|
278
|
+
null;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ──────────────────────────────────────────────
|
|
282
|
+
// Positioning
|
|
283
|
+
// ──────────────────────────────────────────────
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Positions the popover relative to the triggering element.
|
|
287
|
+
* @param {HTMLElement} pop
|
|
288
|
+
* @param {HTMLElement} element
|
|
289
|
+
* @return {void}
|
|
290
|
+
*/
|
|
291
|
+
position(pop, element) {
|
|
292
|
+
// Prep for measurement
|
|
293
|
+
pop.style.opacity = "1";
|
|
294
|
+
pop.style.transform = "translate(-9999px,-9999px)";
|
|
295
|
+
|
|
296
|
+
const rect = element.getBoundingClientRect();
|
|
297
|
+
const pr = pop.getBoundingClientRect();
|
|
298
|
+
|
|
299
|
+
const { x, y, placement } = this.computePlacement(rect, pr);
|
|
300
|
+
this.setPlacementClass(pop, placement);
|
|
301
|
+
pop.style.transform = `translate(${Math.round(x)}px, ${Math.round(y)}px)`;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Computes best placement (bottom/top/right/left) with clamping.
|
|
306
|
+
* @param {DOMRect} rect - trigger rect
|
|
307
|
+
* @param {DOMRect} pr - popover rect
|
|
308
|
+
* @return {{x:number, y:number, placement:"bottom"|"top"|"right"|"left"}}
|
|
309
|
+
*/
|
|
310
|
+
computePlacement(rect, pr) {
|
|
311
|
+
const gap = this.gapValue;
|
|
312
|
+
const margin = this.marginValue;
|
|
313
|
+
const placements = ["bottom", "top", "right", "left"];
|
|
314
|
+
|
|
315
|
+
for (const p of placements) {
|
|
316
|
+
const { x, y } = this.coordsFor(p, rect, pr, gap);
|
|
317
|
+
if (this.fits(p, x, y, pr, margin)) {
|
|
318
|
+
return {
|
|
319
|
+
x: this.clampX(x, pr, margin),
|
|
320
|
+
y: this.clampY(y, pr, margin),
|
|
321
|
+
placement: p,
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Fallback: bottom centered, clamped
|
|
327
|
+
const fx = rect.left + (rect.width - pr.width) / 2;
|
|
328
|
+
const fy = rect.bottom + gap;
|
|
329
|
+
return {
|
|
330
|
+
x: this.clampX(fx, pr, margin),
|
|
331
|
+
y: this.clampY(fy, pr, margin),
|
|
332
|
+
placement: "bottom",
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Returns the (x,y) coordinates for a given placement.
|
|
338
|
+
* @param {"bottom"|"top"|"right"|"left"} p
|
|
339
|
+
* @param {DOMRect} rect
|
|
340
|
+
* @param {DOMRect} pr
|
|
341
|
+
* @param {number} gap
|
|
342
|
+
* @return {{x:number, y:number}}
|
|
343
|
+
*/
|
|
344
|
+
coordsFor(p, rect, pr, gap) {
|
|
345
|
+
switch (p) {
|
|
346
|
+
case "bottom":
|
|
347
|
+
return {
|
|
348
|
+
x: rect.left + (rect.width - pr.width) / 2,
|
|
349
|
+
y: rect.bottom + gap,
|
|
350
|
+
};
|
|
351
|
+
case "top":
|
|
352
|
+
return {
|
|
353
|
+
x: rect.left + (rect.width - pr.width) / 2,
|
|
354
|
+
y: rect.top - pr.height - gap,
|
|
355
|
+
};
|
|
356
|
+
case "right":
|
|
357
|
+
return {
|
|
358
|
+
x: rect.right + gap,
|
|
359
|
+
y: rect.top + (rect.height - pr.height) / 2,
|
|
360
|
+
};
|
|
361
|
+
case "left":
|
|
362
|
+
return {
|
|
363
|
+
x: rect.left - pr.width - gap,
|
|
364
|
+
y: rect.top + (rect.height - pr.height) / 2,
|
|
365
|
+
};
|
|
366
|
+
default:
|
|
367
|
+
return { x: 0, y: 0 };
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Checks if the popover fits within viewport for a given placement.
|
|
373
|
+
* @param {"bottom"|"top"|"right"|"left"} p
|
|
374
|
+
* @param {number} x
|
|
375
|
+
* @param {number} y
|
|
376
|
+
* @param {DOMRect} pr
|
|
377
|
+
* @param {number} margin
|
|
378
|
+
* @return {boolean}
|
|
379
|
+
*/
|
|
380
|
+
fits(p, x, y, pr, margin) {
|
|
381
|
+
switch (p) {
|
|
382
|
+
case "bottom":
|
|
383
|
+
return y + pr.height + margin <= window.innerHeight;
|
|
384
|
+
case "top":
|
|
385
|
+
return y >= margin;
|
|
386
|
+
case "right":
|
|
387
|
+
return x + pr.width + margin <= window.innerWidth;
|
|
388
|
+
case "left":
|
|
389
|
+
return x >= margin;
|
|
390
|
+
default:
|
|
391
|
+
return false;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Clamps X coordinate within viewport margins.
|
|
397
|
+
* @param {number} x
|
|
398
|
+
* @param {DOMRect} pr
|
|
399
|
+
* @param {number} margin
|
|
400
|
+
* @return {number}
|
|
401
|
+
*/
|
|
402
|
+
clampX(x, pr, margin) {
|
|
403
|
+
return Math.max(margin, Math.min(x, window.innerWidth - pr.width - margin));
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Clamps Y coordinate within viewport margins.
|
|
408
|
+
* @param {number} y
|
|
409
|
+
* @param {DOMRect} pr
|
|
410
|
+
* @param {number} margin
|
|
411
|
+
* @return {number}
|
|
412
|
+
*/
|
|
413
|
+
clampY(y, pr, margin) {
|
|
414
|
+
return Math.max(
|
|
415
|
+
margin,
|
|
416
|
+
Math.min(y, window.innerHeight - pr.height - margin),
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Applies a placement modifier class to the popover element.
|
|
422
|
+
* @param {HTMLElement} pop
|
|
423
|
+
* @param {"bottom"|"top"|"right"|"left"} placement
|
|
424
|
+
* @return {void}
|
|
425
|
+
*/
|
|
426
|
+
setPlacementClass(pop, placement) {
|
|
427
|
+
pop.classList.remove(
|
|
428
|
+
"mv-confirm--top",
|
|
429
|
+
"mv-confirm--right",
|
|
430
|
+
"mv-confirm--bottom",
|
|
431
|
+
"mv-confirm--left",
|
|
432
|
+
);
|
|
433
|
+
pop.classList.add(`mv-confirm--${placement}`);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
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: TabsController
|
|
10
|
+
* -----------------------------------
|
|
11
|
+
* Manages an accessible tab interface with URL-synced state and lazy-loaded
|
|
12
|
+
* Turbo Frames inside tab panels.
|
|
13
|
+
*
|
|
14
|
+
* Responsibilities:
|
|
15
|
+
* - Toggle active tab link + associated panel by name.
|
|
16
|
+
* - Sync current tab to a query param (default: `tab`) for deep-linking.
|
|
17
|
+
* - Optionally lazy-load panel content via `<turbo-frame data-src="...">`.
|
|
18
|
+
*
|
|
19
|
+
* Key Components:
|
|
20
|
+
* - Public: `show`, `showByName`
|
|
21
|
+
* - Helpers: `_initialTabName`, `_queryParamPresent`, `_toggleLink`,
|
|
22
|
+
* `_ensureFrameLoaded`, `_updateUrl`
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { Controller } from "@hotwired/stimulus";
|
|
26
|
+
|
|
27
|
+
export default class extends Controller {
|
|
28
|
+
/**
|
|
29
|
+
* Targets:
|
|
30
|
+
* - link: clickable tab links (require data-name)
|
|
31
|
+
* - panel: tab panels (require data-name; set `hidden` when inactive)
|
|
32
|
+
*/
|
|
33
|
+
static targets = ["link", "panel"];
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Values:
|
|
37
|
+
* - param: query param name to store active tab (default: "tab")
|
|
38
|
+
* - defaultTab: fallback tab name when no query param exists
|
|
39
|
+
*/
|
|
40
|
+
static values = {
|
|
41
|
+
param: { type: String, default: "tab" },
|
|
42
|
+
defaultTab: String,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* CSS classes:
|
|
47
|
+
* - activeLink: applied to active tab link (fallback: 'mv-tab--on')
|
|
48
|
+
*/
|
|
49
|
+
static classes = ["activeLink"];
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Lifecycle: connect
|
|
53
|
+
* Initializes active tab from query/default, updates URL if missing,
|
|
54
|
+
* and ensures the initial panel frame is loaded.
|
|
55
|
+
* @return {void}
|
|
56
|
+
*/
|
|
57
|
+
connect() {
|
|
58
|
+
const initialTab = this._initialTabName();
|
|
59
|
+
|
|
60
|
+
if (!this._queryParamPresent()) {
|
|
61
|
+
this._updateUrl(initialTab);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
requestAnimationFrame(() => {
|
|
65
|
+
if (initialTab) this.showByName(initialTab, false);
|
|
66
|
+
else if (this.panelTargets[0])
|
|
67
|
+
this._ensureFrameLoaded(this.panelTargets[0]);
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Click handler for tab links.
|
|
73
|
+
* @param {MouseEvent} event
|
|
74
|
+
* @return {void}
|
|
75
|
+
*/
|
|
76
|
+
show(event) {
|
|
77
|
+
event.preventDefault();
|
|
78
|
+
const name = event.currentTarget?.dataset.name;
|
|
79
|
+
if (!name) return;
|
|
80
|
+
this.showByName(name);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Activates the tab & panel by logical name.
|
|
85
|
+
* Optionally pushes state to the URL (replaceState).
|
|
86
|
+
* Dispatches "tabs:changed" event with `{ name }`.
|
|
87
|
+
* @param {string} name
|
|
88
|
+
* @param {boolean} [pushState=true]
|
|
89
|
+
* @return {void}
|
|
90
|
+
*/
|
|
91
|
+
showByName(name, pushState = true) {
|
|
92
|
+
if (!name) return;
|
|
93
|
+
|
|
94
|
+
this.linkTargets.forEach((link) => {
|
|
95
|
+
const isActive = link.dataset.name === name;
|
|
96
|
+
this._toggleLink(link, isActive);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
this.panelTargets.forEach((panel) => {
|
|
100
|
+
panel.hidden = panel.dataset.name !== name;
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const active = this.panelTargets.find(
|
|
104
|
+
(panel) => panel.dataset.name === name,
|
|
105
|
+
);
|
|
106
|
+
if (active) this._ensureFrameLoaded(active);
|
|
107
|
+
|
|
108
|
+
if (pushState) {
|
|
109
|
+
this._updateUrl(name);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
this.dispatch("changed", { detail: { name } });
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ── Helpers ──────────────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Determines the initial tab name from URL, value, or first link.
|
|
119
|
+
* @return {string|undefined}
|
|
120
|
+
*/
|
|
121
|
+
_initialTabName() {
|
|
122
|
+
const qs = new URLSearchParams(window.location.search);
|
|
123
|
+
const fromQuery = qs.get(this.paramValue);
|
|
124
|
+
if (fromQuery) return fromQuery;
|
|
125
|
+
if (this.hasDefaultTabValue) return this.defaultTabValue;
|
|
126
|
+
return this.linkTargets[0]?.dataset.name;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Returns true if the tab query param is present in the URL.
|
|
131
|
+
* @return {boolean}
|
|
132
|
+
*/
|
|
133
|
+
_queryParamPresent() {
|
|
134
|
+
const qs = new URLSearchParams(window.location.search);
|
|
135
|
+
return qs.has(this.paramValue);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Toggles selected state on a tab link with ARIA updates.
|
|
140
|
+
* Falls back to 'mv-tab--on' if no activeLinkClass is configured.
|
|
141
|
+
* @param {HTMLElement} link
|
|
142
|
+
* @param {boolean} isActive
|
|
143
|
+
* @return {void}
|
|
144
|
+
*/
|
|
145
|
+
_toggleLink(link, isActive) {
|
|
146
|
+
if (this.hasActiveLinkClass) {
|
|
147
|
+
link.classList.toggle(this.activeLinkClass, isActive);
|
|
148
|
+
} else {
|
|
149
|
+
link.classList.toggle("mv-tab--on", isActive);
|
|
150
|
+
}
|
|
151
|
+
link.setAttribute("aria-selected", isActive ? "true" : "false");
|
|
152
|
+
link.tabIndex = isActive ? 0 : -1;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* If the active panel contains a Turbo Frame with data-src, sets its src once.
|
|
157
|
+
* @param {HTMLElement} panel
|
|
158
|
+
* @return {void}
|
|
159
|
+
*/
|
|
160
|
+
_ensureFrameLoaded(panel) {
|
|
161
|
+
const frame = panel.querySelector("turbo-frame");
|
|
162
|
+
if (!frame) return;
|
|
163
|
+
|
|
164
|
+
const src = frame.dataset.src;
|
|
165
|
+
if (src) {
|
|
166
|
+
frame.setAttribute("src", src);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Updates the URL to reflect the active tab and clears datatable params.
|
|
172
|
+
* (Removes dtfilter/dtsort/dtsearch to avoid cross-tab leakage.)
|
|
173
|
+
* @param {string} name
|
|
174
|
+
* @return {void}
|
|
175
|
+
*/
|
|
176
|
+
_updateUrl(name) {
|
|
177
|
+
const url = new URL(window.location.href);
|
|
178
|
+
url.searchParams.set(this.paramValue, name);
|
|
179
|
+
url.searchParams.delete("dtfilter");
|
|
180
|
+
url.searchParams.delete("dtsort");
|
|
181
|
+
url.searchParams.delete("dtsearch");
|
|
182
|
+
history.replaceState({}, "", url);
|
|
183
|
+
}
|
|
184
|
+
}
|