rollout-ui 0.10.0 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5ce5665685cf17063621b6e2612fa89366cf8ec7e353322c8219b874c7d1823d
4
- data.tar.gz: 2ad0fc635c83d3ae4f6cd54b78cc730a5fc32646538f54c6a07f3a1c17370db3
3
+ metadata.gz: 8fe93ad84dab349410b8d45fd548837b3c7057db163916ae51af5415f0b6a9bd
4
+ data.tar.gz: 6b451b50cc1e907a63e94976c103800f26fa6366705ed34e4a31d2faad585507
5
5
  SHA512:
6
- metadata.gz: 9b4653f7bd4940e933f318fcb1d09530b636dbc405ecd44678dfb894e443429a24f4cedbb82c37f9110673adec99deb458796e1cb20b8fb4bb43667b505b548a
7
- data.tar.gz: 5deb01d8c2c3dca6f6687535fb5f125273216ba3fab9afca3cd62326354d2b343a07f2e7e1d31308464a2a898c155716c35ee228867532ad1191e6ced840a9f6
6
+ metadata.gz: '09ad9b11897744854625b4654e7d2a5c543492ac945ad6b91e5359b1c6aad0bab6ba0927781588f4c8b3c16e221597f625cf9acc4ca1e6c208c260d20a6e6ee8'
7
+ data.tar.gz: 837fa191cde919c0a88f881cc31fe300311026acf7559410f3f23bdc9ffd27c5d6f9644955337d64019c96e2642343837569d8da31648706bdf7a80d411b9bbb
@@ -7,7 +7,8 @@ on:
7
7
 
8
8
  jobs:
9
9
  release:
10
- runs-on: ubuntu-latest
10
+ # setup-ruby has no build of the .ruby-version Ruby (3.4.7) for ubuntu-26.04.
11
+ runs-on: ubuntu-24.04
11
12
  permissions:
12
13
  contents: write
13
14
  id-token: write
@@ -37,7 +38,7 @@ jobs:
37
38
 
38
39
  github-release:
39
40
  needs: release
40
- runs-on: ubuntu-latest
41
+ runs-on: ubuntu-26.04
41
42
  permissions:
42
43
  contents: write
43
44
  steps:
@@ -5,12 +5,11 @@ on:
5
5
  branches:
6
6
  - master
7
7
  pull_request:
8
- branches:
9
- - master
10
8
 
11
9
  jobs:
12
10
  test:
13
- runs-on: ubuntu-latest
11
+ # setup-ruby has no build of the .ruby-version Ruby (3.4.7) for ubuntu-26.04.
12
+ runs-on: ubuntu-24.04
14
13
  services:
15
14
  redis:
16
15
  image: redis:7-alpine
data/README.md CHANGED
@@ -61,8 +61,9 @@ end
61
61
  ## Nickname user selector
62
62
 
63
63
  Configure **both** callbacks below to replace the user ID field with a searchable
64
- multi-select. Your application supplies the queries; the UI displays nicknames
65
- and Rollout stores user IDs. This example uses Rails and PostgreSQL:
64
+ nickname picker alongside the enrolled-user list. Your application supplies the
65
+ queries; the UI displays nicknames and Rollout stores user IDs. This example
66
+ uses Rails and PostgreSQL:
66
67
 
67
68
  ```ruby
68
69
  Rollout::UI.configure do
@@ -107,9 +108,59 @@ CREATE INDEX CONCURRENTLY users_nickname_prefix_idx
107
108
  ```
108
109
 
109
110
  Check the query plan against your schema and collation. The selector supports
