rollout-ui 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5ce5665685cf17063621b6e2612fa89366cf8ec7e353322c8219b874c7d1823d
4
- data.tar.gz: 2ad0fc635c83d3ae4f6cd54b78cc730a5fc32646538f54c6a07f3a1c17370db3
3
+ metadata.gz: 30014dd8f03ca67cb9bd7152adb431746e9d1ec399201e2a51abb4f3dedab1a4
4
+ data.tar.gz: bfa5ade0416b04978bc47f461513dcb857e88a2aa8838a140564a12fe9101a8f
5
5
  SHA512:
6
- metadata.gz: 9b4653f7bd4940e933f318fcb1d09530b636dbc405ecd44678dfb894e443429a24f4cedbb82c37f9110673adec99deb458796e1cb20b8fb4bb43667b505b548a
7
- data.tar.gz: 5deb01d8c2c3dca6f6687535fb5f125273216ba3fab9afca3cd62326354d2b343a07f2e7e1d31308464a2a898c155716c35ee228867532ad1191e6ced840a9f6
6
+ metadata.gz: bf00bea7e987f4569fff37e268e48dccaf5b30c9cf62e545d8276ab66f426da578fc454e5ab1fd2b1db065a58284400ea72e4a0b8cd3eac088715c0f4d50a041
7
+ data.tar.gz: cea39dbe21f1a32be247c019e0fe7ca3073344ab918c4deb9ca7fedc80230b852b0d4e5ac745e1b4b776d41d5352030f25f41c98e70fd7a07479de4d034d67ca
@@ -7,7 +7,7 @@ on:
7
7
 
8
8
  jobs:
9
9
  release:
10
- runs-on: ubuntu-latest
10
+ runs-on: ubuntu-26.04
11
11
  permissions:
12
12
  contents: write
13
13
  id-token: write
@@ -37,7 +37,7 @@ jobs:
37
37
 
38
38
  github-release:
39
39
  needs: release
40
- runs-on: ubuntu-latest
40
+ runs-on: ubuntu-26.04
41
41
  permissions:
42
42
  contents: write
43
43
  steps:
@@ -5,12 +5,10 @@ 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
+ runs-on: ubuntu-26.04
14
12
  services:
15
13
  redis:
16
14
  image: redis:7-alpine
data/.mise.toml CHANGED
@@ -1,2 +1,2 @@
1
1
  [tools]
2
- ruby = "3.4.7"
2
+ ruby = "3.4.11"
data/.ruby-version CHANGED
@@ -1 +1 @@
1
- 3.4.7
1
+ 3.4.11
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,84 @@ 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
+ ## Missing definitions
143
+
144
+ The overview and feature page mark stored features that have no definition in
145
+ the current application as **Missing definition**. This is informational: it
146
+ does not change targeting, editing, or deletion, and a missing definition is not
147
+ proof that a feature is unused.
148
+
149
+ With Rollout's `definitions` API, the UI checks `Rollout.definitions` when the
150
+ definitions directory exists. Older Rollout versions, a missing directory, or
151
+ `Rollout.definitions_path = nil` leave checking disabled.
152
+
153
+ Applications that also register features in Ruby can supply their complete list
154
+ of defined names instead:
155
+
156
+ ```ruby
157
+ Rollout::UI.configure do
158
+ defined_features { MyApplication.defined_feature_names }
159
+ end
160
+ ```
161
+
162
+ The callback returns string or symbol names. Returning `nil` disables checking;
163
+ an empty list marks every stored feature as missing. Definitions are read once
164
+ per HTML request, so changes appear on the next request. Loading errors are not
165
+ treated as missing definitions. JSON responses are unchanged.
166
+
167
+ ## Concurrent edits
168
+
169
+ Feature forms include a `feature_version` fingerprint of the editable state:
170
+ percentage, groups, enrolled IDs, and description. Ordering of IDs and groups,
171
+ timestamps, and unrelated metadata do not change the fingerprint. Distinct edits
172
+ in the same second and changes made outside the UI are detected.
173
+
174
+ Updates validate the submitted fingerprint inside `with_feature`, before changing
175
+ state. A stale submission is rejected without saving state or history, and the
176
+ UI redirects to the latest feature so the administrator can review and retry.
177
+ Older non-delta forms without `feature_version` retain the `last_updated_at`
178
+ timestamp check; that legacy check cannot distinguish same-second edits.
179
+
180
+ Atomic conflict detection depends on the adapter's mutation guarantees. The
181
+ ActiveRecord adapter locks existing feature rows during `with_feature`. The
182
+ Redis adapter does not provide the equivalent lock or compare-and-set guarantee.
183
+
184
+ Use uncached feature reads for the Rollout instance supplied to the admin UI.
185
+ Process-local adapter caches can otherwise display an older enrolled list and
186
+ form version after another worker saves. Normal feature evaluation can use a
187
+ separate cached instance with the same group definitions. Feature responses send
188
+ `Cache-Control: no-store`; this does not bypass an adapter's server-side cache.
113
189
 
