recourse 4.8.0 → 5.0.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.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +41 -0
  3. data/README.md +7 -4
  4. data/app/views/layouts/recourses.html.erb +29 -501
  5. data/app/views/recourses/_combobox.html.erb +26 -44
  6. data/lib/recourse/engine.rb +0 -38
  7. data/lib/recourse/helpers/constraints.rb +4 -7
  8. data/lib/recourse/helpers/filters.rb +2 -2
  9. data/lib/recourse/helpers/formats.rb +6 -7
  10. data/lib/recourse/helpers/refreshes.rb +3 -13
  11. data/lib/recourse/themes.rb +2 -1
  12. data/lib/recourse/version.rb +1 -1
  13. metadata +1 -36
  14. data/app/javascript/recourse/bookmark_controller.js +0 -97
  15. data/app/javascript/recourse/clear_controller.js +0 -22
  16. data/app/javascript/recourse/combobox_controller.js +0 -68
  17. data/app/javascript/recourse/confirm.js +0 -46
  18. data/app/javascript/recourse/density_controller.js +0 -21
  19. data/app/javascript/recourse/deselect_controller.js +0 -37
  20. data/app/javascript/recourse/flash.js +0 -35
  21. data/app/javascript/recourse/limit_controller.js +0 -23
  22. data/app/javascript/recourse/phone_controller.js +0 -33
  23. data/app/javascript/recourse/relative_time_controller.js +0 -45
  24. data/app/javascript/recourse/reveal_controller.js +0 -16
  25. data/app/javascript/recourse/scheme_controller.js +0 -99
  26. data/app/javascript/recourse/search_controller.js +0 -78
  27. data/app/javascript/recourse/shortcuts_controller.js +0 -42
  28. data/app/javascript/recourse/timezone_controller.js +0 -27
  29. data/app/javascript/recourse/toast_controller.js +0 -35
  30. data/app/javascript/recourse/tooltip_controller.js +0 -23
  31. data/app/javascript/recourse/written.js +0 -20
  32. data/app/javascript/recourse/written_controller.js +0 -28
  33. data/app/stylesheets/recourse/themes/bootstrap.css +0 -11
  34. data/app/stylesheets/recourse/themes/dawn.css +0 -189
  35. data/app/stylesheets/recourse/themes/dracula.css +0 -189
  36. data/app/stylesheets/recourse/themes/gruvbox.css +0 -189
  37. data/app/stylesheets/recourse/themes/monokai.css +0 -190
  38. data/app/stylesheets/recourse/themes/nord.css +0 -189
  39. data/app/stylesheets/recourse/themes/one_dark.css +0 -189
  40. data/app/stylesheets/recourse/themes/solarized.css +0 -188
  41. data/app/stylesheets/recourse/themes/tokyo_night.css +0 -189
  42. data/app/views/recourses/_confirm.html.erb +0 -14
  43. data/vendor/recourse/bootstrap-icons.min.css +0 -5
  44. data/vendor/recourse/bootstrap.bundle.min.js +0 -9
  45. data/vendor/recourse/bootstrap.min.css +0 -2
  46. data/vendor/recourse/fonts/bootstrap-icons.woff +0 -0
  47. data/vendor/recourse/fonts/bootstrap-icons.woff2 +0 -0
  48. data/vendor/recourse/stimulus.js +0 -2563
@@ -1,38 +1,20 @@
1
1
  <%# locals: (name:, id:, placeholder:, invalid: false, feedback: nil, described: nil, required: false, label: nil, counter: nil, recourses: nil, values: [], multiple: false, selected: [], aria_label: nil, small: false, all: nil, none: nil, shown: nil) -%>
