fino-rails 1.9.0 → 1.9.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 684fac1c4b164feabfda508efa2a495757b84cae91dc8e657487d60fe100c3b8
4
- data.tar.gz: d9a369a7495af11836af27490896199c26a2c679524be7ef700e285f0108d0bb
3
+ metadata.gz: 1d12aba7122ded5a852126002776b70c12cda8f47f659f22a0bf16b70fce9550
4
+ data.tar.gz: ba023c74ac778f9a2ea4184eb1026f64721327de11f79aa52dc26a992a822850
5
5
  SHA512:
6
- metadata.gz: 4dc6d071c9b47fcd03b44586b836b4ded4bed8f4943369bd773fc46f34d473d8af813c8192fa8e4c823b847eb63f2d8388d5f0c79ab8c9586eba584c8dfb7e19
7
- data.tar.gz: 4c50e77568d13ae6eef8081248414cf94364eb2fc1b6f2e643052e11919121f96a6d6c1035883d8f2aee5faeb14fd51d330429fc4afbdc2985fe520a386ab5e8
6
+ metadata.gz: 2334e3fe56c74a6f783ee0ea468957cec699a9878c35db878a1011cb341e09a92bf7ac3296f064ba4833b4d2ad2e1a0bdebac15d31143059a8e61147057404d6
7
+ data.tar.gz: 13cf1854ed3af35dec619991fa1c09fda96e2e3d89c71e27557d060e793c824283ffecaf8185b75bd41810faf23d36c1aab7d3a22d1b1aac9060eaa65732f78e
@@ -48,8 +48,10 @@ class Fino::Rails::SettingsController < Fino::Rails::ApplicationController
48
48
  end
49
49
 
50
50
  def variants
51
- params[:variants].values.each_with_object({}).with_index do |(raw_variant, memo), index|
52
- next if index.zero?
51
+ return {} unless params[:variants]
52
+
53
+ params[:variants].to_unsafe_h.each_with_object({}) do |(key, raw_variant), memo|
54
+ next if key.to_s == "0"
53
55
  next unless raw_variant[:percentage].to_f > 0.0
54
56
 
55
57
  memo[raw_variant[:percentage].to_f] = raw_variant[:value]