110
- keyboard navigation and preserves selections if a lookup fails. Without
111
- JavaScript, the ID field remains usable. Features with more than 150 selected
112
- users show only a count.
111
+ keyboard navigation and preserves pending changes if a lookup fails. The
112
+ enrolled count includes unresolved accounts, shown as `Unknown user` with their
113
+ ID in a separate column, but excludes users enabled through groups or percentage.
114
+
115
+ ### Paginated enrollment editing
116
+
117
+ Every feature shows a paginated enrollment list with **25 users per page**.
118
+ Rows are ordered by user ID. Nicknames are
119
+ looked up only for the visible page, independently of the bounded history lookup.
120
+ There is no longer a 150-user enrollment editing cutoff.
121
+
122
+ Remove/Undo and nickname additions are staged until **Update**. With JavaScript,
123
+ switching enrollment pages leaves pending changes and unsaved description,
124
+ groups, and percentage fields intact. A pending-changes callout below the table
125
+ opens a **Review** popover listing additions and removals across all pages. Search marks
126
+ already-enrolled users even when they are on another page. The heading counts
127
+ stored enrollments; pending changes are reported separately until saved.
128
+
129
+ The form submits additions and removals, not a replacement list from the visible
130
+ page. Delta requests use `user_changes=1` and require a full-state
131
+ `feature_version`; malformed markers and missing versions are rejected. After
132
+ version validation, these deltas are applied to the full membership
133
+ inside `with_feature`. Duplicate additions are idempotent; removals take
134
+ precedence if an ID appears in both sets. Legacy full-list `users` submissions
135
+ remain supported.
136
+
137
+ Without JavaScript or nickname callbacks, add users using comma-separated IDs
138
+ and check Remove for visible rows. **Update before following page links** to
139
+ avoid discarding unsaved form changes. Enrollment pagination remains available
140
+ when nickname lookup fails; unknown IDs are never silently dropped.
141
+
142
+ ## Concurrent edits
143
+
144
+ Feature forms include a `feature_version` fingerprint of the editable state:
145
+ percentage, groups, enrolled IDs, and description. Ordering of IDs and groups,
146
+ timestamps, and unrelated metadata do not change the fingerprint. Distinct edits
147
+ in the same second and changes made outside the UI are detected.
148
+
149
+ Updates validate the submitted fingerprint inside `with_feature`, before changing
150
+ state. A stale submission is rejected without saving state or history, and the
151
+ UI redirects to the latest feature so the administrator can review and retry.
152
+ Older non-delta forms without `feature_version` retain the `last_updated_at`
153
+ timestamp check; that legacy check cannot distinguish same-second edits.
154
+
155
+ Atomic conflict detection depends on the adapter's mutation guarantees. The
156
+ ActiveRecord adapter locks existing feature rows during `with_feature`. The
157
+ Redis adapter does not provide the equivalent lock or compare-and-set guarantee.
158
+
159
+ Use uncached feature reads for the Rollout instance supplied to the admin UI.
160
+ Process-local adapter caches can otherwise display an older enrolled list and
161
+ form version after another worker saves. Normal feature evaluation can use a
162
+ separate cached instance with the same group definitions. Feature responses send
163
+ `Cache-Control: no-store`; this does not bypass an adapter's server-side cache.
113
164
 
114
165
  ## Logging
115
166
 
@@ -160,6 +211,18 @@ It uses the mount's authentication, caps results server-side, and sends
160
211
  list without calling `user_search`. Queries are limited to 100 characters;
161
212
  configured minimum lengths and result limits are bounded to 1–100.
162
213
 
214
+ `GET /features/:name/users?page=2` returns the visible enrollment
215
+ page, total enrollment count, page metadata, full-state `feature_version`, and
216
+ `lookup_failed`. Pages contain at most 25 users and page numbers are clamped to
217
+ the available range. The response never contains the entire
218
+ membership list and sends `Cache-Control: no-store`.
219
+
220
+ `GET /features/:name/users/search?q=alice` uses the same nickname callbacks and
221
+ limits, adds an `enrolled` flag to each match based on the full membership, and
222
+ includes `feature_version`. The unscoped `/users/search` response is unchanged.
223
+ Paginated reads never replace the form's original version token; if the rollout
224
+ changes while editing, the administrator must review the newer state.
225
+
163
226
  ## Host Header Validation