2
- <button class='form-control<%= ' form-control-sm' if small %><%= ' is-invalid' if invalid %> combobox-toggle' type='button' id='<%= id %>'
3
- data-bs-toggle='combobox' data-bs-name='<%= name %>' data-controller='combobox'
4
- data-combobox-more-value='<%= t 'recourse.more' %>'
5
- data-bs-placeholder='<%= placeholder %>' data-bs-search='true'
6
- aria-required='<%= required %>' <%= tag.attributes 'data-bs-multiple': ('true' if multiple), 'aria-label': aria_label, 'aria-describedby': described %>>
7
- <span class='combobox-value'><%= placeholder %></span>
8
- <i class='bi bi-chevron-down combobox-caret'></i>
9
- </button>
10
- <div class='menu'>
11
- <div class='combobox-search' data-controller='clear'>
12
- <input type='text' class='form-control combobox-search-input'
13
- placeholder='<%= t 'recourse.search' %>'
14
- autocomplete='off' aria-label='<%= t 'recourse.search' %>'
15
- data-clear-target='input' data-action='input->clear#toggle'>
16
- <button type='button' class='combobox-search-clear d-none' aria-label='<%= t 'recourse.clear' %>'
17
- data-clear-target='button' data-action='clear#clear'>
18
- <i class='bi bi-x-lg'></i>
19
- </button>
20
- </div>
21
- <%# No `data-bs-value`, so the plugin's own click handler passes it by and this one
22
- is ours; and never `.selected`, which it counts whatever the element is. -%>
23
- <% if all -%>
24
- <button type='button' class='menu-item' data-controller='deselect' data-action='deselect#all'
25
- data-deselect-multiple-value='<%= multiple %>'><%= all %></button>
26
- <div class='menu-divider'></div>
27
- <% end -%>
28
- <%# A value of its own, and an empty one: the plugin builds its hidden input from
29
- whichever item is selected, so this is what sets an optional key back to
30
- nothing. `deselect` cannot serve here — in a single menu the plugin selects
31
- whatever is clicked rather than toggling it, so re-clicking the chosen item
32
- would change nothing at all. -%>
2
+ <%# A plain <select>, which the design bundle dresses as Bootstrap's combobox: this is
3
+ the whole contract, and a page without the script has a working select. The words
4
+ the menu needs travel on it, since a script has no locale of its own; a multiple
5
+ select is named with `[]`, so the browser submits every pick the way Ransack reads a
6
+ list predicate. -%>
7
+ <%= tag.select name: (multiple ? "#{name}[]" : name), id: id, multiple: multiple, required: required,
8
+ class: class_names('form-select', 'form-select-sm': small, 'is-invalid': invalid),
9
+ aria: { label: aria_label, describedby: described },
10
+ data: { controller: 'combobox', combobox_placeholder_value: placeholder,
11
+ combobox_more_value: t('recourse.more'), combobox_all_value: all,
12
+ search: t('recourse.search'), clear: t('recourse.clear'),
13
+ no_results: t('recourse.no_results') } do %>
14
+ <%# An empty option is what sets an optional key back to nothing; a required one
15
+ offers no way to leave itself empty. -%>
33
16
  <% if none -%>
34
- <button type='button' class='menu-item' data-bs-value='' aria-selected='false'><span class='menu-item-content'><span><%= none %></span></span></button>
35
- <div class='menu-divider'></div>
17
+ <%= tag.option none, value: '' %>
36
18
  <% end -%>
37
19
  <%# Records are cached on the relation, and a list of words is not worth caching:
38
20
  an enum has a dozen of them and they are already in memory. Nor is a table with
@@ -42,27 +24,27 @@
42
24
  <% recourses.each do |recourse| %>
43
25
  <% chosen = selected.include? recourse.id.to_s -%>
44
26
  <%# In the menu but not on it: an option accounting for none of the rows being
45
- filtered is `d-none` until `All` asks for it. One already ticked stays put,
27
+ filtered is held back until `All` asks for it. One already ticked stays put,
46
28
  or the box would name a filter its own menu does not offer. -%>
47
29
  <% empty = counter && !chosen && recourse.attributes[counter].to_i.zero? -%>
48
- <%# The name in a `.menu-item-content > span`, which is what the plugin copies into
49
- the closed box: a count belongs in the menu and not on the box. Its wrapper
50
- is `flex: 1`, so the count needs no margin of its own to sit at the right. -%>
51
- <button class='menu-item<%= ' selected' if chosen %><%= ' d-none' if empty %>' type='button' data-bs-value='<%= recourse.id %>' aria-selected='<%= chosen %>'><span class='menu-item-content'><span><%= recourse.attributes[label] %></span></span><% if counter %><span class='recourse-count fg-2'><%= number_with_delimiter recourse.attributes[counter] %></span><% end %><% if multiple %><i class='bi bi-check menu-item-check'></i><% end %></button>
30
+ <%= tag.option recourse.attributes[label], value: recourse.id, selected: chosen,
31
+ data: { count: (number_with_delimiter recourse.attributes[counter] if counter),
32
+ hidden: ('' if empty) } %>
52
33
  <% end %>
53
34
  <% end %>
