rollout-ui 0.9.3 → 0.10.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/.github/workflows/release.yml +3 -2
- data/.github/workflows/test.yml +2 -3
- data/README.md +151 -30
- data/lib/rollout/ui/config.rb +7 -1
- data/lib/rollout/ui/helpers.rb +69 -5
- 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 +174 -0
- data/lib/rollout/ui/version.rb +1 -1
- data/lib/rollout/ui/views/features/index.erb +1 -3
- 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/user_selector.erb +15 -0
- data/lib/rollout/ui/views/features/show.erb +4 -12
- data/lib/rollout/ui/web.rb +258 -9
- metadata +7 -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
|
+
})();
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
(function() {
|
|
2
|
+
const picker = document.getElementById('user-selector');
|
|
3
|
+
if (!picker) return;
|
|
4
|
+
|
|
5
|
+
const field = document.getElementById('users');
|
|
6
|
+
const input = document.getElementById('user-nickname');
|
|
7
|
+
const chips = document.getElementById('selected-users');
|
|
8
|
+
const count = document.getElementById('enrolled-user-count');
|
|
9
|
+
const list = document.getElementById('user-search-results');
|
|
10
|
+
const status = document.getElementById('user-search-status');
|
|
11
|
+
const minLength = Number(picker.dataset.minLength);
|
|
12
|
+
const limit = Number(picker.dataset.limit);
|
|
13
|
+
const selected = new Map();
|
|
14
|
+
chips.querySelectorAll('[data-user-id]').forEach(function(chip) {
|
|
15
|
+
selected.set(chip.dataset.userId, chip.dataset.nickname);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
let timer;
|
|
19
|
+
let controller;
|
|
20
|
+
let revision = 0;
|
|
21
|
+
let results = [];
|
|
22
|
+
let activeIndex = -1;
|
|
23
|
+
const prompt = 'Type at least ' + minLength + ' characters to search.';
|
|
24
|
+
|
|
25
|
+
function closeList() {
|
|
26
|
+
list.hidden = true;
|
|
27
|
+
input.setAttribute('aria-expanded', 'false');
|
|
28
|
+
input.removeAttribute('aria-activedescendant');
|
|
29
|
+
activeIndex = -1;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function cancelSearch() {
|
|
33
|
+
clearTimeout(timer);
|
|
34
|
+
if (controller) controller.abort();
|
|
35
|
+
revision += 1;
|
|
36
|
+
closeList();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function renderChips() {
|
|
40
|
+
if (count) count.textContent = String(selected.size);
|
|
41
|
+
chips.replaceChildren();
|
|
42
|
+
selected.forEach(function(nickname, id) {
|
|
43
|
+
const chip = document.createElement('span');
|
|
44
|
+
chip.className = 'inline-flex items-center bg-gray-200 rounded-sm px-2 py-1 mr-2 mb-2';
|
|
45
|
+
const label = document.createElement('span');
|
|
46
|
+
label.textContent = nickname;
|
|
47
|
+
const remove = document.createElement('button');
|
|
48
|
+
remove.type = 'button';
|
|
49
|
+
remove.className = 'ml-2 text-gray-600';
|
|
50
|
+
remove.textContent = '×';
|
|
51
|
+
remove.setAttribute('aria-label', 'Remove ' + nickname);
|
|
52
|
+
remove.addEventListener('click', function() {
|
|
53
|
+
selected.delete(id);
|
|
54
|
+
updateSelection(nickname + ' removed.');
|
|
55
|
+
});
|
|
56
|
+
chip.append(label, remove);
|
|
57
|
+
chips.append(chip);
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function choose(user) {
|
|
62
|
+
selected.set(user.id, user.nickname);
|
|
63
|
+
updateSelection(user.nickname + ' selected.');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function updateSelection(message) {
|
|
67
|
+
field.value = Array.from(selected.keys()).join(', ');
|
|
68
|
+
renderChips();
|
|
69
|
+
cancelSearch();
|
|
70
|
+
input.value = '';
|
|
71
|
+
input.focus();
|
|
72
|
+
status.textContent = message + ' ' + prompt;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function activate(index) {
|
|
76
|
+
activeIndex = index;
|
|
77
|
+
Array.from(list.children).forEach(function(option, i) {
|
|
78
|
+
option.setAttribute('aria-selected', String(i === index));
|
|
79
|
+
option.classList.toggle('bg-gray-200', i === index);
|
|
80
|
+
});
|
|
81
|
+
const option = list.children[index];
|
|
82
|
+
if (option) {
|
|
83
|
+
input.setAttribute('aria-activedescendant', option.id);
|
|
84
|
+
option.scrollIntoView({ block: 'nearest' });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function search(query, searchRevision) {
|
|
89
|
+
controller = new AbortController();
|
|
90
|
+
status.textContent = 'Searching nicknames…';
|
|
91
|
+
try {
|
|
92
|
+
const url = new URL(picker.dataset.searchUrl, window.location.href);
|
|
93
|
+
url.searchParams.set('q', query);
|
|
94
|
+
const response = await fetch(url, {
|
|
95
|
+
signal: controller.signal,
|
|
96
|
+
headers: { Accept: 'application/json' },
|
|
97
|
+
credentials: 'same-origin',
|
|
98
|
+
cache: 'no-store'
|
|
99
|
+
});
|
|
100
|
+
if (!response.ok) throw new Error('Search failed');
|
|
101
|
+
const data = await response.json();
|
|
102
|
+
if (searchRevision !== revision) return;
|
|
103
|
+
renderResults(data.users);
|
|
104
|
+
} catch (error) {
|
|
105
|
+
if (searchRevision !== revision || error.name === 'AbortError') return;
|
|
106
|
+
closeList();
|
|
107
|
+
status.textContent = 'Nickname search is unavailable. Try typing again. Your selections are preserved.';
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function renderResults(users) {
|
|
112
|
+
results = users.filter(function(user) { return !selected.has(user.id); });
|
|
113
|
+
list.replaceChildren();
|
|
114
|
+
results.forEach(function(user, index) {
|
|
115
|
+
const option = document.createElement('div');
|
|
116
|
+
option.id = 'user-search-option-' + index;
|
|
117
|
+
option.className = 'px-4 py-2 cursor-pointer hover:bg-gray-200';
|
|
118
|
+
option.setAttribute('role', 'option');
|
|
119
|
+
option.setAttribute('aria-selected', 'false');
|
|
120
|
+
option.textContent = user.nickname;
|
|
121
|
+
option.addEventListener('pointerdown', function(event) { event.preventDefault(); });
|
|
122
|
+
option.addEventListener('click', function() { choose(user); });
|
|
123
|
+
list.append(option);
|
|
124
|
+
});
|
|
125
|
+
list.hidden = results.length === 0;
|
|
126
|
+
input.setAttribute('aria-expanded', String(results.length > 0));
|
|
127
|
+
if (users.length === 0) {
|
|
128
|
+
status.textContent = 'No matching nicknames.';
|
|
129
|
+
} else if (results.length === 0) {
|
|
130
|
+
status.textContent = 'Matching users are already selected.';
|
|
131
|
+
} else {
|
|
132
|
+
status.textContent = results.length + (results.length === 1 ? ' match.' : ' matches.') + ' Use ↑/↓ and Enter to select.';
|
|
133
|
+
}
|
|
134
|
+
if (users.length >= limit) status.textContent += ' Refine your nickname to narrow the results.';
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function scheduleSearch() {
|
|
138
|
+
cancelSearch();
|
|
139
|
+
const query = input.value.trim();
|
|
140
|
+
if (Array.from(query).length < minLength) {
|
|
141
|
+
status.textContent = prompt;
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
status.textContent = 'Waiting to search…';
|
|
145
|
+
const searchRevision = revision;
|
|
146
|
+
timer = setTimeout(function() { search(query, searchRevision); }, 300);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
input.addEventListener('input', scheduleSearch);
|
|
150
|
+
input.addEventListener('focus', scheduleSearch);
|
|
151
|
+
|
|
152
|
+
input.addEventListener('keydown', function(event) {
|
|
153
|
+
if (event.key === 'Enter') {
|
|
154
|
+
event.preventDefault();
|
|
155
|
+
if (!list.hidden && activeIndex >= 0) choose(results[activeIndex]);
|
|
156
|
+
} else if ((event.key === 'ArrowDown' || event.key === 'ArrowUp') && !list.hidden) {
|
|
157
|
+
event.preventDefault();
|
|
158
|
+
const next = event.key === 'ArrowDown' ? activeIndex + 1 :
|
|
159
|
+
activeIndex < 0 ? results.length - 1 : activeIndex - 1;
|
|
160
|
+
activate((next + results.length) % results.length);
|
|
161
|
+
} else if (event.key === 'Escape') {
|
|
162
|
+
cancelSearch();
|
|
163
|
+
status.textContent = prompt;
|
|
164
|
+
} else if (event.key === 'Tab') {
|
|
165
|
+
cancelSearch();
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
input.addEventListener('blur', function() { cancelSearch(); });
|
|
170
|
+
renderChips();
|
|
171
|
+
document.querySelector('label[for="users"]').htmlFor = input.id;
|
|
172
|
+
field.hidden = true;
|
|
173
|
+
picker.hidden = false;
|
|
174
|
+
})();
|
data/lib/rollout/ui/version.rb
CHANGED
|
@@ -70,6 +70,4 @@
|
|
|
70
70
|
</table>
|
|
71
71
|
</div>
|
|
72
72
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
<%= erb :"features/partials/event_log", layout: false, locals: { events: global_history_events, show_feature: true, show_header: true, existing_features: @features } %>
|
|
73
|
+
<%= erb :"features/partials/event_log", layout: false, locals: { events: @history_events, show_feature: true, show_header: true, existing_features: @features } %>
|
|
@@ -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,15 @@
|
|
|
1
|
+
<div id="user-selector" data-search-url="<%= h(user_search_path) %>" data-min-length="<%= h(user_search_min_length) %>" data-limit="<%= h(user_search_limit) %>" hidden>
|
|
2
|
+
<div id="selected-users" class="flex flex-wrap mb-2" aria-label="Selected users">
|
|
3
|
+
<% @selected_users.each do |user| %>
|
|
4
|
+
<span data-user-id="<%= h(user[:id]) %>" data-nickname="<%= h(user[:nickname]) %>"></span>
|
|
5
|
+
<% end %>
|
|
6
|
+
</div>
|
|
7
|
+
<label class="block text-gray-500 mb-2" for="user-nickname">Search by nickname</label>
|
|
8
|
+
<input id="user-nickname" class="appearance-none border rounded-sm w-full py-2 px-4 text-gray-600 leading-relaxed bg-white hover:border-gray-500" type="text" role="combobox" aria-autocomplete="list" aria-expanded="false" aria-controls="user-search-results" aria-describedby="user-search-status" autocomplete="off" spellcheck="false" maxlength="<%= USER_SEARCH_MAX_LENGTH %>" placeholder="Type a nickname…">
|
|
9
|
+
<div id="user-search-results" class="border rounded-sm bg-white mt-1" role="listbox" aria-label="Matching nicknames" style="max-height: 16rem; overflow-y: auto" hidden></div>
|
|
10
|
+
<p id="user-search-status" class="text-gray-500 mt-2" role="status" aria-live="polite">Type at least <%= h(user_search_min_length) %> characters to search.</p>
|
|
11
|
+
<% if @selected_user_lookup_failed %>
|
|
12
|
+
<p class="text-gray-500 mt-2" role="status">Selected nicknames could not be loaded. Their user IDs are preserved.</p>
|
|
13
|
+
<% end %>
|
|
14
|
+
</div>
|
|
15
|
+
<script src="<%= h(javascript_path('user-selector')) %>" defer></script>
|