164
227
 
165
228
  When mounted in Rails, requests pass through Rails' `config.hosts` checks. The gem
@@ -1,3 +1,5 @@
1
+ require "digest/sha2"
2
+ require "json"
1
3
  require "rack"
2
4
  require "rollout"
3
5
 
@@ -5,7 +7,7 @@ require "rollout/ui/version"
5
7
 
6
8
  module Rollout::UI
7
9
  module Helpers
8
- USER_EDIT_LIMIT = 150
10
+ USER_PAGE_SIZE = 25
9
11
  USER_SEARCH_MAX_LENGTH = 100
10
12
 
11
13
  def stylesheet_path(name)
@@ -20,6 +22,14 @@ module Rollout::UI
20
22
  "#{request.script_name}/users/search"
21
23
  end
22
24
 
25
+ def enrolled_users_path(feature_name)
26
+ "#{request.script_name}/features/#{feature_name}/users"
27
+ end
28
+
29
+ def feature_user_search_path(feature_name)
30
+ "#{request.script_name}/features/#{feature_name}/users/search"
31
+ end
32
+
23
33
  def user_selector_enabled?
24
34
  config.defined?(:user_search) && config.defined?(:user_lookup)
25
35
  end
@@ -32,11 +42,23 @@ module Rollout::UI
32
42
  config.get(:user_search_limit).to_i.clamp(1, 100)
33
43
  end
34
44
 
35
- def user_label(id)
45
+ def user_label(id, include_id: true)
36
46
  id = id.to_s
37
47
  return @user_nicknames[id] if @user_nicknames&.key?(id)
38
48
 
39
- @user_lookup_attempted&.key?(id) ? "Unknown user (##{id})" : "User (##{id})"
49
+ label = @user_lookup_attempted&.key?(id) ? "Unknown user" : "User"
50
+ include_id ? "#{label} (##{id})" : label
51
+ end
52
+
53
+ def feature_version(feature)
54
+ state = [
55
+ feature.name.to_s,
56
+ feature.percentage.to_f,
57
+ feature.groups.map(&:to_s).uniq.sort,
58
+ feature.users.map(&:to_s).uniq.sort,
59
+ feature.data["description"].to_s,
60
+ ]
61
+ Digest::SHA256.hexdigest(state.to_json)
40
62
  end
41
63
 
42
64
  def index_path