54
35
  <% else -%>
55
36
  <% values.each do |option| %>
56
- <%# Compared as text, because that is what a request carries it back as. -%>
37
+ <%# A value on its own, or a `[label, value]` pair for where what the menu reads
38
+ is not what it submits: a CRM is picked by its own name and carried back as
39
+ the class the column holds. Compared as text, since that is what a request
40
+ carries it back as. -%>
41
+ <% shown_as, option = option.is_a?(Array) ? option : [option, option] -%>
57
42
  <% chosen = selected.include? option.to_s -%>
58
- <%# In the menu but not on it, the way an unused filter option is: a list too long
59
- to read opens on the few worth reading, and `All` asks for the rest. -%>
60
43
  <% waiting = shown && !chosen && shown.exclude?(option.to_s) -%>
61
- <button class='menu-item<%= ' selected' if chosen %><%= ' d-none' if waiting %>' type='button' data-bs-value='<%= option %>' aria-selected='<%= chosen %>'><span class='menu-item-content'><span><%= option %></span></span><% if multiple %><i class='bi bi-check menu-item-check'></i><% end %></button>
44
+ <%= tag.option shown_as, value: option, selected: chosen, data: { hidden: ('' if waiting) } %>
62
45
  <% end %>
63
46
  <% end -%>
64
- <div class='combobox-no-results d-none'><%= t 'recourse.no_results' %></div>
65
- </div>
47
+ <% end %>
66
48
  <% if feedback -%>
67
49
  <small class='invalid-feedback' id='<%= id %>_error'><%= feedback %></small>
68
50
  <% end -%>
@@ -10,47 +10,9 @@ require_relative 'routes'
10
10
  module Recourse
11
11
  # Hooks the gem into a host app's boot so its controllers and views are found.
12
12
  class Engine < ::Rails::Engine
13
- # Prefix the gem answers for with a file. `Rack::Static` matches on
14
- # `start_with?`, so dropping the slash would swallow `/recourses` itself.
15
- STATIC_URLS = %w[/recourse/].freeze
16
-
17
- # Every file is asked for again on every full load, and answered `304 Not Modified`
18
- # while it stands. The URL of a file never changes between versions of the gem, so
19
- # a browser told to keep one for hours ran the previous version's script — with the
20
- # tooltip a release had just taken away — until the hours were up.
21
- STATIC_HEADERS = [[:all, { 'cache-control' => 'no-cache' }]].freeze
22
-
23
13
  # Initializers all run before routes are drawn, so `recourses` exists in time.
24
14
  initializer 'recourse.routes' do
25
15
  ActionDispatch::Routing::Mapper.include Scopes, Routes
26
16
  end
27
-
28
- # Serves what a page needs, since a host may run no asset pipeline at all. The
29
- # prefix keeps its slash: without it `/recourses` would be served as a file.
30
- # Turbo answers first, by exact path — matched on the prefix alone, the statics
31
- # below would swallow it with a 404 instead of passing it on. Then vendored
32
- # files, then our own JavaScript, each cascading so the next shares the URL, and
33
- # finally our own stylesheets — the palettes. All of it sits before
34
- # `Rails::Rack::Logger`, so fetching a stylesheet writes no `Started GET` line:
35
- # a file is not what a log is about, and placement says so without the gem
36
- # touching a host's logging.
37
- initializer 'recourse.assets' do |app|
38
- # turbo-rails' own bundle: the same Turbo plus the cable element, in the
39
- # version that gem signs its streams for. A dependency rather than something
40
- # to find, since a page here cannot do without it.
41
- app.middleware.insert_before Rails::Rack::Logger, Rack::Static,
42
- urls: { '/recourse/turbo.min.js' => 'turbo.min.js' },
43
- root: Turbo::Engine.root.join('app/assets/javascripts').to_s,
44
- header_rules: STATIC_HEADERS
45
- app.middleware.insert_before Rails::Rack::Logger, Rack::Static,
46
- urls: STATIC_URLS, root: Engine.root.join('vendor'),
47
- header_rules: STATIC_HEADERS, cascade: true
48
- app.middleware.insert_before Rails::Rack::Logger, Rack::Static,
49
- urls: STATIC_URLS, root: Engine.root.join('app/javascript'),
50
- header_rules: STATIC_HEADERS, cascade: true
51
- app.middleware.insert_before Rails::Rack::Logger, Rack::Static,
52
- urls: STATIC_URLS, root: Engine.root.join('app/stylesheets'),
53
- header_rules: STATIC_HEADERS
54
- end
55
17
  end
