rollout-ui 0.9.3 → 0.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/workflows/release.yml +3 -2
- data/.github/workflows/test.yml +2 -3
- data/README.md +151 -30
- data/lib/rollout/ui/config.rb +7 -1
- data/lib/rollout/ui/helpers.rb +69 -5
- data/lib/rollout/ui/public/css/enrolled-users.css +191 -0
- data/lib/rollout/ui/public/js/enrolled-users.js +377 -0
- data/lib/rollout/ui/public/js/user-selector.js +174 -0
- data/lib/rollout/ui/version.rb +1 -1
- data/lib/rollout/ui/views/features/index.erb +1 -3
- data/lib/rollout/ui/views/features/partials/enrolled_users.erb +66 -0
- data/lib/rollout/ui/views/features/partials/user_pagination.erb +7 -0
- data/lib/rollout/ui/views/features/partials/user_selector.erb +15 -0
- data/lib/rollout/ui/views/features/show.erb +4 -12
- data/lib/rollout/ui/web.rb +258 -9
- metadata +7 -1
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
|
@@ -1,14 +1,12 @@
|
|
|
1
1
|
# Rollout::UI
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
you can just mount as a Rack app and it will just work.
|
|
3
|
+
A Rack-mountable UI for the [rollout](https://github.com/fetlife/rollout) gem.
|
|
5
4
|
|
|
6
5
|

|
|
7
6
|
<!--  -->
|
|
8
7
|
|
|
9
8
|
## Usage with Rails
|
|
10
9
|
|
|
11
|
-
|
|
12
10
|
Add it to your application's Gemfile:
|
|
13
11
|
|
|
14
12
|
```ruby
|
|
@@ -17,20 +15,20 @@ gem "rollout-redis-adapter", "~> 0.1"
|
|
|
17
15
|
gem "rollout-ui"
|
|
18
16
|
```
|
|
19
17
|
|
|
20
|
-
`rollout-ui` 0.9+ requires Rollout 3.1
|
|
18
|
+
`rollout-ui` 0.9+ requires Rollout 3.1 or later within 3.x. It works with any
|
|
19
|
+
Rollout adapter; Redis applications also need `rollout-redis-adapter`.
|
|
21
20
|
|
|
22
|
-
Mount
|
|
21
|
+
Mount the Rack app:
|
|
23
22
|
|
|
24
23
|
```ruby
|
|
25
24
|
Rails.application.routes.draw do
|
|
26
|
-
mount Rollout::UI::Web.new =>
|
|
25
|
+
mount Rollout::UI::Web.new => "/admin/rollout"
|
|
27
26
|
|
|
28
27
|
# ...
|
|
29
28
|
end
|
|
30
29
|
```
|
|
31
30
|
|
|
32
|
-
|
|
33
|
-
in `routes.rb` or in a standalone initializer.
|
|
31
|
+
Configure your Rollout instance in `routes.rb` or an initializer:
|
|
34
32
|
|
|
35
33
|
```ruby
|
|
36
34
|
Rollout::UI.configure do
|
|
@@ -40,12 +38,10 @@ end
|
|
|
40
38
|
|
|
41
39
|
## Authentication
|
|
42
40
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
So in case of usafe with Devise, your constraints might look like:
|
|
41
|
+
Protect the Rails mount with a constraint. For example, with Devise:
|
|
46
42
|
|
|
47
43
|
```ruby
|
|
48
|
-
|
|
44
|
+
class AdminConstraint
|
|
49
45
|
def self.matches?(request)
|
|
50
46
|
id = request.session["warden.user.user.key"].try(:[], 0).try(:[], 0)
|
|
51
47
|
return false if id.blank?
|
|
@@ -56,32 +52,119 @@ module Constraint::Admin
|
|
|
56
52
|
end
|
|
57
53
|
|
|
58
54
|
Rails.application.routes.draw do
|
|
59
|
-
mount Rollout::UI::Web.new =>
|
|
55
|
+
mount Rollout::UI::Web.new => "/admin/rollout", constraints: AdminConstraint
|
|
60
56
|
|
|
61
57
|
# ...
|
|
62
58
|
end
|
|
63
59
|
```
|
|
64
60
|
|
|
65
|
-
##
|
|
61
|
+
## Nickname user selector
|
|
66
62
|
|
|
67
|
-
|
|
63
|
+
Configure **both** callbacks below to replace the user ID field with a searchable
|
|
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:
|
|
68
67
|
|
|
69
|
-
|
|
70
|
-
|
|
68
|
+
```ruby
|
|
69
|
+
Rollout::UI.configure do
|
|
70
|
+
instance { $rollout }
|
|
71
71
|
|
|
72
|
-
|
|
72
|
+
user_search do |query, limit|
|
|
73
|
+
prefix = User.sanitize_sql_like(query.downcase) + "%"
|
|
74
|
+
User.where("lower(nickname) LIKE ?", prefix)
|
|
75
|
+
.limit(limit)
|
|
76
|
+
.pluck(:id, :nickname)
|
|
77
|
+
.map { |id, nickname| { id: id.to_s, nickname: nickname } }
|
|
78
|
+
end
|
|
73
79
|
|
|
74
|
-
|
|
75
|
-
|
|
80
|
+
user_lookup do |ids|
|
|
81
|
+
User.where(id: ids)
|
|
82
|
+
.pluck(:id, :nickname)
|
|
83
|
+
.map { |id, nickname| { id: id.to_s, nickname: nickname } }
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
```
|
|
76
87
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
88
|
+
Both callbacks return arrays of hashes with `id` and `nickname` keys (symbols or
|
|
89
|
+
strings). `user_search` receives a trimmed nickname query and a result limit.
|
|
90
|
+
`user_lookup` receives a batch of selected or historical user IDs as strings;
|
|
91
|
+
omit missing accounts. Apply your account visibility rules in both callbacks.
|
|
92
|
+
|
|
93
|
+
Search defaults to **3 characters** and **20 results**, with a 300 ms debounce.
|
|
94
|
+
Override these settings inside the same configuration block:
|
|
95
|
+
|
|
96
|
+
```ruby
|
|
97
|
+
user_search_min_length { 3 }
|
|
98
|
+
user_search_limit { 20 }
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
For large directories, use **indexed nickname-only prefix matching** and apply
|
|
102
|
+
the limit in the database. Escape wildcards as above; avoid loading all users or
|
|
103
|
+
running unindexed `%contains%` queries. A matching index for this example is:
|
|
104
|
+
|
|
105
|
+
```sql
|
|
106
|
+
CREATE INDEX CONCURRENTLY users_nickname_prefix_idx
|
|
107
|
+
ON users (lower(nickname) text_pattern_ops);
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Check the query plan against your schema and collation. The selector supports
|
|
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.
|
|
80
164
|
|
|
81
165
|
## Logging
|
|
82
166
|
|
|
83
|
-
|
|
84
|
-
on your rollout instance to see history of changes in the UI.
|
|
167
|
+
Enable logging on your Rollout instance to display change history:
|
|
85
168
|
|
|
86
169
|
```ruby
|
|
87
170
|
require "redis"
|
|
@@ -95,14 +178,12 @@ $rollout = Rollout.new(
|
|
|
95
178
|
)
|
|
96
179
|
```
|
|
97
180
|
|
|
98
|
-
|
|
99
|
-
`actor_url`. So if you are using Rails with Devise, your configuration might
|
|
100
|
-
look like:
|
|
181
|
+
Configure `actor` and `actor_url` to attribute changes. For example, with Devise:
|
|
101
182
|
|
|
102
183
|
```ruby
|
|
103
184
|
Rollout::UI.configure do
|
|
104
185
|
instance { $rollout }
|
|
105
|
-
actor { current_user&.
|
|
186
|
+
actor { current_user&.nickname }
|
|
106
187
|
actor_url { |actor| "/#{actor}" }
|
|
107
188
|
end
|
|
108
189
|
```
|
|
@@ -112,6 +193,46 @@ deletions appear in the overview history with the configured actor. This
|
|
|
112
193
|
requires global logging (`global: true`); adapters without event-aware deletion
|
|
113
194
|
retain the previous behavior and do not add a deletion event.
|
|
114
195
|
|
|
196
|
+
With `user_lookup` configured, feature and overview history display current
|
|
197
|
+
nicknames while audit events retain IDs. History resolves up to 150 additional
|
|
198
|
+
distinct IDs per page; missing accounts, failed lookups, and IDs beyond that limit
|
|
199
|
+
use ID-based labels.
|
|
200
|
+
|
|
201
|
+
## API Endpoints
|
|
202
|
+
|
|
203
|
+
Send `Accept: application/json` to the index or feature route for JSON responses.
|
|
204
|
+
The index also accepts filters such as `/admin/rollout?user=123` and
|
|
205
|
+
`/admin/rollout?group=developers`.
|
|
206
|
+
|
|
207
|
+
When both nickname callbacks are configured, `GET /users/search?q=alice`
|
|
208
|
+
(relative to the mount) returns `{"users":[{"id":"123","nickname":"alice"}]}`.
|
|
209
|
+
It uses the mount's authentication, caps results server-side, and sends
|
|
210
|
+
`Cache-Control: no-store`. Queries below the configured minimum return an empty
|
|
211
|
+
list without calling `user_search`. Queries are limited to 100 characters;
|
|
212
|
+
configured minimum lengths and result limits are bounded to 1–100.
|
|
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
|
+
|
|
226
|
+
## Host Header Validation
|
|
227
|
+
|
|
228
|
+
When mounted in Rails, requests pass through Rails' `config.hosts` checks. The gem
|
|
229
|
+
performs no additional Host header validation.
|
|
230
|
+
|
|
231
|
+
For standalone use (e.g. `rackup`), configure authentication and host validation
|
|
232
|
+
through middleware such as
|
|
233
|
+
[`Rack::Protection::HostAuthorization`](https://github.com/sinatra/sinatra/tree/main/rack-protection#host-authorization-api)
|
|
234
|
+
or a reverse proxy that only forwards trusted hosts.
|
|
235
|
+
|
|
115
236
|
## Contributing
|
|
116
237
|
|
|
117
238
|
Bug reports and pull requests are welcome on GitHub at https://github.com/fetlife/rollout-ui.
|
|
@@ -141,7 +262,7 @@ bundle exec rerun rackup
|
|
|
141
262
|
|
|
142
263
|
And visit [http://localhost:9292/](http://localhost:9292/).
|
|
143
264
|
|
|
144
|
-
|
|
265
|
+
To use a different Redis connection:
|
|
145
266
|
|
|
146
267
|
```sh
|
|
147
268
|
REDIS_HOST=localhost REDIS_PORT=6379 REDIS_DB=10 bundle exec rerun rackup
|
data/lib/rollout/ui/config.rb
CHANGED
|
@@ -9,10 +9,16 @@ module Rollout::UI
|
|
|
9
9
|
actor
|
|
10
10
|
actor_url
|
|
11
11
|
timestamp_format
|
|
12
|
+
user_search
|
|
13
|
+
user_lookup
|
|
14
|
+
user_search_min_length
|
|
15
|
+
user_search_limit
|
|
12
16
|
].freeze
|
|
13
17
|
|
|
14
18
|
DEFAULT_VALUES = {
|
|
15
|
-
timestamp_format: '%Y-%m-%d %H:%M %Z'
|
|
19
|
+
timestamp_format: '%Y-%m-%d %H:%M %Z',
|
|
20
|
+
user_search_min_length: 3,
|
|
21
|
+
user_search_limit: 20
|
|
16
22
|
}.freeze
|
|
17
23
|
|
|
18
24
|
KEYS.each do |key|
|
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,10 +7,60 @@ require "rollout/ui/version"
|
|
|
5
7
|
|
|
6
8
|
module Rollout::UI
|
|
7
9
|
module Helpers
|
|
10
|
+
USER_PAGE_SIZE = 25
|
|
11
|
+
USER_SEARCH_MAX_LENGTH = 100
|
|
12
|
+
|
|
8
13
|
def stylesheet_path(name)
|
|
9
14
|
"#{request.script_name}/css/#{name}.css"
|
|
10
15
|
end
|
|
11
16
|
|
|
17
|
+
def javascript_path(name)
|
|
18
|
+
"#{request.script_name}/js/#{name}.js"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def user_search_path
|
|
22
|
+
"#{request.script_name}/users/search"
|
|
23
|
+
end
|
|
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
|
+
|
|
33
|
+
def user_selector_enabled?
|
|
34
|
+
config.defined?(:user_search) && config.defined?(:user_lookup)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def user_search_min_length
|
|
38
|
+
config.get(:user_search_min_length).to_i.clamp(1, USER_SEARCH_MAX_LENGTH)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def user_search_limit
|
|
42
|
+
config.get(:user_search_limit).to_i.clamp(1, 100)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def user_label(id, include_id: true)
|
|
46
|
+
id = id.to_s
|
|
47
|
+
return @user_nicknames[id] if @user_nicknames&.key?(id)
|
|
48
|
+
|
|
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)
|
|
62
|
+
end
|
|
63
|
+
|
|
12
64
|
def index_path
|
|
13
65
|
"#{request.script_name}/"
|
|
14
66
|
end
|
|
@@ -84,10 +136,15 @@ module Rollout::UI
|
|
|
84
136
|
key.to_s.gsub('data.', '')
|
|
85
137
|
end
|
|
86
138
|
|
|
87
|
-
def format_change_value(value)
|
|
139
|
+
def format_change_value(value, key: nil)
|
|
88
140
|
case value
|
|
89
141
|
when Array
|
|
90
|
-
|
|
142
|
+
values = if key.to_s == 'users' && config.defined?(:user_lookup)
|
|
143
|
+
value.map { |id| user_label(id) }
|
|
144
|
+
else
|
|
145
|
+
value
|
|
146
|
+
end
|
|
147
|
+
"[#{values.join(', ')}]"
|
|
91
148
|
when String, nil
|
|
92
149
|
"'#{value}'"
|
|
93
150
|
else
|
|
@@ -106,9 +163,16 @@ module Rollout::UI
|
|
|
106
163
|
def event_summary(event)
|
|
107
164
|
return "deleted this feature" if event.name.to_s == "delete"
|
|
108
165
|
|
|
109
|
-
changes = event.data.fetch(:before).keys.
|
|
110
|
-
|
|
111
|
-
|
|
166
|
+
changes = event.data.fetch(:before).keys.filter_map do |key|
|
|
167
|
+
next if key.to_s == 'data.updated_at'
|
|
168
|
+
|
|
169
|
+
before_value = event.data.fetch(:before).fetch(key)
|
|
170
|
+
after_value = event.data.fetch(:after).fetch(key)
|
|
171
|
+
next if before_value == after_value
|
|
172
|
+
next if key.to_s == 'data.description' && before_value.to_s == after_value.to_s
|
|
173
|
+
|
|
174
|
+
before = format_change_value(before_value, key: key)
|
|
175
|
+
after = format_change_value(after_value, key: key)
|
|
112
176
|
"#{format_change_key(key)} from #{before} to #{after}"
|
|
113
177
|
end
|
|
114
178
|
"changed #{changes.empty? ? 'nothing!' : changes.join(', ')}"
|
|
@@ -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
|
+
}
|