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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +41 -0
- data/README.md +7 -4
- data/app/views/layouts/recourses.html.erb +29 -501
- data/app/views/recourses/_combobox.html.erb +26 -44
- data/lib/recourse/engine.rb +0 -38
- data/lib/recourse/helpers/constraints.rb +4 -7
- data/lib/recourse/helpers/filters.rb +2 -2
- data/lib/recourse/helpers/formats.rb +6 -7
- data/lib/recourse/helpers/refreshes.rb +3 -13
- data/lib/recourse/themes.rb +2 -1
- data/lib/recourse/version.rb +1 -1
- metadata +1 -36
- data/app/javascript/recourse/bookmark_controller.js +0 -97
- data/app/javascript/recourse/clear_controller.js +0 -22
- data/app/javascript/recourse/combobox_controller.js +0 -68
- data/app/javascript/recourse/confirm.js +0 -46
- data/app/javascript/recourse/density_controller.js +0 -21
- data/app/javascript/recourse/deselect_controller.js +0 -37
- data/app/javascript/recourse/flash.js +0 -35
- data/app/javascript/recourse/limit_controller.js +0 -23
- data/app/javascript/recourse/phone_controller.js +0 -33
- data/app/javascript/recourse/relative_time_controller.js +0 -45
- data/app/javascript/recourse/reveal_controller.js +0 -16
- data/app/javascript/recourse/scheme_controller.js +0 -99
- data/app/javascript/recourse/search_controller.js +0 -78
- data/app/javascript/recourse/shortcuts_controller.js +0 -42
- data/app/javascript/recourse/timezone_controller.js +0 -27
- data/app/javascript/recourse/toast_controller.js +0 -35
- data/app/javascript/recourse/tooltip_controller.js +0 -23
- data/app/javascript/recourse/written.js +0 -20
- data/app/javascript/recourse/written_controller.js +0 -28
- data/app/stylesheets/recourse/themes/bootstrap.css +0 -11
- data/app/stylesheets/recourse/themes/dawn.css +0 -189
- data/app/stylesheets/recourse/themes/dracula.css +0 -189
- data/app/stylesheets/recourse/themes/gruvbox.css +0 -189
- data/app/stylesheets/recourse/themes/monokai.css +0 -190
- data/app/stylesheets/recourse/themes/nord.css +0 -189
- data/app/stylesheets/recourse/themes/one_dark.css +0 -189
- data/app/stylesheets/recourse/themes/solarized.css +0 -188
- data/app/stylesheets/recourse/themes/tokyo_night.css +0 -189
- data/app/views/recourses/_confirm.html.erb +0 -14
- data/vendor/recourse/bootstrap-icons.min.css +0 -5
- data/vendor/recourse/bootstrap.bundle.min.js +0 -9
- data/vendor/recourse/bootstrap.min.css +0 -2
- data/vendor/recourse/fonts/bootstrap-icons.woff +0 -0
- data/vendor/recourse/fonts/bootstrap-icons.woff2 +0 -0
- 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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
<%#
|
|
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
|
-
|
|
44
|
+
<%= tag.option shown_as, value: option, selected: chosen, data: { hidden: ('' if waiting) } %>
|
|
62
45
|
<% end %>
|
|
63
46
|
<% end -%>
|
|
64
|
-
|
|
65
|
-
</div>
|
|
47
|
+
<% end %>
|
|
66
48
|
<% if feedback -%>
|
|
67
49
|
<small class='invalid-feedback' id='<%= id %>_error'><%= feedback %></small>
|
|
68
50
|
<% end -%>
|
data/lib/recourse/engine.rb
CHANGED
|
@@ -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 = { '
|
|
6
|
+
SAMPLE_PLACEHOLDERS = { 'email' => 'michael@example.com' }.freeze
|
|
7
7
|
|
|
8
|
-
#
|
|
9
|
-
#
|
|
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 =
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
|
|
75
|
-
|
|
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
|
|
79
|
-
|
|
80
|
-
|
|
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,
|
|
8
|
-
#
|
|
9
|
-
#
|
|
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
|
data/lib/recourse/themes.rb
CHANGED
|
@@ -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
|
-
|
|
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.
|
data/lib/recourse/version.rb
CHANGED
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
|
+
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
|
-
}
|