56
18
  end
@@ -3,13 +3,10 @@ module Recourse
3
3
  # Turns a model's validators into the HTML that constrains a form field.
4
4
  module Constraints
5
5
  # Shown for a field whose shape has one canonical example.
6
- SAMPLE_PLACEHOLDERS = { 'phone' => '555-555-5555', 'email' => 'michael@example.com' }.freeze
6
+ SAMPLE_PLACEHOLDERS = { 'email' => 'michael@example.com' }.freeze
7
7
 
8
- # What the browser checks where the typed shape differs from the stored one: a
9
- # phone is ten digits in the database but is typed with its separators.
10
- DISPLAY_PATTERNS = { 'phone' => '[2-9]\d{2}-[2-9]\d{2}-\d{4}' }.freeze
11
-
12
- # Hands a phone field to the Stimulus controller that types its separators.
8
+ # Hands a phone field to the controller that decides its shape: the separators as it
9
+ # is typed, and the pattern, placeholder and title the field is left without here.
13
10
  PHONE_CONTROLLER = {
14
11
  controller: 'phone', action: 'keydown->phone#down input->phone#input',
15
12
  }.freeze
@@ -20,7 +17,7 @@ module Recourse
20
17
  # which is the page's model unless a field asks about another one's attribute.
21
18
  def field_html(column, type = nil, model = resource_model)
22
19
  key = (type || column).to_s
23
- pattern = DISPLAY_PATTERNS[key] || column_pattern(model, column)
20
+ pattern = column_pattern(model, column) unless key == 'phone'
24
21
 
25
22
  {
26
23
  maxlength: length_option(model, column, :maximum),
@@ -85,9 +85,9 @@ module Recourse
85
85
  all: all, values: Array(values), **)
86
86
  end
87
87
 
88
- # A multiple combobox submits one comma-joined input, read back out the same way.
88
+ # A multiple select submits one value per pick, and a link carries them the same way.
89
89
  def filter_values(predicate)
90
- query_params[predicate].to_s.split ','
90
+ Array(query_params[predicate]).map(&:to_s).compact_blank
91
91
  end
92
92
  end
93
93
  end
@@ -34,7 +34,7 @@ module Recourse
34
34
 
35
35
  case kind
36
36
  when :integer then number_with_delimiter value
37
- when :phone then number_to_phone value
37
+ when :phone then phone_span value
38
38
  when :monetary then number_to_currency value, **precision_option(column)
39
39
  when :percentage then number_to_percentage value, **precision_option(column)
40
40
  when :decimal then number_with_precision value, **precision_option(column)
@@ -71,13 +71,12 @@ module Recourse
71
71
  tag.pre JSON.pretty_generate(value), class: 'recourse-payload'
72
72
  end
73
73
 
74
- def enum_badge(value)
75
- tag.span value, class: 'badge'
76
- end
74
+ # Ten digits and a controller: how a phone reads is the design bundle's to decide.
75
+ def phone_span(value) = value && tag.span(value, data: { controller: 'phone' })
77
76
 
78
- def web_url?(value)
79
- value.is_a?(String) && value.match?(WEB_URL)
80
- end
77
+ def enum_badge(value) = tag.span(value, class: 'badge')
78
+
79
+ def web_url?(value) = value.is_a?(String) && value.match?(WEB_URL)
81
80
 
82
81
  # The words lead where the value points, in this tab, the way any link does; the
83
82
  # arrow after them — Bootstrap's icon link, stepping under the cursor — opens the
@@ -4,24 +4,14 @@ module Recourse
4
4
  module Refreshes
5
5
  private
6
6
 
7
- # The subscription tag, after asking the head for the two metas that make a
8
- # refresh morph in place and keep the scroll — and nothing at all when the
9
- # model broadcasts nothing, so a host without turbo-rails is untouched.
7
+ # The subscription tag, and nothing at all when the model broadcasts nothing, so a page
8
+ # never listens for what will not come. The layout's head has the two metas that make
9
+ # a refresh morph in place and keep the scroll.
10
10
  def refresh_subscription
11
11
  return unless resource_model.recourse_broadcasting?
12
12
 
13
- content_for :head, refresh_metas
14
13
  turbo_stream_from resource_model.model_name.plural
15
14
  end