@@ -0,0 +1,191 @@
1
+ #enrolled-users .enrolled-user-search-field,
2
+ #enrolled-users #pending-user-slot {
3
+ position: relative;
4
+ }
5
+
6
+ #enrolled-users .text-gray-500,
7
+ #enrolled-users .text-gray-600,
8
+ #enrolled-users .text-gray-400 {
9
+ color: #4a5568;
10
+ }
11
+
12
+ .dark #enrolled-users .text-gray-500,
13
+ .dark #enrolled-users .text-gray-600,
14
+ .dark #enrolled-users .text-gray-400 {
15
+ color: #b8b8b8;
16
+ }
17
+
18
+ #enrolled-users input::placeholder {
19
+ color: #4a5568;
20
+ opacity: 1;
21
+ }
22
+
23
+ .dark #enrolled-users input::placeholder {
24
+ color: #b8b8b8;
25
+ }
26
+
27
+ #enrolled-users #user-search-dropdown {
28
+ position: absolute;
29
+ top: calc(100% + 0.25rem);
30
+ left: 0;
31
+ right: 0;
32
+ z-index: 20;
33
+ box-shadow: 0 4px 12px rgb(0 0 0 / 15%);
34
+ }
35
+
36
+ #enrolled-users #user-search-results {
37
+ max-height: min(16rem, 40vh);
38
+ overflow-y: auto;
39
+ }
40
+
41
+ #enrolled-users #user-search-result-status {
42
+ padding: 0.5rem 0.75rem;
43
+ line-height: 1rem;
44
+ max-height: min(8rem, 30vh);
45
+ overflow-y: auto;
46
+ }
47
+
48
+ #enrolled-users #user-search-results [role="option"] > span:first-child {
49
+ min-width: 0;
50
+ overflow-wrap: anywhere;
51
+ }
52
+
53
+ #enrolled-users #user-search-results [role="option"][aria-disabled="false"]:not([aria-selected="true"]):hover {
54
+ background-color: #edf2f7;
55
+ }
56
+
57
+ .dark #enrolled-users #user-search-results [role="option"][aria-disabled="false"]:not([aria-selected="true"]):hover {
58
+ background-color: #3a3a3a;
59
+ }
60
+
61
+ #enrolled-users #user-search-results [role="option"][aria-selected="true"] {
62
+ background-color: #2563eb;
63
+ color: #ffffff;
64
+ }
65
+
66
+ #enrolled-users #user-search-results [role="option"][aria-selected="true"] .text-gray-500 {
67
+ color: inherit;
68
+ }
69
+
70
+ #enrolled-users #user-search-results [role="option"][aria-disabled="true"] {
71
+ cursor: default;
72
+ }
73
+
74
+ #enrolled-users #user-search-status {
75
+ height: 1.5rem;
76
+ line-height: 1.5rem;
77
+ margin-top: 0.25rem;
78
+ white-space: nowrap;
79
+ overflow: hidden;
80
+ text-overflow: ellipsis;
81
+ }
82
+
83
+ #enrolled-users #pending-user-slot {
84
+ margin-top: 0.75rem;
85
+ }
86
+
87
+ #enrolled-users #pending-user-changes:not([hidden]) {
88
+ display: grid;
89
+ grid-template-columns: minmax(0, 1fr) auto;
90
+ align-items: center;
91
+ column-gap: 0.5rem;
92
+ row-gap: 0.5rem;
93
+ }
94
+
95
+ #enrolled-users #pending-user-summary {
96
+ min-width: 0;
97
+ overflow-wrap: anywhere;
98
+ }
99
+
100
+ #enrolled-users #pending-user-actions {
101
+ display: flex;
102
+ align-items: center;
103
+ gap: 0.5rem;
104
+ }
105
+
106
+ #enrolled-users #pending-user-actions > button {
107
+ display: inline-flex;
108
+ align-items: center;
109
+ justify-content: center;
110
+ min-height: 2.5rem;
111
+ }
112
+
113
+ #enrolled-users #review-user-changes {
114
+ border-color: currentColor;
115
+ }
116
+
117
+ #enrolled-users #pending-user-review {
118
+ position: absolute;
119
+ bottom: calc(100% + 0.5rem);
120
+ right: 0;
121
+ width: min(100%, 28rem);
122
+ z-index: 20;
123
+ box-shadow: 0 4px 12px rgb(0 0 0 / 15%);
124
+ }
125
+
126
+ #enrolled-users #pending-user-details {
127
+ max-height: min(16rem, 50vh);
128
+ overflow-y: auto;
129
+ }
130
+
131
+ #enrolled-users #pending-user-details li {
132
+ display: flex;
133
+ align-items: baseline;
134
+ justify-content: space-between;
135
+ gap: 0.75rem;
136
+ margin-bottom: 0.25rem;
137
+ }
138
+
139
+ #enrolled-users #pending-user-details li span {
140
+ min-width: 0;
141
+ overflow-wrap: anywhere;
142
+ }
143
+
144
+ #enrolled-users button {
145
+ min-height: 24px;
146
+ }
147
+
148
+ #enrolled-users .enrolled-user-pagination {
149
+ margin-bottom: 0.5rem;
150
+ }
151
+
152
+ #enrolled-users .enrolled-user-pagination [aria-disabled="true"] {
153
+ opacity: 0.5;
154
+ }
155
+
156
+ @media (max-width: 480px) {
157
+ #enrolled-users #pending-user-changes:not([hidden]) {
158
+ grid-template-columns: minmax(0, 1fr);
159
+ }
160
+
161
+ #enrolled-users #pending-user-actions {
162
+ justify-self: center;
163
+ }
164
+
165
+ #enrolled-users #enrolled-users-table {
166
+ table-layout: fixed;
167
+ }
168
+
169
+ #enrolled-users #enrolled-users-table th,
170
+ #enrolled-users #enrolled-users-table td {
171
+ padding-left: 0.375rem;
172
+ padding-right: 0.375rem;
173
+ }
174
+
175
+ #enrolled-users #enrolled-users-table th:nth-child(2) {
176
+ width: 35%;
177
+ }
178
+
179
+ #enrolled-users #enrolled-users-table th:last-child {
180
+ width: 4rem;
181
+ }
182
+
183
+ #enrolled-users #enrolled-users-table td:nth-child(2) {
184
+ overflow-wrap: anywhere;
185
+ }
186
+
187
+ #enrolled-users .enrolled-user-pagination a {
188
+ padding-left: 0.5rem;
189
+ padding-right: 0.5rem;
190
+ }
191
+ }
@@ -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');
@@ -1,5 +1,5 @@
1
1
  class Rollout
