recourse 3.0.4 → 4.1.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 (132) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +245 -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/orders.rb +21 -0
  104. data/lib/recourse/recoursive.rb +66 -19
  105. data/lib/recourse/registry.rb +36 -0
  106. data/lib/recourse/routes/nested.rb +36 -0
  107. data/lib/recourse/routes.rb +57 -0
  108. data/lib/recourse/routing.rb +11 -32
  109. data/lib/recourse/schemes.rb +6 -0
  110. data/lib/recourse/scopes.rb +37 -0
  111. data/lib/recourse/search.rb +73 -0
  112. data/lib/recourse/searchable/columns.rb +99 -0
  113. data/lib/recourse/searchable/filters.rb +58 -0
  114. data/lib/recourse/searchable/terms.rb +56 -0
  115. data/lib/recourse/searchable.rb +47 -22
  116. data/lib/recourse/titles.rb +56 -0
  117. data/lib/recourse/version.rb +2 -1
  118. data/lib/recourse/writes.rb +12 -0
  119. data/lib/recourse/zones.rb +6 -0
  120. data/lib/recourse.rb +80 -7
  121. data/vendor/recourse/bootstrap-icons.min.css +5 -0
  122. data/vendor/recourse/bootstrap.bundle.min.js +9 -0
  123. data/vendor/recourse/bootstrap.min.css +2 -0
  124. data/vendor/recourse/fonts/bootstrap-icons.woff +0 -0
  125. data/vendor/recourse/fonts/bootstrap-icons.woff2 +0 -0
  126. data/vendor/recourse/stimulus.js +2563 -0
  127. metadata +170 -30
  128. data/Rakefile +0 -6
  129. data/app/helpers/navigable_helper.rb +0 -36
  130. data/app/helpers/recoursive_helper.rb +0 -10
  131. data/app/helpers/searchable_helper.rb +0 -54
  132. 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: cce1478088ca8a7cd21ab9313499443eeef77727490ca582fc14c8ebf3b6708e
4
- data.tar.gz: e645df54615a3e8ba34cbced95bfdd8507fe0238e498533a8aa61152285a7d7e
3
+ metadata.gz: 4258e3870b7a65f742563e24f02f45e2c2716bcc22666fbbb63867f70b656e71
4
+ data.tar.gz: 5d2ae92ccde9995457f78060fd9f22d74fd3971bae0c5de68ec7aa022e290603
5
5
  SHA512:
