mat_views 0.2.0 → 0.3.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 (132) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +4 -4
  3. data/app/assets/images/mat_views/android-chrome-192x192.png +0 -0
  4. data/app/assets/images/mat_views/android-chrome-512x512.png +0 -0
  5. data/app/assets/images/mat_views/apple-touch-icon.png +0 -0
  6. data/app/assets/images/mat_views/favicon-16x16.png +0 -0
  7. data/app/assets/images/mat_views/favicon-32x32.png +0 -0
  8. data/app/assets/images/mat_views/favicon-48x48.png +0 -0
  9. data/app/assets/images/mat_views/favicon.ico +0 -0
  10. data/app/assets/images/mat_views/favicon.svg +18 -0
  11. data/app/assets/images/mat_views/logo.svg +18 -0
  12. data/app/assets/images/mat_views/mask-icon.svg +5 -0
  13. data/app/assets/stylesheets/mat_views/application.css +323 -12
  14. data/app/controllers/mat_views/admin/application_controller.rb +135 -0
  15. data/app/controllers/mat_views/admin/dashboard_controller.rb +32 -0
  16. data/app/controllers/mat_views/admin/mat_view_definitions_controller.rb +248 -0
  17. data/app/controllers/mat_views/admin/preferences_controller.rb +91 -0
  18. data/app/controllers/mat_views/admin/runs_controller.rb +74 -0
  19. data/app/helpers/mat_views/admin/ui_helper.rb +385 -0
  20. data/app/javascript/mat_views/application.js +8 -0
  21. data/app/javascript/mat_views/controllers/application.js +10 -0
  22. data/app/javascript/mat_views/controllers/details_controller.js +122 -0
  23. data/app/javascript/mat_views/controllers/drawer_controller.js +252 -0
  24. data/app/javascript/mat_views/controllers/filter_controller.js +90 -0
  25. data/app/javascript/mat_views/controllers/flash_controller.js +13 -0
  26. data/app/javascript/mat_views/controllers/index.js +10 -0
  27. data/app/javascript/mat_views/controllers/mv_confirm_controller.js +281 -0
  28. data/app/javascript/mat_views/controllers/submitter_controller.js +15 -0
  29. data/app/javascript/mat_views/controllers/tabs_controller.js +67 -0
  30. data/app/javascript/mat_views/controllers/timezone_controller.js +16 -0
  31. data/app/javascript/mat_views/controllers/tooltip_controller.js +328 -0
  32. data/app/javascript/mat_views/controllers/turbo_frame_lifecycle_controller.js +49 -0
  33. data/app/jobs/mat_views/application_job.rb +2 -2
  34. data/app/jobs/mat_views/create_view_job.rb +9 -8
  35. data/app/jobs/mat_views/delete_view_job.rb +8 -8
  36. data/app/jobs/mat_views/refresh_view_job.rb +8 -9
  37. data/app/models/concerns/mat_views_i18n.rb +139 -0
  38. data/app/models/mat_views/application_record.rb +1 -0
  39. data/app/models/mat_views/mat_view_definition.rb +12 -7
  40. data/app/models/mat_views/mat_view_run.rb +11 -13
  41. data/app/views/layouts/mat_views/_footer.html.erb +41 -0
  42. data/app/views/layouts/mat_views/_header.html.erb +25 -0
  43. data/app/views/layouts/mat_views/admin.html.erb +47 -0
  44. data/app/views/layouts/mat_views/turbo_frame.html.erb +3 -0
  45. data/app/views/mat_views/admin/dashboard/index.html.erb +33 -0
  46. data/app/views/mat_views/admin/mat_view_definitions/_definition_actions.html.erb +94 -0
  47. data/app/views/mat_views/admin/mat_view_definitions/_table.html.erb +48 -0
  48. data/app/views/mat_views/admin/mat_view_definitions/empty.html.erb +1 -0
  49. data/app/views/mat_views/admin/mat_view_definitions/form.html.erb +79 -0
  50. data/app/views/mat_views/admin/mat_view_definitions/index.html.erb +10 -0
  51. data/app/views/mat_views/admin/mat_view_definitions/show.html.erb +40 -0
  52. data/app/views/mat_views/admin/preferences/show.html.erb +50 -0
  53. data/app/views/mat_views/admin/runs/_table.html.erb +61 -0
  54. data/app/views/mat_views/admin/runs/index.html.erb +38 -0
  55. data/app/views/mat_views/admin/runs/show.html.erb +64 -0
  56. data/app/views/mat_views/admin/ui/_card.html.erb +15 -0
  57. data/app/views/mat_views/admin/ui/_details.html.erb +10 -0
  58. data/app/views/mat_views/admin/ui/_flash.html.erb +6 -0
  59. data/app/views/mat_views/admin/ui/_table.html.erb +8 -0
  60. data/config/importmap.rb +9 -0
  61. data/config/locales/en-AU-ocker.yml +187 -0
  62. data/config/locales/en-AU.yml +187 -0
  63. data/config/locales/en-BB.yml +187 -0
  64. data/config/locales/en-BD.yml +187 -0
  65. data/config/locales/en-BE.yml +187 -0
  66. data/config/locales/en-BORK.yml +187 -0
  67. data/config/locales/en-BS.yml +187 -0
  68. data/config/locales/en-BZ.yml +187 -0
  69. data/config/locales/en-CA.yml +187 -0
  70. data/config/locales/en-CM.yml +187 -0
  71. data/config/locales/en-CY.yml +187 -0
  72. data/config/locales/en-EG.yml +187 -0
  73. data/config/locales/en-FJ.yml +187 -0
  74. data/config/locales/en-GB.yml +187 -0
  75. data/config/locales/en-GH.yml +187 -0
  76. data/config/locales/en-GI.yml +187 -0
  77. data/config/locales/en-GM.yml +187 -0
  78. data/config/locales/en-GY.yml +187 -0
  79. data/config/locales/en-HK.yml +187 -0
  80. data/config/locales/en-IE.yml +187 -0
  81. data/config/locales/en-IN.yml +187 -0
  82. data/config/locales/en-JM.yml +187 -0
  83. data/config/locales/en-KE.yml +187 -0
  84. data/config/locales/en-LK.yml +187 -0
  85. data/config/locales/en-LOL.yml +187 -0
  86. data/config/locales/en-LR.yml +187 -0
  87. data/config/locales/en-MS.yml +187 -0
  88. data/config/locales/en-MT.yml +187 -0
  89. data/config/locales/en-MW.yml +187 -0
  90. data/config/locales/en-MY.yml +187 -0
  91. data/config/locales/en-NG.yml +187 -0
  92. data/config/locales/en-NP.yml +187 -0
  93. data/config/locales/en-NZ.yml +187 -0
  94. data/config/locales/en-PG.yml +187 -0
  95. data/config/locales/en-PH.yml +187 -0
  96. data/config/locales/en-PK.yml +187 -0
  97. data/config/locales/en-RW.yml +187 -0
  98. data/config/locales/en-SCOT.yml +187 -0
  99. data/config/locales/en-SG.yml +187 -0
  100. data/config/locales/en-SHAKESPEARE.yml +187 -0
  101. data/config/locales/en-SL.yml +187 -0
  102. data/config/locales/en-SS.yml +187 -0
  103. data/config/locales/en-TH.yml +187 -0
  104. data/config/locales/en-TT.yml +187 -0
  105. data/config/locales/en-TZ.yml +187 -0
  106. data/config/locales/en-UG.yml +187 -0
  107. data/config/locales/en-US-pirate.yml +187 -0
  108. data/config/locales/en-US.yml +187 -0
  109. data/config/locales/en-YODA.yml +187 -0
  110. data/config/locales/en-ZA.yml +187 -0
  111. data/config/locales/en-ZW.yml +187 -0
  112. data/config/locales/en.yml +187 -0
  113. data/config/routes.rb +27 -3
  114. data/lib/generators/mat_views/install/templates/create_mat_view_definitions.rb +7 -7
  115. data/lib/generators/mat_views/install/templates/create_mat_view_runs.rb +5 -5
  116. data/lib/mat_views/admin/auth_bridge.rb +93 -0
  117. data/lib/mat_views/admin/default_auth.rb +61 -0
  118. data/lib/mat_views/configuration.rb +9 -0
  119. data/lib/mat_views/engine.rb +50 -2
  120. data/lib/mat_views/helpers/ui_test_ids.rb +43 -0
  121. data/lib/mat_views/services/base_service.rb +46 -38
  122. data/lib/mat_views/services/check_matview_exists.rb +76 -0
  123. data/lib/mat_views/services/concurrent_refresh.rb +9 -6
  124. data/lib/mat_views/services/create_view.rb +15 -15
  125. data/lib/mat_views/services/delete_view.rb +8 -11
  126. data/lib/mat_views/services/regular_refresh.rb +6 -5
  127. data/lib/mat_views/services/swap_refresh.rb +11 -9
  128. data/lib/mat_views/version.rb +1 -1
  129. data/lib/mat_views.rb +10 -4
  130. data/lib/tasks/helpers.rb +13 -13
  131. data/lib/tasks/mat_views_tasks.rake +15 -15
  132. metadata +130 -5