16
-
17
- # Without these Turbo answers a refresh by replacing the whole body, which
18
- # drops the caret, the scroll and any menu that was open.
19
- def refresh_metas
20
- safe_join [
21
- tag.meta(name: 'turbo-refresh-method', content: 'morph'),
22
- tag.meta(name: 'turbo-refresh-scroll', content: 'preserve'),
23
- ]
24
- end
25
15
  end
26
16
  end
27
17
  end
@@ -24,7 +24,8 @@ module Recourse
24
24
 
25
25
  # Where the palettes are served from, named once: the layout links one, the layout's
26
26
  # own script puts back the one a reader chose, and the sidebar's controller swaps it.
27
- THEMES_PATH = '/recourse/themes'
27
+ # The version is the one the layout links its stylesheet and script at.
28
+ THEMES_PATH = 'https://design.houseaccount.com/v0.3.1/theme'
28
29
 
29
30
  # Where a reader's chosen palette and mode are kept in their browser. Named once for
30
31
  # the same reason: the sidebar's controller writes it and the layout reads it back.
@@ -1,4 +1,4 @@
1
1
  module Recourse
2
2
  # Version of the gem, read by the gemspec and by hosts checking compatibility.
3
- VERSION = '4.8.0'
3
+ VERSION = '5.0.0'
4
4
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: recourse
3
3
  version: !ruby/object:Gem::Version
4
- version: 4.8.0
4
+ version: 5.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - claudiob
@@ -132,40 +132,11 @@ files:
132
132
  - app/controllers/recourse/base_controller.rb
133
133
  - app/controllers/recourse/bookmarks_controller.rb
134
134
  - app/controllers/recourses_controller.rb
135
- - app/javascript/recourse/bookmark_controller.js
136
- - app/javascript/recourse/clear_controller.js
137
- - app/javascript/recourse/combobox_controller.js
138
- - app/javascript/recourse/confirm.js
139
- - app/javascript/recourse/density_controller.js
140
- - app/javascript/recourse/deselect_controller.js
141
- - app/javascript/recourse/flash.js
142
- - app/javascript/recourse/limit_controller.js
143
- - app/javascript/recourse/phone_controller.js
144
- - app/javascript/recourse/relative_time_controller.js
145
- - app/javascript/recourse/reveal_controller.js
146
- - app/javascript/recourse/scheme_controller.js
147
- - app/javascript/recourse/search_controller.js
148
- - app/javascript/recourse/shortcuts_controller.js
149
- - app/javascript/recourse/timezone_controller.js
150
- - app/javascript/recourse/toast_controller.js
151
- - app/javascript/recourse/tooltip_controller.js
152
- - app/javascript/recourse/written.js
153
- - app/javascript/recourse/written_controller.js
154
- - app/stylesheets/recourse/themes/bootstrap.css
155
- - app/stylesheets/recourse/themes/dawn.css
156
- - app/stylesheets/recourse/themes/dracula.css
157
- - app/stylesheets/recourse/themes/gruvbox.css
158
- - app/stylesheets/recourse/themes/monokai.css
159
- - app/stylesheets/recourse/themes/nord.css
160
- - app/stylesheets/recourse/themes/one_dark.css
161
- - app/stylesheets/recourse/themes/solarized.css
162
- - app/stylesheets/recourse/themes/tokyo_night.css
163
135
  - app/views/layouts/recourses.html.erb
164
136
  - app/views/recourses/_breadcrumb.html.erb
165
137
  - app/views/recourses/_card.html.erb
166
138
  - app/views/recourses/_color.html.erb
167
139
  - app/views/recourses/_combobox.html.erb
168
- - app/views/recourses/_confirm.html.erb
169
140
  - app/views/recourses/_fields.html.erb
170
141
  - app/views/recourses/_flash.html.erb
171
142
  - app/views/recourses/_footer.html.erb
@@ -250,12 +221,6 @@ files:
250
221
  - lib/recourse/version.rb
251
222
  - lib/recourse/writes.rb
252
223
  - lib/recourse/zones.rb
253
- - vendor/recourse/bootstrap-icons.min.css
254
- - vendor/recourse/bootstrap.bundle.min.js
255
- - vendor/recourse/bootstrap.min.css
256
- - vendor/recourse/fonts/bootstrap-icons.woff
257
- - vendor/recourse/fonts/bootstrap-icons.woff2
258
- - vendor/recourse/stimulus.js
259
224
  homepage: https://github.com/claudiob/recourse