114
190
  ## Logging
115
191
 
@@ -160,6 +236,18 @@ It uses the mount's authentication, caps results server-side, and sends
160
236
  list without calling `user_search`. Queries are limited to 100 characters;
161
237
  configured minimum lengths and result limits are bounded to 1–100.
162
238
 
239
+ `GET /features/:name/users?page=2` returns the visible enrollment
240
+ page, total enrollment count, page metadata, full-state `feature_version`, and
241
+ `lookup_failed`. Pages contain at most 25 users and page numbers are clamped to
242
+ the available range. The response never contains the entire
243
+ membership list and sends `Cache-Control: no-store`.
244
+
245
+ `GET /features/:name/users/search?q=alice` uses the same nickname callbacks and
246
+ limits, adds an `enrolled` flag to each match based on the full membership, and
247
+ includes `feature_version`. The unscoped `/users/search` response is unchanged.
248
+ Paginated reads never replace the form's original version token; if the rollout
249
+ changes while editing, the administrator must review the newer state.
250
+
163
251
  ## Host Header Validation
164
252
 
165
253
  When mounted in Rails, requests pass through Rails' `config.hosts` checks. The gem
@@ -6,6 +6,7 @@ module Rollout::UI
6
6
  class Config
7
7
  KEYS = %i[
8
8
  instance
9
+ defined_features
9
10
  actor
10
11
  actor_url
11
12
  timestamp_format
@@ -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,10 +22,22 @@ 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
26
36
 
37
+ def missing_definition?(feature_name)
38
+ @defined_features && !@defined_features.include?(feature_name.to_s)
39
+ end
40
+
27
41
  def user_search_min_length
28
42
  config.get(:user_search_min_length).to_i.clamp(1, USER_SEARCH_MAX_LENGTH)
29
43
  end
@@ -32,11 +46,23 @@ module Rollout::UI
32
46
  config.get(:user_search_limit).to_i.clamp(1, 100)
33
47
  end
34
48
 
35
- def user_label(id)
49
+ def user_label(id, include_id: true)
36
50
  id = id.to_s
37
51
  return @user_nicknames[id] if @user_nicknames&.key?(id)
38
52
 
39
- @user_lookup_attempted&.key?(id) ? "Unknown user (##{id})" : "User (##{id})"
53
+ label = @user_lookup_attempted&.key?(id) ? "Unknown user" : "User"
54
+ include_id ? "#{label} (##{id})" : label
55
+ end
56
+
57
+ def feature_version(feature)
58
+ state = [
59
+ feature.name.to_s,
60
+ feature.percentage.to_f,
61
+ feature.groups.map(&:to_s).uniq.sort,
62
+ feature.users.map(&:to_s).uniq.sort,
63
+ feature.data["description"].to_s,
64
+ ]
65
+ Digest::SHA256.hexdigest(state.to_json)
40
66
  end
41
67
 
42
68
  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
+ }