@@ -0,0 +1,330 @@
1
+ <div class="px-4 sm:px-0">
2
+ <h3 class="text-base/7 font-semibold text-gray-900">A/B Testing</h3>
3
+ <p class="mt-1 text-sm/6 text-gray-600">Set up variants for A/B testing with percentage-based traffic distribution</p>
4
+ </div>
5
+
6
+ <div class="bg-white shadow-xs outline outline-gray-900/5 sm:rounded-xl md:col-span-2">
7
+ <div class="px-4 py-6 sm:p-6">
8
+ <% if setting.experiment == nil %>
9
+ <div class="text-center py-6" id="ab-setup">
10
+ <p class="text-sm text-gray-500 mb-4">No A/B experiment configured</p>
11
+ <%= button_tag "Set Up", type: "button", id: "setup-ab-testing", class: "rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-indigo-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" %>
12
+ </div>
13
+ <% else %>
14
+ <div id="ab-testing-content">
15
+ <div id="variant-tabs">
16
+ <div class="grid grid-cols-1 sm:hidden">
17
+ <select id="variant-select" aria-label="Select a variant" class="col-start-1 row-start-1 w-full appearance-none rounded-md bg-white py-2 pr-8 pl-3 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600">
18
+ <option value="control">Control</option>
19
+ <% setting.experiment.variants.each_with_index do |variant, index| %>
20
+ <% if index == 0 %>
21
+ <!-- Control is handled separately -->
22
+ <% else %>
23
+ <option value="variant-<%= index - 1 %>">Variant <%= index %></option>
24
+ <% end %>
25
+ <% end %>
26
+ </select>
27
+ <svg viewBox="0 0 16 16" fill="currentColor" data-slot="icon" aria-hidden="true" class="pointer-events-none col-start-1 row-start-1 mr-2 size-5 self-center justify-self-end fill-gray-500">
28
+ <path d="M4.22 6.22a.75.75 0 0 1 1.06 0L8 8.94l2.72-2.72a.75.75 0 1 1 1.06 1.06l-3.25 3.25a.75.75 0 0 1-1.06 0L4.22 7.28a.75.75 0 0 1 0-1.06Z" clip-rule="evenodd" fill-rule="evenodd" />
29
+ </svg>
30
+ </div>
31
+ <div class="hidden sm:block">
32
+ <div class="border-b border-gray-200">
33
+ <nav aria-label="Tabs" class="-mb-px flex space-x-8">
34
+ <a href="#" data-tab="control" class="variant-tab border-b-2 border-indigo-500 px-1 py-4 text-sm font-medium whitespace-nowrap text-indigo-600" aria-current="page">Control</a>
35
+ <% setting.experiment.variants.each_with_index do |variant, index| %>
36
+ <% if index == 0 %>
37
+ <!-- Control is handled separately -->
38
+ <% else %>
39
+ <a href="#" data-tab="variant-<%= index - 1 %>" class="variant-tab border-b-2 border-transparent px-1 py-4 text-sm font-medium whitespace-nowrap text-gray-500 hover:border-gray-300 hover:text-gray-700">Variant <%= index %></a>
40
+ <% end %>
41
+ <% end %>
42
+ <button type="button" id="add-variant" class="border-b-2 border-transparent px-1 py-4 text-sm font-medium whitespace-nowrap text-gray-500 hover:border-gray-300 hover:text-gray-700">+ Add Variant</button>
43
+ </nav>
44
+ </div>
45
+ </div>
46
+ </div>
47
+
48
+ <div id="variant-content" class="mt-6">
49
+ <% setting.experiment.variants.each_with_index do |variant, index| %>
50
+ <div id="<%= index == 0 ? 'control' : "variant-#{index - 1}" %>-content" class="variant-content <%= 'hidden' unless index == 0 %>">
51
+ <div class="flex flex-col gap-3">
52
+ <p class="mt-1 text-xs text-gray-500">Control variant falls back to the global setting value, percentage is calculated automatically</p>
53
+ <div class="grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-6">
54
+ <div class="sm:col-span-2">
55
+ <label for="variants_<%= index %>_percentage" class="block text-sm/6 font-medium text-gray-900">Percentage</label>
56
+ <div class="mt-2">
57
+ <input type="number" name="variants[<%= index %>][percentage]" id="variants_<%= index %>_percentage" class="<%= index == 0 ? '' : 'variant-percentage' %> block w-full rounded-md px-3 py-1.5 text-base outline-1 -outline-offset-1 outline-gray-300 sm:text-sm/6 <%= index == 0 ? 'bg-gray-100 text-gray-500' : 'bg-white text-gray-900 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600' %>" value="<%= variant.percentage %>" min="0" max="100" <%= 'readonly' if index == 0 %> />
58
+ </div>
59
+ </div>
60
+ <div class="sm:col-span-3">
61
+ <label for="variants_<%= index %>_value" class="block text-sm/6 font-medium text-gray-900">Value</label>
62
+ <div class="mt-2">
63
+ <% if index == 0 %>
64
+ <div class="block w-full rounded-md bg-gray-100 px-3 py-1.5 text-base text-gray-500 outline-1 -outline-offset-1 outline-gray-300 sm:text-sm/6">
65
+ <%= setting.value.inspect %>
66
+ </div>
67
+ <input type="hidden" name="variants[<%= index %>][value]" value="<%= variant.value %>" />
68
+ <% else %>
69
+ <%= render "fino/rails/settings/types/#{setting.type}",
70
+ form: form,
71
+ setting: setting,
72
+ field_name: "variants_#{index}_value",
73
+ field_id: "variants_#{index}_value",
74
+ field_name_attr: "variants[#{index}][value]",
75
+ field_value: variant.value,
76
+ wrapper_class: "",
77
+ input_class: "block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6" %>
78
+ <% end %>
79
+ </div>
80
+ </div>
81
+ <div class="sm:col-span-1 flex items-end">
82
+ <% if index != 0 %>
83
+ <%= button_tag "Remove", type: "button", class: "remove-variant rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-red-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600", data: { variant_index: index - 1 } %>
84
+ <% end %>
85
+ </div>
86
+ </div>
87
+ </div>
88
+ </div>
89
+ <% end %>
90
+ </div>
91
+ </div>
92
+ <% end %>
93
+
94
+ <div id="ab-testing-content" class="hidden">
95
+ <div id="variant-tabs">
96
+ <div class="grid grid-cols-1 sm:hidden">
97
+ <select id="variant-select" aria-label="Select a variant" class="col-start-1 row-start-1 w-full appearance-none rounded-md bg-white py-2 pr-8 pl-3 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600">
98
+ <option value="control">Control</option>
99
+ </select>
100
+ <svg viewBox="0 0 16 16" fill="currentColor" data-slot="icon" aria-hidden="true" class="pointer-events-none col-start-1 row-start-1 mr-2 size-5 self-center justify-self-end fill-gray-500">
101
+ <path d="M4.22 6.22a.75.75 0 0 1 1.06 0L8 8.94l2.72-2.72a.75.75 0 1 1 1.06 1.06l-3.25 3.25a.75.75 0 0 1-1.06 0L4.22 7.28a.75.75 0 0 1 0-1.06Z" clip-rule="evenodd" fill-rule="evenodd" />
102
+ </svg>
103
+ </div>
104
+ <div class="hidden sm:block">
105
+ <div class="border-b border-gray-200">
106
+ <nav aria-label="Tabs" class="-mb-px flex space-x-8">
107
+ <a href="#" data-tab="control" class="variant-tab border-b-2 border-indigo-500 px-1 py-4 text-sm font-medium whitespace-nowrap text-indigo-600" aria-current="page">Control</a>
108
+ <button type="button" id="add-variant" class="border-b-2 border-transparent px-1 py-4 text-sm font-medium whitespace-nowrap text-gray-500 hover:border-gray-300 hover:text-gray-700">+ Add Variant</button>
109
+ </nav>
110
+ </div>
111
+ </div>
112
+ </div>
113
+
114
+ <div id="variant-content" class="mt-6">
115
+ <div id="control-content" class="variant-content">
116
+ <div class="grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-6">
117
+ <div class="sm:col-span-2">
118
+ <label class="block text-sm/6 font-medium text-gray-900">Percentage</label>
119
+ <div class="mt-2">
120
+ <input type="number" id="control-percentage" class="block w-full rounded-md bg-gray-100 px-3 py-1.5 text-base text-gray-500 outline-1 -outline-offset-1 outline-gray-300 sm:text-sm/6" value="100" readonly />
121
+ </div>
122
+ </div>
123
+ <div class="sm:col-span-4">
124
+ <label class="block text-sm/6 font-medium text-gray-900">Value</label>
125
+ <div class="mt-2">
126
+ <div class="block w-full rounded-md bg-gray-100 px-3 py-1.5 text-base text-gray-500 outline-1 -outline-offset-1 outline-gray-300 sm:text-sm/6">
127
+ <%= setting.value.inspect %>
128
+ </div>
129
+ </div>
130
+ </div>
131
+ </div>
132
+ </div>
133
+ </div>
134
+ </div>
135
+ </div>
136
+ </div>
137
+
138
+ <div id="variant-templates" style="display: none;">
139
+ <%= form.fields_for :template do |template_form| %>
140
+ <%= render "fino/rails/settings/types/#{setting.type}",
141
+ form: template_form,
142
+ setting: setting,
143
+ field_name: "variants_TEMPLATE_INDEX_value",
144
+ field_id: "variants_TEMPLATE_INDEX_value",
145
+ field_name_attr: "variants[TEMPLATE_INDEX][value]",
146
+ field_value: nil,
147
+ wrapper_class: "",
148
+ input_class: "block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6" %>
149
+ <% end %>
150
+ </div>
151
+
152
+ <script>
153
+ document.addEventListener('DOMContentLoaded', function() {
154
+ const setupAbButton = document.getElementById('setup-ab-testing');
155
+ const abSetup = document.getElementById('ab-setup');
156
+ const abTestingContent = document.getElementById('ab-testing-content');
157
+ const addVariantButton = document.getElementById('add-variant');
158
+ const variantTemplates = document.getElementById('variant-templates');
159
+ let variantIndex = <%= setting.experiment ? setting.experiment.variants.length - 1 : 0 %>;
160
+
161
+ if (setupAbButton) {
162
+ setupAbButton.addEventListener('click', function() {
163
+ abSetup.style.display = 'none';
164
+ abTestingContent.style.display = 'block';
165
+ abTestingContent.classList.remove('hidden');
166
+ });
167
+ }
168
+
169
+ function switchToTab(tabName) {
170
+ document.querySelectorAll('.variant-tab').forEach(tab => {
171
+ tab.classList.remove('border-indigo-500', 'text-indigo-600');
172
+ tab.classList.add('border-transparent', 'text-gray-500');
173
+ tab.removeAttribute('aria-current');
174
+ });
175
+
176
+ const activeTab = document.querySelector(`[data-tab="${tabName}"]`);
177
+ if (activeTab) {
178
+ activeTab.classList.remove('border-transparent', 'text-gray-500');
179
+ activeTab.classList.add('border-indigo-500', 'text-indigo-600');
180
+ activeTab.setAttribute('aria-current', 'page');
181
+ }
182
+
183
+ document.querySelectorAll('.variant-content').forEach(content => {
184
+ content.classList.add('hidden');
185
+ });
186
+
187
+ const activeContent = document.getElementById(`${tabName}-content`);
188
+ if (activeContent) {
189
+ activeContent.classList.remove('hidden');
190
+ }
191
+
192
+ const variantSelect = document.getElementById('variant-select');
193
+ if (variantSelect) {
194
+ variantSelect.value = tabName;
195
+ }
196
+ }
197
+
198
+ document.addEventListener('click', function(e) {
199
+ if (e.target.classList.contains('variant-tab')) {
200
+ e.preventDefault();
201
+ const tabName = e.target.getAttribute('data-tab');
202
+ switchToTab(tabName);
203
+ }
204
+ });
205
+
206
+ const variantSelect = document.getElementById('variant-select');
207
+ if (variantSelect) {
208
+ variantSelect.addEventListener('change', function() {
209
+ switchToTab(this.value);
210
+ });
211
+ }
212
+
213
+ if (addVariantButton) {
214
+ addVariantButton.addEventListener('click', function() {
215
+ const actualArrayIndex = variantIndex + 1;
216
+
217
+ let valueInputTemplate = variantTemplates.innerHTML
218
+ .replace(/TEMPLATE_INDEX/g, actualArrayIndex)
219
+ .replace(/variants_\w+_value/g, `variants_${actualArrayIndex}_value`)
220
+ .trim();
221
+
222
+ const tabsNav = document.querySelector('nav[aria-label="Tabs"]');
223
+ const newTab = document.createElement('a');
224
+ newTab.href = '#';
225
+ newTab.setAttribute('data-tab', `variant-${variantIndex}`);
226
+ newTab.className = 'variant-tab border-b-2 border-transparent px-1 py-4 text-sm font-medium whitespace-nowrap text-gray-500 hover:border-gray-300 hover:text-gray-700';
227
+ newTab.textContent = `Variant ${actualArrayIndex}`;
228
+
229
+ tabsNav.insertBefore(newTab, addVariantButton);
230
+
231
+ const option = document.createElement('option');
232
+ option.value = `variant-${variantIndex}`;
233
+ option.textContent = `Variant ${actualArrayIndex}`;
234
+ variantSelect.appendChild(option);
235
+
236
+ const variantContent = document.getElementById('variant-content');
237
+ const newContent = document.createElement('div');
238
+ newContent.id = `variant-${variantIndex}-content`;
239
+ newContent.className = 'variant-content hidden';
240
+ newContent.innerHTML = `
241
+ <div class="grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-6">
242
+ <div class="sm:col-span-2">
243
+ <label for="variants_${actualArrayIndex}_percentage" class="block text-sm/6 font-medium text-gray-900">Percentage</label>
244
+ <div class="mt-2">
245
+ <input type="number" name="variants[${actualArrayIndex}][percentage]" id="variants_${actualArrayIndex}_percentage" class="variant-percentage block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6" value="20" min="0" max="100" />
246
+ </div>
247
+ </div>
248
+ <div class="sm:col-span-3">
249
+ <label for="variants_${actualArrayIndex}_value" class="block text-sm/6 font-medium text-gray-900">Value</label>
250
+ <div class="mt-2">
251
+ ${valueInputTemplate}
252
+ </div>
253
+ </div>
254
+ <div class="sm:col-span-1 flex items-end">
255
+ <button type="button" class="remove-variant rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-red-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600" data-variant-index="${variantIndex}">Remove</button>
256
+ </div>
257
+ </div>
258
+ `;
259
+
260
+ variantContent.appendChild(newContent);
261
+
262
+ switchToTab(`variant-${variantIndex}`);
263
+
264
+ recalculatePercentages();
265
+
266
+ variantIndex++;
267
+ });
268
+ }
269
+
270
+ document.addEventListener('click', function(e) {
271
+ if (e.target.classList.contains('remove-variant')) {
272
+ const variantIndex = e.target.getAttribute('data-variant-index');
273
+ const tabName = `variant-${variantIndex}`;
274
+
275
+ const tab = document.querySelector(`[data-tab="${tabName}"]`);
276
+ if (tab) {
277
+ tab.remove();
278
+ }
279
+
280
+ const option = document.querySelector(`option[value="${tabName}"]`);
281
+ if (option) {
282
+ option.remove();
283
+ }
284
+
285
+ const content = document.getElementById(`${tabName}-content`);
286
+ if (content) {
287
+ content.remove();
288
+ }
289
+
290
+ switchToTab('control');
291
+
292
+ recalculatePercentages();
293
+ }
294
+ });
295
+
296
+ function recalculatePercentages() {
297
+ const percentageInputs = document.querySelectorAll('.variant-percentage');
298
+
299
+ const controlPercentage = document.getElementById('variants_0_percentage')
300
+ || document.getElementById('control-percentage');
301
+
302
+ if (!controlPercentage) return;
303
+
304
+ let totalVariantPercentage = 0;
305
+ percentageInputs.forEach(input => {
306
+ const value = parseInt(input.value) || 0;
307
+ totalVariantPercentage += value;
308
+ });
309
+
310
+ const controlValue = Math.max(0, 100 - totalVariantPercentage);
311
+ controlPercentage.value = controlValue;
312
+
313
+ if (totalVariantPercentage > 100) {
314
+ controlPercentage.classList.add('text-red-600');
315
+ controlPercentage.classList.remove('text-gray-500');
316
+ } else {
317
+ controlPercentage.classList.remove('text-red-600');
318
+ controlPercentage.classList.add('text-gray-500');
319
+ }
320
+ }
321
+
322
+ document.addEventListener('input', function(e) {
323
+ if (e.target.classList.contains('variant-percentage')) {
324
+ recalculatePercentages();
325
+ }
326
+ });
327
+
328
+ recalculatePercentages();
329
+ });
330
+ </script>
@@ -0,0 +1,130 @@
1
+ <div class="px-4 sm:px-0">
2
+ <h3 class="text-base/7 font-semibold text-gray-900">Overrides</h3>
3
+ <p class="mt-1 text-sm/6 text-gray-600">Override the setting value for specific scopes</p>
4
+ </div>
5
+
6
+ <div class="bg-white shadow-xs outline outline-gray-900/5 sm:rounded-xl md:col-span-2">
7
+ <div class="px-4 py-6 sm:p-6">
8
+ <div id="scope-overrides">
9
+ <%= form.fields_for :overrides do |override_form| %>
10
+ <% setting.overrides.each_with_index do |(scope, value), index| %>
11
+ <div class="scope-override-item mb-6 last:mb-0" data-index="<%= index %>">
12
+ <div class="grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-6">
13
+ <div class="sm:col-span-2">
14
+ <%= override_form.label "scope_#{index}", "Scope", class: "block text-sm/6 font-medium text-gray-900" %>
15
+ <div class="mt-2">
16
+ <%= override_form.text_field "scope_#{index}",
17
+ value: scope,
18
+ name: "overrides[#{index}][scope]",
19
+ class: "block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6",
20
+ placeholder: "e.g., qa, admin" %>
21
+ </div>
22
+ </div>
23
+
24
+ <div class="sm:col-span-3">
25
+ <%= override_form.label "value_#{index}", "Value", class: "block text-sm/6 font-medium text-gray-900" %>
26
+ <div class="mt-2">
27
+ <%= render "fino/rails/settings/types/#{setting.type}",
28
+ form: override_form,
29
+ setting: setting,
30
+ field_name: "value_#{index}",
31
+ field_id: "overrides_#{index}_value",
32
+ field_name_attr: "overrides[#{index}][value]",
33
+ field_value: value,
34
+ wrapper_class: "",
35
+ input_class: "block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6" %>
36
+ </div>
37
+ </div>
38
+
39
+ <div class="sm:col-span-1 flex items-end">
40
+ <%= button_tag "Remove", type: "button", class: "remove-override rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-red-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600" %>
41
+ </div>
42
+ </div>
43
+ </div>
44
+ <% end %>
45
+ <% end %>
46
+
47
+ <% if setting.overrides.empty? %>
48
+ <div class="text-center py-6">
49
+ <p class="text-sm text-gray-500">No scope overrides configured</p>
50
+ </div>
51
+ <% end %>
52
+ </div>
53
+
54
+ <div class="mt-6">
55
+ <%= button_tag "Add Override", type: "button", id: "add-override", class: "rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-indigo-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" %>
56
+ </div>
57
+ </div>
58
+ </div>
59
+
60
+ <div id="override-templates" style="display: none;">
61
+ <%= form.fields_for :template do |template_form| %>
62
+ <%= render "fino/rails/settings/types/#{setting.type}",
63
+ form: template_form,
64
+ setting: setting,
65
+ field_name: "value_TEMPLATE_INDEX",
66
+ field_id: "overrides_TEMPLATE_INDEX_value",
67
+ field_name_attr: "overrides[TEMPLATE_INDEX][value]",
68
+ field_value: nil,
69
+ wrapper_class: "",
70
+ input_class: "block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6" %>
71
+ <% end %>
72
+ </div>
73
+
74
+ <script>
75
+ document.addEventListener('DOMContentLoaded', function() {
76
+ const addButton = document.getElementById('add-override');
77
+ const overridesContainer = document.getElementById('scope-overrides');
78
+ const templateContainer = document.getElementById('override-templates');
79
+ let overrideIndex = <%= setting.overrides.length %>;
80
+
81
+ addButton.addEventListener('click', function() {
82
+ let valueInputTemplate = templateContainer.innerHTML
83
+ .replace(/TEMPLATE_INDEX/g, overrideIndex)
84
+ .replace(/value_\d+/g, `value_${overrideIndex}`)
85
+ .trim();
86
+
87
+ const template = `
88
+ <div class="scope-override-item mb-6" data-index="${overrideIndex}">
89
+ <div class="grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-6">
90
+ <div class="sm:col-span-2">
91
+ <label for="overrides_${overrideIndex}_scope" class="block text-sm/6 font-medium text-gray-900">Scope</label>
92
+ <div class="mt-2">
93
+ <input type="text" name="overrides[${overrideIndex}][scope]" id="overrides_${overrideIndex}_scope" class="block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6" placeholder="e.g., qa, admin" />
94
+ </div>
95
+ </div>
96
+
97
+ <div class="sm:col-span-3">
98
+ <label for="overrides_${overrideIndex}_value" class="block text-sm/6 font-medium text-gray-900">Value</label>
99
+ <div class="mt-2">
100
+ ${valueInputTemplate}
101
+ </div>
102
+ </div>
103
+
104
+ <div class="sm:col-span-1 flex items-end">
105
+ <button type="button" class="remove-override rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-red-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600">Remove</button>
106
+ </div>
107
+ </div>
108
+ </div>
109
+ `;
110
+
111
+ const noOverridesMsg = overridesContainer.querySelector('.text-center');
112
+ if (noOverridesMsg) {
113
+ noOverridesMsg.remove();
114
+ }
115
+
116
+ overridesContainer.insertAdjacentHTML('beforeend', template);
117
+ overrideIndex++;
118
+ });
119
+
120
+ overridesContainer.addEventListener('click', function(e) {
121
+ if (e.target.classList.contains('remove-override')) {
122
+ e.target.closest('.scope-override-item').remove();
123
+
124
+ if (!overridesContainer.querySelector('.scope-override-item')) {
125
+ overridesContainer.innerHTML = '<div class="text-center py-6"><p class="text-sm text-gray-500">No scope overrides configured.</p></div>';
126
+ }
127
+ }
128
+ });
129
+ });
130
+ </script>
@@ -54,495 +54,11 @@
54
54
  </div>