2
2
  module UI
3
- VERSION = "0.10.0"
3
+ VERSION = "0.10.1"
4
4
  end
5
5
  end
@@ -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 %>>&larr; 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 &rarr;</a>
6
+ </div>
7
+ </nav>
@@ -6,8 +6,9 @@
6
6
  <h2 class="font-semibold text-xl text-gray-500 pt-12"><%= h(@feature.name) %></h2>
7
7
 
8
8
  <div class="w-8 h-1 bg-gray-300 my-10"></div>
9
- <main class="p-6 bg-gray-100 max-w-lg w-full text-sm rounded-sm">
9
+ <main class="p-6 bg-gray-100 max-w-3xl w-full text-sm rounded-sm">
10
10
  <form id="updateFormSubmit" action="<%= h(feature_path(@feature.name)) %>" method="POST">
11
+ <input type="hidden" name="feature_version" value="<%= h(feature_version(@feature)) %>">
11
12
  <input type="hidden" name="last_updated_at" value="<%= h(@feature.data['updated_at']) %>">
12
13
  <div class="mb-5">
13
14
  <label class="block text-gray-500 mb-2" for="description">Description</label>
@@ -29,17 +30,7 @@
29
30
  <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
31
  </div>
31
32
 
32
- <div class="mb-5">
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>
33
+ <%= erb :"features/partials/enrolled_users", layout: false %>
43
34
  </form>
44
35
 
45
36
  <div class="flex items-center justify-end">
@@ -30,6 +30,14 @@ module Rollout::UI
30
30
  class Dispatcher
31
31
  include Helpers
32
32
 
33
+ class StaleFeature < StandardError
34
+ end
35
+
36
+ class InvalidEnrollment < StandardError
37
+ end
38
+
39
+ STALE_FEATURE_MESSAGE = 'This rollout changed since you opened it. Your update was not saved. Review the latest state and try again.'
40
+
33
41
  VIEWS_PATH = File.expand_path('views', __dir__)
34
42
  TEMPLATE_CACHE = {}
35
43
  HISTORY_USER_LOOKUP_LIMIT = 150
