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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4af968fe0271d80faec7b339779f81a5b8eb9bd06d23b984c7fb5436384b0593
4
- data.tar.gz: 4d4ef32df14c4282b72ea2c5f6a3fee00266dd137ea612b211bcab1e9bc0786f
3
+ metadata.gz: 5ce5665685cf17063621b6e2612fa89366cf8ec7e353322c8219b874c7d1823d
4
+ data.tar.gz: 2ad0fc635c83d3ae4f6cd54b78cc730a5fc32646538f54c6a07f3a1c17370db3
5
5
  SHA512:
6
- metadata.gz: 6af36b891a09fcf1f5f22abe858c10c266db3c9c48444d58e3a72f38daa1dfa3e444b7e443857d77261d041540c0533501e9548f1ad6e7582902773b0c399f85
7
- data.tar.gz: cbeb563460654cb4405a3f2eaaf515426d92311f0ae3eb32dd8eaeea1455262b86e80acb2379c41556459ab579f8cb779cb8923522bdb0dc42c4ff19d67fb369
6
+ metadata.gz: 9b4653f7bd4940e933f318fcb1d09530b636dbc405ecd44678dfb894e443429a24f4cedbb82c37f9110673adec99deb458796e1cb20b8fb4bb43667b505b548a
7
+ data.tar.gz: 5deb01d8c2c3dca6f6687535fb5f125273216ba3fab9afca3cd62326354d2b343a07f2e7e1d31308464a2a898c155716c35ee228867532ad1191e6ced840a9f6
data/README.md CHANGED
@@ -1,14 +1,12 @@
1
1
  # Rollout::UI
2
2
 
3
- Minimalist UI for the [rollout](https://github.com/fetlife/rollout) gem that
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
  ![Index Page](./screenshot_index.png)
7
6
  <!-- ![Feature Page](./screenshot_show.png) -->
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 and does not support Rollout 2.x. The UI is backend-neutral: applications using Redis should add `rollout-redis-adapter` and pass a configured Rollout instance into the UI.
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 it
21
+ Mount the Rack app:
23
22
 
24
23
  ```ruby
25
24
  Rails.application.routes.draw do
26
- mount Rollout::UI::Web.new => '/admin/rollout'
25
+ mount Rollout::UI::Web.new => "/admin/rollout"
27
26
 
28
27
  # ...
29
28
  end
30
29
  ```
31
30
 
32
- And to configure it with your `Rollout` instance, you can put your configuration
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
- If you are using Rails, you can put `constraints` on your mount.
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
- module Constraint::Admin
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 => '/admin/rollout', constraints: Constraints::Admin
55
+ mount Rollout::UI::Web.new => "/admin/rollout", constraints: AdminConstraint
60
56
 
61
57
  # ...
62
58
  end
63
59
  ```
64
60
 
65
- ## Host Header Validation
61
+ ## Nickname user selector
66
62
 
67
- This gem performs no Host header validation of its own. When mounted inside a Rails app (as shown above), requests already pass through Rails' own `config.hosts` checks before reaching this mount point.
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
- If you run this as a standalone app (e.g. via `rackup`), you're responsible for host header validation in the same way you're responsible for authentication, for example with
70
- [`Rack::Protection::HostAuthorization`](https://github.com/sinatra/sinatra/tree/main/rack-protection#host-authorization-api) in front of it, or a reverse proxy that only forwards trusted hosts.
67
+ ```ruby
68
+ Rollout::UI.configure do
69
+ instance { $rollout }
71
70
 
72
- ## API Endpoints
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
- The index and show routes can also respond with JSON data instead of HTML when the request's `Accept` header is
75
- `application/json`
95
+ ```ruby
96
+ user_search_min_length { 3 }
97
+ user_search_limit { 20 }
98
+ ```
76
99
 
77
- The index route also accepts query parameters to filter by user or group:
78
- `/admin/rollout?user=someone`
79
- `/admin/rollout?group=developers`
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
- To get the most out of **rollout-ui**, we recommend you to turn on logging
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
- To also see who updated states of your rollouts, you can configure `actor` and
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&.username }
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
- Alternatively, you can also configure which Redis with:
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
@@ -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|
@@ -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
- "[#{value.join(', ')}]"
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.reject { |key| key.to_s == 'data.updated_at' }.map do |key|
110
- before = format_change_value(event.data.fetch(:before).fetch(key))
111
- after = format_change_value(event.data.fetch(:after).fetch(key))
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
+ })();
@@ -1,5 +1,5 @@
1
1
  class Rollout
2
2
  module UI
3
- VERSION = "0.9.3"
3
+ VERSION = "0.10.0"
4
4
  end
5
5
  end
@@ -70,6 +70,4 @@
70
70
  </table>
71
71
  </div>
72
72
 
73
- <% global_history_events = @rollout.respond_to?(:logging) ? @rollout.logging.global_events.reverse : [] %>
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 > 150 %>
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" value="<%= h(@feature.users.join(', ')) %>" rows="2"><%= h(@feature.users.join(', ')) %></textarea>
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
- <% history_events = @rollout.respond_to?(:logging) ? @rollout.logging.events(@feature.name).reverse : [] %>
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 } %>
@@ -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.9.3
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