55
55
  </div>
56
56
 
57
- <div class="px-4 sm:px-0">
58
- <h3 class="text-base/7 font-semibold text-gray-900">Overrides</h3>
59
- <p class="mt-1 text-sm/6 text-gray-600">Override the setting value for specific scopes</p>
60
- </div>
61
-
62
- <div class="bg-white shadow-xs outline outline-gray-900/5 sm:rounded-xl md:col-span-2">
63
- <div class="px-4 py-6 sm:p-6">
64
- <div id="scope-overrides">
65
- <%= f.fields_for :overrides do |override_form| %>
66
- <% @setting.overrides.each_with_index do |(scope, value), index| %>
67
- <div class="scope-override-item mb-6 last:mb-0" data-index="<%= index %>">
68
- <div class="grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-6">
69
- <div class="sm:col-span-2">
70
- <%= override_form.label "scope_#{index}", "Scope", class: "block text-sm/6 font-medium text-gray-900" %>
71
- <div class="mt-2">
72
- <%= override_form.text_field "scope_#{index}",
73
- value: scope,
74
- name: "overrides[#{index}][scope]",
75
- class: "block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6",
76
- placeholder: "e.g., qa, admin" %>
77
- </div>
78
- </div>
79
-
80
- <div class="sm:col-span-3">
81
- <%= override_form.label "value_#{index}", "Value", class: "block text-sm/6 font-medium text-gray-900" %>
82
- <div class="mt-2">
83
- <%= render "fino/rails/settings/types/#{@setting.type}",
84
- form: override_form,
85
- setting: @setting,
86
- field_name: "value_#{index}",
87
- field_id: "overrides_#{index}_value",
88
- field_name_attr: "overrides[#{index}][value]",
89
- field_value: value,
90
- wrapper_class: "",
91
- input_class: "block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6" %>
92
- </div>
93
- </div>
94
-
95
- <div class="sm:col-span-1 flex items-end">
96
- <%= button_tag "Remove", type: "button", class: "remove-override rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-red-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600" %>
97
- </div>
98
- </div>
99
- </div>
100
- <% end %>
101
- <% end %>
102
-
103
- <% if @setting.overrides.empty? %>
104
- <div class="text-center py-6">
105
- <p class="text-sm text-gray-500">No scope overrides configured</p>
106
- </div>
107
- <% end %>
108
- </div>
109
-
110
- <div class="mt-6">
111
- <%= button_tag "Add Override", type: "button", id: "add-override", class: "rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-indigo-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" %>
112
- </div>
113
- </div>
114
- </div>
115
-
116
- <div id="override-templates" style="display: none;">
117
- <%= f.fields_for :template do |template_form| %>
118
- <%= render "fino/rails/settings/types/#{@setting.type}",
119
- form: template_form,
120
- setting: @setting,
121
- field_name: "value_TEMPLATE_INDEX",
122
- field_id: "overrides_TEMPLATE_INDEX_value",
123
- field_name_attr: "overrides[TEMPLATE_INDEX][value]",
124
- field_value: nil,
125
- wrapper_class: "",
126
- input_class: "block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6" %>
127
- <% end %>
128
- </div>
129
-
130
- <div class="px-4 sm:px-0">
131
- <h3 class="text-base/7 font-semibold text-gray-900">A/B Testing</h3>
132
- <p class="mt-1 text-sm/6 text-gray-600">Set up variants for A/B testing with percentage-based traffic distribution</p>
133
- </div>
134
-
135
- <div class="bg-white shadow-xs outline outline-gray-900/5 sm:rounded-xl md:col-span-2">
136
- <div class="px-4 py-6 sm:p-6">
137
- <% if @setting.experiment == nil %>
138
- <div class="text-center py-6" id="ab-setup">
139
- <p class="text-sm text-gray-500 mb-4">No A/B experiment configured</p>
140
- <%= button_tag "Set Up", type: "button", id: "setup-ab-testing", class: "rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-indigo-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" %>
141
- </div>
142
- <% else %>
143
- <div id="ab-testing-content">
144
- <div id="variant-tabs">
145
- <div class="grid grid-cols-1 sm:hidden">
146
- <select id="variant-select" aria-label="Select a variant" class="col-start-1 row-start-1 w-full appearance-none rounded-md bg-white py-2 pr-8 pl-3 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600">
147
- <option value="control">Control</option>
148
- <% @setting.experiment.variants.each_with_index do |variant, index| %>
149
- <% if index == 0 %>
150
- <!-- Control is handled separately -->
151
- <% else %>
152
- <option value="variant-<%= index - 1 %>">Variant <%= index %></option>
153
- <% end %>
154
- <% end %>
155
- </select>
156
- <svg viewBox="0 0 16 16" fill="currentColor" data-slot="icon" aria-hidden="true" class="pointer-events-none col-start-1 row-start-1 mr-2 size-5 self-center justify-self-end fill-gray-500">
157
- <path d="M4.22 6.22a.75.75 0 0 1 1.06 0L8 8.94l2.72-2.72a.75.75 0 1 1 1.06 1.06l-3.25 3.25a.75.75 0 0 1-1.06 0L4.22 7.28a.75.75 0 0 1 0-1.06Z" clip-rule="evenodd" fill-rule="evenodd" />
158
- </svg>
159
- </div>
160
- <div class="hidden sm:block">
161
- <div class="border-b border-gray-200">
162
- <nav aria-label="Tabs" class="-mb-px flex space-x-8">
163
- <a href="#" data-tab="control" class="variant-tab border-b-2 border-indigo-500 px-1 py-4 text-sm font-medium whitespace-nowrap text-indigo-600" aria-current="page">Control</a>
164
- <% @setting.experiment.variants.each_with_index do |variant, index| %>
165
- <% if index == 0 %>
166
- <!-- Control is handled separately -->
167
- <% else %>
168
- <a href="#" data-tab="variant-<%= index - 1 %>" class="variant-tab border-b-2 border-transparent px-1 py-4 text-sm font-medium whitespace-nowrap text-gray-500 hover:border-gray-300 hover:text-gray-700">Variant <%= index %></a>
169
- <% end %>
170
- <% end %>
171
- <button type="button" id="add-variant" class="border-b-2 border-transparent px-1 py-4 text-sm font-medium whitespace-nowrap text-gray-500 hover:border-gray-300 hover:text-gray-700">+ Add Variant</button>
172
- </nav>
173
- </div>
174
- </div>
175
- </div>
57
+ <%= render "fino/rails/settings/overrides", setting: @setting, form: f %>
176
58
 