@@ -37,6 +45,8 @@ module Rollout::UI
37
45
  ROUTES = [
38
46
  [:GET, %r{\A/\z}, :index],
39
47
  [:GET, %r{\A/users/search\z}, :search_users],
48
+ [:GET, %r{\A/features/(?<feature_name>[^/]+)/users/search\z}, :search_feature_users],
49
+ [:GET, %r{\A/features/(?<feature_name>[^/]+)/users\z}, :enrolled_users],
40
50
  [:GET, %r{\A/features/new\z}, :new_feature],
41
51
  [:POST, %r{\A/features/new\z}, :create_feature],
42
52
  [:POST, %r{\A/features/(?<feature_name>[^/]+)/activate-percentage\z}, :activate_percentage],
@@ -103,13 +113,14 @@ module Rollout::UI
103
113
  end
104
114
 
105
115
  def show
116
+ response['cache-control'] = 'no-store'
106
117
  @rollout = config.get(:instance)
107
118
  @feature = @rollout.get(params[:feature_name])
108
119
 
109
120
  if json_request?
110
121
  json(feature_to_hash(@feature))
111
122
  else
112
- load_selected_users if user_selector_enabled? && @feature.users.count <= USER_EDIT_LIMIT
123
+ load_selected_users
113
124
  @history_events = @rollout.respond_to?(:logging) ? @rollout.logging.events(@feature.name).reverse : []
114
125
  load_history_user_nicknames(@history_events)
115
126
  render_view(:'features/show')
@@ -141,11 +152,91 @@ module Rollout::UI
141
152
  end
142
153
  end
143
154
 
155
+ def search_feature_users
156
+ return not_found unless user_selector_enabled?
157
+
158
+ response['cache-control'] = 'no-store'
159
+ @rollout = config.get(:instance)
160
+ begin
161
+ @feature = @rollout.get(params[:feature_name])
162
+ rescue StandardError
163
+ return not_found
164
+ end
165
+
166
+ query = params[:q]
167
+ unless query.nil? || query.is_a?(String)
168
+ response.status = 400
169
+ return json(error: 'Nickname must be a string.')
170
+ end
171
+ query = query.to_s.strip
172
+ if query.length > USER_SEARCH_MAX_LENGTH
173
+ response.status = 400
174
+ return json(error: "Nickname must be at most #{USER_SEARCH_MAX_LENGTH} characters.")
175
+ end
176
+ return json(users: [], feature_version: feature_version(@feature)) if query.length < user_search_min_length
177
+
178
+ enrolled_ids = @feature.users.map(&:to_s)
179
+ begin
180
+ users = config.get(:user_search, query, user_search_limit)
181
+ json(
182
+ users: users.take(user_search_limit).map do |user|
183
+ hashed = user_to_hash(user)
184
+ hashed.merge(enrolled: enrolled_ids.include?(hashed[:id]))
185
+ end,
186
+ feature_version: feature_version(@feature),
187
+ )
188
+ rescue StandardError
189
+ response.status = 503
190
+ json(error: 'Nickname search is temporarily unavailable. Please try again.')
191
+ end
192
+ end
193
+
194
+ def enrolled_users
195
+ response['cache-control'] = 'no-store'
196
+ @rollout ||= config.get(:instance)
197
+ begin
198
+ @feature = @rollout.get(params[:feature_name])
199
+ rescue StandardError
200
+ return not_found
201
+ end
202
+ load_selected_users
203
+
204
+ json(
205
+ users: @selected_users,
206
+ total: @users_total_count,
207
+ page: @user_page,
208
+ per_page: @users_per_page,
209
+ total_pages: @users_total_pages,
210
+ feature_version: feature_version(@feature),
211
+ lookup_failed: @selected_user_lookup_failed || false,
212
+ )
213
+ end
214
+
144
215
  def load_selected_users
145
- ids = @feature.users.map(&:to_s)
146
- lookup_user_nicknames(ids)
147
- @selected_users = ids.map { |id| { id: id, nickname: user_label(id) } }
148
- @selected_user_lookup_failed = @user_lookup_failed
216
+ @users_per_page = USER_PAGE_SIZE
217
+ all_ids = @feature.users.map { |id| id.to_s }.uniq.sort
218
+ @users_total_count = all_ids.size
219
+ @users_total_pages = [ (all_ids.size.to_f / @users_per_page).ceil, 1 ].max
220
+ @user_page = parse_users_page(params[:page], @users_total_pages)
221
+ offset = (@user_page - 1) * @users_per_page
222
+ page_ids = all_ids[offset, @users_per_page] || []
223
+ if config.defined?(:user_lookup)
224
+ lookup_user_nicknames(page_ids)
225
+ end
226
+ @selected_users = page_ids.map { |id| { id: id, nickname: user_label(id, include_id: false) } }
227
+ @selected_user_lookup_failed = @user_lookup_failed == true
228
+ end
229
+
230
+ def parse_users_page(value, total_pages)
231
+ candidate = 1
232
+ unless value.is_a?(Array) || value.is_a?(Hash)
233
+ raw = value.to_s.strip
234
+ candidate = raw.to_i if raw.match?(/\A\d+\z/)
235
+ end
236
+ candidate = 1 if candidate < 1
237
+ candidate = total_pages if candidate > total_pages
238
+ candidate = 1 if candidate < 1
239
+ candidate
149
240
  end
150
241
 
151
242
  def load_history_user_nicknames(events)
@@ -170,6 +261,8 @@ module Rollout::UI
170
261
  end
171
262
 
172
263
  def lookup_user_nicknames(ids)
264
+ return unless config.defined?(:user_lookup)
265
+
173
266
  @user_nicknames ||= {}
174
267
  @user_lookup_attempted ||= {}
175
268
  missing_ids = ids.uniq.reject { |id| @user_lookup_attempted.key?(id) }
@@ -195,26 +288,97 @@ module Rollout::UI
195
288
  end
196
289
 
197
290
  def update
291
+ enrollment = parse_enrollment!
198
292
  rollout = config.get(:instance)
199
293
  actor = config.get(:actor, scope: self)
200
- feature_data = rollout.get(params[:feature_name]).data
201
- if feature_data['updated_at'] && params[:last_updated_at].to_s != feature_data['updated_at'].to_s
202
- return redirect_to("#{feature_path(params[:feature_name])}?error=Rollout version outdated. Review changes below and try again.")
203
- end
204
294
 
205
295
  with_rollout_context(rollout, actor: actor) do
206
296
  rollout.with_feature(params[:feature_name]) do |feature|
297
+ validate_feature_version!(feature, enrollment)
207
298
  feature.percentage = params[:percentage].to_f.clamp(0.0, 100.0)
208
299
  feature.groups = (params[:groups] || []).reject(&:empty?).map(&:to_sym)
209
- if params[:users]
210
- feature.users = params[:users].split(',').map(&:strip).reject(&:empty?).uniq.sort
211
- end
300
+ apply_enrollment!(feature, enrollment)
212
301
  feature.data["description"] = params[:description]
213
302
  feature.data["updated_at"] = Time.now.to_i
214
303
  end
215
304
  end
216
305
 
217
- redirect_to feature_path(params[:feature_name])
306
+ redirect_location = feature_path(params[:feature_name])
307
+ if params.key?(:page)
308
+ after_feature = config.get(:instance).get(params[:feature_name])
309
+ total_pages = [ (after_feature.users.map(&:to_s).uniq.size.to_f / USER_PAGE_SIZE).ceil, 1 ].max
310
+ page = parse_users_page(params[:page], total_pages)
311
+ redirect_location = "#{feature_path(params[:feature_name])}?#{Rack::Utils.build_query('page' => page)}"
312
+ end
313
+ redirect_to redirect_location
314
+ rescue StaleFeature => error
315
+ query = Rack::Utils.build_query(error: error.message)
316
+ redirect_to("#{feature_path(params[:feature_name])}?#{query}")
317
+ rescue InvalidEnrollment
318
+ bad_request(StandardError.new('invalid enrollment update'))
319
+ end
320
+
321
+ # Delta mode is only the scalar marker "1" plus a real fingerprint.
322
+ # Any other present marker is rejected so it cannot fall through to a
323
+ # legacy full-list replacement. Legacy replacement runs only when the
324
+ # marker is absent, and only for a scalar string.
325
+ def parse_enrollment!
326
+ if params.key?(:user_changes)
327
+ raise InvalidEnrollment unless params[:user_changes] == '1'
328
+ raise InvalidEnrollment unless params[:feature_version].is_a?(String) && !params[:feature_version].empty?
329
+ additions = parse_delta_ids(params[:users_to_add])
330
+ removals = parse_delta_ids(params[:users_to_remove])
331
+ raise InvalidEnrollment if additions.nil? || removals.nil?
332
+ return [:delta, additions, removals]
333
+ end
334
+
335
+ return nil unless params.key?(:users)
336
+
337
+ users = params[:users]
338
+ return nil if users.nil?
339
+ raise InvalidEnrollment unless users.is_a?(String)
340
+ [:legacy, users]
341
+ end
342
+
343
+ def apply_enrollment!(feature, enrollment)
344
+ return if enrollment.nil?
345
+
346
+ kind, *payload = enrollment
347
+ if kind == :delta
348
+ additions, removals = payload
349
+ current = feature.users.map { |id| id.to_s }.uniq
350
+ feature.users = (current + additions - removals).uniq.sort
351
+ else
352
+ feature.users = payload.fetch(0).split(',').map(&:strip).reject(&:empty?).uniq.sort
353
+ end
354
+ end
355
+
356
+ def parse_delta_ids(value)
357
+ return [] if value.nil?
358
+ return nil unless value.is_a?(String) || value.is_a?(Array)
359
+ list = value.is_a?(Array) ? value : value.to_s.split(',')
360
+ return nil unless list.all? { |entry| entry.is_a?(String) }
361
+ list.map(&:strip).reject(&:empty?)
362
+ rescue StandardError
363
+ nil
364
+ end
365
+
366
+ def validate_feature_version!(feature, enrollment = nil)
367
+ if enrollment&.first == :delta
368
+ version = params[:feature_version]
369
+ raise InvalidEnrollment unless version.is_a?(String) && !version.empty?
370
+ if version != feature_version(feature)
371
+ raise StaleFeature, STALE_FEATURE_MESSAGE
372
+ end
373
+ return
374
+ end
375
+
376
+ stale = if params.key?(:feature_version)
377
+ params[:feature_version].to_s != feature_version(feature)
378
+ else
379
+ feature.data['updated_at'] && params[:last_updated_at].to_s != feature.data['updated_at'].to_s
380
+ end
381
+ raise StaleFeature, STALE_FEATURE_MESSAGE if stale
218
382
  end
219
383
 
220
384
  def activate_percentage
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rollout-ui
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.10.0
4
+ version: 0.10.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - FetLife
@@ -227,12 +227,16 @@ files:
227
227
  - lib/rollout/ui.rb
228
228
  - lib/rollout/ui/config.rb
229
229
  - lib/rollout/ui/helpers.rb
230
+ - lib/rollout/ui/public/css/enrolled-users.css
230
231
  - lib/rollout/ui/public/css/tailwind.min.css
232
+ - lib/rollout/ui/public/js/enrolled-users.js
231
233
  - lib/rollout/ui/public/js/user-selector.js
232
234
  - lib/rollout/ui/version.rb
233
235
  - lib/rollout/ui/views/features/index.erb
234
236
  - lib/rollout/ui/views/features/new.erb
237
+ - lib/rollout/ui/views/features/partials/enrolled_users.erb
235
238
  - lib/rollout/ui/views/features/partials/event_log.erb
239
+ - lib/rollout/ui/views/features/partials/user_pagination.erb
236
240
  - lib/rollout/ui/views/features/partials/user_selector.erb
237
241
  - lib/rollout/ui/views/features/show.erb
238
242
  - lib/rollout/ui/views/layout.erb