recourse 3.0.4 → 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.
Files changed (131) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +234 -0
  3. data/README.md +150 -20
  4. data/app/controllers/bookmarks_controller.rb +7 -0
  5. data/app/controllers/concerns/recourse/landing.rb +49 -0
  6. data/app/controllers/concerns/recourse/list_resolution.rb +28 -0
  7. data/app/controllers/concerns/recourse/paging.rb +17 -0
  8. data/app/controllers/concerns/recourse/parent_naming.rb +24 -0
  9. data/app/controllers/concerns/recourse/parent_resolution.rb +63 -0
  10. data/app/controllers/concerns/recourse/reference_resolution.rb +70 -0
  11. data/app/controllers/concerns/recourse/resource_resolution.rb +68 -0
  12. data/app/controllers/concerns/recourse/zoning.rb +29 -0
  13. data/app/controllers/recourse/base_controller.rb +85 -0
  14. data/app/controllers/recourse/bookmarks_controller.rb +76 -0
  15. data/app/controllers/recourses_controller.rb +6 -25
  16. data/app/javascript/recourse/bookmark_controller.js +100 -0
  17. data/app/javascript/recourse/clear_controller.js +22 -0
  18. data/app/javascript/recourse/combobox_controller.js +68 -0
  19. data/app/javascript/recourse/confirm.js +46 -0
  20. data/app/javascript/recourse/deselect_controller.js +37 -0
  21. data/app/javascript/recourse/flash.js +35 -0
  22. data/app/javascript/recourse/limit_controller.js +23 -0
  23. data/app/javascript/recourse/phone_controller.js +33 -0
  24. data/app/javascript/recourse/relative_time_controller.js +45 -0
  25. data/app/javascript/recourse/reveal_controller.js +16 -0
  26. data/app/javascript/recourse/scheme_controller.js +51 -0
  27. data/app/javascript/recourse/search_controller.js +78 -0
  28. data/app/javascript/recourse/shortcuts_controller.js +42 -0
  29. data/app/javascript/recourse/timezone_controller.js +27 -0
  30. data/app/javascript/recourse/toast_controller.js +35 -0
  31. data/app/javascript/recourse/tooltip_controller.js +16 -0
  32. data/app/javascript/recourse/written.js +29 -0
  33. data/app/javascript/recourse/written_controller.js +28 -0
  34. data/app/views/layouts/recourses.html.erb +442 -0
  35. data/app/views/recourses/_breadcrumb.html.erb +35 -0
  36. data/app/views/recourses/_card.html.erb +33 -0
  37. data/app/views/recourses/_color.html.erb +20 -0
  38. data/app/views/recourses/_combobox.html.erb +68 -0
  39. data/app/views/recourses/_confirm.html.erb +14 -0
  40. data/app/views/recourses/_fields.html.erb +3 -0
  41. data/app/views/recourses/_flash.html.erb +26 -0
  42. data/app/views/recourses/_footer.html.erb +22 -0
  43. data/app/views/recourses/_form.html.erb +11 -0
  44. data/app/views/recourses/_none.html.erb +1 -0
  45. data/app/views/recourses/_results.html.erb +10 -0
  46. data/app/views/recourses/_row.html.erb +4 -10
  47. data/app/views/recourses/_scheme.html.erb +14 -0
  48. data/app/views/recourses/_search.html.erb +24 -0
  49. data/app/views/recourses/_sidebar.html.erb +40 -0
  50. data/app/views/recourses/_table.html.erb +47 -42
  51. data/app/views/recourses/_values.html.erb +3 -0
  52. data/app/views/recourses/edit.html.erb +10 -0
  53. data/app/views/recourses/index.html.erb +27 -24
  54. data/app/views/recourses/new.html.erb +3 -0
  55. data/app/views/recourses/show.html.erb +10 -0
  56. data/config/locales/recourse.en.yml +72 -0
  57. data/lib/recourse/bookmarks.rb +51 -0
  58. data/lib/recourse/broadcasting.rb +33 -0
  59. data/lib/recourse/colors.rb +32 -0
  60. data/lib/recourse/columns.rb +80 -0
  61. data/lib/recourse/controllers.rb +26 -0
  62. data/lib/recourse/engine.rb +33 -13
  63. data/lib/recourse/helpers/actions.rb +56 -0
  64. data/lib/recourse/helpers/bookmarks.rb +88 -0
  65. data/lib/recourse/helpers/buttons.rb +65 -0
  66. data/lib/recourse/helpers/cards.rb +67 -0
  67. data/lib/recourse/helpers/cells.rb +89 -0
  68. data/lib/recourse/helpers/choices.rb +49 -0
  69. data/lib/recourse/helpers/colors.rb +20 -0
  70. data/lib/recourse/helpers/comboboxes.rb +87 -0
  71. data/lib/recourse/helpers/constraints.rb +92 -0
  72. data/lib/recourse/helpers/counters.rb +75 -0
  73. data/lib/recourse/helpers/deletions.rb +87 -0
  74. data/lib/recourse/helpers/details.rb +41 -0
  75. data/lib/recourse/helpers/examples.rb +35 -0
  76. data/lib/recourse/helpers/fields.rb +78 -0
  77. data/lib/recourse/helpers/filters.rb +91 -0
  78. data/lib/recourse/helpers/formats.rb +94 -0
  79. data/lib/recourse/helpers/inputs.rb +92 -0
  80. data/lib/recourse/helpers/kinds.rb +87 -0
  81. data/lib/recourse/helpers/limits.rb +37 -0
  82. data/lib/recourse/helpers/names.rb +44 -0
  83. data/lib/recourse/helpers/navigation.rb +75 -0
  84. data/lib/recourse/helpers/parents.rb +82 -0
  85. data/lib/recourse/helpers/pictures.rb +33 -0
  86. data/lib/recourse/helpers/references.rb +99 -0
  87. data/lib/recourse/helpers/refreshes.rb +27 -0
  88. data/lib/recourse/helpers/resources.rb +47 -0
  89. data/lib/recourse/helpers/routing.rb +28 -0
  90. data/lib/recourse/helpers/rows.rb +33 -0
  91. data/lib/recourse/helpers/schemes.rb +14 -0
  92. data/lib/recourse/helpers/searches.rb +57 -0
  93. data/lib/recourse/helpers/shortcuts.rb +30 -0
  94. data/lib/recourse/helpers/sidebars.rb +39 -0
  95. data/lib/recourse/helpers/sorts.rb +62 -0
  96. data/lib/recourse/helpers/tabs.rb +82 -0
  97. data/lib/recourse/helpers/times.rb +43 -0
  98. data/lib/recourse/helpers/values.rb +64 -0
  99. data/lib/recourse/helpers/zones.rb +38 -0
  100. data/lib/recourse/helpers.rb +81 -0
  101. data/lib/recourse/icons.rb +21 -0
  102. data/lib/recourse/limits.rb +12 -0
  103. data/lib/recourse/recoursive.rb +66 -19
  104. data/lib/recourse/registry.rb +36 -0
  105. data/lib/recourse/routes/nested.rb +36 -0
  106. data/lib/recourse/routes.rb +57 -0
  107. data/lib/recourse/routing.rb +11 -32
  108. data/lib/recourse/schemes.rb +6 -0
  109. data/lib/recourse/scopes.rb +37 -0
  110. data/lib/recourse/search.rb +65 -0
  111. data/lib/recourse/searchable/columns.rb +99 -0
  112. data/lib/recourse/searchable/filters.rb +58 -0
  113. data/lib/recourse/searchable/terms.rb +56 -0
  114. data/lib/recourse/searchable.rb +47 -22
  115. data/lib/recourse/titles.rb +56 -0
  116. data/lib/recourse/version.rb +2 -1
  117. data/lib/recourse/writes.rb +12 -0
  118. data/lib/recourse/zones.rb +6 -0
  119. data/lib/recourse.rb +79 -7
  120. data/vendor/recourse/bootstrap-icons.min.css +5 -0
  121. data/vendor/recourse/bootstrap.bundle.min.js +9 -0
  122. data/vendor/recourse/bootstrap.min.css +2 -0
  123. data/vendor/recourse/fonts/bootstrap-icons.woff +0 -0
  124. data/vendor/recourse/fonts/bootstrap-icons.woff2 +0 -0
  125. data/vendor/recourse/stimulus.js +2563 -0
  126. metadata +169 -30
  127. data/Rakefile +0 -6
  128. data/app/helpers/navigable_helper.rb +0 -36
  129. data/app/helpers/recoursive_helper.rb +0 -10
  130. data/app/helpers/searchable_helper.rb +0 -54
  131. 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
- # Base class for recoursive controllers.
2
- class RecoursesController < ApplicationController
3
- helper NavigableHelper, RecoursiveHelper, SearchableHelper
4
- include Pagy::Method
5
-
6
- def index
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
+ }