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.
Files changed (124) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +168 -0
  4. data/Rakefile +15 -0
  5. data/app/assets/images/smriti/android-chrome-192x192.png +0 -0
  6. data/app/assets/images/smriti/android-chrome-512x512.png +0 -0
  7. data/app/assets/images/smriti/apple-touch-icon.png +0 -0
  8. data/app/assets/images/smriti/favicon-16x16.png +0 -0
  9. data/app/assets/images/smriti/favicon-32x32.png +0 -0
  10. data/app/assets/images/smriti/favicon-48x48.png +0 -0
  11. data/app/assets/images/smriti/favicon.ico +0 -0
  12. data/app/assets/images/smriti/favicon.svg +18 -0
  13. data/app/assets/images/smriti/logo.svg +18 -0
  14. data/app/assets/images/smriti/mask-icon.svg +5 -0
  15. data/app/assets/stylesheets/smriti/application.css +1040 -0
  16. data/app/controllers/smriti/admin/application_controller.rb +135 -0
  17. data/app/controllers/smriti/admin/dashboard_controller.rb +32 -0
  18. data/app/controllers/smriti/admin/mat_view_definitions_controller.rb +372 -0
  19. data/app/controllers/smriti/admin/mat_view_runs_controller.rb +185 -0
  20. data/app/controllers/smriti/admin/preferences_controller.rb +91 -0
  21. data/app/helpers/smriti/admin/datatable_helper.rb +249 -0
  22. data/app/helpers/smriti/admin/localized_digit_helper.rb +70 -0
  23. data/app/helpers/smriti/admin/ui_helper.rb +539 -0
  24. data/app/javascript/smriti/application.js +8 -0
  25. data/app/javascript/smriti/controllers/application.js +10 -0
  26. data/app/javascript/smriti/controllers/body_setup_controller.js +120 -0
  27. data/app/javascript/smriti/controllers/datatable_controller.js +351 -0
  28. data/app/javascript/smriti/controllers/details_controller.js +200 -0
  29. data/app/javascript/smriti/controllers/drawer_controller.js +470 -0
  30. data/app/javascript/smriti/controllers/flash_controller.js +112 -0
  31. data/app/javascript/smriti/controllers/index.js +10 -0
  32. data/app/javascript/smriti/controllers/mv_confirm_controller.js +435 -0
  33. data/app/javascript/smriti/controllers/tabs_controller.js +184 -0
  34. data/app/javascript/smriti/controllers/tooltip_controller.js +525 -0
  35. data/app/javascript/smriti/controllers/turbo_frame_lifecycle_controller.js +342 -0
  36. data/app/jobs/smriti/application_job.rb +144 -0
  37. data/app/jobs/smriti/create_view_job.rb +87 -0
  38. data/app/jobs/smriti/delete_view_job.rb +89 -0
  39. data/app/jobs/smriti/refresh_view_job.rb +94 -0
  40. data/app/models/concerns/smriti_i18n.rb +139 -0
  41. data/app/models/concerns/smriti_paginate.rb +70 -0
  42. data/app/models/concerns/smriti_query_helper.rb +36 -0
  43. data/app/models/smriti/application_record.rb +39 -0
  44. data/app/models/smriti/mat_view_definition.rb +254 -0
  45. data/app/models/smriti/mat_view_run.rb +275 -0
  46. data/app/views/layouts/smriti/_footer.html.erb +47 -0
  47. data/app/views/layouts/smriti/_header.html.erb +25 -0
  48. data/app/views/layouts/smriti/admin.html.erb +47 -0
  49. data/app/views/layouts/smriti/turbo_frame.html.erb +3 -0
  50. data/app/views/smriti/admin/dashboard/index.html.erb +38 -0
  51. data/app/views/smriti/admin/mat_view_definitions/_definition_actions.html.erb +94 -0
  52. data/app/views/smriti/admin/mat_view_definitions/_dt-index-empty-row.html.erb +11 -0
  53. data/app/views/smriti/admin/mat_view_definitions/_dt-index-row.html.erb +27 -0
  54. data/app/views/smriti/admin/mat_view_definitions/empty.html.erb +1 -0
  55. data/app/views/smriti/admin/mat_view_definitions/form.html.erb +79 -0
  56. data/app/views/smriti/admin/mat_view_definitions/index.html.erb +10 -0
  57. data/app/views/smriti/admin/mat_view_definitions/show.html.erb +40 -0
  58. data/app/views/smriti/admin/mat_view_runs/_dt-index-empty-row.html.erb +11 -0
  59. data/app/views/smriti/admin/mat_view_runs/_dt-index-row.html.erb +41 -0
  60. data/app/views/smriti/admin/mat_view_runs/index.html.erb +1 -0
  61. data/app/views/smriti/admin/mat_view_runs/show.html.erb +64 -0
  62. data/app/views/smriti/admin/preferences/show.html.erb +49 -0
  63. data/app/views/smriti/admin/ui/_card.html.erb +15 -0
  64. data/app/views/smriti/admin/ui/_datatable.html.erb +34 -0
  65. data/app/views/smriti/admin/ui/_datatable_filters.html.erb +45 -0
  66. data/app/views/smriti/admin/ui/_datatable_tbody.html.erb +11 -0
  67. data/app/views/smriti/admin/ui/_datatable_tfoot.html.erb +70 -0
  68. data/app/views/smriti/admin/ui/_datatable_thead.html.erb +105 -0
  69. data/app/views/smriti/admin/ui/_details.html.erb +10 -0
  70. data/app/views/smriti/admin/ui/_flash.html.erb +6 -0
  71. data/app/views/smriti/admin/ui/_table.html.erb +8 -0
  72. data/config/importmap.rb +9 -0
  73. data/config/locales/ar.yml +223 -0
  74. data/config/locales/de.yml +230 -0
  75. data/config/locales/en-AU-ocker.yml +223 -0
  76. data/config/locales/en-AU.yml +202 -0
  77. data/config/locales/en-BORK.yml +225 -0
  78. data/config/locales/en-CA.yml +223 -0
  79. data/config/locales/en-GB.yml +223 -0
  80. data/config/locales/en-LOL.yml +219 -0
  81. data/config/locales/en-SCOT.yml +223 -0
  82. data/config/locales/en-SHAKESPEARE.yml +225 -0
  83. data/config/locales/en-US-pirate.yml +222 -0
  84. data/config/locales/en-US.yml +225 -0
  85. data/config/locales/en-YODA.yml +221 -0
  86. data/config/locales/en.yml +223 -0
  87. data/config/locales/es.yml +226 -0
  88. data/config/locales/fa.yml +223 -0
  89. data/config/locales/fr-CA.yml +227 -0
  90. data/config/locales/fr.yml +227 -0
  91. data/config/locales/he.yml +218 -0
  92. data/config/locales/hi.yml +223 -0
  93. data/config/locales/it.yml +225 -0
  94. data/config/locales/ja-JP.yml +215 -0
  95. data/config/locales/pt.yml +225 -0
  96. data/config/locales/ru.yml +228 -0
  97. data/config/locales/ur.yml +225 -0
  98. data/config/locales/zh-CN.yml +214 -0
  99. data/config/locales/zh-TW.yml +214 -0
  100. data/config/routes.rb +36 -0
  101. data/lib/ext/exception.rb +20 -0
  102. data/lib/generators/smriti/install/install_generator.rb +86 -0
  103. data/lib/generators/smriti/install/templates/create_mat_view_definitions.rb +29 -0
  104. data/lib/generators/smriti/install/templates/create_mat_view_runs.rb +32 -0
  105. data/lib/generators/smriti/install/templates/smriti_initializer.rb +23 -0
  106. data/lib/smriti/admin/auth_bridge.rb +93 -0
  107. data/lib/smriti/admin/default_auth.rb +62 -0
  108. data/lib/smriti/configuration.rb +58 -0
  109. data/lib/smriti/engine.rb +82 -0
  110. data/lib/smriti/helpers/ui_test_ids.rb +49 -0
  111. data/lib/smriti/jobs/adapter.rb +81 -0
  112. data/lib/smriti/service_response.rb +75 -0
  113. data/lib/smriti/services/base_service.rb +471 -0
  114. data/lib/smriti/services/check_matview_exists.rb +76 -0
  115. data/lib/smriti/services/concurrent_refresh.rb +94 -0
  116. data/lib/smriti/services/create_view.rb +173 -0
  117. data/lib/smriti/services/delete_view.rb +111 -0
  118. data/lib/smriti/services/regular_refresh.rb +90 -0
  119. data/lib/smriti/services/swap_refresh.rb +181 -0
  120. data/lib/smriti/version.rb +21 -0
  121. data/lib/smriti.rb +64 -0
  122. data/lib/tasks/helpers.rb +185 -0
  123. data/lib/tasks/smriti_tasks.rake +151 -0
  124. 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
+ }