recourse 3.0.3 → 4.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 +234 -0
- data/README.md +150 -20
- data/app/controllers/bookmarks_controller.rb +7 -0
- data/app/controllers/concerns/recourse/landing.rb +49 -0
- data/app/controllers/concerns/recourse/list_resolution.rb +28 -0
- data/app/controllers/concerns/recourse/paging.rb +17 -0
- data/app/controllers/concerns/recourse/parent_naming.rb +24 -0
- data/app/controllers/concerns/recourse/parent_resolution.rb +63 -0
- data/app/controllers/concerns/recourse/reference_resolution.rb +70 -0
- data/app/controllers/concerns/recourse/resource_resolution.rb +68 -0
- data/app/controllers/concerns/recourse/zoning.rb +29 -0
- data/app/controllers/recourse/base_controller.rb +85 -0
- data/app/controllers/recourse/bookmarks_controller.rb +76 -0
- data/app/controllers/recourses_controller.rb +6 -25
- data/app/javascript/recourse/bookmark_controller.js +100 -0
- data/app/javascript/recourse/clear_controller.js +22 -0
- data/app/javascript/recourse/combobox_controller.js +68 -0
- data/app/javascript/recourse/confirm.js +46 -0
- data/app/javascript/recourse/deselect_controller.js +37 -0
- data/app/javascript/recourse/flash.js +35 -0
- data/app/javascript/recourse/limit_controller.js +23 -0
- data/app/javascript/recourse/phone_controller.js +33 -0
- data/app/javascript/recourse/relative_time_controller.js +45 -0
- data/app/javascript/recourse/reveal_controller.js +16 -0
- data/app/javascript/recourse/scheme_controller.js +51 -0
- data/app/javascript/recourse/search_controller.js +78 -0
- data/app/javascript/recourse/shortcuts_controller.js +42 -0
- data/app/javascript/recourse/timezone_controller.js +27 -0
- data/app/javascript/recourse/toast_controller.js +35 -0
- data/app/javascript/recourse/tooltip_controller.js +16 -0
- data/app/javascript/recourse/written.js +29 -0
- data/app/javascript/recourse/written_controller.js +28 -0
- data/app/views/layouts/recourses.html.erb +442 -0
- data/app/views/recourses/_breadcrumb.html.erb +35 -0
- data/app/views/recourses/_card.html.erb +33 -0
- data/app/views/recourses/_color.html.erb +20 -0
- data/app/views/recourses/_combobox.html.erb +68 -0
- data/app/views/recourses/_confirm.html.erb +14 -0
- data/app/views/recourses/_fields.html.erb +3 -0
- data/app/views/recourses/_flash.html.erb +26 -0
- data/app/views/recourses/_footer.html.erb +22 -0
- data/app/views/recourses/_form.html.erb +11 -0
- data/app/views/recourses/_none.html.erb +1 -0
- data/app/views/recourses/_results.html.erb +10 -0
- data/app/views/recourses/_row.html.erb +4 -10
- data/app/views/recourses/_scheme.html.erb +14 -0
- data/app/views/recourses/_search.html.erb +24 -0
- data/app/views/recourses/_sidebar.html.erb +40 -0
- data/app/views/recourses/_table.html.erb +47 -42
- data/app/views/recourses/_values.html.erb +3 -0
- data/app/views/recourses/edit.html.erb +10 -0
- data/app/views/recourses/index.html.erb +27 -24
- data/app/views/recourses/new.html.erb +3 -0
- data/app/views/recourses/show.html.erb +10 -0
- data/config/locales/recourse.en.yml +72 -0
- data/lib/recourse/bookmarks.rb +51 -0
- data/lib/recourse/broadcasting.rb +33 -0
- data/lib/recourse/colors.rb +32 -0
- data/lib/recourse/columns.rb +80 -0
- data/lib/recourse/controllers.rb +26 -0
- data/lib/recourse/engine.rb +33 -13
- data/lib/recourse/helpers/actions.rb +56 -0
- data/lib/recourse/helpers/bookmarks.rb +88 -0
- data/lib/recourse/helpers/buttons.rb +65 -0
- data/lib/recourse/helpers/cards.rb +67 -0
- data/lib/recourse/helpers/cells.rb +89 -0
- data/lib/recourse/helpers/choices.rb +49 -0
- data/lib/recourse/helpers/colors.rb +20 -0
- data/lib/recourse/helpers/comboboxes.rb +87 -0
- data/lib/recourse/helpers/constraints.rb +92 -0
- data/lib/recourse/helpers/counters.rb +75 -0
- data/lib/recourse/helpers/deletions.rb +87 -0
- data/lib/recourse/helpers/details.rb +41 -0
- data/lib/recourse/helpers/examples.rb +35 -0
- data/lib/recourse/helpers/fields.rb +78 -0
- data/lib/recourse/helpers/filters.rb +91 -0
- data/lib/recourse/helpers/formats.rb +94 -0
- data/lib/recourse/helpers/inputs.rb +92 -0
- data/lib/recourse/helpers/kinds.rb +87 -0
- data/lib/recourse/helpers/limits.rb +37 -0
- data/lib/recourse/helpers/names.rb +44 -0
- data/lib/recourse/helpers/navigation.rb +75 -0
- data/lib/recourse/helpers/parents.rb +82 -0
- data/lib/recourse/helpers/pictures.rb +33 -0
- data/lib/recourse/helpers/references.rb +99 -0
- data/lib/recourse/helpers/refreshes.rb +27 -0
- data/lib/recourse/helpers/resources.rb +47 -0
- data/lib/recourse/helpers/routing.rb +28 -0
- data/lib/recourse/helpers/rows.rb +33 -0
- data/lib/recourse/helpers/schemes.rb +14 -0
- data/lib/recourse/helpers/searches.rb +57 -0
- data/lib/recourse/helpers/shortcuts.rb +30 -0
- data/lib/recourse/helpers/sidebars.rb +39 -0
- data/lib/recourse/helpers/sorts.rb +62 -0
- data/lib/recourse/helpers/tabs.rb +82 -0
- data/lib/recourse/helpers/times.rb +43 -0
- data/lib/recourse/helpers/values.rb +64 -0
- data/lib/recourse/helpers/zones.rb +38 -0
- data/lib/recourse/helpers.rb +81 -0
- data/lib/recourse/icons.rb +21 -0
- data/lib/recourse/limits.rb +12 -0
- data/lib/recourse/recoursive.rb +66 -19
- data/lib/recourse/registry.rb +36 -0
- data/lib/recourse/routes/nested.rb +36 -0
- data/lib/recourse/routes.rb +57 -0
- data/lib/recourse/routing.rb +11 -32
- data/lib/recourse/schemes.rb +6 -0
- data/lib/recourse/scopes.rb +37 -0
- data/lib/recourse/search.rb +65 -0
- data/lib/recourse/searchable/columns.rb +99 -0
- data/lib/recourse/searchable/filters.rb +58 -0
- data/lib/recourse/searchable/terms.rb +56 -0
- data/lib/recourse/searchable.rb +47 -22
- data/lib/recourse/titles.rb +56 -0
- data/lib/recourse/version.rb +2 -1
- data/lib/recourse/writes.rb +12 -0
- data/lib/recourse/zones.rb +6 -0
- data/lib/recourse.rb +79 -7
- data/vendor/recourse/bootstrap-icons.min.css +5 -0
- data/vendor/recourse/bootstrap.bundle.min.js +9 -0
- data/vendor/recourse/bootstrap.min.css +2 -0
- data/vendor/recourse/fonts/bootstrap-icons.woff +0 -0
- data/vendor/recourse/fonts/bootstrap-icons.woff2 +0 -0
- data/vendor/recourse/stimulus.js +2563 -0
- metadata +169 -30
- data/Rakefile +0 -6
- data/app/helpers/navigable_helper.rb +0 -35
- data/app/helpers/recoursive_helper.rb +0 -10
- data/app/helpers/searchable_helper.rb +0 -54
- data/db/migrate/20260323234318_add_baby_to_posts.rb +0 -5
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
module Recourse
|
|
2
|
+
# Resolves a submitted foreign key back to an id, for a belongs_to whose label
|
|
3
|
+
# is typed rather than picked from a menu — and refuses the write where a label
|
|
4
|
+
# named more than one row.
|
|
5
|
+
module ReferenceResolution
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
# A new record, unless a typed label named more than one row, which is answered on
|
|
9
|
+
# the form rather than by writing a guess.
|
|
10
|
+
def create_resource(record)
|
|
11
|
+
return false if ambiguous_references? record
|
|
12
|
+
|
|
13
|
+
record.save
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# The same for one that already exists. The parameters are read before the refusal
|
|
17
|
+
# is asked about, since reading them is what notices an ambiguous label.
|
|
18
|
+
def update_resource(record)
|
|
19
|
+
attributes = resource_params
|
|
20
|
+
return false if ambiguous_references? record
|
|
21
|
+
|
|
22
|
+
record.update attributes
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# A foreign key whose label is typed arrives as that label, so it is looked up
|
|
26
|
+
# here. Nothing found leaves the key nil, and `belongs_to` reports it missing.
|
|
27
|
+
def resolve_references(attributes)
|
|
28
|
+
resource_class.recourse_references.each do |association|
|
|
29
|
+
key = association.foreign_key.to_s
|
|
30
|
+
next unless attributes.key?(key) && association.klass.recourse_typed_reference?
|
|
31
|
+
|
|
32
|
+
attributes[key] = reference_id association, attributes[key]
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
attributes
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# The row a label names, where it names one. Two rows are not an answer: a label is
|
|
39
|
+
# offered to be typed because it is short enough to say, not because it identifies
|
|
40
|
+
# anything, and a table whose label is not unique has rows that answer to the same
|
|
41
|
+
# words. Picking the first of them would point the key somewhere nobody asked for
|
|
42
|
+
# and report that it worked, so the write is refused instead and the field says why.
|
|
43
|
+
def reference_id(association, label)
|
|
44
|
+
found = association.klass.where(association.klass.recourse_label => label).limit 2
|
|
45
|
+
return found.first&.id unless found.length > 1
|
|
46
|
+
|
|
47
|
+
ambiguous_references[association.name] = label
|
|
48
|
+
nil
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# What a label matched more than one of, kept until the record is built: the error
|
|
52
|
+
# belongs on the record, and there is none yet when the parameters are read.
|
|
53
|
+
def ambiguous_references
|
|
54
|
+
@ambiguous_references ||= {}
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Whether any label named more than one row — and, where one did, says so on the
|
|
58
|
+
# field that asked. Added after validating rather than before, since validating
|
|
59
|
+
# clears what was there, so such a record is never saved at all.
|
|
60
|
+
def ambiguous_references?(record)
|
|
61
|
+
return false if ambiguous_references.empty?
|
|
62
|
+
|
|
63
|
+
ambiguous_references.each do |name, label|
|
|
64
|
+
record.errors.add name, :ambiguous, message: t('recourse.ambiguous', label: label)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
true
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
module Recourse
|
|
2
|
+
# Resolves what a route names: the model behind it, the record an id points at,
|
|
3
|
+
# and the attributes a form may submit for one.
|
|
4
|
+
module ResourceResolution
|
|
5
|
+
private
|
|
6
|
+
|
|
7
|
+
# The record the id names, under the name Rails would use. `find`, so an id naming
|
|
8
|
+
# nothing answers 404.
|
|
9
|
+
def find_resource = assign resource_class.find(params.expect(:id))
|
|
10
|
+
|
|
11
|
+
def assign(record)
|
|
12
|
+
@recourse = record
|
|
13
|
+
instance_variable_set "@#{controller_name.singularize}", record
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Whether there is a model behind this page at all. A bare action has none: it is a
|
|
17
|
+
# verb the host answers itself — `recourses :sweeps, only: :create` — labelled from the
|
|
18
|
+
# path alone, and still a `RecoursesController`, that being where a host keeps the
|
|
19
|
+
# filters guarding its admin. So what runs on every request asks this first, and the
|
|
20
|
+
# actions the gem serves reach for the model again and raise where it is missing.
|
|
21
|
+
# Asked of `resource_class`, which a bookmark answers from the listing.
|
|
22
|
+
def resource_model?
|
|
23
|
+
resource_class.present?
|
|
24
|
+
rescue Error
|
|
25
|
+
false
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# The model the route is named after.
|
|
29
|
+
def resource_class = recourse_model
|
|
30
|
+
|
|
31
|
+
# The model this screen is about, and the second thing a host overrides to put a
|
|
32
|
+
# page of its own behind a screen the gem otherwise draws whole:
|
|
33
|
+
# `def recourse_model = Location`. The route's own name answers by default, which
|
|
34
|
+
# asks the app for a class of that name — so a page listing what a measurement
|
|
35
|
+
# answers rather than what a table holds is named for the answer and has no class
|
|
36
|
+
# to match it. Private, the way `recourse_relation` beside it is: naming a model
|
|
37
|
+
# adds no action.
|
|
38
|
+
def recourse_model
|
|
39
|
+
Recourse.model controller_name
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def human_name
|
|
43
|
+
resource_class.model_name.human
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# What a form may submit: every column a user may set, less the ones the model
|
|
47
|
+
# keeps off its screens. The other thing a host overrides, and for the same
|
|
48
|
+
# reason as `recourse_relation` — a form of its own asks for what it asks for,
|
|
49
|
+
# which may be a hidden column or an attribute that is no column at all:
|
|
50
|
+
# `def resource_params = params.expect(provider: %i[name cid])`.
|
|
51
|
+
def resource_params
|
|
52
|
+
# The parent is merged after resolving, so the one the route names is never
|
|
53
|
+
# mistaken for a label.
|
|
54
|
+
submitted_attributes.merge parent_columns
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# What the form sent, with a typed reference read back as the id it names. A bare
|
|
58
|
+
# `Create` submits no attributes at all, so the key may be absent: the parent a
|
|
59
|
+
# nested route names is everything such a record starts from.
|
|
60
|
+
def submitted_attributes
|
|
61
|
+
permitted = Recourse.editable_columns resource_class
|
|
62
|
+
key = controller_name.singularize.to_sym
|
|
63
|
+
return {} unless params.key? key
|
|
64
|
+
|
|
65
|
+
resolve_lists resolve_references(params.expect(key => permitted))
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
module Recourse
|
|
2
|
+
# Whose clock a page is drawn against. The browser says which zone it is in and the
|
|
3
|
+
# server renders in it, so a time, a date and the field that edits one all agree —
|
|
4
|
+
# which is what rendering in the browser instead could never have given.
|
|
5
|
+
module Zoning
|
|
6
|
+
extend ActiveSupport::Concern
|
|
7
|
+
|
|
8
|
+
included do
|
|
9
|
+
around_action :use_recourse_zone
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
# For the length of one of the gem's own requests and no longer: `use_zone` swaps
|
|
15
|
+
# `Time.zone`, yields, and puts the old one back in an `ensure`, and `Time.zone` is
|
|
16
|
+
# per-thread state rather than config. So the host's setting is never written, and
|
|
17
|
+
# a host's own screens are drawn against it as before, in the same second.
|
|
18
|
+
def use_recourse_zone(&) = Time.use_zone(recourse_zone, &)
|
|
19
|
+
|
|
20
|
+
# Nil where the browser has not said, or said something no zone answers to — and
|
|
21
|
+
# `Time.zone = nil` falls back to the host's own setting, so an absent cookie and a
|
|
22
|
+
# forged one both leave the page exactly as it is drawn today.
|
|
23
|
+
def recourse_zone
|
|
24
|
+
name = cookies[Recourse::ZONE_STORAGE]
|
|
25
|
+
|
|
26
|
+
ActiveSupport::TimeZone[name] if name.present?
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
module Recourse
|
|
2
|
+
# Everything a recoursed screen does, in a class of its own so a host can put
|
|
3
|
+
# its own behavior above it — `class RecoursesController < Recourse::BaseController`
|
|
4
|
+
# with a `before_action :authenticate!` guards every screen the gem serves.
|
|
5
|
+
class BaseController < ApplicationController
|
|
6
|
+
include Pagy::Method,
|
|
7
|
+
Landing, Paging, ListResolution, ParentNaming,
|
|
8
|
+
ParentResolution, ReferenceResolution, ResourceResolution,
|
|
9
|
+
Zoning
|
|
10
|
+
|
|
11
|
+
helper Helpers
|
|
12
|
+
|
|
13
|
+
# `find` raises RecordNotFound, so an id that names nothing answers 404.
|
|
14
|
+
before_action :find_resource, only: %i[show edit update destroy]
|
|
15
|
+
|
|
16
|
+
# The model behind the page, assigned once, and only where the route names one.
|
|
17
|
+
before_action { @recourse_model = resource_class if resource_model? }
|
|
18
|
+
|
|
19
|
+
# The model broadcasts refreshes for its index, before `create` commits its own.
|
|
20
|
+
before_action :broadcast_resource_changes
|
|
21
|
+
|
|
22
|
+
# Lists one page of the model the route is named after. `@q` is Ransack's own name.
|
|
23
|
+
def index
|
|
24
|
+
search = Search.new recourse_relation, params[:q]
|
|
25
|
+
@q = search.query
|
|
26
|
+
@pagy, @resources = pagy search.scope, limit: recourse_limit
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Builds a blank record under the name Rails would use: @contact for contacts, with
|
|
30
|
+
# the parent a nested route names already set on it.
|
|
31
|
+
def new
|
|
32
|
+
assign resource_class.new(parent_columns)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Saves a submitted record, then shows the index again or says what turned it down.
|
|
36
|
+
def create
|
|
37
|
+
record = assign resource_class.new
|
|
38
|
+
record.assign_attributes resource_params
|
|
39
|
+
model = human_name
|
|
40
|
+
|
|
41
|
+
if create_resource record
|
|
42
|
+
wrote t('recourse.created', model: model), record
|
|
43
|
+
else
|
|
44
|
+
rejected record, :new, t('recourse.created_error', model: model)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Reads out the record the id names, which is already known to exist.
|
|
49
|
+
def show; end
|
|
50
|
+
|
|
51
|
+
# Shows the form for the record the id names, which is already known to exist.
|
|
52
|
+
def edit; end
|
|
53
|
+
|
|
54
|
+
# Saves changes to a record, then shows the index again or says what turned it down.
|
|
55
|
+
def update
|
|
56
|
+
if update_resource @recourse
|
|
57
|
+
wrote t('recourse.updated', model: human_name), @recourse
|
|
58
|
+
else
|
|
59
|
+
rejected @recourse, :edit, t('recourse.updated_error', model: human_name)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Deletes the record and shows the index without it. `destroy!`, so a callback that
|
|
64
|
+
# stops one says so rather than leaving the page claiming it worked.
|
|
65
|
+
def destroy
|
|
66
|
+
@recourse.destroy!
|
|
67
|
+
wrote t('recourse.deleted', model: human_name)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def broadcast_resource_changes
|
|
73
|
+
@recourse_model.recourse_broadcast if @recourse_model.respond_to? :recourse_broadcast
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# The rows the index lists, before the search, the sort and the page reach them:
|
|
77
|
+
# every row of the model, narrowed by the parent a nested route names. The one thing
|
|
78
|
+
# a host overrides to put a scope of its own behind a screen the gem draws whole —
|
|
79
|
+
# `def recourse_relation = County.with_boosts_for(@recourse_parent)`. Private, so
|
|
80
|
+
# overriding it adds a query and never an action.
|
|
81
|
+
def recourse_relation
|
|
82
|
+
resource_class.where parent_columns
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
module Recourse
|
|
2
|
+
# The row behind a bookmark square: one record kept by whoever is looking, written
|
|
3
|
+
# and dropped a row at a time and never read back here. The path names the record
|
|
4
|
+
# and the host's own declaration names the viewer, so there is nothing to submit.
|
|
5
|
+
class BookmarksController < ::RecoursesController
|
|
6
|
+
# `destroy` is a member action everywhere else, so the inherited callback would
|
|
7
|
+
# look for an `:id` a singular resource never carries.
|
|
8
|
+
skip_before_action :find_resource
|
|
9
|
+
|
|
10
|
+
# Keeps the record the path names. `find_or_create_by!` rather than `create!`:
|
|
11
|
+
# the square answers before the request does, so a second click can arrive while
|
|
12
|
+
# the first is still in flight, and twice kept is once kept.
|
|
13
|
+
def create
|
|
14
|
+
viewer_bookmarks.find_or_create_by! bookmark_key
|
|
15
|
+
answer 'bookmark_added'
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# And drops it. `destroy_all` rather than `destroy!`: a unique index is what keeps
|
|
19
|
+
# this to one row, and a page should not fail for having none.
|
|
20
|
+
def destroy
|
|
21
|
+
viewer_bookmarks.where(bookmark_key).destroy_all
|
|
22
|
+
answer 'bookmark_removed'
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
# The model this bookmarks, read off the listing the route was nested under
|
|
28
|
+
# rather than off this controller's own name, which is always `bookmarks`.
|
|
29
|
+
def resource_class
|
|
30
|
+
Recourse.model listing_path
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# A resource whose model keeps no bookmarks draws no square, so the only way here
|
|
34
|
+
# is by hand — which earns a 404 rather than a 500 from somewhere below.
|
|
35
|
+
def bookmark_reflection!
|
|
36
|
+
bookmark_reflection || raise(ActiveRecord::RecordNotFound)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def bookmark_reflection
|
|
40
|
+
Recourse.bookmarks_for resource_class
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def viewer_bookmarks
|
|
44
|
+
Recourse.bookmarks_of bookmark_reflection!
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def bookmark_key
|
|
48
|
+
{ bookmark_reflection!.foreign_key => params.expect(:"#{listing_name}_id") }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Where the two paths part. A background request is answered with nothing at all:
|
|
52
|
+
# a flash it never renders would not be spent, and would surface as a toast on
|
|
53
|
+
# the next page announcing a bookmark from several pages ago.
|
|
54
|
+
def answer(message)
|
|
55
|
+
return head :no_content unless request.format.html?
|
|
56
|
+
|
|
57
|
+
flash.notice = t "recourse.#{message}"
|
|
58
|
+
redirect_back fallback_location: listing_url, status: :see_other
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# One segment up, which is where the routes drew it — the gem draws this path
|
|
62
|
+
# itself, so unlike a host's nesting there is nothing to look up.
|
|
63
|
+
def listing_path
|
|
64
|
+
controller_path.rpartition('/').first
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def listing_name
|
|
68
|
+
listing_path.split('/').last.singularize
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Where the square was, for a request that arrived without a referer.
|
|
72
|
+
def listing_url
|
|
73
|
+
url_for controller: "/#{listing_path}", action: :index
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -1,26 +1,7 @@
|
|
|
1
|
-
#
|
|
2
|
-
class
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
model = controller_name.classify.constantize
|
|
8
|
-
@where = request.path_parameters.except(:controller, :action)
|
|
9
|
-
@order = model.recourse_order
|
|
10
|
-
@includes = model.recourse_includes
|
|
11
|
-
|
|
12
|
-
@pagy, @resources = paginate model, where: @where, order: model.recourse_order, includes: model.recourse_includes
|
|
13
|
-
end
|
|
14
|
-
|
|
15
|
-
private
|
|
16
|
-
|
|
17
|
-
def paginate(model, where: nil, includes: [], order: nil)
|
|
18
|
-
if model.recourse_searchable? || model.recourse_sortable?
|
|
19
|
-
@q = model.where(where).ransack params[:q]
|
|
20
|
-
@q.sorts = (order || 'created_at desc') if @q.sorts.empty?
|
|
21
|
-
pagy includes.present? ? @q.result.includes(includes) : @q.result.distinct(true)
|
|
22
|
-
else
|
|
23
|
-
pagy model.where(where).includes(includes).order(order)
|
|
24
|
-
end
|
|
25
|
-
end
|
|
1
|
+
# The superclass every recoursed controller gets, and what puts its templates
|
|
2
|
+
# under `recourses/`. A host defines this class itself — its `app/controllers`
|
|
3
|
+
# comes first — to put a `before_action` of its own above every screen.
|
|
4
|
+
class RecoursesController < Recourse::BaseController
|
|
5
|
+
# Empty on purpose: the behavior is the base class, so a host redefining this
|
|
6
|
+
# one loses none of it.
|
|
26
7
|
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { Controller } from '/recourse/stimulus.js'
|
|
2
|
+
import { flash } from '/recourse/flash.js'
|
|
3
|
+
import { marked } from '/recourse/written.js'
|
|
4
|
+
|
|
5
|
+
// The square that keeps a row, answered before the server does. The icon flips under
|
|
6
|
+
// the cursor and the request goes in the background, so the table is never redrawn and
|
|
7
|
+
// the row stays where the eye left it — until the next load, where kept-first belongs.
|
|
8
|
+
//
|
|
9
|
+
// The form is still a real one. Without this controller it submits, redirects and
|
|
10
|
+
// reloads, which is the same floor every other button here degrades to.
|
|
11
|
+
export default class extends Controller {
|
|
12
|
+
static values = { kept: Boolean, error: String }
|
|
13
|
+
|
|
14
|
+
connect() {
|
|
15
|
+
this.form = this.element.closest('form')
|
|
16
|
+
this.row = this.element.closest('tr')
|
|
17
|
+
this.icon = this.element.querySelector('i')
|
|
18
|
+
this.form.addEventListener('submit', this.submit)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
disconnect() {
|
|
22
|
+
this.form.removeEventListener('submit', this.submit)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// An arrow so `this` survives being handed to the listener, and so the same
|
|
26
|
+
// function object is the one removed again.
|
|
27
|
+
submit = (event) => {
|
|
28
|
+
event.preventDefault()
|
|
29
|
+
const kept = !this.keptValue
|
|
30
|
+
// Read the form before flipping it. The verb it is still wearing is the one this
|
|
31
|
+
// click means — `post` to keep the row, `delete` to drop it — while `render`
|
|
32
|
+
// dresses it for the click after this one, which is the opposite.
|
|
33
|
+
const body = new FormData(this.form)
|
|
34
|
+
this.render(kept)
|
|
35
|
+
this.send(body, kept)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// What the eye reads, what a screen reader reads, and what the next click will do:
|
|
39
|
+
// the path never changes, only the verb Rails wrote into the form.
|
|
40
|
+
render(kept) {
|
|
41
|
+
this.keptValue = kept
|
|
42
|
+
this.icon.className = kept ? 'bi bi-bookmark-fill' : 'bi bi-bookmark'
|
|
43
|
+
this.element.setAttribute('aria-pressed', kept)
|
|
44
|
+
this.method.value = kept ? 'delete' : 'post'
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// The token in the head, not the one in the form. Rails scopes a form's own token
|
|
48
|
+
// to the method it was drawn with and this square flips that method; the form's is
|
|
49
|
+
// inside a cached fragment besides, so it belongs to whichever session drew the
|
|
50
|
+
// table. The head's is global to the session and fresh per request, and Rails takes
|
|
51
|
+
// whichever of the two is valid.
|
|
52
|
+
get token() {
|
|
53
|
+
return document.querySelector('meta[name="csrf-token"]')?.content
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Rails' own override field, which `button_to` writes only for a delete — so a
|
|
57
|
+
// square that started hollow has none until the first click makes one.
|
|
58
|
+
get method() {
|
|
59
|
+
let field = this.form.querySelector('input[name="_method"]')
|
|
60
|
+
if (!field) {
|
|
61
|
+
field = document.createElement('input')
|
|
62
|
+
field.type = 'hidden'
|
|
63
|
+
field.name = '_method'
|
|
64
|
+
this.form.prepend(field)
|
|
65
|
+
}
|
|
66
|
+
return field
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// The response is never rendered, but it is read: a 500, a dropped connection or
|
|
70
|
+
// an expired session would otherwise leave a filled square that was never saved.
|
|
71
|
+
// `Accept` is what tells the server this one wants no flash and no redirect.
|
|
72
|
+
async send(body, kept) {
|
|
73
|
+
try {
|
|
74
|
+
const response = await fetch(this.form.action, {
|
|
75
|
+
method: 'post',
|
|
76
|
+
body,
|
|
77
|
+
headers: { Accept: 'application/json', 'X-CSRF-Token': this.token },
|
|
78
|
+
})
|
|
79
|
+
if (!response.ok) return this.revert(kept)
|
|
80
|
+
|
|
81
|
+
// Two halves of one report, and the reason there is no toast. The tint lasts and
|
|
82
|
+
// says which rows are kept; the mark passes and says this one was written just
|
|
83
|
+
// now — the same outline a create or an update leaves while its message stands.
|
|
84
|
+
// The icon flipped on the click and would have flipped under a request that
|
|
85
|
+
// never landed, so both are the half only the server can give.
|
|
86
|
+
this.row?.classList.toggle('recourse-kept', kept)
|
|
87
|
+
marked(this.row)
|
|
88
|
+
} catch {
|
|
89
|
+
this.revert(kept)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Put the square back, and say why — the one time this column speaks, since a click
|
|
94
|
+
// that worked is reported by the row taking color. Nothing to put back but the
|
|
95
|
+
// square: the tint is never laid on until the row is written.
|
|
96
|
+
revert(kept) {
|
|
97
|
+
this.render(!kept)
|
|
98
|
+
flash(this.errorValue)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
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
|
+
})
|
|
@@ -0,0 +1,37 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// The toast the server ships, built in the browser for the one message that has no
|
|
2
|
+
// response to arrive with. `data-controller` is what hands it to the toast
|
|
3
|
+
// controller for its timer and its X, so the two kinds fade alike.
|
|
4
|
+
export function flash(message, theme = 'theme-danger') {
|
|
5
|
+
const toast = document.createElement('div')
|
|
6
|
+
toast.className = `toast fade show ${theme}`
|
|
7
|
+
toast.setAttribute('role', 'alert')
|
|
8
|
+
toast.setAttribute('aria-live', 'assertive')
|
|
9
|
+
toast.dataset.controller = 'toast'
|
|
10
|
+
toast.dataset.action = ['mouseenter->toast#stopTimer', 'mouseleave->toast#startTimer',
|
|
11
|
+
'focusin->toast#stopTimer', 'focusout->toast#startTimer'].join(' ')
|
|
12
|
+
const header = document.createElement('div')
|
|
13
|
+
header.className = 'toast-header border-0'
|
|
14
|
+
const text = document.createElement('span')
|
|
15
|
+
text.className = 'me-auto'
|
|
16
|
+
// Never innerHTML: this is a message, and a message is data.
|
|
17
|
+
text.textContent = message
|
|
18
|
+
header.append(text)
|
|
19
|
+
toast.append(header)
|
|
20
|
+
container().append(toast)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// A page with nothing to say ships no container at all, so the first message is what
|
|
24
|
+
// makes one.
|
|
25
|
+
function container() {
|
|
26
|
+
let container = document.querySelector('.toast-container')
|
|
27
|
+
if (container) return container
|
|
28
|
+
|
|
29
|
+
container = document.createElement('div')
|
|
30
|
+
container.className = 'toast-container position-fixed bottom-0 end-0 p-3'
|
|
31
|
+
container.dataset.turboTemporary = ''
|
|
32
|
+
document.body.append(container)
|
|
33
|
+
|
|
34
|
+
return container
|
|
35
|
+
}
|