@@ -0,0 +1,252 @@
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
+ import { Controller } from "@hotwired/stimulus";
8
+
9
+ export default class extends Controller {
10
+ static targets = ["overlay", "panel", "frame"];
11
+ static currentTargetStack = [];
12
+
13
+ connect() {
14
+ this.currentTargetStack = [];
15
+ this._escHandler = (e) => {
16
+ if (e.key === "Escape") this.close();
17
+ };
18
+ if (this.hasFrameTarget) {
19
+ this._submitEndHandler = (e) => this.onSubmitEnd(e);
20
+ this._frameLoadHandler = (e) => this.onFrameLoad(e);
21
+ this.frameTarget.addEventListener(
22
+ "turbo:submit-end",
23
+ this._submitEndHandler,
24
+ );
25
+ this.frameTarget.addEventListener(
26
+ "turbo:frame-load",
27
+ this._frameLoadHandler,
28
+ );
29
+ }
30
+ const qs = new URLSearchParams(window.location.search);
31
+ const open = qs.get("open");
32
+ if (open) {
33
+ this.openDrawerByName(open);
34
+ }
35
+ }
36
+
37
+ disconnect() {
38
+ if (this.hasFrameTarget) {
39
+ this.frameTarget.removeEventListener(
40
+ "turbo:submit-end",
41
+ this._submitEndHandler,
42
+ );
43
+ this.frameTarget.removeEventListener(
44
+ "turbo:frame-load",
45
+ this._frameLoadHandler,
46
+ );
47
+ }
48
+ document.removeEventListener("keydown", this._escHandler);
49
+ }
50
+
51
+ loadAndShow() {
52
+ let currentTarget = this.currentTargetStack.at(-1);
53
+ if (!currentTarget) return;
54
+
55
+ const title =
56
+ currentTarget?.dataset.drawerTitle ||
57
+ currentTarget?.getAttribute("title") ||
58
+ currentTarget?.getAttribute("aria-label");
59
+ const url =
60
+ currentTarget?.dataset.drawerUrl || currentTarget?.getAttribute("href");
61
+ if (!url || url === "#" || url === "javascript:void(0);") return;
62
+
63
+ const header = this._drawerHeader();
64
+ header.setAttribute("aria-label", title);
65
+ const titleEle = header.querySelector("h2");
66
+ titleEle.textContent = title;
67
+ this.show();
68
+ this.load(url);
69
+ }
70
+
71
+ open(event) {
72
+ if (!this.currentTargetStack) {
73
+ this.currentTargetStack = [];
74
+ }
75
+ if (event) {
76
+ event.preventDefault();
77
+ event.stopPropagation();
78
+ if (event.stopImmediatePropagation) event.stopImmediatePropagation();
79
+ }
80
+ this.currentTargetStack.push(event?.currentTarget);
81
+ this.loadAndShow();
82
+ }
83
+
84
+ show() {
85
+ const root = this._root();
86
+ document.addEventListener("keydown", this._escHandler);
87
+ root?.classList.add("is-open");
88
+ root?.setAttribute("aria-hidden", "false");
89
+ }
90
+
91
+ close() {
92
+ this.currentTargetStack.pop();
93
+ if (this.currentTargetStack.length > 0) {
94
+ this.loadAndShow();
95
+ return;
96
+ }
97
+
98
+ const root = this._root();
99
+ document.activeElement?.blur();
100
+ document.body.focus();
101
+ root?.classList.remove("is-open");
102
+ document.removeEventListener("keydown", this._escHandler);
103
+ root?.setAttribute("aria-hidden", "true");
104
+
105
+ const url = new URL(window.location);
106
+ url.searchParams.delete("open");
107
+ history.replaceState({}, "", url);
108
+
109
+ this.frameTarget.removeAttribute("src");
110
+ }
111
+
112
+ openDrawerByName(name) {
113
+ const type = name.split("_")[0];
114
+ const action = name.split("_")[1];
115
+ const id = name.split("_")[2];
116
+ switch (type) {
117
+ case "definitions":
118
+ this.openDrawerByDefinition(action, id);
119
+ break;
120
+ case "runs":
121
+ this.openDrawerByRun(action, id);
122
+ break;
123
+ case "preferences":
124
+ this.openDrawerByPreferences(action);
125
+ break;
126
+ }
127
+ }
128
+
129
+ openDrawerByRun(action, id) {
130
+ if (action == "view") {
131
+ this.show();
132
+ this.load(window.MatViewsRoutes.runsPath + `/${id}?frame_id=mv-drawer`);
133
+ }
134
+ }
135
+
136
+ openDrawerByPreferences(action) {
137
+ if (action == "edit") {
138
+ this.show();
139
+ this.load(window.MatViewsRoutes.preferencesPath + `?frame_id=mv-drawer`);
140
+ }
141
+ }
142
+
143
+ openDrawerByDefinition(action, id) {
144
+ switch (action) {
145
+ case "new":
146
+ this.show();
147
+ this.load(
148
+ window.MatViewsRoutes.definitionsPath + "/new?frame_id=mv-drawer",
149
+ );
150
+ break;
151
+ case "view":
152
+ this.show();
153
+ this.load(
154
+ window.MatViewsRoutes.definitionsPath + `/${id}?frame_id=mv-drawer`,
155
+ );
156
+ break;
157
+ case "edit":
158
+ this.show();
159
+ this.load(
160
+ window.MatViewsRoutes.definitionsPath +
161
+ `/${id}/edit?frame_id=mv-drawer`,
162
+ );
163
+ break;
164
+ }
165
+ }
166
+
167
+ refresh() {
168
+ if (this.frameTarget?.src) {
169
+ this.load(this.frameTarget.src);
170
+ }
171
+ }
172
+
173
+ load(url) {
174
+ const frame = this.frameTarget;
175
+ if (!frame) return;
176
+
177
+ const u = new URL(url, window.location.href);
178
+ frame.src = u.toString();
179
+ }
180
+
181
+ // ── Events ───────────────────────────────────────────────────────
182
+
183
+ onFrameLoad(event) {
184
+ // get title from hidden input if present
185
+ const frame = event.target;
186
+ const titleInput = frame?.querySelector("#mv-drawer-title-text")?.value;
187
+ if (titleInput) {
188
+ const header = this._drawerHeader();
189
+ header.setAttribute("aria-label", titleInput);
190
+ const titleEle = header.querySelector("h2");
191
+ titleEle.textContent = titleInput;
192
+ }
193
+
194
+ const urlIdentifier = frame?.querySelector(
195
+ "#mv-drawer-open-url-identifier",
196
+ )?.value;
197
+ if (urlIdentifier) {
198
+ const url = new URL(window.location);
199
+ url.searchParams.set("open", urlIdentifier);
200
+ history.replaceState({}, "", url);
201
+ }
202
+ }
203
+
204
+ onSubmitEnd(event) {
205
+ if (event.detail.fetchResponse.statusCode === 299) {
206
+ const url = new URL(window.location);
207
+ url.searchParams.delete("open");
208
+ history.replaceState({}, "", url);
209
+ window.location.reload();
210
+ return;
211
+ } else if (event.detail.fetchResponse.statusCode === 298) {
212
+ this.close();
213
+ }
214
+
215
+ this.refreshActiveFrame();
216
+ }
217
+
218
+ // ── Helpers ──────────────────────────────────────────────────────
219
+
220
+ refreshActiveFrame() {
221
+ const activeTabAnchor = document.querySelector(".mv-tab.mv-tab--on");
222
+ const dataName = activeTabAnchor?.dataset.name;
223
+ const activePanel = document.querySelector(
224
+ `[data-tabs-target="panel"][data-name="${dataName}"]`,
225
+ );
226
+ const activeTurboFrame = activePanel?.querySelector("turbo-frame");
227
+ this.refreshFrameById(activeTurboFrame.id);
228
+ }
229
+
230
+ refreshFrameById(id) {
231
+ const frame = document.getElementById(id);
232
+ if (!frame) return;
233
+ let url = frame.getAttribute("src") || frame.dataset.src;
234
+ if (!url) return;
235
+ try {
236
+ const u = new URL(url, window.location.href);
237
+ u.searchParams.set("_", Date.now().toString());
238
+ frame.src = u.toString();
239
+ } catch {
240
+ const sep = url.includes("?") ? "&" : "?";
241
+ frame.src = url + sep + "_=" + Date.now();
242
+ }
243
+ }
244
+
245
+ _root() {
246
+ return document.querySelector(".mv-drawer-root");
247
+ }
248
+
249
+ _drawerHeader() {
250
+ return this._root()?.querySelector(".mv-drawer-head");
251
+ }
252
+ }
@@ -0,0 +1,90 @@
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
+ import { Controller } from "@hotwired/stimulus";
8
+
9
+ export default class extends Controller {
10
+ connect() {
11
+ const queryParams = this.element.dataset.queryParams;
12
+ this.queryParamsArray = queryParams ? queryParams.split(",") : [];
13
+
14
+ if (this.queryParamsArray.length === 0) {
15
+ return;
16
+ }
17
+
18
+ const qs = new URLSearchParams(window.location.search);
19
+
20
+ // return if qs params are equal to form values, this.queryParamsArray
21
+ const allMatch = this.queryParamsArray.every((param) => {
22
+ const formElement = this.element.querySelector(`[name="${param}"]`);
23
+ if (!formElement) {
24
+ return true; // skip if form element not found
25
+ }
26
+ const formValue = formElement.value || "";
27
+ const qsValue = qs.get(param) || "";
28
+ return formValue === qsValue;
29
+ });
30
+
31
+ if (allMatch) {
32
+ return;
33
+ }
34
+
35
+ // set form values from qs params, this.queryParamsArray
36
+ this.queryParamsArray.forEach((param) => {
37
+ const formElement = this.element.querySelector(`[name="${param}"]`);
38
+ if (!formElement) {
39
+ return; // skip if form element not found
40
+ }
41
+ const qsValue = qs.get(param);
42
+ if (qsValue !== null) {
43
+ formElement.value = qsValue;
44
+ } else {
45
+ formElement.value = ""; // reset to empty if param not in URL
46
+ }
47
+ });
48
+
49
+ this.element.requestSubmit();
50
+ }
51
+
52
+ reset() {
53
+ const selects = this.element.querySelectorAll("select");
54
+ selects.forEach((select) => {
55
+ if (select.options.length > 0) {
56
+ select.selectedIndex = 0;
57
+ }
58
+ });
59
+
60
+ // remove this.queryParamsArray from URL
61
+ const url = new URL(window.location);
62
+ this.queryParamsArray.forEach((param) => {
63
+ url.searchParams.delete(param);
64
+ });
65
+ window.history.replaceState({}, "", url);
66
+
67
+ this.element.requestSubmit();
68
+ }
69
+
70
+ autoSubmit(event) {
71
+ if (!event.target) {
72
+ return;
73
+ }
74
+ if (!event.target.name) {
75
+ return;
76
+ }
77
+
78
+ const argName = event.target.name;
79
+ const argValue = event.target.value;
80
+
81
+ const url = new URL(window.location);
82
+ if (event.target.value == "") {
83
+ url.searchParams.delete(argName);
84
+ } else {
85
+ url.searchParams.set(argName, argValue);
86
+ }
87
+ window.history.replaceState({}, "", url);
88
+ this.element.requestSubmit();
89
+ }
90
+ }
@@ -0,0 +1,13 @@
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
+ import { Controller } from "@hotwired/stimulus";
8
+
9
+ export default class extends Controller {
10
+ connect() {
11
+ setTimeout(() => this.element.remove(), 10000);
12
+ }
13
+ }
@@ -0,0 +1,10 @@
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
+ import { application } from "mat_views/controllers/application";
8
+ import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading";
9
+
10
+ eagerLoadControllersFrom("mat_views/controllers", application);
@@ -0,0 +1,281 @@
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
+ import { Controller } from "@hotwired/stimulus";
8
+ export default class extends Controller {
9
+ static values = {
10
+ enabled: { type: Boolean, default: true },
11
+ gap: { type: Number, default: 8 },
12
+ margin: { type: Number, default: 8 },
13
+ };
14
+
15
+ connect() {
16
+ if (!this.enabledValue) return;
17
+ this.installConfirmHooks();
18
+ }
19
+
20
+ disconnect() {
21
+ this.uninstallConfirmHooks();
22
+ this.cleanup(false);
23
+ }
24
+
25
+ // ──────────────────────────────────────────────
26
+ // Turbo hook wiring
27
+ // ──────────────────────────────────────────────
28
+ installConfirmHooks() {
29
+ if (!this.hasTurbo()) return;
30
+ this.prevFormsConfirm = window.Turbo.config.forms?.confirm;
31
+ this.prevLinksConfirm = window.Turbo.config.links?.confirm;
32
+
33
+ const handler = this.confirm.bind(this);
34
+ if (window.Turbo.config.forms) window.Turbo.config.forms.confirm = handler;
35
+ if (window.Turbo.config.links) window.Turbo.config.links.confirm = handler;
36
+ }
37
+
38
+ uninstallConfirmHooks() {
39
+ if (!this.hasTurbo()) return;
40
+ if (window.Turbo.config.forms)
41
+ window.Turbo.config.forms.confirm = this.prevFormsConfirm;
42
+ if (window.Turbo.config.links)
43
+ window.Turbo.config.links.confirm = this.prevLinksConfirm;
44
+ }
45
+
46
+ hasTurbo() {
47
+ return !!window.Turbo?.config;
48
+ }
49
+
50
+ // ──────────────────────────────────────────────
51
+ // Confirm entry point
52
+ // ──────────────────────────────────────────────
53
+ confirm(message, element) {
54
+ // Fallback for Turbo < 8 or if anything is missing
55
+ if (!this.hasTurbo()) return Promise.resolve(window.confirm(message));
56
+
57
+ return new Promise((resolve) => {
58
+ this._resolver = resolve;
59
+ this.open(message, element);
60
+ });
61
+ }
62
+
63
+ // ──────────────────────────────────────────────
64
+ // Open/close lifecycle
65
+ // ──────────────────────────────────────────────
66
+ open(message, element) {
67
+ this.pop = this.ensurePopover();
68
+ this.triggerEl = element;
69
+
70
+ const { content } = this.getParts(this.pop);
71
+ content.textContent = message;
72
+
73
+ this.position(this.pop, element);
74
+ this.show(this.pop);
75
+ this.bindGlobalListeners();
76
+ }
77
+
78
+ cleanup(result) {
79
+ if (!this.pop) return;
80
+ this.hide(this.pop);
81
+ this.unbindGlobalListeners();
82
+ const resolve = this._resolver;
83
+ this._resolver = null;
84
+ if (resolve) resolve(!!result);
85
+ }
86
+
87
+ // ──────────────────────────────────────────────
88
+ // DOM helpers
89
+ // ──────────────────────────────────────────────
90
+ ensurePopover() {
91
+ let el = document.getElementById("mv-confirm");
92
+ if (el) return el;
93
+ el = this.createPopover();
94
+ document.body.appendChild(el);
95
+ return el;
96
+ }
97
+
98
+ createPopover() {
99
+ const el = document.createElement("div");
100
+ el.id = "mv-confirm";
101
+ el.className = "mv-confirm";
102
+ Object.assign(el.style, {
103
+ position: "fixed",
104
+ top: "0",
105
+ left: "0",
106
+ transform: "translate(-9999px,-9999px)",
107
+ });
108
+ el.innerHTML = `
109
+ <div class="mv-confirm__content"></div>
110
+ <div class="mv-confirm__buttons">
111
+ <button type="button" class="mv-btn mv-btn--sm mv-btn--negative mv-confirm__yes">Yes</button>
112
+ <button type="button" class="mv-btn mv-btn--sm mv-btn--secondary mv-confirm__no">No</button>
113
+ </div>
114
+ <div class="mv-confirm__arrow"></div>`;
115
+ return el;
116
+ }
117
+
118
+ getParts(pop) {
119
+ return {
120
+ content: pop.querySelector(".mv-confirm__content"),
121
+ yes: pop.querySelector(".mv-confirm__yes"),
122
+ no: pop.querySelector(".mv-confirm__no"),
123
+ };
124
+ }
125
+
126
+ show(pop) {
127
+ pop.setAttribute("data-show", "true");
128
+ }
129
+
130
+ hide(pop) {
131
+ pop.removeAttribute("data-show");
132
+ pop.style.transform = "translate(-9999px,-9999px)";
133
+ }
134
+
135
+ // ──────────────────────────────────────────────
136
+ // Listeners
137
+ // ──────────────────────────────────────────────
138
+ bindGlobalListeners() {
139
+ const { yes, no } = this.getParts(this.pop);
140
+
141
+ this.onYes = () => this.cleanup(true);
142
+ this.onNo = () => this.cleanup(false);
143
+ this.onEsc = (e) => (e.key === "Escape" ? this.cleanup(false) : null);
144
+ this.onOutside = (e) => {
145
+ if (!this.pop.contains(e.target) && e.target !== this.triggerEl)
146
+ this.cleanup(false);
147
+ };
148
+ this.onScrollOrResize = () => this.cleanup(false);
149
+
150
+ yes.addEventListener("click", this.onYes);
151
+ no.addEventListener("click", this.onNo);
152
+ document.addEventListener("keydown", this.onEsc);
153
+ document.addEventListener("click", this.onOutside, true);
154
+ window.addEventListener("scroll", this.onScrollOrResize, true);
155
+ window.addEventListener("resize", this.onScrollOrResize, true);
156
+ }
157
+
158
+ unbindGlobalListeners() {
159
+ if (!this.pop) return;
160
+ const { yes, no } = this.getParts(this.pop);
161
+
162
+ yes.removeEventListener("click", this.onYes);
163
+ no.removeEventListener("click", this.onNo);
164
+ document.removeEventListener("keydown", this.onEsc);
165
+ document.removeEventListener("click", this.onOutside, true);
166
+ window.removeEventListener("scroll", this.onScrollOrResize, true);
167
+ window.removeEventListener("resize", this.onScrollOrResize, true);
168
+
169
+ this.onYes =
170
+ this.onNo =
171
+ this.onEsc =
172
+ this.onOutside =
173
+ this.onScrollOrResize =
174
+ null;
175
+ }
176
+
177
+ // ──────────────────────────────────────────────
178
+ // Positioning
179
+ // ──────────────────────────────────────────────
180
+ position(pop, element) {
181
+ // Prep for measurement
182
+ pop.style.opacity = "1";
183
+ pop.style.transform = "translate(-9999px,-9999px)";
184
+
185
+ const rect = element.getBoundingClientRect();
186
+ const pr = pop.getBoundingClientRect();
187
+
188
+ const { x, y, placement } = this.computePlacement(rect, pr);
189
+ this.setPlacementClass(pop, placement);
190
+ pop.style.transform = `translate(${Math.round(x)}px, ${Math.round(y)}px)`;
191
+ }
192
+
193
+ computePlacement(rect, pr) {
194
+ const gap = this.gapValue;
195
+ const margin = this.marginValue;
196
+ const placements = ["bottom", "top", "right", "left"];
197
+
198
+ for (const p of placements) {
199
+ const { x, y } = this.coordsFor(p, rect, pr, gap);
200
+ if (this.fits(p, x, y, pr, margin)) {
201
+ return {
202
+ x: this.clampX(x, pr, margin),
203
+ y: this.clampY(y, pr, margin),
204
+ placement: p,
205
+ };
206
+ }
207
+ }
208
+
209
+ // Fallback: bottom centered, clamped
210
+ const fx = rect.left + (rect.width - pr.width) / 2;
211
+ const fy = rect.bottom + gap;
212
+ return {
213
+ x: this.clampX(fx, pr, margin),
214
+ y: this.clampY(fy, pr, margin),
215
+ placement: "bottom",
216
+ };
217
+ }
218
+
219
+ coordsFor(p, rect, pr, gap) {
220
+ switch (p) {
221
+ case "bottom":
222
+ return {
223
+ x: rect.left + (rect.width - pr.width) / 2,
224
+ y: rect.bottom + gap,
225
+ };
226
+ case "top":
227
+ return {
228
+ x: rect.left + (rect.width - pr.width) / 2,
229
+ y: rect.top - pr.height - gap,
230
+ };
231
+ case "right":
232
+ return {
233
+ x: rect.right + gap,
234
+ y: rect.top + (rect.height - pr.height) / 2,
235
+ };
236
+ case "left":
237
+ return {
238
+ x: rect.left - pr.width - gap,
239
+ y: rect.top + (rect.height - pr.height) / 2,
240
+ };
241
+ default:
242
+ return { x: 0, y: 0 };
243
+ }
244
+ }
245
+
246
+ fits(p, x, y, pr, margin) {
247
+ switch (p) {
248
+ case "bottom":
249
+ return y + pr.height + margin <= window.innerHeight;
250
+ case "top":
251
+ return y >= margin;
252
+ case "right":
253
+ return x + pr.width + margin <= window.innerWidth;
254
+ case "left":
255
+ return x >= margin;
256
+ default:
257
+ return false;
258
+ }
259
+ }
260
+
261
+ clampX(x, pr, margin) {
262
+ return Math.max(margin, Math.min(x, window.innerWidth - pr.width - margin));
263
+ }
264
+
265
+ clampY(y, pr, margin) {
266
+ return Math.max(
267
+ margin,
268
+ Math.min(y, window.innerHeight - pr.height - margin),
269
+ );
270
+ }
271
+
272
+ setPlacementClass(pop, placement) {
273
+ pop.classList.remove(
274
+ "mv-confirm--top",
275
+ "mv-confirm--right",
276
+ "mv-confirm--bottom",
277
+ "mv-confirm--left",
278
+ );
279
+ pop.classList.add(`mv-confirm--${placement}`);
280
+ }
281
+ }
@@ -0,0 +1,15 @@
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
+ import { Controller } from "@hotwired/stimulus";
8
+
9
+ export default class extends Controller {
10
+ submit() {
11
+ this.element.disabled = true;
12
+ this.element.classList.add("opacity-60");
13
+ if (this.element.form) this.element.form.submit();
14
+ }
15
+ }