6
- metadata.gz: 90004a8f07a6133836cd96addbf3effcba9ab62f9f8fe00711b94b64d88f332e86a3c2a92c95ae7d824a6f80667ec4f78bf39e9a05d3a3504587758edd2e0295
7
- data.tar.gz: c28b768ae7d3b8b0c60aa5b36931b98af42511bd52f3ed116c54a281b6b0ec3c21cb4c6641e91c4f331955564e0ef806963975edaecd0a277ea775dcdc099661
6
+ metadata.gz: 343ca9a2eba9663618236339649c017f00934d3d01cf8741e6b152a8cc6c6bf17036eff91ea63f46acf5cf804e65ae7f59b68ffdf541ae1bd06e9c5dd1332793
7
+ data.tar.gz: 5f5946a02e7d397bec49ac1089912933f730758215ea4e18559f003a5a13522e5de587db0abb9c75d00a9fab5ba7978a73858a634bfda05956d0267c70617df0
data/CHANGELOG.md ADDED
@@ -0,0 +1,245 @@
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.1.0 - 2026-09-08
9
+
10
+ * [Feature] Rows with nothing in the sorted column come last
11
+
12
+ Whichever way a table is ordered — by the model's own `recourse_order`, or by the
13
+ heading a reader clicked — the rows with nothing in that column now come after every
14
+ row that has something, where the database's default put them first on a descending
15
+ sort. A `recourse_order` given as a Symbol or a Hash earns this; a SQL string a host
16
+ wrote is still taken as written, so `'size desc nulls last'` and `{ size: :desc }` now
17
+ say the same thing.
18
+
19
+ ## 4.0.0 - 2026-09-08
20
+
21
+ Version 4 is a rewrite, developed under the working name `drive` and released here
22
+ because it is the same library: the module is still `Recourse` and the entry point is
23
+ still one word in `config/routes.rb`.
24
+
25
+ Version 3 drew the routes and served one screen — a paginated, searchable index — and
26
+ left the controller, the other six actions and every form to the host app. Version 4
27
+ serves all seven, defines the controllers itself, and works out what each screen should
28
+ look like by reading the model: its validators, its associations, its column types and
29
+ its indexes. Most of what a host used to override is now something it no longer writes.
30
+
31
+ * [BREAKING CHANGE] Rails 8.1 and Ruby 3.2 are the minimum; so are Pagy 43 and Ransack 4.4
32
+ * [BREAKING CHANGE] `recourses` defines the controller as well as the routes
33
+
34
+ A host no longer writes `class PostsController < RecoursesController` for each
35
+ resource. The controller is defined as the route is drawn, and a host that wants one
36
+ of its own still writes it — the gem only fills the gap. `RecoursesController`
37
+ changes meaning with that: it is no longer the class each resource subclasses but the
38
+ one they all inherit, and a host defines it — `class RecoursesController <
39
+ Recourse::BaseController` — to put a `before_action` above every screen at once.
40
+
41
+ * [BREAKING CHANGE] `search_field`, `search_prompt` and `searchable_fields` are no
42
+ longer yours to define
43
+
44
+ The search box looks through every indexed string column the table shows, plus the
45
+ label behind a foreign key too long to list, and says so in its own placeholder.
46
+
47
+ * [BREAKING CHANGE] The Ransack hooks default to something instead of to nothing
48
+
49
+ `ransackable_attributes`, `ransackable_associations`, `ransortable_attributes` and
50
+ `filter_fields` are still yours to override, but a model that says nothing is now
51
+ fully searchable, sortable and filterable: a column is sortable when an index covers
52
+ it, an enum and a boolean earn a filter, and so does each `belongs_to`.
53
+
54
+ * [BREAKING CHANGE] A `filter_fields` entry is keyed by the predicate and carries its
55
+ options: `{ 'state_id_in' => { label: 'Home state' } }`
56
+ * [BREAKING CHANGE] `recourse_searchable?`, `recourse_sortable?`, `recourse_cachable?`
57
+ and `recourse_timestamps` are gone
58
+
59
+ The first two follow from the indexes. The table is a fragment keyed on the
60
+ relation, so caching needs no switch. `recourse_displayed` names a timestamp back.
61
+
62
+ * [BREAKING CHANGE] `Recourse.resources`, `navigation_links` and `NavigableHelper` are gone
63
+
64
+ The gem draws its own sidebar from the resources `recourses` declared. An icon comes
65
+ from the `unicon` gem, named by `recourse_icon`, which defaults to the model's name.
66
+
67
+ * [BREAKING CHANGE] `search_highlight` takes the column, not the model:
68
+ `search_highlight(post.content, :content)`
69
+ * [BREAKING CHANGE] A row partial sorts with `sort_header`, not Ransack's `sort_link`
70
+ * [BREAKING CHANGE] A row reads in the order its columns' kinds earn — state, keys,
71
+ words, flags, long values, dates, timestamps, counts — and the schema's order inside each
72
+ * [BREAKING CHANGE] A link reads as its host rather than as the whole address
73
+ * [Feature] Every action is served: index, show, new, create, edit, update and destroy
74
+ * [Feature] A form field is chosen to suit each column, and carries the rules the
75
+ model's validators state — a length becomes a `maxlength`, a format becomes a
76
+ `pattern`, a numericality becomes a numeric keyboard
77
+ * [Feature] An enum becomes a badge on a page and a menu in a form; a foreign key
78
+ becomes a menu of the records it points at, or a field to type into where there are
79
+ too many to list — and a typed label naming two rows is refused rather than guessed at
80
+ * [Feature] Values are formatted by what the column holds: delimited integers, money,
81
+ phone numbers, a month by its name, a URL as a link, JSON as JSON, a list as a count
82
+ that opens, and a timestamp in the reader's own time zone saying how long ago
83
+ * [Feature] Nested resources are drawn as tabs on the parent's card, counted where a
84
+ counter cache exists; a nested `create` with no index is a button on the parent
85
+ * [Feature] Active Record Encryption is respected throughout: an encrypted column never
86
+ reaches a table, arrives masked on a record's own page behind a `Show`, and is offered
87
+ in the clear on the form that edits it
88
+ * [Feature] A viewer keeps a row: `Recourse.bookmarks` names the viewer's rows, and every
89
+ table that can hold one opens with a square, kept rows first
90
+ * [Feature] A route named `exit` gives the sidebar a log-out button
91
+ * [Feature] Turbo drives the screens — frames, live refreshes of an open index when a row
92
+ changes, a delete that names what goes with it before it goes, and the row a write
93
+ landed on marked
94
+ * [Feature] A reader picks how many rows a page shows, and whether the page is light or dark
95
+ * [Feature] Bootstrap 6 and Bootstrap Icons are vendored and served by the engine, so a
96
+ host with no asset pipeline and no CDN still gets styled screens
97
+ * [Feature] `Recourse.color = :purple` restyles every screen at once
98
+ * [Feature] Model hooks: `recourse_label`, `recourse_hidden`, `recourse_displayed`,
99
+ `recourse_order`, `recourse_icon`, `recourse_includes`, `recourse_comment` and
100
+ `recourse_broadcasts?`
101
+ * [Feature] Every string the gem shows is a key in `config/locales/recourse.en.yml`
102
+
103
+ ## 3.0.4 - 2026-07-24
104
+
105
+ * [Feature] Add "Inquiries" icon
106
+
107
+ ## 3.0.3 - 2026-07-24
108
+
109
+ * [Feature] Add "Agents" icon
110
+
111
+ ## 3.0.2 - 2026-07-23
112
+
113
+ * [Feature] Add "Contacts" icon
114
+
115
+ ## 3.0.1 - 2026-07-22
116
+
117
+ * [Feature] Add searchable_fields to models
118
+
119
+ ## 3.0.0 - 2026-07-22
120
+
121
+ * [BREAKING CHANGE] Replace single filter_field with multiple filter_fields
122
+
123
+ ## 2.0.2 - 2026-07-20
124
+
125
+ * [Feature] Add "Contract", "Profile", "CRM" icons
126
+
127
+ ## 2.0.1 - 2026-07-13
128
+
129
+ * [Fix] Improve search bar responsiveness
130
+
131
+ ## 2.0.0 - 2026-07-13
132
+
133
+ * [BREAKING CHANGE] Restyle table and search field to take advantage of Bootstrap 6
134
+
135
+ ## 1.4.6 - 2026-07-10
136
+
137
+ * [Feature] Replace "Benches" with "Markets" icons
138
+
139
+ ## 1.4.5 - 2026-06-24
140
+
141
+ * [Feature] Add "Benches" icons
142
+
143
+ ## 1.4.4 - 2026-06-24
144
+
145
+ * [Feature] Add "Platforms" icons
146
+
147
+ ## 1.4.3 - 2026-06-24
148
+
149
+ * [Feature] Add "Brands" icons
150
+
151
+ ## 1.4.2 - 2026-06-24
152
+
153
+ * [Feature] Add common recourse icons
154
+ * [Feature] Add common acronyms, e.g.: API, CRM, ZIP
155
+
156
+ ## 1.4.1 - 2026-06-23
157
+
158
+ * [Fix] Remove deprecated LookupContext.find_template!
159
+
160
+ ## 1.4.0 - 2026-06-23
161
+
162
+ * [Feature] Add navigation_links method
163
+
164
+ ## 1.3.5 - 2026-05-15
165
+
166
+ * [Feature] Display pagy info with number delimiters
167
+
168
+ ## 1.3.3 - 2026-04-09
169
+
170
+ * [Fix] Allow for nested resources not defined at the root level
171
+
172
+ ## 1.3.2 - 2026-04-09
173
+
174
+ * Temporarily disable caching
175
+
176
+ ## 1.3.1 - 2026-04-06
177
+
178
+ * [Fix] Use a different caching key based on the controller path
179
+
180
+ Posts could be displayed differently under /users/:id/posts or under /topics/:id/posts
181
+ so they should be cached separately.
182
+
183
+ ## 1.3.0 - 2026-04-03
184
+
185
+ * [BREAKING CHANGE] Rename `search_placeholder` to `search_prompt`
186
+
187
+ ## 1.2.0 - 2026-03-31
188
+
189
+ * [BREAKING CHANGE] `RecourseController` is now `RecoursesController`
190
+ * [BREAKING CHANGE] "Add" button is now yield in the `content_for :actions`
191
+ * [BREAKING CHANGE] `recourse_positionable?` is no longer supported
192
+ * [Deprecation] `header:` parameter is no longer required in `column`.
193
+ * [Feature] support for nested resources
194
+ * [Feature] support for ransack searches
195
+
196
+ ## 1.1.0 - 2026-03-24
197
+
198
+ * [BREAKING CHANGE] `recourses` only accepts one resource if a block is provided
199
+ * [BREAKING CHANGE] `recourses` automatically sets the module for nested resources
200
+
201
+ Before this change this config/routes.rb was valid:
202
+
203
+ ```ruby
204
+ recourses(:users, :posts) { resources :comments }
205
+ ```
206
+
207
+ and followed Rails `resources` behavior of creating **two** nested resources: `users/comments` and
208
+ `posts/comments`. After this change, each base resource needs to be defined separately:
209
+
210
+ ```ruby
211
+ recourses(:users) { resources :comments }
212
+ recourses(:posts) { resources :comments }
213
+ ```
214
+
215
+ This syntax is more explicit and allows nested resources to be defined under the parent's module.
216
+ In other words, the previous code is equivalent to:
217
+
218
+ ```ruby
219
+ resources(:users) { resources :comments, module: :users }
220
+ resources(:posts) { resources :comments, module: :posts }
221
+ ```
222
+
223
+ which allows developers to have two different controllers/actions to display a user comments
224
+ (/users/:id/comments) or to display a post comments (/posts/:id/comments)
225
+
226
+ ## 1.0.2 - 2026-03-23
227
+
228
+ * [BUG] Avoid Zeitweirk conflict when loading Active Record
229
+
230
+ ## 1.0.1 - 2026-03-10
231
+
232
+ * [BUG] Only show search form when search attributes are present
233
+
234
+ ## 1.0.0 - 2026-03-10
235
+
236
+ * [FEATURE] New `recourses` method that can be invoked inside config/routes.rb
237
+
238
+ `recourses` is like `resources` on steroids for admin-only routes:
239
+
240
+ - All the routes are included in `Recourse.resources` to easily display in a navbar
241
+ - Their controllers do not need to define the `index` action: they inherit a predefined one
242
+ - There is also a predefined `index.html` view which displays the resources paginated/searchable.
243
+ - The content of each row can be customized defining a new `_row.html.erb` partial
244
+
245
+
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 } # 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, a Symbol or a Hash; rows with nothing in the column come last |
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