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 +4 -4
- data/lib/fino/rails/app/controllers/fino/rails/settings_controller.rb +4 -2
- data/lib/fino/rails/app/views/fino/rails/settings/_ab_testing.html.erb +330 -0
- data/lib/fino/rails/app/views/fino/rails/settings/_overrides.html.erb +130 -0
- data/lib/fino/rails/app/views/fino/rails/settings/edit.html.erb +2 -486
- data/lib/fino/version.rb +1 -1
- metadata +6 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1d12aba7122ded5a852126002776b70c12cda8f47f659f22a0bf16b70fce9550
|
|
4
|
+
data.tar.gz: ba023c74ac778f9a2ea4184eb1026f64721327de11f79aa52dc26a992a822850
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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]
|
|
52
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
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.
|
|
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.
|
|
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.
|
|
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:
|
|
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: []
|