rollout-ui 0.9.3 → 0.10.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 +4 -4
- data/README.md +88 -30
- data/lib/rollout/ui/config.rb +7 -1
- data/lib/rollout/ui/helpers.rb +47 -5
- data/lib/rollout/ui/public/js/user-selector.js +172 -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/user_selector.erb +15 -0
- data/lib/rollout/ui/views/features/show.erb +7 -6
- data/lib/rollout/ui/web.rb +87 -2
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5ce5665685cf17063621b6e2612fa89366cf8ec7e353322c8219b874c7d1823d
|
|
4
|
+
data.tar.gz: 2ad0fc635c83d3ae4f6cd54b78cc730a5fc32646538f54c6a07f3a1c17370db3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9b4653f7bd4940e933f318fcb1d09530b636dbc405ecd44678dfb894e443429a24f4cedbb82c37f9110673adec99deb458796e1cb20b8fb4bb43667b505b548a
|
|
7
|
+
data.tar.gz: 5deb01d8c2c3dca6f6687535fb5f125273216ba3fab9afca3cd62326354d2b343a07f2e7e1d31308464a2a898c155716c35ee228867532ad1191e6ced840a9f6
|
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,68 @@ 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
|
+
multi-select. Your application supplies the queries; the UI displays nicknames
|
|
65
|
+
and Rollout stores user IDs. This example uses Rails and PostgreSQL:
|
|
68
66
|
|
|
69
|
-
|
|
70
|
-
|
|
67
|
+
```ruby
|
|
68
|
+
Rollout::UI.configure do
|
|
69
|
+
instance { $rollout }
|
|
71
70
|
|
|
72
|
-
|
|
71
|
+
user_search do |query, limit|
|
|
72
|
+
prefix = User.sanitize_sql_like(query.downcase) + "%"
|
|
73
|
+
User.where("lower(nickname) LIKE ?", prefix)
|
|
74
|
+
.limit(limit)
|
|
75
|
+
.pluck(:id, :nickname)
|
|
76
|
+
.map { |id, nickname| { id: id.to_s, nickname: nickname } }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
user_lookup do |ids|
|
|
80
|
+
User.where(id: ids)
|
|
81
|
+
.pluck(:id, :nickname)
|
|
82
|
+
.map { |id, nickname| { id: id.to_s, nickname: nickname } }
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Both callbacks return arrays of hashes with `id` and `nickname` keys (symbols or
|
|
88
|
+
strings). `user_search` receives a trimmed nickname query and a result limit.
|
|
89
|
+
`user_lookup` receives a batch of selected or historical user IDs as strings;
|
|
90
|
+
omit missing accounts. Apply your account visibility rules in both callbacks.
|
|
91
|
+
|
|
92
|
+
Search defaults to **3 characters** and **20 results**, with a 300 ms debounce.
|
|
93
|
+
Override these settings inside the same configuration block:
|
|
73
94
|
|
|
74
|
-
|
|
75
|
-
|
|
95
|
+
```ruby
|
|
96
|
+
user_search_min_length { 3 }
|
|
97
|
+
user_search_limit { 20 }
|
|
98
|
+
```
|
|
76
99
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
100
|
+
For large directories, use **indexed nickname-only prefix matching** and apply
|
|
101
|
+
the limit in the database. Escape wildcards as above; avoid loading all users or
|
|
102
|
+
running unindexed `%contains%` queries. A matching index for this example is:
|
|
103
|
+
|
|
104
|
+
```sql
|
|
105
|
+
CREATE INDEX CONCURRENTLY users_nickname_prefix_idx
|
|
106
|
+
ON users (lower(nickname) text_pattern_ops);
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
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.
|
|
80
113
|
|
|
81
114
|
## Logging
|
|
82
115
|
|
|
83
|
-
|
|
84
|
-
on your rollout instance to see history of changes in the UI.
|
|
116
|
+
Enable logging on your Rollout instance to display change history:
|
|
85
117
|
|
|
86
118
|
```ruby
|
|
87
119
|
require "redis"
|
|
@@ -95,14 +127,12 @@ $rollout = Rollout.new(
|
|
|
95
127
|
)
|
|
96
128
|
```
|
|
97
129
|
|
|
98
|
-
|
|
99
|
-
`actor_url`. So if you are using Rails with Devise, your configuration might
|
|
100
|
-
look like:
|
|
130
|
+
Configure `actor` and `actor_url` to attribute changes. For example, with Devise:
|
|
101
131
|
|
|
102
132
|
```ruby
|
|
103
133
|
Rollout::UI.configure do
|
|
104
134
|
instance { $rollout }
|
|
105
|
-
actor { current_user&.
|
|
135
|
+
actor { current_user&.nickname }
|
|
106
136
|
actor_url { |actor| "/#{actor}" }
|
|
107
137
|
end
|
|
108
138
|
```
|
|
@@ -112,6 +142,34 @@ deletions appear in the overview history with the configured actor. This
|
|
|
112
142
|
requires global logging (`global: true`); adapters without event-aware deletion
|
|
113
143
|
retain the previous behavior and do not add a deletion event.
|
|
114
144
|
|
|
145
|
+
With `user_lookup` configured, feature and overview history display current
|
|
146
|
+
nicknames while audit events retain IDs. History resolves up to 150 additional
|
|
147
|
+
distinct IDs per page; missing accounts, failed lookups, and IDs beyond that limit
|
|
148
|
+
use ID-based labels.
|
|
149
|
+
|
|
150
|
+
## API Endpoints
|
|
151
|
+
|
|
152
|
+
Send `Accept: application/json` to the index or feature route for JSON responses.
|
|
153
|
+
The index also accepts filters such as `/admin/rollout?user=123` and
|
|
154
|
+
`/admin/rollout?group=developers`.
|
|
155
|
+
|
|
156
|
+
When both nickname callbacks are configured, `GET /users/search?q=alice`
|
|
157
|
+
(relative to the mount) returns `{"users":[{"id":"123","nickname":"alice"}]}`.
|
|
158
|
+
It uses the mount's authentication, caps results server-side, and sends
|
|
159
|
+
`Cache-Control: no-store`. Queries below the configured minimum return an empty
|
|
160
|
+
list without calling `user_search`. Queries are limited to 100 characters;
|
|
161
|
+
configured minimum lengths and result limits are bounded to 1–100.
|
|
162
|
+
|
|
163
|
+
## Host Header Validation
|
|
164
|
+
|
|
165
|
+
When mounted in Rails, requests pass through Rails' `config.hosts` checks. The gem
|
|
166
|
+
performs no additional Host header validation.
|
|
167
|
+
|
|
168
|
+
For standalone use (e.g. `rackup`), configure authentication and host validation
|
|
169
|
+
through middleware such as
|
|
170
|
+
[`Rack::Protection::HostAuthorization`](https://github.com/sinatra/sinatra/tree/main/rack-protection#host-authorization-api)
|
|
171
|
+
or a reverse proxy that only forwards trusted hosts.
|
|
172
|
+
|
|
115
173
|
## Contributing
|
|
116
174
|
|
|
117
175
|
Bug reports and pull requests are welcome on GitHub at https://github.com/fetlife/rollout-ui.
|
|
@@ -141,7 +199,7 @@ bundle exec rerun rackup
|
|
|
141
199
|
|
|
142
200
|
And visit [http://localhost:9292/](http://localhost:9292/).
|
|
143
201
|
|
|
144
|
-
|
|
202
|
+
To use a different Redis connection:
|
|
145
203
|
|
|
146
204
|
```sh
|
|
147
205
|
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
|
@@ -5,10 +5,40 @@ require "rollout/ui/version"
|
|
|
5
5
|
|
|
6
6
|
module Rollout::UI
|
|
7
7
|
module Helpers
|
|
8
|
+
USER_EDIT_LIMIT = 150
|
|
9
|
+
USER_SEARCH_MAX_LENGTH = 100
|
|
10
|
+
|
|
8
11
|
def stylesheet_path(name)
|
|
9
12
|
"#{request.script_name}/css/#{name}.css"
|
|
10
13
|
end
|
|
11
14
|
|
|
15
|
+
def javascript_path(name)
|
|
16
|
+
"#{request.script_name}/js/#{name}.js"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def user_search_path
|
|
20
|
+
"#{request.script_name}/users/search"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def user_selector_enabled?
|
|
24
|
+
config.defined?(:user_search) && config.defined?(:user_lookup)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def user_search_min_length
|
|
28
|
+
config.get(:user_search_min_length).to_i.clamp(1, USER_SEARCH_MAX_LENGTH)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def user_search_limit
|
|
32
|
+
config.get(:user_search_limit).to_i.clamp(1, 100)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def user_label(id)
|
|
36
|
+
id = id.to_s
|
|
37
|
+
return @user_nicknames[id] if @user_nicknames&.key?(id)
|
|
38
|
+
|
|
39
|
+
@user_lookup_attempted&.key?(id) ? "Unknown user (##{id})" : "User (##{id})"
|
|
40
|
+
end
|
|
41
|
+
|
|
12
42
|
def index_path
|
|
13
43
|
"#{request.script_name}/"
|
|
14
44
|
end
|
|
@@ -84,10 +114,15 @@ module Rollout::UI
|
|
|
84
114
|
key.to_s.gsub('data.', '')
|
|
85
115
|
end
|
|
86
116
|
|
|
87
|
-
def format_change_value(value)
|
|
117
|
+
def format_change_value(value, key: nil)
|
|
88
118
|
case value
|
|
89
119
|
when Array
|
|
90
|
-
|
|
120
|
+
values = if key.to_s == 'users' && config.defined?(:user_lookup)
|
|
121
|
+
value.map { |id| user_label(id) }
|
|
122
|
+
else
|
|
123
|
+
value
|
|
124
|
+
end
|
|
125
|
+
"[#{values.join(', ')}]"
|
|
91
126
|
when String, nil
|
|
92
127
|
"'#{value}'"
|
|
93
128
|
else
|
|
@@ -106,9 +141,16 @@ module Rollout::UI
|
|
|
106
141
|
def event_summary(event)
|
|
107
142
|
return "deleted this feature" if event.name.to_s == "delete"
|
|
108
143
|
|
|
109
|
-
changes = event.data.fetch(:before).keys.
|
|
110
|
-
|
|
111
|
-
|
|
144
|
+
changes = event.data.fetch(:before).keys.filter_map do |key|
|
|
145
|
+
next if key.to_s == 'data.updated_at'
|
|
146
|
+
|
|
147
|
+
before_value = event.data.fetch(:before).fetch(key)
|
|
148
|
+
after_value = event.data.fetch(:after).fetch(key)
|
|
149
|
+
next if before_value == after_value
|
|
150
|
+
next if key.to_s == 'data.description' && before_value.to_s == after_value.to_s
|
|
151
|
+
|
|
152
|
+
before = format_change_value(before_value, key: key)
|
|
153
|
+
after = format_change_value(after_value, key: key)
|
|
112
154
|
"#{format_change_key(key)} from #{before} to #{after}"
|
|
113
155
|
end
|
|
114
156
|
"changed #{changes.empty? ? 'nothing!' : changes.join(', ')}"
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
(function() {
|
|
2
|
+
const picker = document.getElementById('user-selector');
|
|
3
|
+
if (!picker) return;
|
|
4
|
+
|
|
5
|
+
const field = document.getElementById('users');
|
|
6
|
+
const input = document.getElementById('user-nickname');
|
|
7
|
+
const chips = document.getElementById('selected-users');
|
|
8
|
+
const list = document.getElementById('user-search-results');
|
|
9
|
+
const status = document.getElementById('user-search-status');
|
|
10
|
+
const minLength = Number(picker.dataset.minLength);
|
|
11
|
+
const limit = Number(picker.dataset.limit);
|
|
12
|
+
const selected = new Map();
|
|
13
|
+
chips.querySelectorAll('[data-user-id]').forEach(function(chip) {
|
|
14
|
+
selected.set(chip.dataset.userId, chip.dataset.nickname);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
let timer;
|
|
18
|
+
let controller;
|
|
19
|
+
let revision = 0;
|
|
20
|
+
let results = [];
|
|
21
|
+
let activeIndex = -1;
|
|
22
|
+
const prompt = 'Type at least ' + minLength + ' characters to search.';
|
|
23
|
+
|
|
24
|
+
function closeList() {
|
|
25
|
+
list.hidden = true;
|
|
26
|
+
input.setAttribute('aria-expanded', 'false');
|
|
27
|
+
input.removeAttribute('aria-activedescendant');
|
|
28
|
+
activeIndex = -1;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function cancelSearch() {
|
|
32
|
+
clearTimeout(timer);
|
|
33
|
+
if (controller) controller.abort();
|
|
34
|
+
revision += 1;
|
|
35
|
+
closeList();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function renderChips() {
|
|
39
|
+
chips.replaceChildren();
|
|
40
|
+
selected.forEach(function(nickname, id) {
|
|
41
|
+
const chip = document.createElement('span');
|
|
42
|
+
chip.className = 'inline-flex items-center bg-gray-200 rounded-sm px-2 py-1 mr-2 mb-2';
|
|
43
|
+
const label = document.createElement('span');
|
|
44
|
+
label.textContent = nickname;
|
|
45
|
+
const remove = document.createElement('button');
|
|
46
|
+
remove.type = 'button';
|
|
47
|
+
remove.className = 'ml-2 text-gray-600';
|
|
48
|
+
remove.textContent = '×';
|
|
49
|
+
remove.setAttribute('aria-label', 'Remove ' + nickname);
|
|
50
|
+
remove.addEventListener('click', function() {
|
|
51
|
+
selected.delete(id);
|
|
52
|
+
updateSelection(nickname + ' removed.');
|
|
53
|
+
});
|
|
54
|
+
chip.append(label, remove);
|
|
55
|
+
chips.append(chip);
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function choose(user) {
|
|
60
|
+
selected.set(user.id, user.nickname);
|
|
61
|
+
updateSelection(user.nickname + ' selected.');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function updateSelection(message) {
|
|
65
|
+
field.value = Array.from(selected.keys()).join(', ');
|
|
66
|
+
renderChips();
|
|
67
|
+
cancelSearch();
|
|
68
|
+
input.value = '';
|
|
69
|
+
input.focus();
|
|
70
|
+
status.textContent = message + ' ' + prompt;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function activate(index) {
|
|
74
|
+
activeIndex = index;
|
|
75
|
+
Array.from(list.children).forEach(function(option, i) {
|
|
76
|
+
option.setAttribute('aria-selected', String(i === index));
|
|
77
|
+
option.classList.toggle('bg-gray-200', i === index);
|
|
78
|
+
});
|
|
79
|
+
const option = list.children[index];
|
|
80
|
+
if (option) {
|
|
81
|
+
input.setAttribute('aria-activedescendant', option.id);
|
|
82
|
+
option.scrollIntoView({ block: 'nearest' });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function search(query, searchRevision) {
|
|
87
|
+
controller = new AbortController();
|
|
88
|
+
status.textContent = 'Searching nicknames…';
|
|
89
|
+
try {
|
|
90
|
+
const url = new URL(picker.dataset.searchUrl, window.location.href);
|
|
91
|
+
url.searchParams.set('q', query);
|
|
92
|
+
const response = await fetch(url, {
|
|
93
|
+
signal: controller.signal,
|
|
94
|
+
headers: { Accept: 'application/json' },
|
|
95
|
+
credentials: 'same-origin',
|
|
96
|
+
cache: 'no-store'
|
|
97
|
+
});
|
|
98
|
+
if (!response.ok) throw new Error('Search failed');
|
|
99
|
+
const data = await response.json();
|
|
100
|
+
if (searchRevision !== revision) return;
|
|
101
|
+
renderResults(data.users);
|
|
102
|
+
} catch (error) {
|
|
103
|
+
if (searchRevision !== revision || error.name === 'AbortError') return;
|
|
104
|
+
closeList();
|
|
105
|
+
status.textContent = 'Nickname search is unavailable. Try typing again. Your selections are preserved.';
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function renderResults(users) {
|
|
110
|
+
results = users.filter(function(user) { return !selected.has(user.id); });
|
|
111
|
+
list.replaceChildren();
|
|
112
|
+
results.forEach(function(user, index) {
|
|
113
|
+
const option = document.createElement('div');
|
|
114
|
+
option.id = 'user-search-option-' + index;
|
|
115
|
+
option.className = 'px-4 py-2 cursor-pointer hover:bg-gray-200';
|
|
116
|
+
option.setAttribute('role', 'option');
|
|
117
|
+
option.setAttribute('aria-selected', 'false');
|
|
118
|
+
option.textContent = user.nickname;
|
|
119
|
+
option.addEventListener('pointerdown', function(event) { event.preventDefault(); });
|
|
120
|
+
option.addEventListener('click', function() { choose(user); });
|
|
121
|
+
list.append(option);
|
|
122
|
+
});
|
|
123
|
+
list.hidden = results.length === 0;
|
|
124
|
+
input.setAttribute('aria-expanded', String(results.length > 0));
|
|
125
|
+
if (users.length === 0) {
|
|
126
|
+
status.textContent = 'No matching nicknames.';
|
|
127
|
+
} else if (results.length === 0) {
|
|
128
|
+
status.textContent = 'Matching users are already selected.';
|
|
129
|
+
} else {
|
|
130
|
+
status.textContent = results.length + (results.length === 1 ? ' match.' : ' matches.') + ' Use ↑/↓ and Enter to select.';
|
|
131
|
+
}
|
|
132
|
+
if (users.length >= limit) status.textContent += ' Refine your nickname to narrow the results.';
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function scheduleSearch() {
|
|
136
|
+
cancelSearch();
|
|
137
|
+
const query = input.value.trim();
|
|
138
|
+
if (Array.from(query).length < minLength) {
|
|
139
|
+
status.textContent = prompt;
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
status.textContent = 'Waiting to search…';
|
|
143
|
+
const searchRevision = revision;
|
|
144
|
+
timer = setTimeout(function() { search(query, searchRevision); }, 300);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
input.addEventListener('input', scheduleSearch);
|
|
148
|
+
input.addEventListener('focus', scheduleSearch);
|
|
149
|
+
|
|
150
|
+
input.addEventListener('keydown', function(event) {
|
|
151
|
+
if (event.key === 'Enter') {
|
|
152
|
+
event.preventDefault();
|
|
153
|
+
if (!list.hidden && activeIndex >= 0) choose(results[activeIndex]);
|
|
154
|
+
} else if ((event.key === 'ArrowDown' || event.key === 'ArrowUp') && !list.hidden) {
|
|
155
|
+
event.preventDefault();
|
|
156
|
+
const next = event.key === 'ArrowDown' ? activeIndex + 1 :
|
|
157
|
+
activeIndex < 0 ? results.length - 1 : activeIndex - 1;
|
|
158
|
+
activate((next + results.length) % results.length);
|
|
159
|
+
} else if (event.key === 'Escape') {
|
|
160
|
+
cancelSearch();
|
|
161
|
+
status.textContent = prompt;
|
|
162
|
+
} else if (event.key === 'Tab') {
|
|
163
|
+
cancelSearch();
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
input.addEventListener('blur', function() { cancelSearch(); });
|
|
168
|
+
renderChips();
|
|
169
|
+
document.querySelector('label[for="users"]').htmlFor = input.id;
|
|
170
|
+
field.hidden = true;
|
|
171
|
+
picker.hidden = false;
|
|
172
|
+
})();
|
data/lib/rollout/ui/version.rb
CHANGED
|
@@ -70,6 +70,4 @@
|
|
|
70
70
|
</table>
|
|
71
71
|
</div>
|
|
72
72
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
<%= erb :"features/partials/event_log", layout: false, locals: { events: global_history_events, show_feature: true, show_header: true, existing_features: @features } %>
|
|
73
|
+
<%= erb :"features/partials/event_log", layout: false, locals: { events: @history_events, show_feature: true, show_header: true, existing_features: @features } %>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<div id="user-selector" data-search-url="<%= h(user_search_path) %>" data-min-length="<%= h(user_search_min_length) %>" data-limit="<%= h(user_search_limit) %>" hidden>
|
|
2
|
+
<div id="selected-users" class="flex flex-wrap mb-2" aria-label="Selected users">
|
|
3
|
+
<% @selected_users.each do |user| %>
|
|
4
|
+
<span data-user-id="<%= h(user[:id]) %>" data-nickname="<%= h(user[:nickname]) %>"></span>
|
|
5
|
+
<% end %>
|
|
6
|
+
</div>
|
|
7
|
+
<label class="block text-gray-500 mb-2" for="user-nickname">Search by nickname</label>
|
|
8
|
+
<input id="user-nickname" class="appearance-none border rounded-sm w-full py-2 px-4 text-gray-600 leading-relaxed bg-white hover:border-gray-500" type="text" role="combobox" aria-autocomplete="list" aria-expanded="false" aria-controls="user-search-results" aria-describedby="user-search-status" autocomplete="off" spellcheck="false" maxlength="<%= USER_SEARCH_MAX_LENGTH %>" placeholder="Type a nickname…">
|
|
9
|
+
<div id="user-search-results" class="border rounded-sm bg-white mt-1" role="listbox" aria-label="Matching nicknames" style="max-height: 16rem; overflow-y: auto" hidden></div>
|
|
10
|
+
<p id="user-search-status" class="text-gray-500 mt-2" role="status" aria-live="polite">Type at least <%= h(user_search_min_length) %> characters to search.</p>
|
|
11
|
+
<% if @selected_user_lookup_failed %>
|
|
12
|
+
<p class="text-gray-500 mt-2" role="status">Selected nicknames could not be loaded. Their user IDs are preserved.</p>
|
|
13
|
+
<% end %>
|
|
14
|
+
</div>
|
|
15
|
+
<script src="<%= h(javascript_path('user-selector')) %>" defer></script>
|
|
@@ -30,11 +30,14 @@
|
|
|
30
30
|
</div>
|
|
31
31
|
|
|
32
32
|
<div class="mb-5">
|
|
33
|
-
<label class="block text-gray-500 mb-2">Users</label>
|
|
34
|
-
<% if @feature.users.count >
|
|
33
|
+
<label class="block text-gray-500 mb-2" for="users">Users</label>
|
|
34
|
+
<% if @feature.users.count > USER_EDIT_LIMIT %>
|
|
35
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
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"
|
|
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 %>
|
|
38
41
|
<% end %>
|
|
39
42
|
</div>
|
|
40
43
|
</form>
|
|
@@ -47,6 +50,4 @@
|
|
|
47
50
|
</div>
|
|
48
51
|
</main>
|
|
49
52
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
<%= erb :"features/partials/event_log", layout: false, locals: { events: history_events } %>
|
|
53
|
+
<%= erb :"features/partials/event_log", layout: false, locals: { events: @history_events } %>
|
data/lib/rollout/ui/web.rb
CHANGED
|
@@ -14,7 +14,7 @@ module Rollout::UI
|
|
|
14
14
|
NOT_FOUND = ->(env) { [404, { 'content-type' => 'text/plain', 'x-cascade' => 'pass' }, ['Not Found']] }
|
|
15
15
|
|
|
16
16
|
def initialize
|
|
17
|
-
@static = Rack::Static.new(NOT_FOUND, urls: ['/css'], root: PUBLIC_PATH)
|
|
17
|
+
@static = Rack::Static.new(NOT_FOUND, urls: ['/css', '/js'], root: PUBLIC_PATH)
|
|
18
18
|
end
|
|
19
19
|
|
|
20
20
|
def call(env)
|
|
@@ -32,9 +32,11 @@ module Rollout::UI
|
|
|
32
32
|
|
|
33
33
|
VIEWS_PATH = File.expand_path('views', __dir__)
|
|
34
34
|
TEMPLATE_CACHE = {}
|
|
35
|
+
HISTORY_USER_LOOKUP_LIMIT = 150
|
|
35
36
|
|
|
36
37
|
ROUTES = [
|
|
37
38
|
[:GET, %r{\A/\z}, :index],
|
|
39
|
+
[:GET, %r{\A/users/search\z}, :search_users],
|
|
38
40
|
[:GET, %r{\A/features/new\z}, :new_feature],
|
|
39
41
|
[:POST, %r{\A/features/new\z}, :create_feature],
|
|
40
42
|
[:POST, %r{\A/features/(?<feature_name>[^/]+)/activate-percentage\z}, :activate_percentage],
|
|
@@ -86,6 +88,8 @@ module Rollout::UI
|
|
|
86
88
|
end
|
|
87
89
|
)
|
|
88
90
|
else
|
|
91
|
+
@history_events = @rollout.respond_to?(:logging) ? @rollout.logging.global_events.reverse : []
|
|
92
|
+
load_history_user_nicknames(@history_events)
|
|
89
93
|
render_view(:'features/index')
|
|
90
94
|
end
|
|
91
95
|
end
|
|
@@ -105,10 +109,91 @@ module Rollout::UI
|
|
|
105
109
|
if json_request?
|
|
106
110
|
json(feature_to_hash(@feature))
|
|
107
111
|
else
|
|
112
|
+
load_selected_users if user_selector_enabled? && @feature.users.count <= USER_EDIT_LIMIT
|
|
113
|
+
@history_events = @rollout.respond_to?(:logging) ? @rollout.logging.events(@feature.name).reverse : []
|
|
114
|
+
load_history_user_nicknames(@history_events)
|
|
108
115
|
render_view(:'features/show')
|
|
109
116
|
end
|
|
110
117
|
end
|
|
111
118
|
|
|
119
|
+
def search_users
|
|
120
|
+
return not_found unless user_selector_enabled?
|
|
121
|
+
|
|
122
|
+
response['cache-control'] = 'no-store'
|
|
123
|
+
query = params[:q]
|
|
124
|
+
unless query.nil? || query.is_a?(String)
|
|
125
|
+
response.status = 400
|
|
126
|
+
return json(error: 'Nickname must be a string.')
|
|
127
|
+
end
|
|
128
|
+
query = query.to_s.strip
|
|
129
|
+
if query.length > USER_SEARCH_MAX_LENGTH
|
|
130
|
+
response.status = 400
|
|
131
|
+
return json(error: "Nickname must be at most #{USER_SEARCH_MAX_LENGTH} characters.")
|
|
132
|
+
end
|
|
133
|
+
return json(users: []) if query.length < user_search_min_length
|
|
134
|
+
|
|
135
|
+
begin
|
|
136
|
+
users = config.get(:user_search, query, user_search_limit)
|
|
137
|
+
json(users: users.take(user_search_limit).map { |user| user_to_hash(user) })
|
|
138
|
+
rescue StandardError
|
|
139
|
+
response.status = 503
|
|
140
|
+
json(error: 'Nickname search is temporarily unavailable. Please try again.')
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def load_selected_users
|
|
145
|
+
ids = @feature.users.map(&:to_s)
|
|
146
|
+
lookup_user_nicknames(ids)
|
|
147
|
+
@selected_users = ids.map { |id| { id: id, nickname: user_label(id) } }
|
|
148
|
+
@selected_user_lookup_failed = @user_lookup_failed
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def load_history_user_nicknames(events)
|
|
152
|
+
return unless config.defined?(:user_lookup)
|
|
153
|
+
|
|
154
|
+
ids = {}
|
|
155
|
+
events.each do |event|
|
|
156
|
+
next unless event.name.to_s == 'update'
|
|
157
|
+
|
|
158
|
+
[:before, :after].each do |side|
|
|
159
|
+
changes = event.data.fetch(side)
|
|
160
|
+
Array(changes[:users] || changes['users']).each do |id|
|
|
161
|
+
id = id.to_s
|
|
162
|
+
next if @user_lookup_attempted&.key?(id)
|
|
163
|
+
|
|
164
|
+
ids[id] = true
|
|
165
|
+
return lookup_user_nicknames(ids.keys) if ids.size >= HISTORY_USER_LOOKUP_LIMIT
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
lookup_user_nicknames(ids.keys)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def lookup_user_nicknames(ids)
|
|
173
|
+
@user_nicknames ||= {}
|
|
174
|
+
@user_lookup_attempted ||= {}
|
|
175
|
+
missing_ids = ids.uniq.reject { |id| @user_lookup_attempted.key?(id) }
|
|
176
|
+
return if missing_ids.empty?
|
|
177
|
+
|
|
178
|
+
missing_ids.each { |id| @user_lookup_attempted[id] = true }
|
|
179
|
+
config.get(:user_lookup, missing_ids).each do |user|
|
|
180
|
+
user = user_to_hash(user)
|
|
181
|
+
@user_nicknames[user[:id]] = user[:nickname]
|
|
182
|
+
end
|
|
183
|
+
rescue StandardError
|
|
184
|
+
@user_lookup_failed = true
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def user_to_hash(user)
|
|
188
|
+
# Only expose the fields needed by the selector, even if a callback
|
|
189
|
+
# returns additional application-specific attributes.
|
|
190
|
+
id = user[:id] || user['id']
|
|
191
|
+
nickname = user[:nickname] || user['nickname']
|
|
192
|
+
raise ArgumentError, 'Users must have an id and nickname' if id.nil? || nickname.to_s.empty?
|
|
193
|
+
|
|
194
|
+
{ id: id.to_s, nickname: nickname.to_s }
|
|
195
|
+
end
|
|
196
|
+
|
|
112
197
|
def update
|
|
113
198
|
rollout = config.get(:instance)
|
|
114
199
|
actor = config.get(:actor, scope: self)
|
|
@@ -122,7 +207,7 @@ module Rollout::UI
|
|
|
122
207
|
feature.percentage = params[:percentage].to_f.clamp(0.0, 100.0)
|
|
123
208
|
feature.groups = (params[:groups] || []).reject(&:empty?).map(&:to_sym)
|
|
124
209
|
if params[:users]
|
|
125
|
-
feature.users = params[:users].split(',').map(&:strip).uniq.sort
|
|
210
|
+
feature.users = params[:users].split(',').map(&:strip).reject(&:empty?).uniq.sort
|
|
126
211
|
end
|
|
127
212
|
feature.data["description"] = params[:description]
|
|
128
213
|
feature.data["updated_at"] = Time.now.to_i
|
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.
|
|
4
|
+
version: 0.10.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- FetLife
|
|
@@ -228,10 +228,12 @@ files:
|
|
|
228
228
|
- lib/rollout/ui/config.rb
|
|
229
229
|
- lib/rollout/ui/helpers.rb
|
|
230
230
|
- lib/rollout/ui/public/css/tailwind.min.css
|
|
231
|
+
- lib/rollout/ui/public/js/user-selector.js
|
|
231
232
|
- lib/rollout/ui/version.rb
|
|
232
233
|
- lib/rollout/ui/views/features/index.erb
|
|
233
234
|
- lib/rollout/ui/views/features/new.erb
|
|
234
235
|
- lib/rollout/ui/views/features/partials/event_log.erb
|
|
236
|
+
- lib/rollout/ui/views/features/partials/user_selector.erb
|
|
235
237
|
- lib/rollout/ui/views/features/show.erb
|
|
236
238
|
- lib/rollout/ui/views/layout.erb
|
|
237
239
|
- lib/rollout/ui/web.rb
|