rollout-ui 0.10.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/workflows/release.yml +2 -2
- data/.github/workflows/test.yml +1 -3
- data/.mise.toml +1 -1
- data/.ruby-version +1 -1
- data/README.md +93 -5
- data/lib/rollout/ui/config.rb +1 -0
- data/lib/rollout/ui/helpers.rb +29 -3
- data/lib/rollout/ui/public/css/enrolled-users.css +191 -0
- data/lib/rollout/ui/public/js/enrolled-users.js +377 -0
- data/lib/rollout/ui/public/js/user-selector.js +2 -0
- data/lib/rollout/ui/version.rb +1 -1
- data/lib/rollout/ui/views/features/index.erb +16 -1
- data/lib/rollout/ui/views/features/partials/enrolled_users.erb +66 -0
- data/lib/rollout/ui/views/features/partials/user_pagination.erb +7 -0
- data/lib/rollout/ui/views/features/partials/warning_icon.erb +3 -0
- data/lib/rollout/ui/views/features/show.erb +12 -12
- data/lib/rollout/ui/views/layout.erb +7 -0
- data/lib/rollout/ui/web.rb +191 -13
- metadata +6 -1
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
(function() {
|
|
2
|
+
const section = document.getElementById('enrolled-users');
|
|
3
|
+
if (!section) return;
|
|
4
|
+
|
|
5
|
+
const rows = document.getElementById('enrolled-users-rows');
|
|
6
|
+
const addField = document.getElementById('users-to-add');
|
|
7
|
+
const changeInputs = document.getElementById('user-change-inputs');
|
|
8
|
+
const pageField = document.getElementById('users-page');
|
|
9
|
+
const status = document.getElementById('enrollment-status');
|
|
10
|
+
const pending = document.getElementById('pending-user-changes');
|
|
11
|
+
const details = document.getElementById('pending-user-details');
|
|
12
|
+
const review = document.getElementById('pending-user-review');
|
|
13
|
+
const reviewButton = document.getElementById('review-user-changes');
|
|
14
|
+
const version = document.querySelector('#updateFormSubmit [name="feature_version"]').value;
|
|
15
|
+
const picker = document.getElementById('user-selector');
|
|
16
|
+
const additions = new Map();
|
|
17
|
+
const removals = new Map();
|
|
18
|
+
const knownMembers = new Map();
|
|
19
|
+
let currentPage = Number(section.dataset.page);
|
|
20
|
+
let perPage = Number(section.dataset.perPage);
|
|
21
|
+
let total = Number(section.dataset.total);
|
|
22
|
+
let totalPages = Number(section.dataset.totalPages);
|
|
23
|
+
let pageRevision = 0;
|
|
24
|
+
let pageController;
|
|
25
|
+
const conflict = 'This rollout changed while you were editing. Your pending changes are preserved. Reload the page to review the latest state before updating.';
|
|
26
|
+
|
|
27
|
+
function checkVersion(data) {
|
|
28
|
+
if (data.feature_version !== version) {
|
|
29
|
+
status.textContent = conflict;
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function removeButton(row, user) {
|
|
36
|
+
const cell = row.lastElementChild;
|
|
37
|
+
let button = cell.querySelector('button');
|
|
38
|
+
if (!button) {
|
|
39
|
+
button = document.createElement('button');
|
|
40
|
+
button.type = 'button';
|
|
41
|
+
button.className = 'text-xs text-gray-600 hover:underline';
|
|
42
|
+
button.addEventListener('click', function() {
|
|
43
|
+
if (removals.has(user.id)) removals.delete(user.id);
|
|
44
|
+
else removals.set(user.id, user.nickname);
|
|
45
|
+
renderPending();
|
|
46
|
+
});
|
|
47
|
+
cell.replaceChildren(button);
|
|
48
|
+
}
|
|
49
|
+
button.textContent = removals.has(user.id) ? 'Undo' : 'Remove';
|
|
50
|
+
button.setAttribute('aria-label', (removals.has(user.id) ? 'Undo removal of ' : 'Remove ') + user.nickname + ' (#' + user.id + ')');
|
|
51
|
+
row.firstElementChild.classList.toggle('line-through', removals.has(user.id));
|
|
52
|
+
row.firstElementChild.classList.toggle('text-gray-400', removals.has(user.id));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function enhanceRows() {
|
|
56
|
+
rows.querySelectorAll('[data-user-id]').forEach(function(row) {
|
|
57
|
+
const user = { id: row.dataset.userId, nickname: row.dataset.nickname };
|
|
58
|
+
knownMembers.set(user.id, user.nickname);
|
|
59
|
+
removeButton(row, user);
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function focusAddField() {
|
|
64
|
+
(picker ? document.getElementById('user-nickname') : addField).focus();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function closeReview(restoreFocus) {
|
|
68
|
+
review.hidden = true;
|
|
69
|
+
reviewButton.setAttribute('aria-expanded', 'false');
|
|
70
|
+
if (restoreFocus && !pending.hidden) reviewButton.focus();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function renderPending() {
|
|
74
|
+
if (picker) addField.value = Array.from(additions.keys()).join(', ');
|
|
75
|
+
changeInputs.replaceChildren();
|
|
76
|
+
if (removals.size) {
|
|
77
|
+
const input = document.createElement('input');
|
|
78
|
+
input.type = 'hidden';
|
|
79
|
+
input.name = 'users_to_remove';
|
|
80
|
+
input.value = Array.from(removals.keys()).join(', ');
|
|
81
|
+
changeInputs.append(input);
|
|
82
|
+
}
|
|
83
|
+
pending.hidden = additions.size + removals.size === 0;
|
|
84
|
+
document.getElementById('pending-user-slot').hidden = pending.hidden;
|
|
85
|
+
if (pending.hidden) closeReview(false);
|
|
86
|
+
const counts = [];
|
|
87
|
+
if (additions.size) counts.push(additions.size + (additions.size === 1 ? ' addition' : ' additions'));
|
|
88
|
+
if (removals.size) counts.push(removals.size + (removals.size === 1 ? ' removal' : ' removals'));
|
|
89
|
+
const summary = document.getElementById('pending-user-summary');
|
|
90
|
+
summary.textContent = counts.length ? counts.join(' · ') + ' pending' : '';
|
|
91
|
+
summary.title = summary.textContent;
|
|
92
|
+
details.replaceChildren();
|
|
93
|
+
[additions, removals].forEach(function(changes, index) {
|
|
94
|
+
changes.forEach(function(nickname, id) {
|
|
95
|
+
const item = document.createElement('li');
|
|
96
|
+
const label = document.createElement('span');
|
|
97
|
+
label.textContent = (index === 0 ? '+ ' : '− ') + nickname + ' (#' + id + ')';
|
|
98
|
+
const undo = document.createElement('button');
|
|
99
|
+
undo.type = 'button';
|
|
100
|
+
undo.className = 'text-gray-600 hover:underline';
|
|
101
|
+
undo.textContent = 'Undo';
|
|
102
|
+
undo.setAttribute('aria-label', 'Undo ' + (index === 0 ? 'addition' : 'removal') + ' of ' + nickname + ' (#' + id + ')');
|
|
103
|
+
undo.addEventListener('click', function() {
|
|
104
|
+
const position = Array.from(details.querySelectorAll('button')).indexOf(undo);
|
|
105
|
+
changes.delete(id);
|
|
106
|
+
if (!picker) addField.value = Array.from(additions.keys()).join(', ');
|
|
107
|
+
renderPending();
|
|
108
|
+
const buttons = details.querySelectorAll('button');
|
|
109
|
+
if (buttons.length) buttons[Math.min(position, buttons.length - 1)].focus();
|
|
110
|
+
else focusAddField();
|
|
111
|
+
});
|
|
112
|
+
item.append(label, undo);
|
|
113
|
+
details.append(item);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
enhanceRows();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function updateNavigation() {
|
|
120
|
+
pageField.value = String(currentPage);
|
|
121
|
+
document.getElementById('enrolled-user-count').textContent = String(total);
|
|
122
|
+
section.querySelectorAll('[data-user-page-number]').forEach(function(label) {
|
|
123
|
+
label.textContent = 'Page ' + currentPage + ' of ' + totalPages;
|
|
124
|
+
});
|
|
125
|
+
section.querySelectorAll('[data-user-page-direction]').forEach(function(link) {
|
|
126
|
+
const previous = link.dataset.userPageDirection === 'previous';
|
|
127
|
+
const page = previous ? Math.max(currentPage - 1, 1) : Math.min(currentPage + 1, totalPages);
|
|
128
|
+
const disabled = previous ? currentPage === 1 : currentPage === totalPages;
|
|
129
|
+
const url = new URL(document.getElementById('updateFormSubmit').action);
|
|
130
|
+
url.searchParams.set('page', page);
|
|
131
|
+
if (disabled) link.removeAttribute('href');
|
|
132
|
+
else link.href = url.toString();
|
|
133
|
+
link.dataset.page = String(page);
|
|
134
|
+
link.setAttribute('aria-disabled', String(disabled));
|
|
135
|
+
link.tabIndex = disabled ? -1 : 0;
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function showPageStart(direction) {
|
|
140
|
+
const navigation = document.getElementById('enrolled-users-pagination');
|
|
141
|
+
const preferred = navigation.querySelector('[data-user-page-direction="' + direction + '"]');
|
|
142
|
+
const target = preferred && preferred.getAttribute('aria-disabled') !== 'true' ? preferred : navigation.querySelector('[aria-disabled="false"]') || document.getElementById('enrolled-users-table');
|
|
143
|
+
target.focus({ preventScroll: true });
|
|
144
|
+
const box = navigation.getBoundingClientRect();
|
|
145
|
+
if (box.top < 0 || box.bottom + 80 > window.innerHeight) navigation.scrollIntoView({ block: 'start' });
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function loadPage(page, direction) {
|
|
149
|
+
if (pageController) pageController.abort();
|
|
150
|
+
const revision = ++pageRevision;
|
|
151
|
+
pageController = new AbortController();
|
|
152
|
+
status.textContent = 'Loading enrolled users…';
|
|
153
|
+
try {
|
|
154
|
+
const url = new URL(section.dataset.usersUrl, window.location.href);
|
|
155
|
+
url.searchParams.set('page', page);
|
|
156
|
+
const response = await fetch(url, { headers: { Accept: 'application/json' }, credentials: 'same-origin', cache: 'no-store', signal: pageController.signal });
|
|
157
|
+
if (!response.ok) throw new Error('Enrollment page unavailable');
|
|
158
|
+
const data = await response.json();
|
|
159
|
+
if (revision !== pageRevision) return;
|
|
160
|
+
if (!checkVersion(data)) return;
|
|
161
|
+
currentPage = data.page;
|
|
162
|
+
perPage = data.per_page;
|
|
163
|
+
total = data.total;
|
|
164
|
+
totalPages = data.total_pages;
|
|
165
|
+
rows.replaceChildren();
|
|
166
|
+
data.users.forEach(function(user) {
|
|
167
|
+
const row = document.createElement('tr');
|
|
168
|
+
row.className = 'border-b border-gray-200';
|
|
169
|
+
row.dataset.userId = user.id;
|
|
170
|
+
row.dataset.nickname = user.nickname;
|
|
171
|
+
const nickname = document.createElement('td');
|
|
172
|
+
nickname.className = 'px-3 py-2 break-all';
|
|
173
|
+
nickname.textContent = user.nickname;
|
|
174
|
+
const id = document.createElement('td');
|
|
175
|
+
id.className = 'px-3 py-2 text-xs text-gray-500';
|
|
176
|
+
id.textContent = '#' + user.id;
|
|
177
|
+
const action = document.createElement('td');
|
|
178
|
+
action.className = 'px-3 py-2 text-right';
|
|
179
|
+
row.append(nickname, id, action);
|
|
180
|
+
rows.append(row);
|
|
181
|
+
});
|
|
182
|
+
if (!data.users.length) {
|
|
183
|
+
const row = document.createElement('tr');
|
|
184
|
+
const cell = document.createElement('td');
|
|
185
|
+
cell.colSpan = 3;
|
|
186
|
+
cell.className = 'px-3 py-3 text-gray-500';
|
|
187
|
+
cell.textContent = 'No explicitly enrolled users.';
|
|
188
|
+
row.append(cell);
|
|
189
|
+
rows.append(row);
|
|
190
|
+
}
|
|
191
|
+
enhanceRows();
|
|
192
|
+
updateNavigation();
|
|
193
|
+
status.textContent = data.lookup_failed ? 'Nicknames could not be loaded. Their user IDs are preserved.' : '';
|
|
194
|
+
showPageStart(direction);
|
|
195
|
+
} catch (error) {
|
|
196
|
+
if (revision !== pageRevision || error.name === 'AbortError') return;
|
|
197
|
+
status.textContent = 'The enrollment list is temporarily unavailable. Your pending changes are preserved. Try again.';
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
section.querySelectorAll('[data-user-page-direction]').forEach(function(link) {
|
|
202
|
+
link.addEventListener('click', function(event) {
|
|
203
|
+
event.preventDefault();
|
|
204
|
+
if (this.getAttribute('aria-disabled') !== 'true') loadPage(Number(this.dataset.page), this.dataset.userPageDirection);
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
reviewButton.addEventListener('click', function() {
|
|
208
|
+
if (!review.hidden) { closeReview(true); return; }
|
|
209
|
+
review.hidden = false;
|
|
210
|
+
reviewButton.setAttribute('aria-expanded', 'true');
|
|
211
|
+
(details.querySelector('button') || document.getElementById('close-user-review')).focus();
|
|
212
|
+
});
|
|
213
|
+
document.getElementById('close-user-review').addEventListener('click', function() { closeReview(true); });
|
|
214
|
+
document.addEventListener('pointerdown', function(event) {
|
|
215
|
+
if (!pending.contains(event.target)) closeReview(false);
|
|
216
|
+
});
|
|
217
|
+
pending.addEventListener('focusout', function(event) {
|
|
218
|
+
if (event.relatedTarget && !pending.contains(event.relatedTarget)) closeReview(false);
|
|
219
|
+
});
|
|
220
|
+
document.addEventListener('keydown', function(event) {
|
|
221
|
+
if (event.key === 'Escape' && !review.hidden) {
|
|
222
|
+
event.preventDefault();
|
|
223
|
+
closeReview(true);
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
document.getElementById('discard-user-changes').addEventListener('click', function() {
|
|
227
|
+
additions.clear();
|
|
228
|
+
removals.clear();
|
|
229
|
+
addField.value = '';
|
|
230
|
+
renderPending();
|
|
231
|
+
focusAddField();
|
|
232
|
+
});
|
|
233
|
+
if (!picker) {
|
|
234
|
+
addField.addEventListener('input', function() {
|
|
235
|
+
additions.clear();
|
|
236
|
+
this.value.split(',').map(function(id) { return id.trim(); }).filter(Boolean).forEach(function(id) {
|
|
237
|
+
if (!knownMembers.has(id)) additions.set(id, 'User');
|
|
238
|
+
});
|
|
239
|
+
renderPending();
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
enhanceRows();
|
|
243
|
+
updateNavigation();
|
|
244
|
+
|
|
245
|
+
if (!picker) return;
|
|
246
|
+
const input = document.getElementById('user-nickname');
|
|
247
|
+
const dropdown = document.getElementById('user-search-dropdown');
|
|
248
|
+
const list = document.getElementById('user-search-results');
|
|
249
|
+
const searchStatus = document.getElementById('user-search-status');
|
|
250
|
+
const resultStatus = document.getElementById('user-search-result-status');
|
|
251
|
+
const minLength = Number(picker.dataset.minLength);
|
|
252
|
+
const limit = Number(picker.dataset.limit);
|
|
253
|
+
let timer;
|
|
254
|
+
let searchController;
|
|
255
|
+
let searchRevision = 0;
|
|
256
|
+
let results = [];
|
|
257
|
+
let activeIndex = -1;
|
|
258
|
+
|
|
259
|
+
function closeList() {
|
|
260
|
+
dropdown.hidden = true;
|
|
261
|
+
list.hidden = true;
|
|
262
|
+
resultStatus.textContent = '';
|
|
263
|
+
input.setAttribute('aria-expanded', 'false');
|
|
264
|
+
input.removeAttribute('aria-activedescendant');
|
|
265
|
+
activeIndex = -1;
|
|
266
|
+
}
|
|
267
|
+
function cancelSearch() {
|
|
268
|
+
clearTimeout(timer);
|
|
269
|
+
if (searchController) searchController.abort();
|
|
270
|
+
searchRevision++;
|
|
271
|
+
closeList();
|
|
272
|
+
searchStatus.textContent = '';
|
|
273
|
+
searchStatus.title = '';
|
|
274
|
+
}
|
|
275
|
+
function showSearchMessage(message) {
|
|
276
|
+
closeList();
|
|
277
|
+
searchStatus.textContent = '';
|
|
278
|
+
resultStatus.textContent = message;
|
|
279
|
+
dropdown.hidden = false;
|
|
280
|
+
}
|
|
281
|
+
function choose(user) {
|
|
282
|
+
if (user.enrolled) {
|
|
283
|
+
if (!removals.has(user.id)) return;
|
|
284
|
+
removals.delete(user.id);
|
|
285
|
+
} else {
|
|
286
|
+
if (additions.has(user.id)) return;
|
|
287
|
+
additions.set(user.id, user.nickname);
|
|
288
|
+
}
|
|
289
|
+
renderPending();
|
|
290
|
+
cancelSearch();
|
|
291
|
+
input.value = '';
|
|
292
|
+
input.focus();
|
|
293
|
+
searchStatus.textContent = user.nickname + ' selected.';
|
|
294
|
+
searchStatus.title = searchStatus.textContent;
|
|
295
|
+
}
|
|
296
|
+
function activate(index) {
|
|
297
|
+
activeIndex = index;
|
|
298
|
+
Array.from(list.children).forEach(function(option, i) {
|
|
299
|
+
option.setAttribute('aria-selected', String(i === index));
|
|
300
|
+
});
|
|
301
|
+
const option = list.children[index];
|
|
302
|
+
if (option) {
|
|
303
|
+
input.setAttribute('aria-activedescendant', option.id);
|
|
304
|
+
option.scrollIntoView({ block: 'nearest' });
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
async function search(query, revision) {
|
|
308
|
+
searchController = new AbortController();
|
|
309
|
+
searchStatus.textContent = 'Searching nicknames…';
|
|
310
|
+
try {
|
|
311
|
+
const url = new URL(picker.dataset.searchUrl, window.location.href);
|
|
312
|
+
url.searchParams.set('q', query);
|
|
313
|
+
const response = await fetch(url, { signal: searchController.signal, headers: { Accept: 'application/json' }, credentials: 'same-origin', cache: 'no-store' });
|
|
314
|
+
if (!response.ok) throw new Error('Search unavailable');
|
|
315
|
+
const data = await response.json();
|
|
316
|
+
if (revision !== searchRevision) return;
|
|
317
|
+
if (!checkVersion(data)) { showSearchMessage(conflict); return; }
|
|
318
|
+
results = data.users;
|
|
319
|
+
list.replaceChildren();
|
|
320
|
+
results.forEach(function(user, index) {
|
|
321
|
+
const option = document.createElement('div');
|
|
322
|
+
option.id = 'user-search-option-' + index;
|
|
323
|
+
option.className = 'px-4 py-2 cursor-pointer flex items-center justify-between';
|
|
324
|
+
const disabled = (user.enrolled && !removals.has(user.id)) || additions.has(user.id);
|
|
325
|
+
option.setAttribute('role', 'option');
|
|
326
|
+
option.setAttribute('aria-selected', 'false');
|
|
327
|
+
option.setAttribute('aria-disabled', String(disabled));
|
|
328
|
+
const nickname = document.createElement('span');
|
|
329
|
+
nickname.textContent = user.nickname;
|
|
330
|
+
const note = document.createElement('span');
|
|
331
|
+
note.className = 'text-xs text-gray-500 ml-2';
|
|
332
|
+
note.textContent = user.enrolled ? removals.has(user.id) ? 'Undo removal' : 'Already enrolled' : additions.has(user.id) ? 'Pending addition' : 'Add';
|
|
333
|
+
option.append(nickname, note);
|
|
334
|
+
option.addEventListener('pointerdown', function(event) { event.preventDefault(); });
|
|
335
|
+
option.addEventListener('click', function() { choose(user); });
|
|
336
|
+
list.append(option);
|
|
337
|
+
});
|
|
338
|
+
list.hidden = results.length === 0;
|
|
339
|
+
dropdown.hidden = results.length === 0;
|
|
340
|
+
input.setAttribute('aria-expanded', String(results.length > 0));
|
|
341
|
+
searchStatus.textContent = results.length ? '' : 'No matching nicknames.';
|
|
342
|
+
resultStatus.textContent = results.length ? results.length + (results.length === 1 ? ' match.' : ' matches.') + ' Use ↑/↓ and Enter to select.' : '';
|
|
343
|
+
if (results.length >= limit) resultStatus.textContent += ' Refine your nickname to narrow the results.';
|
|
344
|
+
} catch (error) {
|
|
345
|
+
if (revision !== searchRevision || error.name === 'AbortError') return;
|
|
346
|
+
showSearchMessage('Nickname search is unavailable. Your pending changes are preserved. Try again.');
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
function scheduleSearch() {
|
|
350
|
+
cancelSearch();
|
|
351
|
+
const query = input.value.trim();
|
|
352
|
+
if (Array.from(query).length < minLength) {
|
|
353
|
+
searchStatus.textContent = query ? 'At least ' + minLength + ' characters' : '';
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
searchStatus.textContent = 'Waiting to search…';
|
|
357
|
+
const revision = searchRevision;
|
|
358
|
+
timer = setTimeout(function() { search(query, revision); }, 300);
|
|
359
|
+
}
|
|
360
|
+
input.addEventListener('input', scheduleSearch);
|
|
361
|
+
input.addEventListener('focus', scheduleSearch);
|
|
362
|
+
input.addEventListener('blur', cancelSearch);
|
|
363
|
+
input.addEventListener('keydown', function(event) {
|
|
364
|
+
if (event.key === 'Enter') { event.preventDefault(); if (!list.hidden && activeIndex >= 0) choose(results[activeIndex]); }
|
|
365
|
+
else if ((event.key === 'ArrowDown' || event.key === 'ArrowUp') && !list.hidden) {
|
|
366
|
+
event.preventDefault();
|
|
367
|
+
const direction = event.key === 'ArrowDown' ? 1 : -1;
|
|
368
|
+
let next = activeIndex < 0 ? direction === 1 ? -1 : 0 : activeIndex;
|
|
369
|
+
for (let i = 0; i < results.length; i++) {
|
|
370
|
+
next = (next + direction + results.length) % results.length;
|
|
371
|
+
if (list.children[next].getAttribute('aria-disabled') !== 'true') { activate(next); break; }
|
|
372
|
+
}
|
|
373
|
+
} else if (event.key === 'Escape' || event.key === 'Tab') cancelSearch();
|
|
374
|
+
});
|
|
375
|
+
document.getElementById('user-id-addition').hidden = true;
|
|
376
|
+
picker.hidden = false;
|
|
377
|
+
})();
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
const field = document.getElementById('users');
|
|
6
6
|
const input = document.getElementById('user-nickname');
|
|
7
7
|
const chips = document.getElementById('selected-users');
|
|
8
|
+
const count = document.getElementById('enrolled-user-count');
|
|
8
9
|
const list = document.getElementById('user-search-results');
|
|
9
10
|
const status = document.getElementById('user-search-status');
|
|
10
11
|
const minLength = Number(picker.dataset.minLength);
|
|
@@ -36,6 +37,7 @@
|
|
|
36
37
|
}
|
|
37
38
|
|
|
38
39
|
function renderChips() {
|
|
40
|
+
if (count) count.textContent = String(selected.size);
|
|
39
41
|
chips.replaceChildren();
|
|
40
42
|
selected.forEach(function(nickname, id) {
|
|
41
43
|
const chip = document.createElement('span');
|
data/lib/rollout/ui/version.rb
CHANGED
|
@@ -11,6 +11,9 @@
|
|
|
11
11
|
<table class="text-sm w-100 text-left w-full">
|
|
12
12
|
<thead class="font-semibold text-gray-500 border-b border-gray-200">
|
|
13
13
|
<th class="pb-2 pr-3">Name</th>
|
|
14
|
+
<% if @defined_features %>
|
|
15
|
+
<th class="w-10 px-3" aria-label="Definition"></th>
|
|
16
|
+
<% end %>
|
|
14
17
|
<th class="pb-2 pr-3">Percentage</th>
|
|
15
18
|
<th class="pb-2 pr-3">Groups</th>
|
|
16
19
|
<th class="pb-2 pr-3">Users</th>
|
|
@@ -25,9 +28,21 @@
|
|
|
25
28
|
<% feature = @rollout.get(feature_name) %>
|
|
26
29
|
<tr class="border-b border-gray-200">
|
|
27
30
|
<td class="py-2">
|
|
28
|
-
<a class="text-blue-600 hover:text-blue-700 hover:underline" href="<%= h(feature_path(feature.name)) %>"><%= h(feature_name) %></a>
|
|
31
|
+
<a class="text-blue-600 hover:text-blue-700 hover:underline whitespace-no-wrap" href="<%= h(feature_path(feature.name)) %>"><%= h(feature_name) %></a>
|
|
29
32
|
<div class="text-gray-500 text-xs"><%= h(feature.data['description']) %></div>
|
|
30
33
|
</td>
|
|
34
|
+
<% if @defined_features %>
|
|
35
|
+
<td class="py-2 px-3">
|
|
36
|
+
<% if missing_definition?(feature_name) %>
|
|
37
|
+
<div class="flex justify-center">
|
|
38
|
+
<span class="definition-warning relative inline-flex text-yellow-800" tabindex="0" role="img" aria-label="Missing definition">
|
|
39
|
+
<%= erb :"features/partials/warning_icon", layout: false %>
|
|
40
|
+
<span class="definition-tooltip hidden absolute right-0 px-3 border border-yellow-700 bg-yellow-100 text-xs text-center leading-normal whitespace-no-wrap rounded-sm" aria-hidden="true">Missing definition</span>
|
|
41
|
+
</span>
|
|
42
|
+
</div>
|
|
43
|
+
<% end %>
|
|
44
|
+
</td>
|
|
45
|
+
<% end %>
|
|
31
46
|
<td class="py-2 whitespace-no-wrap">
|
|
32
47
|
<% if feature.percentage == 0 %>
|
|
33
48
|
<span class="text-gray-400"><%= h(feature.percentage) %>%</span>
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
<link rel="stylesheet" href="<%= h(stylesheet_path('enrolled-users')) %>">
|
|
2
|
+
<section id="enrolled-users" class="mb-5" aria-labelledby="enrolled-users-heading" data-users-url="<%= h(enrolled_users_path(@feature.name)) %>" data-total="<%= h(@users_total_count) %>" data-page="<%= h(@user_page) %>" data-per-page="<%= h(@users_per_page) %>" data-total-pages="<%= h(@users_total_pages) %>">
|
|
3
|
+
<input type="hidden" name="user_changes" value="1">
|
|
4
|
+
<input type="hidden" name="page" id="users-page" value="<%= h(@user_page) %>">
|
|
5
|
+
<h3 id="enrolled-users-heading" class="text-gray-500 mb-2">Users (<span id="enrolled-user-count"><%= h(@users_total_count) %></span>)</h3>
|
|
6
|
+
<div id="user-id-addition" class="mb-4">
|
|
7
|
+
<label class="block text-gray-500 mb-2" for="users-to-add">Add user IDs</label>
|
|
8
|
+
<input id="users-to-add" name="users_to_add" class="appearance-none border rounded-sm w-full py-2 px-4 text-gray-600 bg-white" placeholder="Comma-separated user IDs" autocomplete="off">
|
|
9
|
+
</div>
|
|
10
|
+
<% if user_selector_enabled? %>
|
|
11
|
+
<div id="user-selector" class="mb-2" data-search-url="<%= h(feature_user_search_path(@feature.name)) %>" data-min-length="<%= h(user_search_min_length) %>" data-limit="<%= h(user_search_limit) %>" hidden>
|
|
12
|
+
<label class="block text-gray-500 mb-2" for="user-nickname" title="Search by nickname. At least <%= h(user_search_min_length) %> characters.">Add user</label>
|
|
13
|
+
<div class="enrolled-user-search-field">
|
|
14
|
+
<input id="user-nickname" class="appearance-none border rounded-sm w-full py-2 px-4 text-gray-600 bg-white" type="text" role="combobox" aria-autocomplete="list" aria-expanded="false" aria-controls="user-search-results" aria-describedby="user-search-status user-search-result-status" autocomplete="off" spellcheck="false" maxlength="<%= USER_SEARCH_MAX_LENGTH %>" placeholder="Search by nickname…" title="Search by nickname. At least <%= h(user_search_min_length) %> characters.">
|
|
15
|
+
<div id="user-search-dropdown" class="border rounded-sm bg-white" hidden>
|
|
16
|
+
<div id="user-search-results" role="listbox" aria-label="Matching nicknames" hidden></div>
|
|
17
|
+
<p id="user-search-result-status" class="text-xs text-gray-600" role="status" aria-live="polite"></p>
|
|
18
|
+
</div>
|
|
19
|
+
</div>
|
|
20
|
+
<p id="user-search-status" class="text-xs text-gray-600" role="status" aria-live="polite"></p>
|
|
21
|
+
</div>
|
|
22
|
+
<% end %>
|
|
23
|
+
<div id="user-change-inputs" hidden></div>
|
|
24
|
+
<%= erb :"features/partials/user_pagination", layout: false %>
|
|
25
|
+
<table id="enrolled-users-table" class="w-full text-left" aria-label="Explicitly enrolled users" tabindex="-1">
|
|
26
|
+
<thead class="text-xs text-gray-500 bg-gray-200">
|
|
27
|
+
<tr><th scope="col" class="px-3 py-2 font-normal">Nickname</th><th scope="col" class="px-3 py-2 font-normal">User ID</th><th scope="col" class="px-3 py-2 font-normal text-right">Action</th></tr>
|
|
28
|
+
</thead>
|
|
29
|
+
<tbody id="enrolled-users-rows">
|
|
30
|
+
<% if @selected_users.empty? %>
|
|
31
|
+
<tr><td colspan="3" class="px-3 py-3 text-gray-500">No explicitly enrolled users.</td></tr>
|
|
32
|
+
<% end %>
|
|
33
|
+
<% @selected_users.each do |user| %>
|
|
34
|
+
<tr class="border-b border-gray-200" data-user-id="<%= h(user[:id]) %>" data-nickname="<%= h(user[:nickname]) %>">
|
|
35
|
+
<td class="px-3 py-2 break-all"><%= h(user[:nickname]) %></td>
|
|
36
|
+
<td class="px-3 py-2 text-xs text-gray-500">#<%= h(user[:id]) %></td>
|
|
37
|
+
<td class="px-3 py-2 text-right">
|
|
38
|
+
<label class="text-xs text-gray-600"><input type="checkbox" name="users_to_remove[]" value="<%= h(user[:id]) %>" aria-label="Remove <%= h(user[:nickname]) %> (#<%= h(user[:id]) %>)"> Remove</label>
|
|
39
|
+
</td>
|
|
40
|
+
</tr>
|
|
41
|
+
<% end %>
|
|
42
|
+
</tbody>
|
|
43
|
+
</table>
|
|
44
|
+
<p id="enrollment-status" class="text-gray-500 mt-2" role="status" aria-live="polite"></p>
|
|
45
|
+
<% if @selected_user_lookup_failed %>
|
|
46
|
+
<p class="text-gray-500 mt-2" role="status">Selected nicknames could not be loaded. Their user IDs are preserved.</p>
|
|
47
|
+
<% end %>
|
|
48
|
+
<noscript><p class="text-xs text-gray-500 mt-2">Update changes before switching pages. Add users by ID and check Remove for users on the current page.</p></noscript>
|
|
49
|
+
<div id="pending-user-slot" hidden>
|
|
50
|
+
<div id="pending-user-changes" class="border border-gray-300 rounded-sm bg-white p-3 text-xs text-gray-600" hidden>
|
|
51
|
+
<p id="pending-user-summary" class="text-sm font-semibold" role="status" aria-live="polite" aria-atomic="true"></p>
|
|
52
|
+
<div id="pending-user-actions" role="group" aria-label="Pending user actions">
|
|
53
|
+
<button id="review-user-changes" type="button" class="border rounded-sm bg-gray-100 px-3 py-2 font-semibold hover:bg-gray-200" aria-expanded="false" aria-controls="pending-user-review">Review</button>
|
|
54
|
+
<button id="discard-user-changes" type="button" class="px-2 py-2 hover:underline" aria-label="Discard user changes">Discard</button>
|
|
55
|
+
</div>
|
|
56
|
+
<div id="pending-user-review" class="border rounded-sm bg-white p-3" role="region" aria-labelledby="pending-user-review-heading" hidden>
|
|
57
|
+
<div class="flex items-center justify-between mb-2">
|
|
58
|
+
<h4 id="pending-user-review-heading">Pending user changes</h4>
|
|
59
|
+
<button id="close-user-review" type="button" class="hover:underline" aria-label="Close pending user changes">Close</button>
|
|
60
|
+
</div>
|
|
61
|
+
<ul id="pending-user-details"></ul>
|
|
62
|
+
</div>
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
65
|
+
</section>
|
|
66
|
+
<script src="<%= h(javascript_path('enrolled-users')) %>" defer></script>
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
<nav id="enrolled-users-pagination" class="enrolled-user-pagination flex items-center justify-between text-xs" aria-label="Enrolled user pages">
|
|
2
|
+
<span id="users-page-number" class="text-gray-600" data-user-page-number aria-live="polite">Page <%= h(@user_page) %> of <%= h(@users_total_pages) %></span>
|
|
3
|
+
<div class="flex items-center">
|
|
4
|
+
<a id="users-previous" class="border rounded-sm py-2 px-3 text-gray-600 mr-2" role="link"<% if @user_page > 1 %> href="<%= h("#{feature_path(@feature.name)}?#{Rack::Utils.build_query(page: @user_page - 1)}") %>"<% end %> data-user-page-direction="previous" data-page="<%= [@user_page - 1, 1].max %>" aria-disabled="<%= @user_page == 1 %>"<% if @user_page == 1 %> tabindex="-1"<% end %>>← Previous</a>
|
|
5
|
+
<a id="users-next" class="border rounded-sm py-2 px-3 text-gray-600" role="link"<% if @user_page < @users_total_pages %> href="<%= h("#{feature_path(@feature.name)}?#{Rack::Utils.build_query(page: @user_page + 1)}") %>"<% end %> data-user-page-direction="next" data-page="<%= [@user_page + 1, @users_total_pages].min %>" aria-disabled="<%= @user_page == @users_total_pages %>"<% if @user_page == @users_total_pages %> tabindex="-1"<% end %>>Next →</a>
|
|
6
|
+
</div>
|
|
7
|
+
</nav>
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
<svg class="w-4 h-4 fill-current" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" aria-hidden="true" focusable="false">
|
|
2
|
+
<path d="M1.267 15H14.74c.942 0 1.548-.962 1.122-1.773L9.126 1.674c-.465-.899-1.794-.899-2.258 0L.132 13.227C-.281 14.038.325 15 1.267 15m7.794-2.685c0 .6-.451 1.061-1.097 1.061s-1.096-.462-1.096-1.061v-.025c0-.6.451-1.062 1.096-1.062.646 0 1.097.462 1.097 1.062zm-1.69-7.806h1.264c.349 0 .555.288.517.65l-.555 4.696c-.04.337-.271.55-.594.55s-.555-.213-.594-.55l-.555-4.696c-.038-.362.169-.65.517-.65"/>
|
|
3
|
+
</svg>
|
|
@@ -4,10 +4,20 @@
|
|
|
4
4
|
<pre class="text-red-500"><%= h(params[:error]) %></pre>
|
|
5
5
|
<% end %>
|
|
6
6
|
<h2 class="font-semibold text-xl text-gray-500 pt-12"><%= h(@feature.name) %></h2>
|
|
7
|
+
<% if missing_definition?(@feature.name) %>
|
|
8
|
+
<section class="mt-4 p-4 max-w-3xl flex items-center border border-yellow-700 bg-yellow-100 text-yellow-800 text-sm rounded-sm" aria-labelledby="missing-definition-heading">
|
|
9
|
+
<div class="mr-4 flex-shrink-0"><%= erb :"features/partials/warning_icon", layout: false %></div>
|
|
10
|
+
<div>
|
|
11
|
+
<h3 id="missing-definition-heading" class="font-semibold">Missing definition</h3>
|
|
12
|
+
<p class="mt-1 leading-relaxed">This feature has no definition in the current application.</p>
|
|
13
|
+
</div>
|
|
14
|
+
</section>
|
|
15
|
+
<% end %>
|
|
7
16
|
|
|
8
17
|
<div class="w-8 h-1 bg-gray-300 my-10"></div>
|
|
9
|
-
<main class="p-6 bg-gray-100 max-w-
|
|
18
|
+
<main class="p-6 bg-gray-100 max-w-3xl w-full text-sm rounded-sm">
|
|
10
19
|
<form id="updateFormSubmit" action="<%= h(feature_path(@feature.name)) %>" method="POST">
|
|
20
|
+
<input type="hidden" name="feature_version" value="<%= h(feature_version(@feature)) %>">
|
|
11
21
|
<input type="hidden" name="last_updated_at" value="<%= h(@feature.data['updated_at']) %>">
|
|
12
22
|
<div class="mb-5">
|
|
13
23
|
<label class="block text-gray-500 mb-2" for="description">Description</label>
|
|
@@ -29,17 +39,7 @@
|
|
|
29
39
|
<input class="appearance-none border rounded-sm w-full py-2 px-4 text-gray-600 leading-relaxed bg-white hover:border-gray-500" name="percentage" id="percentage" value="<%= h(@feature.percentage) %>" type="number" step="0.1">
|
|
30
40
|
</div>
|
|
31
41
|
|
|
32
|
-
|
|
33
|
-
<label class="block text-gray-500 mb-2" for="users">Users</label>
|
|
34
|
-
<% if @feature.users.count > USER_EDIT_LIMIT %>
|
|
35
|
-
<div class="appearance-none border rounded-sm w-full py-2 px-4 text-gray-600 leading-relaxed bg-gray-100"><%= h(@feature.users.count) %></div>
|
|
36
|
-
<% else %>
|
|
37
|
-
<textarea class="appearance-none border rounded-sm w-full py-2 px-4 text-gray-600 leading-relaxed bg-white hover:border-gray-500" name="users" id="users" rows="2"><%= h(@feature.users.join(', ')) %></textarea>
|
|
38
|
-
<% if user_selector_enabled? %>
|
|
39
|
-
<%= erb :"features/partials/user_selector", layout: false %>
|
|
40
|
-
<% end %>
|
|
41
|
-
<% end %>
|
|
42
|
-
</div>
|
|
42
|
+
<%= erb :"features/partials/enrolled_users", layout: false %>
|
|
43
43
|
</form>
|
|
44
44
|
|
|
45
45
|
<div class="flex items-center justify-end">
|
|
@@ -25,6 +25,13 @@
|
|
|
25
25
|
.dark .bg-white { background-color: #333333; }
|
|
26
26
|
.dark .border-gray-300 { border-color: #555555; }
|
|
27
27
|
.dark .hover\:bg-gray-800:hover { background-color: #4a4a4a; }
|
|
28
|
+
.dark .bg-yellow-100 { background-color: #352b16; }
|
|
29
|
+
.dark .border-yellow-700 { border-color: #975a16; }
|
|
30
|
+
.dark .text-yellow-800 { color: #fbd38d; }
|
|
31
|
+
.definition-tooltip { bottom: 100%; z-index: 1; padding-top: 10px; padding-bottom: 6px; }
|
|
32
|
+
.definition-warning:hover .definition-tooltip,
|
|
33
|
+
.definition-warning:focus .definition-tooltip { display: block; }
|
|
34
|
+
.definition-warning:focus { outline: 2px solid currentColor; outline-offset: 2px; }
|
|
28
35
|
</style>
|
|
29
36
|
</head>
|
|
30
37
|
<body>
|