260
225
  licenses:
261
226
  - MIT
@@ -1,97 +0,0 @@
1
- import { Controller } from '/recourse/stimulus.js'
2
- import { flash } from '/recourse/flash.js'
3
-
4
- // The square that keeps a row, answered before the server does. The icon flips under
5
- // the cursor and the request goes in the background, so the table is never redrawn and
6
- // the row stays where the eye left it — until the next load, where kept-first belongs.
7
- //
8
- // The form is still a real one. Without this controller it submits, redirects and
9
- // reloads, which is the same floor every other button here degrades to.
10
- export default class extends Controller {
11
- static values = { kept: Boolean, error: String }
12
-
13
- connect() {
14
- this.form = this.element.closest('form')
15
- this.row = this.element.closest('tr')
16
- this.icon = this.element.querySelector('i')
17
- this.form.addEventListener('submit', this.submit)
18
- }
19
-
20
- disconnect() {
21
- this.form.removeEventListener('submit', this.submit)
22
- }
23
-
24
- // An arrow so `this` survives being handed to the listener, and so the same
25
- // function object is the one removed again.
26
- submit = (event) => {
27
- event.preventDefault()
28
- const kept = !this.keptValue
29
- // Read the form before flipping it. The verb it is still wearing is the one this
30
- // click means — `post` to keep the row, `delete` to drop it — while `render`
31
- // dresses it for the click after this one, which is the opposite.
32
- const body = new FormData(this.form)
33
- this.render(kept)
34
- this.send(body, kept)
35
- }
36
-
37
- // What the eye reads, what a screen reader reads, and what the next click will do:
38
- // the path never changes, only the verb Rails wrote into the form.
39
- render(kept) {
40
- this.keptValue = kept
41
- this.icon.className = kept ? 'bi bi-bookmark-fill' : 'bi bi-bookmark'
42
- this.element.setAttribute('aria-pressed', kept)
43
- this.method.value = kept ? 'delete' : 'post'
44
- }
45
-
46
- // The token in the head, not the one in the form. Rails scopes a form's own token
47
- // to the method it was drawn with and this square flips that method; the form's is
48
- // inside a cached fragment besides, so it belongs to whichever session drew the
49
- // table. The head's is global to the session and fresh per request, and Rails takes
50
- // whichever of the two is valid.
51
- get token() {
52
- return document.querySelector('meta[name="csrf-token"]')?.content
53
- }
54
-
55
- // Rails' own override field, which `button_to` writes only for a delete — so a
56
- // square that started hollow has none until the first click makes one.
57
- get method() {
58
- let field = this.form.querySelector('input[name="_method"]')
59
- if (!field) {
60
- field = document.createElement('input')
61
- field.type = 'hidden'
62
- field.name = '_method'
63
- this.form.prepend(field)
64
- }
65
- return field
66
- }
67
-
68
- // The response is never rendered, but it is read: a 500, a dropped connection or
69
- // an expired session would otherwise leave a filled square that was never saved.
70
- // `Accept` is what tells the server this one wants no flash and no redirect.
71
- async send(body, kept) {
72
- try {
73
- const response = await fetch(this.form.action, {
74
- method: 'post',
75
- body,
76
- headers: { Accept: 'application/json', 'X-CSRF-Token': this.token },
77
- })
78
- if (!response.ok) return this.revert(kept)
79
-
80
- // The report, and the reason there is no toast: the row takes or loses its tint,
81
- // which says which rows are kept. The icon flipped on the click and would have
82
- // flipped under a request that never landed, so this is the half only the server
83
- // can give.
84
- this.row?.classList.toggle('recourse-kept', kept)
85
- } catch {
86
- this.revert(kept)
87
- }
88
- }
89
-
90
- // Put the square back, and say why — the one time this column speaks, since a click
91
- // that worked is reported by the row taking color. Nothing to put back but the
92
- // square: the tint is never laid on until the row is written.
93
- revert(kept) {
94
- this.render(!kept)
95
- flash(this.errorValue)
96
- }
97
- }
@@ -1,22 +0,0 @@
1
- import { Controller } from '/recourse/stimulus.js'
2
-
3
- export default class extends Controller {
4
- static targets = ['input', 'button']
5
-
6
- connect() {
7
- this.toggle()
8
- }
9
-
10
- toggle() {
11
- this.buttonTarget.classList.toggle('d-none', !this.inputTarget.value)
12
- }
13
-
14
- clear() {
15
- this.inputTarget.value = ''
16
- // The combobox filters its menu on `input`, so it has to hear one to put every
17
- // row back. Bootstrap listens on the field itself, and the event bubbles anyway.
18
- this.inputTarget.dispatchEvent(new Event('input', { bubbles: true }))
19
- this.inputTarget.focus()
20
- this.toggle()
21
- }
22
- }
@@ -1,68 +0,0 @@
1
- import { Controller } from '/recourse/stimulus.js'
2
- import { Combobox } from '/recourse/bootstrap.bundle.min.js'
3
-
4
- // The plugin keeps a menu's value in a hidden input it creates itself, which
5
- // Turbo's DOM surgery knows nothing about: a morphing refresh deletes the input
6
- // while the instance keeps writing to the detached node, and a snapshot restore
7
- // resurrects an old input beside the one a new instance makes — either way the
8
- // next click submits a filter that is stale, doubled or missing. Owning the
9
- // lifecycle here keeps the input and the instance one thing.
10
- //
11
- // And what a menu with several picks reads as. The plugin writes `2 selected`, which
12
- // says how many and not which; the first pick named and the rest counted says both,
13
- // in the words the locale gives `more`. Written after the plugin writes its own — on
14
- // every change, and once the instance is made — so the plugin's text never shows.
15
- export default class extends Controller {
16
- static values = { more: String }
17
-
18
- connect() {
19
- // A restored snapshot arrives with the last visit's input baked in. The
20
- // instance it belonged to is gone, so it is only a second submission.
21
- if (!Combobox.getInstance(this.element)) { this.#clearStaleInputs() }
22
-
23
- this.combobox = Combobox.getOrCreateInstance(this.element)
24
- this.#name()
25
- this.named = () => this.#name()
26
- this.element.addEventListener('change.bs.combobox', this.named)
27
- this.morphed = () => this.#remake()
28
- document.addEventListener('turbo:morph', this.morphed)
29
- }
30
-
31
- disconnect() {
32
- document.removeEventListener('turbo:morph', this.morphed)
33
- this.element.removeEventListener('change.bs.combobox', this.named)
34
- this.combobox.dispose()
35
- }
36
-
37
- // Remade whole rather than repaired: the constructor reads the `.selected`
38
- // items the morph just made truthful, so disposing and starting over syncs the
39
- // input, the toggle's text and the listeners in one move.
40
- #remake() {
41
- if (!this.element.isConnected) { return }
42
-
43
- this.combobox.dispose()
44
- this.combobox = Combobox.getOrCreateInstance(this.element)
45
- this.#name()
46
- }
47
-
48
- // `California + 1 more` over the plugin's `2 selected`. Only a multiple menu with
49
- // more than one pick: with one, the plugin already names it, and with none it
50
- // shows the placeholder.
51
- #name() {
52
- const picked = this.element.nextElementSibling.querySelectorAll('.menu-item.selected')
53
- if (!this.element.dataset.bsMultiple || picked.length < 2) { return }
54
-
55
- const first = picked[0].querySelector('.menu-item-content > span:first-child')
56
- this.element.querySelector('.combobox-value').textContent = this.moreValue
57
- .replace('%{first}', first.textContent)
58
- .replace('%{count}', picked.length - 1)
59
- }
60
-
61
- #clearStaleInputs() {
62
- const name = this.element.dataset.bsName
63
-
64
- for (const input of this.element.parentNode.querySelectorAll('input[type="hidden"]')) {
65
- if (input.name === name) { input.remove() }
66
- }
67
- }
68
- }
@@ -1,46 +0,0 @@
1
- import { Dialog } from '/recourse/bootstrap.bundle.min.js'
2
-
3
- // Turbo hands over the whole warning as one string. The first line is the question
4
- // and each remaining line a paragraph — real paragraphs here, where a confirm() box
5
- // had newlines. Always textContent, never innerHTML: the title carries a record's
6
- // own name, and a name is data.
7
- export default function confirm(message) {
8
- const dialog = document.querySelector('#recourse-confirm')
9
- const [title, ...lines] = message.split('\n')
10
- dialog.querySelector('.dialog-title').textContent = title
11
- dialog.querySelector('.dialog-body').replaceChildren(...paragraphs(lines))
12
-
13
- return new Promise(resolve => {
14
- // `onclick` rather than addEventListener: reassigning replaces the previous
15
- // answer's handler, so asking twice on one page never wires the button twice.
16
- dialog.querySelector('.recourse-confirm-delete').onclick = () => {
17
- resolve(true)
18
- Dialog.getOrCreateInstance(dialog).hide()
19
- }
20
- // Cancel, Esc and a click on the backdrop all close through here. After a
21
- // Delete the promise is settled, and settling it again is a no-op.
22
- dialog.addEventListener('hidden.bs.dialog', () => resolve(false), { once: true })
23
- Dialog.getOrCreateInstance(dialog).show()
24
- })
25
- }
26
-
27
- function paragraphs(lines) {
28
- return lines.filter(line => line).map(line => {
29
- const paragraph = document.createElement('p')
30
- paragraph.textContent = line
31
- return paragraph
32
- })
33
- }
34
-
35
- // Three things outlive a Turbo visit that starts mid-close: the snapshot, which would
36
- // restore an open dialog; `dialog-open` on <html>, which is the scroll lock; and
37
- // `hiding` on the dialog, the class its closing animation runs under. dispose() closes
38
- // instantly and lifts the lock, but cuts the animation short of the end that would
39
- // have taken `hiding` off — and a dialog still wearing it opens invisible the next
40
- // time it is asked, which on a table of Remove buttons is the very next click. In the
41
- // module, so it registers once.
42
- document.addEventListener('turbo:before-cache', () => {
43
- const dialog = document.querySelector('#recourse-confirm')
44
- if (dialog?.open) Dialog.getOrCreateInstance(dialog).dispose()
45
- dialog?.classList.remove('hiding')
46
- })
@@ -1,21 +0,0 @@
1
- import { Controller } from '/recourse/stimulus.js'
2
-
3
- // The arrows at the foot of the sidebar, drawn on a phone alone: a tap puts the words
4
- // back beside every icon the chrome shows — the sidebar's entries, the crumbs, the tabs,
5
- // the foot's own controls — and the next tap takes them away again. The choice goes to
6
- // the server in a cookie, the way the zone does, and the page is loaded again outright
7
- // rather than reshaped in place: Safari left the row of entries where it was when the
8
- // words came out of hiding, one link over the next, until a reload laid it out afresh.
9
- // A Turbo visit to the same address is no better — an index carries the metas that make
10
- // a refresh morph, so the visit reshaped the body it had rather than drawing a new one.
11
- export default class extends Controller {
12
- static values = { storage: String }
13
-
14
- toggle() {
15
- const expanded = document.body.classList.contains('recourse-expanded')
16
- const density = expanded ? 'compact' : 'expanded'
17
-
18
- document.cookie = `${this.storageValue}=${density}; path=/; max-age=31536000; samesite=lax`
19
- window.location.reload()
20
- }
21
- }
@@ -1,37 +0,0 @@
1
- import { Controller } from '/recourse/stimulus.js'
2
-
3
- export default class extends Controller {
4
- static values = { multiple: Boolean }
5
-
6
- // `All` asks for the options the menu is holding back — they are in it already, and
7
- // this is what puts them on it.
8
- all(event) {
9
- // A menu that sets a value is configured to close on any click inside it, this
10
- // button included — which would shut it over the options it was clicked to see. The
11
- // listener doing that is on the document, so stopping the click here is what keeps
12
- // them in view. A menu that narrows a table closes on outside clicks only, and is
13
- // unaffected either way.
14
- event.stopPropagation()
15
-
16
- const menu = this.element.closest('.menu')
17
-
18
- for (const waiting of menu.querySelectorAll('.menu-item.d-none')) {
19
- waiting.classList.remove('d-none')
20
- }
21
-
22
- // And, on a menu that narrows a table, it means every row as well as every option,
23
- // which is what nothing being ticked says. Clicking each chosen item is what the
24
- // plugin is already listening for, so the hidden input, the toggle's text and the
25
- // events stay its business rather than ours — it has no method for this, and
26
- // reaching into its state would be guessing.
27
- //
28
- // Only there. A menu that sets a value cannot mean none of them, and a click on the
29
- // one already chosen is the plugin being told to choose it again — which closes the
30
- // menu over the options this button was clicked to see.
31
- if (!this.multipleValue) return
32
-
33
- for (const item of menu.querySelectorAll('.menu-item.selected')) {
34
- item.click()
35
- }
36
- }
37
- }