177
- <div id="variant-content" class="mt-6">
178
- <% @setting.experiment.variants.each_with_index do |variant, index| %>
179
- <div id="<%= index == 0 ? 'control' : "variant-#{index - 1}" %>-content" class="variant-content <%= 'hidden' unless index == 0 %>">
180
- <div class="flex flex-col gap-3">
181
- <p class="mt-1 text-xs text-gray-500">Control variant falls back to the global setting value, percentage is calculated automatically</p>
182
- <div class="grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-6">
183
- <div class="sm:col-span-2">
184
- <label for="variants_<%= index %>_percentage" class="block text-sm/6 font-medium text-gray-900">Percentage</label>
185
- <div class="mt-2">
186
- <input type="number" name="variants[<%= index %>][percentage]" id="variants_<%= index %>_percentage" class="<%= index == 0 ? '' : 'variant-percentage' %> block w-full rounded-md px-3 py-1.5 text-base outline-1 -outline-offset-1 outline-gray-300 sm:text-sm/6 <%= index == 0 ? 'bg-gray-100 text-gray-500' : 'bg-white text-gray-900 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600' %>" value="<%= variant.percentage %>" min="0" max="100" <%= 'readonly' if index == 0 %> />
187
- </div>
188
- </div>
189
- <div class="sm:col-span-3">
190
- <label for="variants_<%= index %>_value" class="block text-sm/6 font-medium text-gray-900">Value</label>
191
- <div class="mt-2">
192
- <% if index == 0 %>
193
- <div class="block w-full rounded-md bg-gray-100 px-3 py-1.5 text-base text-gray-500 outline-1 -outline-offset-1 outline-gray-300 sm:text-sm/6">
194
- <%= @setting.value.inspect %>
195
- </div>
196
- <input type="hidden" name="variants[<%= index %>][value]" value="<%= variant.value %>" />
197
- <% else %>
198
- <%= render "fino/rails/settings/types/#{@setting.type}",
199
- form: f,
200
- setting: @setting,
201
- field_name: "variants_#{index}_value",
202
- field_id: "variants_#{index}_value",
203
- field_name_attr: "variants[#{index}][value]",
204
- field_value: variant.value,
205
- wrapper_class: "",
206
- input_class: "block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6" %>
207
- <% end %>
208
- </div>
209
- </div>
210
- <div class="sm:col-span-1 flex items-end">
211
- <% if index != 0 %>
212
- <%= button_tag "Remove", type: "button", class: "remove-variant rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-red-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600", data: { variant_index: index } %>
213
- <% end %>
214
- </div>
215
- </div>
216
- </div>
217
- </div>
218
- <% end %>
219
- </div>
220
- </div>
221
- <% end %>
222
-
223
- <div id="ab-testing-content" class="hidden">
224
- <div id="variant-tabs">
225
- <div class="grid grid-cols-1 sm:hidden">
226
- <select id="variant-select" aria-label="Select a variant" class="col-start-1 row-start-1 w-full appearance-none rounded-md bg-white py-2 pr-8 pl-3 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600">
227
- <option value="control">Control</option>
228
- </select>
229
- <svg viewBox="0 0 16 16" fill="currentColor" data-slot="icon" aria-hidden="true" class="pointer-events-none col-start-1 row-start-1 mr-2 size-5 self-center justify-self-end fill-gray-500">
230
- <path d="M4.22 6.22a.75.75 0 0 1 1.06 0L8 8.94l2.72-2.72a.75.75 0 1 1 1.06 1.06l-3.25 3.25a.75.75 0 0 1-1.06 0L4.22 7.28a.75.75 0 0 1 0-1.06Z" clip-rule="evenodd" fill-rule="evenodd" />
231
- </svg>
232
- </div>
233
- <div class="hidden sm:block">
234
- <div class="border-b border-gray-200">
235
- <nav aria-label="Tabs" class="-mb-px flex space-x-8">
236
- <a href="#" data-tab="control" class="variant-tab border-b-2 border-indigo-500 px-1 py-4 text-sm font-medium whitespace-nowrap text-indigo-600" aria-current="page">Control</a>
237
- <button type="button" id="add-variant" class="border-b-2 border-transparent px-1 py-4 text-sm font-medium whitespace-nowrap text-gray-500 hover:border-gray-300 hover:text-gray-700">+ Add Variant</button>
238
- </nav>
239
- </div>
240
- </div>
241
- </div>
242
-
243
- <div id="variant-content" class="mt-6">
244
- <div id="control-content" class="variant-content">
245
- <div class="grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-6">
246
- <div class="sm:col-span-2">
247
- <label class="block text-sm/6 font-medium text-gray-900">Percentage</label>
248
- <div class="mt-2">
249
- <input type="number" id="control-percentage" class="block w-full rounded-md bg-gray-100 px-3 py-1.5 text-base text-gray-500 outline-1 -outline-offset-1 outline-gray-300 sm:text-sm/6" value="100" readonly />
250
- </div>
251
- </div>
252
- <div class="sm:col-span-4">
253
- <label class="block text-sm/6 font-medium text-gray-900">Value</label>
254
- <div class="mt-2">
255
- <div class="block w-full rounded-md bg-gray-100 px-3 py-1.5 text-base text-gray-500 outline-1 -outline-offset-1 outline-gray-300 sm:text-sm/6">
256
- <%= @setting.value.inspect %>
257
- </div>
258
- </div>
259
- </div>
260
- </div>
261
- </div>
262
- </div>
263
- </div>
264
- </div>
265
- </div>
266
-
267
- <div id="variant-templates" style="display: none;">
268
- <%= f.fields_for :template do |template_form| %>
269
- <%= render "fino/rails/settings/types/#{@setting.type}",
270
- form: template_form,
271
- setting: @setting,
272
- field_name: "variants_TEMPLATE_INDEX_value",
273
- field_id: "variants_TEMPLATE_INDEX_value",
274
- field_name_attr: "variants[TEMPLATE_INDEX][value]",
275
- field_value: nil,
276
- wrapper_class: "",
277
- input_class: "block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6" %>
278
- <% end %>
279
- </div>
59
+ <%= render "fino/rails/settings/ab_testing", setting: @setting, form: f %>
280
60
  </div>
