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 +4 -4
- data/.github/workflows/release.yml +3 -2
- data/.github/workflows/test.yml +2 -3
- data/README.md +68 -5
- data/lib/rollout/ui/helpers.rb +25 -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/partials/enrolled_users.erb +66 -0
- data/lib/rollout/ui/views/features/partials/user_pagination.erb +7 -0
- data/lib/rollout/ui/views/features/show.erb +3 -12
- data/lib/rollout/ui/web.rb +177 -13
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8fe93ad84dab349410b8d45fd548837b3c7057db163916ae51af5415f0b6a9bd
|
|
4
|
+
data.tar.gz: 6b451b50cc1e907a63e94976c103800f26fa6366705ed34e4a31d2faad585507
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: '09ad9b11897744854625b4654e7d2a5c543492ac945ad6b91e5359b1c6aad0bab6ba0927781588f4c8b3c16e221597f625cf9acc4ca1e6c208c260d20a6e6ee8'
|
|
7
|
+
data.tar.gz: 837fa191cde919c0a88f881cc31fe300311026acf7559410f3f23bdc9ffd27c5d6f9644955337d64019c96e2642343837569d8da31648706bdf7a80d411b9bbb
|
|
@@ -7,7 +7,8 @@ on:
|
|
|
7
7
|
|
|
8
8
|
jobs:
|
|
9
9
|
release:
|
|
10
|
-
|
|
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-
|
|
41
|
+
runs-on: ubuntu-26.04
|
|
41
42
|
permissions:
|
|
42
43
|
contents: write
|
|
43
44
|
steps:
|
data/.github/workflows/test.yml
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
65
|
-
and Rollout stores user IDs. This example
|
|
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
|
|
111
|
-
|
|
112
|
-
users
|
|
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
|
data/lib/rollout/ui/helpers.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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');
|
data/lib/rollout/ui/version.rb
CHANGED
|
@@ -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>
|
|
@@ -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-
|
|
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
|
-
|
|
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">
|
data/lib/rollout/ui/web.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
@
|
|
148
|
-
@
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|