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.
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 -35
  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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 66a7892ff476d0ed9ec43f20f1a204c823ef930720fed109fb675d83e55864d5
4
- data.tar.gz: 12271455e90b626354c63f8e5f159495959c08173f0dd41caa4de8bb663ccecd
3
+ metadata.gz: 48a53c1e56600fb74f444c4cd101c9927562a1571a7d3b31e0837f46e0d894af
4
+ data.tar.gz: cdd8ae93dbddeb9bbf3b0467ae7ee33c927a4692d1c70b59e5f157c7a49a3843
5
5
  SHA512:
6
- metadata.gz: 47af60da8ff314d090ebc19607002a96414ea90fc002160f1678c1eef25068801babfaa3bfcd3a33cd9f97d8da0aad0263e321ac98bfa256b7883cc92ce0865b
7
- data.tar.gz: 8a8e89f78b17988e09af4313a2e6bafa5b59798f7aab6d1a147e87c57f882c254e49635b63fdd2ca262ccb226f0cd36a76b6849c539c63eb1d21fd08e6fd16d6
6
+ metadata.gz: d70d2a2d28bc6a44cf2f3b138dbf088a2390a316e7baa74ff554d477d48cf561612f3c06fc6760a48a4fa950f4a0e85f555f4d8b050bae81d78228cdd90b179a
7
+ data.tar.gz: 2e736d79aaecff3ecec5f9f0881a049456e6eb3a99072a4bea6a484152c889569d77e5c39b118449e0c6595eab190654640df12b2a9e570c36d8fa99e639d88c
data/CHANGELOG.md ADDED
@@ -0,0 +1,234 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ For more information about changelogs, check [Keep a Changelog](http://keepachangelog.com) and
6
+ [Vandamme](http://tech-angels.github.io/vandamme).
7
+
8
+ ## 4.0.0 - 2026-09-08
9
+
10
+ Version 4 is a rewrite, developed under the working name `drive` and released here
11
+ because it is the same library: the module is still `Recourse` and the entry point is
12
+ still one word in `config/routes.rb`.
13
+
14
+ Version 3 drew the routes and served one screen — a paginated, searchable index — and
15
+ left the controller, the other six actions and every form to the host app. Version 4
16
+ serves all seven, defines the controllers itself, and works out what each screen should
17
+ look like by reading the model: its validators, its associations, its column types and
18
+ its indexes. Most of what a host used to override is now something it no longer writes.
19
+
20
+ * [BREAKING CHANGE] Rails 8.1 and Ruby 3.2 are the minimum; so are Pagy 43 and Ransack 4.4
21
+ * [BREAKING CHANGE] `recourses` defines the controller as well as the routes
22
+
23
+ A host no longer writes `class PostsController < RecoursesController` for each
24
+ resource. The controller is defined as the route is drawn, and a host that wants one
25
+ of its own still writes it — the gem only fills the gap. `RecoursesController`
26
+ changes meaning with that: it is no longer the class each resource subclasses but the
27
+ one they all inherit, and a host defines it — `class RecoursesController <
28
+ Recourse::BaseController` — to put a `before_action` above every screen at once.
29
+
30
+ * [BREAKING CHANGE] `search_field`, `search_prompt` and `searchable_fields` are no
31
+ longer yours to define
32
+
33
+ The search box looks through every indexed string column the table shows, plus the
34
+ label behind a foreign key too long to list, and says so in its own placeholder.
35
+
36
+ * [BREAKING CHANGE] The Ransack hooks default to something instead of to nothing
37
+
38
+ `ransackable_attributes`, `ransackable_associations`, `ransortable_attributes` and
39
+ `filter_fields` are still yours to override, but a model that says nothing is now
40
+ fully searchable, sortable and filterable: a column is sortable when an index covers
41
+ it, an enum and a boolean earn a filter, and so does each `belongs_to`.
42
+
43
+ * [BREAKING CHANGE] A `filter_fields` entry is keyed by the predicate and carries its
44
+ options: `{ 'state_id_in' => { label: 'Home state' } }`
45
+ * [BREAKING CHANGE] `recourse_searchable?`, `recourse_sortable?`, `recourse_cachable?`
46
+ and `recourse_timestamps` are gone
47
+
48
+ The first two follow from the indexes. The table is a fragment keyed on the
49
+ relation, so caching needs no switch. `recourse_displayed` names a timestamp back.
50
+
51
+ * [BREAKING CHANGE] `Recourse.resources`, `navigation_links` and `NavigableHelper` are gone
52
+
53
+ The gem draws its own sidebar from the resources `recourses` declared. An icon comes
54
+ from the `unicon` gem, named by `recourse_icon`, which defaults to the model's name.
55
+
56
+ * [BREAKING CHANGE] `search_highlight` takes the column, not the model:
57
+ `search_highlight(post.content, :content)`
58
+ * [BREAKING CHANGE] A row partial sorts with `sort_header`, not Ransack's `sort_link`
59
+ * [BREAKING CHANGE] A row reads in the order its columns' kinds earn — state, keys,
60
+ words, flags, long values, dates, timestamps, counts — and the schema's order inside each
61
+ * [BREAKING CHANGE] A link reads as its host rather than as the whole address
62
+ * [Feature] Every action is served: index, show, new, create, edit, update and destroy
63
+ * [Feature] A form field is chosen to suit each column, and carries the rules the
64
+ model's validators state — a length becomes a `maxlength`, a format becomes a
65
+ `pattern`, a numericality becomes a numeric keyboard
66
+ * [Feature] An enum becomes a badge on a page and a menu in a form; a foreign key
67
+ becomes a menu of the records it points at, or a field to type into where there are
68
+ too many to list — and a typed label naming two rows is refused rather than guessed at
69
+ * [Feature] Values are formatted by what the column holds: delimited integers, money,
70
+ phone numbers, a month by its name, a URL as a link, JSON as JSON, a list as a count
71
+ that opens, and a timestamp in the reader's own time zone saying how long ago
72
+ * [Feature] Nested resources are drawn as tabs on the parent's card, counted where a
73
+ counter cache exists; a nested `create` with no index is a button on the parent
74
+ * [Feature] Active Record Encryption is respected throughout: an encrypted column never
75
+ reaches a table, arrives masked on a record's own page behind a `Show`, and is offered
76
+ in the clear on the form that edits it
77
+ * [Feature] A viewer keeps a row: `Recourse.bookmarks` names the viewer's rows, and every
78
+ table that can hold one opens with a square, kept rows first
79
+ * [Feature] A route named `exit` gives the sidebar a log-out button
80
+ * [Feature] Turbo drives the screens — frames, live refreshes of an open index when a row
81
+ changes, a delete that names what goes with it before it goes, and the row a write
82
+ landed on marked
83
+ * [Feature] A reader picks how many rows a page shows, and whether the page is light or dark
84
+ * [Feature] Bootstrap 6 and Bootstrap Icons are vendored and served by the engine, so a
85
+ host with no asset pipeline and no CDN still gets styled screens
86
+ * [Feature] `Recourse.color = :purple` restyles every screen at once
87
+ * [Feature] Model hooks: `recourse_label`, `recourse_hidden`, `recourse_displayed`,
88
+ `recourse_order`, `recourse_icon`, `recourse_includes`, `recourse_comment` and
89
+ `recourse_broadcasts?`
90
+ * [Feature] Every string the gem shows is a key in `config/locales/recourse.en.yml`
91
+
92
+ ## 3.0.4 - 2026-07-24
93
+
94
+ * [Feature] Add "Inquiries" icon
95
+
96
+ ## 3.0.3 - 2026-07-24
97
+
98
+ * [Feature] Add "Agents" icon
99
+
100
+ ## 3.0.2 - 2026-07-23
101
+
102
+ * [Feature] Add "Contacts" icon
103
+
104
+ ## 3.0.1 - 2026-07-22
105
+
106
+ * [Feature] Add searchable_fields to models
107
+
108
+ ## 3.0.0 - 2026-07-22
109
+
110
+ * [BREAKING CHANGE] Replace single filter_field with multiple filter_fields
111
+
112
+ ## 2.0.2 - 2026-07-20
113
+
114
+ * [Feature] Add "Contract", "Profile", "CRM" icons
115
+
116
+ ## 2.0.1 - 2026-07-13
117
+
118
+ * [Fix] Improve search bar responsiveness
119
+
120
+ ## 2.0.0 - 2026-07-13
121
+
122
+ * [BREAKING CHANGE] Restyle table and search field to take advantage of Bootstrap 6
123
+
124
+ ## 1.4.6 - 2026-07-10
125
+
126
+ * [Feature] Replace "Benches" with "Markets" icons
127
+
128
+ ## 1.4.5 - 2026-06-24
129
+
130
+ * [Feature] Add "Benches" icons
131
+
132
+ ## 1.4.4 - 2026-06-24
133
+
134
+ * [Feature] Add "Platforms" icons
135
+
136
+ ## 1.4.3 - 2026-06-24
137
+
138
+ * [Feature] Add "Brands" icons
139
+
140
+ ## 1.4.2 - 2026-06-24
141
+
142
+ * [Feature] Add common recourse icons
143
+ * [Feature] Add common acronyms, e.g.: API, CRM, ZIP
144
+
145
+ ## 1.4.1 - 2026-06-23
146
+
147
+ * [Fix] Remove deprecated LookupContext.find_template!
148
+
149
+ ## 1.4.0 - 2026-06-23
150
+
151
+ * [Feature] Add navigation_links method
152
+
153
+ ## 1.3.5 - 2026-05-15
154
+
155
+ * [Feature] Display pagy info with number delimiters
156
+
157
+ ## 1.3.3 - 2026-04-09
158
+
159
+ * [Fix] Allow for nested resources not defined at the root level
160
+
161
+ ## 1.3.2 - 2026-04-09
162
+
163
+ * Temporarily disable caching
164
+
165
+ ## 1.3.1 - 2026-04-06
166
+
167
+ * [Fix] Use a different caching key based on the controller path
168
+
169
+ Posts could be displayed differently under /users/:id/posts or under /topics/:id/posts
170
+ so they should be cached separately.
171
+
172
+ ## 1.3.0 - 2026-04-03
173
+
174
+ * [BREAKING CHANGE] Rename `search_placeholder` to `search_prompt`
175
+
176
+ ## 1.2.0 - 2026-03-31
177
+
178
+ * [BREAKING CHANGE] `RecourseController` is now `RecoursesController`
179
+ * [BREAKING CHANGE] "Add" button is now yield in the `content_for :actions`
180
+ * [BREAKING CHANGE] `recourse_positionable?` is no longer supported
181
+ * [Deprecation] `header:` parameter is no longer required in `column`.
182
+ * [Feature] support for nested resources
183
+ * [Feature] support for ransack searches
184
+
185
+ ## 1.1.0 - 2026-03-24
186
+
187
+ * [BREAKING CHANGE] `recourses` only accepts one resource if a block is provided
188
+ * [BREAKING CHANGE] `recourses` automatically sets the module for nested resources
189
+
190
+ Before this change this config/routes.rb was valid:
191
+
192
+ ```ruby
193
+ recourses(:users, :posts) { resources :comments }
194
+ ```
195
+
196
+ and followed Rails `resources` behavior of creating **two** nested resources: `users/comments` and
197
+ `posts/comments`. After this change, each base resource needs to be defined separately:
198
+
199
+ ```ruby
200
+ recourses(:users) { resources :comments }
201
+ recourses(:posts) { resources :comments }
202
+ ```
203
+
204
+ This syntax is more explicit and allows nested resources to be defined under the parent's module.
205
+ In other words, the previous code is equivalent to:
206
+
207
+ ```ruby
208
+ resources(:users) { resources :comments, module: :users }
209
+ resources(:posts) { resources :comments, module: :posts }
210
+ ```
211
+
212
+ which allows developers to have two different controllers/actions to display a user comments
213
+ (/users/:id/comments) or to display a post comments (/posts/:id/comments)
214
+
215
+ ## 1.0.2 - 2026-03-23
216
+
217
+ * [BUG] Avoid Zeitweirk conflict when loading Active Record
218
+
219
+ ## 1.0.1 - 2026-03-10
220
+
221
+ * [BUG] Only show search form when search attributes are present
222
+
223
+ ## 1.0.0 - 2026-03-10
224
+
225
+ * [FEATURE] New `recourses` method that can be invoked inside config/routes.rb
226
+
227
+ `recourses` is like `resources` on steroids for admin-only routes:
228
+
229
+ - All the routes are included in `Recourse.resources` to easily display in a navbar
230
+ - Their controllers do not need to define the `index` action: they inherit a predefined one
231
+ - There is also a predefined `index.html` view which displays the resources paginated/searchable.
232
+ - The content of each row can be customized defining a new `_row.html.erb` partial
233
+
234
+
data/README.md CHANGED
@@ -1,33 +1,163 @@
1
- Recourse
2
- ========
1
+ # Recourse
3
2
 
4
- Provides a new `recourses` method that can be invoked in a Rails app inside config/routes.rb
3
+ Admin screens for a Rails app, drawn from its models. One word in `config/routes.rb`
4
+ serves a resource's seven actions — a paginated, searchable, sortable, filterable index
5
+ table, a record's page, its form and its delete — with no controller, view or partial
6
+ added to the app. What each screen shows is read off the model: its columns, its
7
+ validators, its associations and its indexes.
5
8
 
6
- `recourses` is like `resources` on steroids for admin-only routes:
9
+ Everything a host may call is listed below with a link to its reference on
10
+ [RubyDoc](https://rubydoc.info/gems/recourse). Every default can be overridden by
11
+ defining the same thing in the app.
7
12
 
8
- - All the routes are included in `Recourse.resources` to easily display in a navbar
9
- - Their controllers do not need to define the `index` action: they inherit a predefined one
10
- - There is also a predefined `index.html` view which displays the resources paginated/searchable.
11
- - The content of each row can be customized defining a new `_row.html.erb` partial
13
+ ## How to install
12
14
 
13
- How to install
14
- ==============
15
+ ```bash
16
+ gem install recourse
17
+ ```
15
18
 
16
- 1. Add `gem 'recourse'` to the `Gemfile` file of your Rails app.
19
+ ```ruby
20
+ # Gemfile
21
+ gem 'recourse', '~> 4.0'
22
+ ```
17
23
 
18
- Available methods
19
- =================
24
+ `~> 4.0` follows Semantic Versioning: `bundle update` takes every 4.x and never a
25
+ breaking change. Rails 8.1 and Ruby 3.2 are the minimum; the pages need Turbo, which
26
+ `turbo-rails` brings, and nothing else — Bootstrap and its icons are vendored and served
27
+ by the engine, so a host without an asset pipeline gets the same screens.
20
28
 
21
- In config/routes.rb:
29
+ ## In `config/routes.rb`
22
30
 
23
- - `recourses` inside the routes
31
+ [`recourses`](https://rubydoc.info/gems/recourse/Recourse/Routes#recourses-instance_method)
32
+ is `resources` with a controller and views supplied. It takes the same `only:` and
33
+ `except:`, and a block nests children under the parent:
24
34
 
25
- In a resourceful Active Record model:
35
+ ```ruby
36
+ scope module: :admin do
37
+ recourses :providers do
38
+ recourses :markets, only: :index # a counted tab on each provider
39
+ recourses :retrievals, only: :create # a button on each provider
40
+ end
41
+ recourses :searches, only: %i[index show new create]
42
+ end
43
+ ```
26
44
 
27
- - `recourse_includes`: the associations to include when fetching the resources
28
- - `recourse_order`: the SQL to sort the resources by
45
+ - Every resource with an `index` gets a sidebar entry, in routes order, with a keyboard
46
+ shortcut on its first letter. `only: []` declares a resource with no page of its own.
47
+ - A nested `index` is a tab on the parent's card, counted where a `counter_cache` exists.
48
+ - A nested `create` with no index is a button on the parent's page, named after the
49
+ route. The host answers it in a controller of its own, or the gem creates the record.
50
+ - Nested controllers are namespaced after the parent (`Admin::Providers::MarketsController`),
51
+ and a nested index lists only the parent's rows.
52
+ - Do you name a route `exit`? The sidebar ends with a log-out button submitting a
53
+ `DELETE` to it:
29
54
 
30
- Anywhere:
55
+ ```ruby
56
+ resource :session, only: :destroy, as: :exit
57
+ ```
31
58
 
32
- - `Recourse.resources` to list all the routes
59
+ ## In `app/controllers/recourses_controller.rb`
33
60
 
61
+ Every controller the gem defines inherits
62
+ [`RecoursesController`](https://rubydoc.info/gems/recourse/Recourse/BaseController),
63
+ which the host defines to put behavior above every screen at once:
64
+
65
+ ```ruby
66
+ class RecoursesController < Recourse::BaseController
67
+ before_action :authenticate!
68
+ end
69
+ ```
70
+
71
+ A controller the app already defines is left alone.
72
+
73
+ ## In `config/initializers/recourse.rb`
74
+
75
+ - [`Recourse.color=`](https://rubydoc.info/gems/recourse/Recourse#color%3D-class_method):
76
+ which Bootstrap family is primary on every page. Readers switch light and dark themselves.
77
+ - [`Recourse.bookmarks=`](https://rubydoc.info/gems/recourse/Recourse#bookmarks%3D-class_method):
78
+ a Proc answering the viewer's bookmark rows. Every table of a model with a `has_many`
79
+ at that class then opens with a square to keep a row by, kept rows first.
80
+
81
+ ```ruby
82
+ Recourse.color = :orange
83
+ Recourse.bookmarks = -> { Keepsake.where agent: Current.agent }
84
+ ```
85
+
86
+ ## In a model
87
+
88
+ Every Active Record model answers these; a model overrides one in a
89
+ [`Recoursive`](https://rubydoc.info/gems/recourse/Recourse/Recoursive) concern of its own.
90
+
91
+ ```ruby
92
+ # app/models/market/recoursive.rb
93
+ module Market::Recoursive extend ActiveSupport::Concern
94
+ class_methods do
95
+ def recourse_label = :slug # what a menu and a cell call one
96
+ def recourse_hidden = %i[callback_url payload] # off every screen
97
+ def recourse_displayed = :created_at # back on the table
98
+ def recourse_order = 'size desc nulls last' # how the index sorts
99
+ def recourse_icon = :question # a Unicon concept
100
+ end
101
+ end
102
+ ```
103
+
104
+ | Hook | Default | Decides |
105
+ | --- | --- | --- |
106
+ | [`recourse_label`](https://rubydoc.info/gems/recourse/Recourse/Recoursive#recourse_label-instance_method) | `:name` | the column a combobox shows and a foreign-key cell reads; typed rather than picked where it has a length validator |
107
+ | [`recourse_hidden`](https://rubydoc.info/gems/recourse/Recourse/Recoursive#recourse_hidden-instance_method) | `[]` | columns kept off the table, the page, the form and the search |
108
+ | [`recourse_displayed`](https://rubydoc.info/gems/recourse/Recourse/Recoursive#recourse_displayed-instance_method) | `[]` | columns a table draws that it would leave off: encrypted ones, the id, timestamps, JSON |
109
+ | [`recourse_order`](https://rubydoc.info/gems/recourse/Recourse/Recoursive#recourse_order-instance_method) | `:id` | the index's order, in any shape `order` takes |
110
+ | [`recourse_icon`](https://rubydoc.info/gems/recourse/Recourse/Recoursive#recourse_icon-instance_method) | the model's name | the icon on the sidebar, the crumbs and the tabs |
111
+
112
+ What needs no hook, being read off the model:
113
+
114
+ - The index eager-loads every `belongs_to` the table names, sorts by whatever an index
115
+ covers, searches through its indexed string columns and the labels behind its foreign
116
+ keys, marks what it matched, and offers a filter menu per enum, boolean and `belongs_to`.
117
+ - Saving a row refreshes every open index of it.
118
+ - A form field per column, carrying the rules the validators state: a length is a
119
+ `maxlength`, a format a `pattern`, a numericality a numeric keyboard.
120
+ - An enum is a badge on a page and a menu on a form; a foreign key is a menu of the
121
+ records it points at, or a field to type the label into where there are too many.
122
+ - Values formatted by kind: delimited integers, money, phone numbers, dates and times in
123
+ the reader's own time zone, a `<time>` saying how long ago, a URL as a link, a list as
124
+ a count that opens.
125
+ - An encrypted column stays off the table, arrives masked on the record's page behind a
126
+ `Show`, and is edited in the clear.
127
+ - The delete button names what goes with the record, counted one association down.
128
+
129
+ ## In `app/views`
130
+
131
+ Do you want your own cells? Add `app/views/<resources>/_row.html.erb` and build it from
132
+ [`column`](https://rubydoc.info/gems/recourse/Recourse/Helpers/Cells#column-instance_method),
133
+ [`sort_header`](https://rubydoc.info/gems/recourse/Recourse/Helpers/Sorts#sort_header-instance_method)
134
+ and [`search_highlight`](https://rubydoc.info/gems/recourse/Recourse/Helpers/Searches#search_highlight-instance_method):
135
+
136
+ ```erb
137
+ <%# app/views/admin/providers/_row.html.erb %>
138
+ <%# locals: (provider:) -%>
139
+ <%= column header: sort_header(:name) do %>
140
+ <%= search_highlight provider.name, :name %>
141
+ <% end %>
142
+ <%= column header: 'Phone' do %><%= number_to_phone provider.phone %><% end %>
143
+ ```
144
+
145
+ Any template or partial of the gem's is replaced by defining it in the app, under the
146
+ resource (`app/views/admin/providers/index.html.erb`) or for every resource at once
147
+ (`app/views/recourses/_sidebar.html.erb`). Every string the gem shows is a key under
148
+ `recourse` in `config/locales/recourse.en.yml`, so a host rewords `Add provider` in a
149
+ locale file of its own.
150
+
151
+ ## Development
152
+
153
+ ```bash
154
+ bin/setup # installs the bundle
155
+ bundle exec rake # the suite at 100% coverage, RuboCop, and the file-length ceiling
156
+ cd test/dummy && bin/rails server
157
+ ```
158
+
159
+ The dummy app under `test/dummy` runs on SQLite; the gem names no adapter.
160
+
161
+ ## License
162
+
163
+ [MIT](MIT-LICENSE).
@@ -0,0 +1,7 @@
1
+ # The superclass every bookmark controller gets. A host defines this class itself —
2
+ # its `app/controllers` comes first — to write the row its own way, the same way it
3
+ # redefines `RecoursesController` to guard every screen.
4
+ class BookmarksController < Recourse::BookmarksController
5
+ # Empty on purpose: the behavior is the base class, so a host redefining this one
6
+ # loses none of it.
7
+ end
@@ -0,0 +1,49 @@
1
+ module Recourse
2
+ # Where a write goes once it has landed, and what the page it lands on is told about
3
+ # it. Apart from the actions that write, because the answer is the routes' rather than
4
+ # any one action's: three of them ask it, and none of them decides it.
5
+ module Landing
6
+ private
7
+
8
+ # What a write says once it has landed: the message, and the row it landed on where
9
+ # one survives, for the page to mark while that message stands.
10
+ def wrote(message, record = nil)
11
+ flash.notice = message
12
+ flash[Recourse::WRITTEN] = Recourse.row_id record if record
13
+ redirect_to written_url, status: :see_other
14
+ end
15
+
16
+ # What a rejected write does instead: the form again, with the message over it and
17
+ # the errors beside the fields that earned them.
18
+ def rejected(record, page, message)
19
+ return refused record, message unless Recourse.routed? controller_path, page.to_s
20
+
21
+ flash.now.alert = message
22
+ render page, status: :unprocessable_entity
23
+ end
24
+
25
+ # And where a rejection goes when there is no form to send it back to. A bare
26
+ # action's button stands on a page about something else, so what turned the write
27
+ # down is the whole of what there is to say, said where the button was.
28
+ def refused(record, message)
29
+ flash.alert = record.errors.full_messages.to_sentence.presence || message
30
+ redirect_to written_url, status: :see_other
31
+ end
32
+
33
+ # And where a write goes: the index, or — where the routes drew none — back to the
34
+ # record it hangs off, which for a bare action is the page its button stood on.
35
+ def written_url
36
+ return url_for action: :index if Recourse.routed? controller_path, 'index'
37
+
38
+ parent_url
39
+ end
40
+
41
+ # That record's own page. The path is read back from the routes rather than chopped
42
+ # off this controller's own: how many segments the nesting added — a `namespace`
43
+ # among them — is something the routes knew and a path no longer says.
44
+ def parent_url
45
+ url_for controller: "/#{Recourse.parent_of controller_path}", action: :show,
46
+ id: @recourse_parent
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,28 @@
1
+ module Recourse
2
+ # Resolves a submitted list back to the values it holds, for a column that keeps
3
+ # several of them where a form can only send one string.
4
+ module ListResolution
5
+ private
6
+
7
+ # A list is typed one value to a line and arrives as those lines, so it is split
8
+ # back here — the same place a typed reference is looked up, and for the same
9
+ # reason: no host model needs a virtual attribute and no host needs a strong
10
+ # parameter of its own. A line that is only spaces is not a value, and neither is
11
+ # an empty box: both leave the list empty rather than holding one blank string.
12
+ def resolve_lists(attributes)
13
+ list_columns.each do |column|
14
+ next unless attributes.key? column
15
+
16
+ attributes[column] = attributes[column].to_s.lines.filter_map do |line|
17
+ line.strip.presence
18
+ end
19
+ end
20
+
21
+ attributes
22
+ end
23
+
24
+ def list_columns
25
+ resource_class.column_names.select { |column| Recourse.list_column? resource_class, column }
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,17 @@
1
+ module Recourse
2
+ # How much of a table one page shows: pagy's twenty by default, or whatever the
3
+ # reader picked from the menu under the table, kept in their own browser.
4
+ module Paging
5
+ private
6
+
7
+ # Checked against the two we offer rather than taken as read — a cookie is a value
8
+ # a stranger can write, and an unchecked one is `?limit=100000` by another route.
9
+ # Which is also why pagy's `max_limit` stays unset: the query string is still not
10
+ # asked, and this is the one thing that is.
11
+ def recourse_limit
12
+ limit = cookies[Recourse::LIMIT_STORAGE].to_i
13
+
14
+ Recourse::LIMITS.include?(limit) ? limit : Recourse::LIMITS.first
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,24 @@
1
+ module Recourse
2
+ # Names the record a nested route sits under, so a host's own view can read it.
3
+ module ParentNaming
4
+ private
5
+
6
+ # `/providers/5/openings` sets `@provider`, the way a hand-written controller would
7
+ # -- the contract `ResourceResolution` already keeps for the record a page is about,
8
+ # kept here for the one above it, and the reason a host needs no scoping concern of
9
+ # its own to write a form or a row that names the parent.
10
+ #
11
+ # Named from the route rather than from the association, because the two need not
12
+ # meet through a key: a page listing every vertical under a provider is nested by
13
+ # the path alone, and no `belongs_to` joins the two. What `find_parent` resolved is
14
+ # what gets the name, and only where it is the record the path names: a bare action
15
+ # has no model, and a path may name a class that answers no queries.
16
+ def name_parent
17
+ path = Recourse.parent_of controller_path
18
+ model = path && Recourse.model?(path)
19
+ return unless model && @recourse_parent.is_a?(model)
20
+
21
+ instance_variable_set "@#{model.model_name.singular}", @recourse_parent
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,63 @@
1
+ module Recourse
2
+ # Finds the record a nested route names, so `/counties/5/zips` lists county 5's
3
+ # ZIPs, builds ZIPs inside it, and answers 404 when no county 5 exists.
4
+ module ParentResolution
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ before_action :find_parent
9
+ end
10
+
11
+ private
12
+
13
+ # The record a key points at where the resource has one, and otherwise the record
14
+ # the path names: a page nested by the path alone — the memos of a team no memo
15
+ # belongs to — still sits under something.
16
+ def find_parent
17
+ @recourse_parent_association = parent_association
18
+ @recourse_parent = @recourse_parent_association ? parent_model.find(parent_id) : path_parent
19
+
20
+ name_parent
21
+ end
22
+
23
+ # The record the segment above this one names, read off the routes rather than off
24
+ # a key, or nil where the path names nothing or the name is no model's.
25
+ def path_parent
26
+ parent = Recourse.parent_of controller_path
27
+ model = parent && Recourse.model?(parent)
28
+ return unless model
29
+
30
+ id = request.path_parameters[:"#{model.model_name.singular}_id"]
31
+ model.find_by id: id if id
32
+ end
33
+
34
+ # What the route settled and every action honours: the index lists rows carrying
35
+ # these columns, and `new` and `create` build records that do.
36
+ def parent_columns
37
+ return {} if @recourse_parent_association.nil?
38
+
39
+ { @recourse_parent_association.foreign_key => @recourse_parent.id }
40
+ end
41
+
42
+ # The belongs_to whose record the path names, or nil at the top level. Path
43
+ # parameters rather than `params`, so a stray `?county_id=` nests nothing.
44
+ def parent_association
45
+ own_references.find { |one| path_names? one.name }
46
+ end
47
+
48
+ # A host may serve a page over a verb with no class behind it, whose action the
49
+ # host answers itself, and a verb answers no question about keys. The routes still
50
+ # named a parent, and the host still finds it.
51
+ def own_references
52
+ resource_model? ? resource_class.recourse_references : []
53
+ end
54
+
55
+ def parent_id
56
+ request.path_parameters[:"#{@recourse_parent_association.name}_id"]
57
+ end
58
+
59
+ def parent_model = @recourse_parent_association.klass
60
+
61
+ def path_names?(name) = request.path_parameters.key?(:"#{name}_id")
62
+ end
63
+ end