281
61
  </div>
282
62
  <% end %>
283
63
  </div>
284
64
  </div>
285
-
286
- <script>
287
- document.addEventListener('DOMContentLoaded', function() {
288
- const addButton = document.getElementById('add-override');
289
- const overridesContainer = document.getElementById('scope-overrides');
290
- const templateContainer = document.getElementById('override-templates');
291
- let overrideIndex = <%= @setting.overrides.length %>;
292
-
293
- addButton.addEventListener('click', function() {
294
- // Get the template HTML and replace placeholders with actual index
295
- let valueInputTemplate = templateContainer.innerHTML
296
- .replace(/TEMPLATE_INDEX/g, overrideIndex)
297
- .replace(/value_\d+/g, `value_${overrideIndex}`)
298
- .trim();
299
-
300
- const template = `
301
- <div class="scope-override-item mb-6" data-index="${overrideIndex}">
302
- <div class="grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-6">
303
- <div class="sm:col-span-2">
304
- <label for="overrides_${overrideIndex}_scope" class="block text-sm/6 font-medium text-gray-900">Scope</label>
305
- <div class="mt-2">
306
- <input type="text" name="overrides[${overrideIndex}][scope]" id="overrides_${overrideIndex}_scope" class="block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6" placeholder="e.g., qa, admin" />
307
- </div>
308
- </div>
309
-
310
- <div class="sm:col-span-3">
311
- <label for="overrides_${overrideIndex}_value" class="block text-sm/6 font-medium text-gray-900">Value</label>
312
- <div class="mt-2">
313
- ${valueInputTemplate}
314
- </div>
315
- </div>
316
-
317
- <div class="sm:col-span-1 flex items-end">
318
- <button type="button" class="remove-override rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-red-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600">Remove</button>
319
- </div>
320
- </div>
321
- </div>
322
- `;
323
-
324
- // Remove "no overrides" message if it exists
325
- const noOverridesMsg = overridesContainer.querySelector('.text-center');
326
- if (noOverridesMsg) {
327
- noOverridesMsg.remove();
328
- }
329
-
330
- overridesContainer.insertAdjacentHTML('beforeend', template);
331
- overrideIndex++;
332
- });
333
-
334
- // Handle remove buttons
335
- overridesContainer.addEventListener('click', function(e) {
336
- if (e.target.classList.contains('remove-override')) {
337
- e.target.closest('.scope-override-item').remove();
338
-
339
- // Show "no overrides" message if all removed
340
- if (!overridesContainer.querySelector('.scope-override-item')) {
341
- overridesContainer.innerHTML = '<div class="text-center py-6"><p class="text-sm text-gray-500">No scope overrides configured.</p></div>';
342
- }
343
- }
344
- });
345
-
346
- // A/B Testing functionality
347
- const setupAbButton = document.getElementById('setup-ab-testing');
348
- const abSetup = document.getElementById('ab-setup');
349
- const abTestingContent = document.getElementById('ab-testing-content');
350
- const addVariantButton = document.getElementById('add-variant');
351
- const variantTemplates = document.getElementById('variant-templates');
352
- let variantIndex = <%= @setting.experiment&.variants&.length.to_i - 1 %>;
353
-
354
- // Set up A/B testing
355
- if (setupAbButton) {
356
- setupAbButton.addEventListener('click', function() {
357
- abSetup.style.display = 'none';
358
- abTestingContent.style.display = 'block';
359
- abTestingContent.classList.remove('hidden');
360
- });
361
- }
362
-
363
- // Tab switching functionality
364
- function switchToTab(tabName) {
365
- // Update tab appearance
366
- document.querySelectorAll('.variant-tab').forEach(tab => {
367
- tab.classList.remove('border-indigo-500', 'text-indigo-600');
368
- tab.classList.add('border-transparent', 'text-gray-500');
369
- tab.removeAttribute('aria-current');
370
- });
371
-
372
- const activeTab = document.querySelector(`[data-tab="${tabName}"]`);
373
- if (activeTab) {
374
- activeTab.classList.remove('border-transparent', 'text-gray-500');
375
- activeTab.classList.add('border-indigo-500', 'text-indigo-600');
376
- activeTab.setAttribute('aria-current', 'page');
377
- }
378
-
379
- // Show/hide content
380
- document.querySelectorAll('.variant-content').forEach(content => {
381
- content.classList.add('hidden');
382
- });
383
-
384
- const activeContent = document.getElementById(`${tabName}-content`);
385
- if (activeContent) {
386
- activeContent.classList.remove('hidden');
387
- }
388
-
389
- // Update mobile select
390
- const variantSelect = document.getElementById('variant-select');
391
- if (variantSelect) {
392
- variantSelect.value = tabName;
393
- }
394
- }
395
-
396
- // Tab click handlers
397
- document.addEventListener('click', function(e) {
398
- if (e.target.classList.contains('variant-tab')) {
399
- e.preventDefault();
400
- const tabName = e.target.getAttribute('data-tab');
401
- switchToTab(tabName);
402
- }
403
- });
404
-
405
- // Mobile select handler
406
- const variantSelect = document.getElementById('variant-select');
407
- if (variantSelect) {
408
- variantSelect.addEventListener('change', function() {
409
- switchToTab(this.value);
410
- });
411
- }
412
-
413
- // Add variant functionality
414
- if (addVariantButton) {
415
- addVariantButton.addEventListener('click', function() {
416
- // The actual array index will be variantIndex + 1 (since control is at index 0)
417
- const actualArrayIndex = variantIndex + 1;
418
-
419
- // Get template HTML
420
- let valueInputTemplate = variantTemplates.innerHTML
421
- .replace(/TEMPLATE_INDEX/g, actualArrayIndex)
422
- .replace(/variants_\w+_value/g, `variants_${actualArrayIndex}_value`)
423
- .trim();
424
-
425
- // Add new tab
426
- const tabsNav = document.querySelector('nav[aria-label="Tabs"]');
427
- const newTab = document.createElement('a');
428
- newTab.href = '#';
429
- newTab.setAttribute('data-tab', `variant-${variantIndex}`);
430
- newTab.className = 'variant-tab border-b-2 border-transparent px-1 py-4 text-sm font-medium whitespace-nowrap text-gray-500 hover:border-gray-300 hover:text-gray-700';
431
- newTab.textContent = `Variant ${actualArrayIndex}`;
432
-
433
- tabsNav.insertBefore(newTab, addVariantButton);
434
-
435
- // Add to mobile select
436
- const option = document.createElement('option');
437
- option.value = `variant-${variantIndex}`;
438
- option.textContent = `Variant ${actualArrayIndex}`;
439
- variantSelect.appendChild(option);
440
-
441
- // Add new content
442
- const variantContent = document.getElementById('variant-content');
443
- const newContent = document.createElement('div');
444
- newContent.id = `variant-${variantIndex}-content`;
445
- newContent.className = 'variant-content hidden';
446
- newContent.innerHTML = `
447
- <div class="grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-6">
448
- <div class="sm:col-span-2">
449
- <label for="variants_${actualArrayIndex}_percentage" class="block text-sm/6 font-medium text-gray-900">Percentage</label>
450
- <div class="mt-2">
451
- <input type="number" name="variants[${actualArrayIndex}][percentage]" id="variants_${actualArrayIndex}_percentage" class="variant-percentage block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6" value="20" min="0" max="100" />
452
- </div>
453
- </div>
454
- <div class="sm:col-span-3">
455
- <label for="variants_${actualArrayIndex}_value" class="block text-sm/6 font-medium text-gray-900">Value</label>
456
- <div class="mt-2">
457
- ${valueInputTemplate}
458
- </div>
459
- </div>
460
- <div class="sm:col-span-1 flex items-end">
461
- <button type="button" class="remove-variant rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-red-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600" data-variant-index="${actualArrayIndex}">Remove</button>
462
- </div>
463
- </div>
464
- `;
465
-
466
- variantContent.appendChild(newContent);
467
-
468
- // Switch to new tab
469
- switchToTab(`variant-${variantIndex}`);
470
-
471
- // Recalculate percentages
472
- recalculatePercentages();
473
-
474
- variantIndex++;
475
- });
476
- }
477
-
478
- // Remove variant functionality
479
- document.addEventListener('click', function(e) {
480
- if (e.target.classList.contains('remove-variant')) {
481
- const variantIndex = e.target.getAttribute('data-variant-index');
482
- const tabName = `variant-${variantIndex}`;
483
-
484
- // Remove tab
485
- const tab = document.querySelector(`[data-tab="${tabName}"]`);
486
- if (tab) {
487
- tab.remove();
488
- }
489
-
490
- // Remove from mobile select
491
- const option = document.querySelector(`option[value="${tabName}"]`);
492
- if (option) {
493
- option.remove();
494
- }
495
-
496
- // Remove content
497
- const content = document.getElementById(`${tabName}-content`);
498
- if (content) {
499
- content.remove();
500
- }
501
-
502
- // Switch to control tab
503
- switchToTab('control');
504
-
505
- // Recalculate percentages
506
- recalculatePercentages();
507
- }
508
- });
509
-
510
- // Percentage recalculation
511
- function recalculatePercentages() {
512
- const percentageInputs = document.querySelectorAll('.variant-percentage');
513
-
514
- // Find the control percentage input (first variant, index 0)
515
- const controlPercentage = document.getElementById('variants_0_percentage');
516
-
517
- if (!controlPercentage) return;
518
-
519
- let totalVariantPercentage = 0;
520
- percentageInputs.forEach(input => {
521
- const value = parseInt(input.value) || 0;
522
- totalVariantPercentage += value;
523
- });
524
-
525
- const controlValue = Math.max(0, 100 - totalVariantPercentage);
526
- controlPercentage.value = controlValue;
527
-
528
- // Update control percentage color based on validity
529
- if (totalVariantPercentage > 100) {
530
- controlPercentage.classList.add('text-red-600');
531
- controlPercentage.classList.remove('text-gray-500');
532
- } else {
533
- controlPercentage.classList.remove('text-red-600');
534
- controlPercentage.classList.add('text-gray-500');
535
- }
536
- }
537
-
538
- // Listen for percentage changes
539
- document.addEventListener('input', function(e) {
540
- if (e.target.classList.contains('variant-percentage')) {
541
- recalculatePercentages();
542
- }
543
- });
544
-
545
- // Initial percentage calculation
546
- recalculatePercentages();
547
- });
548
- </script>
data/lib/fino/version.rb CHANGED
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Fino
4
- VERSION = "1.9.0"
4
+ VERSION = "1.9.1"
5
5
  REQUIRED_RUBY_VERSION = ">= 3.2.0"
6
6
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: fino-rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.9.0
4
+ version: 1.9.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Egor Iskrenkov
@@ -15,14 +15,14 @@ dependencies:
15
15
  requirements:
16
16
  - - "~>"
17
17
  - !ruby/object:Gem::Version
18
- version: 1.9.0
18
+ version: 1.9.1
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - "~>"
24
24
  - !ruby/object:Gem::Version
25
- version: 1.9.0
25
+ version: 1.9.1
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: rails
28
28
  requirement: !ruby/object:Gem::Requirement
@@ -61,6 +61,8 @@ files:
61
61
  - lib/fino/rails/app/views/fino/rails/common/_topbar.html.erb
62
62
  - lib/fino/rails/app/views/fino/rails/dashboard/index.html.erb
63
63
  - lib/fino/rails/app/views/fino/rails/sections/show.html.erb
64
+ - lib/fino/rails/app/views/fino/rails/settings/_ab_testing.html.erb
65
+ - lib/fino/rails/app/views/fino/rails/settings/_overrides.html.erb
64
66
  - lib/fino/rails/app/views/fino/rails/settings/_setting.html.erb
65
67
  - lib/fino/rails/app/views/fino/rails/settings/_setting_input.html.erb
66
68
  - lib/fino/rails/app/views/fino/rails/settings/edit.html.erb
@@ -106,7 +108,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
106
108
  - !ruby/object:Gem::Version
107
109
  version: '0'
108
110
  requirements: []
109
- rubygems_version: 3.6.9
111
+ rubygems_version: 4.0.3
110
112
  specification_version: 4
111
113
  summary: Rails integration and UI for Fino settings engine
112
114
  test_files: []