drive 0.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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +6 -0
- data/CLAUDE.md +690 -0
- data/LICENSE.txt +21 -0
- data/README.md +77 -0
- data/Rakefile +32 -0
- data/STYLE.md +435 -0
- data/app/controllers/recourses_controller.rb +96 -0
- data/app/javascript/recourse/phone_controller.js +33 -0
- data/app/views/layouts/application.html.erb +69 -0
- data/app/views/recourses/_breadcrumb.html.erb +18 -0
- data/app/views/recourses/_combobox.html.erb +23 -0
- data/app/views/recourses/_fields.html.erb +3 -0
- data/app/views/recourses/_flash.html.erb +13 -0
- data/app/views/recourses/_form.html.erb +9 -0
- data/app/views/recourses/_none.html.erb +1 -0
- data/app/views/recourses/_row.html.erb +4 -0
- data/app/views/recourses/_sidebar.html.erb +10 -0
- data/app/views/recourses/_table.html.erb +30 -0
- data/app/views/recourses/edit.html.erb +3 -0
- data/app/views/recourses/index.html.erb +14 -0
- data/app/views/recourses/new.html.erb +3 -0
- data/lib/drive.rb +3 -0
- data/lib/recourse/controllers.rb +13 -0
- data/lib/recourse/engine.rb +27 -0
- data/lib/recourse/helpers/cells.rb +44 -0
- data/lib/recourse/helpers/comboboxes.rb +33 -0
- data/lib/recourse/helpers/constraints.rb +94 -0
- data/lib/recourse/helpers/examples.rb +35 -0
- data/lib/recourse/helpers/fields.rb +54 -0
- data/lib/recourse/helpers/navigation.rb +68 -0
- data/lib/recourse/helpers/references.rb +89 -0
- data/lib/recourse/helpers.rb +60 -0
- data/lib/recourse/icons.rb +21 -0
- data/lib/recourse/recoursive.rb +19 -0
- data/lib/recourse/routes.rb +14 -0
- data/lib/recourse/version.rb +4 -0
- data/lib/recourse.rb +31 -0
- data/vendor/recourse/bootstrap-icons.min.css +5 -0
- data/vendor/recourse/bootstrap.bundle.min.js +9 -0
- data/vendor/recourse/bootstrap.min.css +2 -0
- data/vendor/recourse/fonts/bootstrap-icons.woff +0 -0
- data/vendor/recourse/fonts/bootstrap-icons.woff2 +0 -0
- data/vendor/recourse/stimulus.js +2563 -0
- metadata +144 -0
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 claudiob
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# Recourse
|
|
2
|
+
|
|
3
|
+
A `routes.rb` DSL that mounts ready-made resource screens.
|
|
4
|
+
|
|
5
|
+
Add one line to `config/routes.rb` and Recourse draws the routes and serves the
|
|
6
|
+
controllers and views needed to browse a resource. Nothing is written into your
|
|
7
|
+
app — and when you want to customize a screen, you eject it.
|
|
8
|
+
|
|
9
|
+
> **Status:** early development. `index`, `new`, `create`, `edit` and `update`
|
|
10
|
+
> work; `show`, `destroy` and the eject generator are not implemented yet.
|
|
11
|
+
|
|
12
|
+
## Installation
|
|
13
|
+
|
|
14
|
+
Add the gem to your Gemfile:
|
|
15
|
+
|
|
16
|
+
```ruby
|
|
17
|
+
gem 'drive'
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Then run `bundle install`.
|
|
21
|
+
|
|
22
|
+
## Usage
|
|
23
|
+
|
|
24
|
+
```ruby
|
|
25
|
+
# config/routes.rb
|
|
26
|
+
Rails.application.routes.draw do
|
|
27
|
+
recourses :contacts, only: :index
|
|
28
|
+
end
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
With no `ContactsController` and no templates in your app, `/contacts` now lists
|
|
32
|
+
the id of every `Contact`. Recourse supplies both the controller and the view.
|
|
33
|
+
|
|
34
|
+
Anything you write yourself wins. Add `app/controllers/contacts_controller.rb`
|
|
35
|
+
and Recourse leaves it alone; add `app/views/contacts/index.html.erb` and Rails
|
|
36
|
+
renders yours instead of the one the gem ships.
|
|
37
|
+
|
|
38
|
+
The usual thing to override is a single row. Add
|
|
39
|
+
`app/views/contacts/_row.html.erb` and Recourse's table renders yours for
|
|
40
|
+
`/contacts` while every other resource keeps the default:
|
|
41
|
+
|
|
42
|
+
```erb
|
|
43
|
+
<%# locals: (recourse: nil, heading: false) -%>
|
|
44
|
+
<% if heading %>
|
|
45
|
+
<th scope='col'>Contact</th>
|
|
46
|
+
<% else %>
|
|
47
|
+
<td data-cell='Contact'><%= recourse.name %></td>
|
|
48
|
+
<% end %>
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
The partial is rendered once for the header row with `heading: true` and no
|
|
52
|
+
record, then once per record with `heading: false`. It builds the cells only —
|
|
53
|
+
the table, the pagination and the layout stay Recourse's.
|
|
54
|
+
|
|
55
|
+
Recourse's controllers inherit from your `ApplicationController`, so its pages
|
|
56
|
+
render inside `app/views/layouts/application.html.erb` alongside the rest of your
|
|
57
|
+
app, and go through whatever that base class already does. Each page sets its
|
|
58
|
+
title with `content_for :title`, so put `yield :title` in that layout's `<title>`
|
|
59
|
+
to see it.
|
|
60
|
+
|
|
61
|
+
## Development
|
|
62
|
+
|
|
63
|
+
After checking out the repo, run `bin/setup` to install dependencies. Then run
|
|
64
|
+
`rake test` to run the tests, or `rake` to run the tests and RuboCop. You can
|
|
65
|
+
also run `bin/console` for an interactive prompt.
|
|
66
|
+
|
|
67
|
+
To install this gem onto your local machine, run `bundle exec rake install`.
|
|
68
|
+
|
|
69
|
+
## Contributing
|
|
70
|
+
|
|
71
|
+
Bug reports and pull requests are welcome on GitHub at
|
|
72
|
+
https://github.com/claudiob/recourse.
|
|
73
|
+
|
|
74
|
+
## License
|
|
75
|
+
|
|
76
|
+
The gem is available as open source under the terms of the
|
|
77
|
+
[MIT License](https://opensource.org/licenses/MIT).
|
data/Rakefile
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
require 'bundler/gem_tasks'
|
|
2
|
+
require 'minitest/test_task'
|
|
3
|
+
|
|
4
|
+
Minitest::TestTask.create
|
|
5
|
+
|
|
6
|
+
require 'rubocop/rake_task'
|
|
7
|
+
|
|
8
|
+
RuboCop::RakeTask.new
|
|
9
|
+
|
|
10
|
+
# Ceiling for every code file, blank and comment lines included.
|
|
11
|
+
MAX_FILE_LINES = 100
|
|
12
|
+
|
|
13
|
+
# Prose and markup are exempt: docs, the license, and views of any length.
|
|
14
|
+
EXEMPT_EXTENSIONS = %w[.erb .html .md .txt].freeze
|
|
15
|
+
|
|
16
|
+
# Two directories are exempt: a backfill is as long as the data it carries, and
|
|
17
|
+
# upstream's formatting is not ours to fix.
|
|
18
|
+
EXEMPT_DIRECTORIES = %w[db/migrate/ vendor/].freeze
|
|
19
|
+
|
|
20
|
+
desc "Fail if any code file is longer than #{MAX_FILE_LINES} lines"
|
|
21
|
+
task :file_length do
|
|
22
|
+
files = `git ls-files -z`.split "\x0"
|
|
23
|
+
code = files.reject do |file|
|
|
24
|
+
EXEMPT_EXTENSIONS.include?(File.extname(file)) ||
|
|
25
|
+
EXEMPT_DIRECTORIES.any? { |directory| file.include? directory }
|
|
26
|
+
end
|
|
27
|
+
too_long = code.select { |file| File.readlines(file).size > MAX_FILE_LINES }
|
|
28
|
+
|
|
29
|
+
abort "Longer than #{MAX_FILE_LINES} lines: #{too_long.join ', '}" if too_long.any?
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
task default: %i[test rubocop file_length]
|
data/STYLE.md
ADDED
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
# Design Guidelines
|
|
2
|
+
|
|
3
|
+
**Scope:** how the pages this gem serves look and are marked up. `CLAUDE.md` is
|
|
4
|
+
the authority for code style; this file is the authority for design. Read it
|
|
5
|
+
before writing or editing any layout, view or partial.
|
|
6
|
+
|
|
7
|
+
## Bootstrap 6 Alpha for all markup
|
|
8
|
+
|
|
9
|
+
- Every layout, view and partial follows Bootstrap 6 Alpha conventions.
|
|
10
|
+
Reference: https://v6-dev--twbs-bootstrap.netlify.app/llms-full.txt
|
|
11
|
+
- Check class names against those docs rather than recalling Bootstrap 5. v6
|
|
12
|
+
renames and removes plenty: responsive utilities are prefixed
|
|
13
|
+
(`md:col-6`, not `col-md-6`), `.bg-light` / `.bg-dark` are gone in favour of
|
|
14
|
+
the `.bg-1` / `.bg-2` scale, and `.text-body-secondary` is now `.fg-2`.
|
|
15
|
+
- Page wrappers use `.container-fluid`, never `.container`. A table wants the
|
|
16
|
+
whole width on a desktop, not a centred column with margins either side.
|
|
17
|
+
- Still current from v5: `.container-fluid`, `.table`, `.table-responsive`, and
|
|
18
|
+
`data-bs-theme="light|dark"` for color modes — though `color-scheme: light
|
|
19
|
+
dark` on `:root` follows the system by default, so most pages need no theme
|
|
20
|
+
attribute at all.
|
|
21
|
+
- The gem ships `app/views/layouts/application.html.erb` for hosts that have
|
|
22
|
+
none: Bootstrap's CSS in the head, the JS bundle as a module before `</body>`,
|
|
23
|
+
and Geist and Geist Mono from Google Fonts.
|
|
24
|
+
- Bootstrap is *vendored*, not linked. `vendor/recourse/` holds
|
|
25
|
+
`bootstrap.min.css`, `bootstrap.bundle.min.js`, `bootstrap-icons.min.css` and
|
|
26
|
+
the two icon fonts, and the layout asks for `/recourse/…`. A CDN that moves or
|
|
27
|
+
goes down would otherwise take every page's styling with it, and the Bootstrap
|
|
28
|
+
6 CSS is served from a preview host rather than a release one.
|
|
29
|
+
- The icon fonts are not optional extras. `bootstrap-icons.min.css` reaches for
|
|
30
|
+
`fonts/bootstrap-icons.woff2` beside itself, so vendoring the CSS alone leaves
|
|
31
|
+
every icon a blank box.
|
|
32
|
+
- The engine serves them with `Rack::Static`, since a host may run no asset
|
|
33
|
+
pipeline at all — see CLAUDE.md, "Vendor what a page cannot render without".
|
|
34
|
+
|
|
35
|
+
## The navbar
|
|
36
|
+
|
|
37
|
+
- Every page opens with a navbar holding a breadcrumb, then `yield :actions`,
|
|
38
|
+
both to the left.
|
|
39
|
+
- The breadcrumb ends at the current page, and that last item is *not* a link:
|
|
40
|
+
a `<span class='breadcrumb-link active'>` inside an
|
|
41
|
+
`<li class='breadcrumb-item' aria-current='page'>`. Bootstrap's own example
|
|
42
|
+
uses an `<a>` there; we deliberately do not.
|
|
43
|
+
- Earlier items are links and carry the resource icon, separated by empty
|
|
44
|
+
`<li class='breadcrumb-divider'>` elements — v6 draws the chevron from that
|
|
45
|
+
element, not from a CSS `content` string on `::before` as v5 did.
|
|
46
|
+
- An index has one item, its own name. Any other page links back to the index
|
|
47
|
+
first and then names itself: `/counties/new` reads `Counties` as a link, then
|
|
48
|
+
`New county` as plain text.
|
|
49
|
+
- A view contributes buttons with `content_for :actions`; the layout only
|
|
50
|
+
yields. Nothing else belongs in the navbar.
|
|
51
|
+
- A breadcrumb link and its sidebar twin line up vertically, which constrains
|
|
52
|
+
both. The `<nav class='navbar'>` carries no horizontal margin or padding of
|
|
53
|
+
its own, so both columns reduce to `container-fluid` (0.75rem) plus a link
|
|
54
|
+
padding of 0.75rem — the sidebar's own 0.75rem container padding is cancelled
|
|
55
|
+
by `.row`'s negative margin. Adding `px-*` or `mx-*` to the navbar shifts the
|
|
56
|
+
breadcrumb out of line by exactly that much.
|
|
57
|
+
- `.breadcrumb-link` needs `gap-2`. Both link types are flex, but only
|
|
58
|
+
`.nav-link` ships a `gap`, and a whitespace-only text node is not a flex item
|
|
59
|
+
— so without it the breadcrumb's icon and text would touch while the
|
|
60
|
+
sidebar's sit 0.5rem apart.
|
|
61
|
+
- An index offers `Add <resource>` only when there is somewhere to go: the
|
|
62
|
+
`new` route has to be drawn *and* the controller has to implement the action,
|
|
63
|
+
or the button would 404 or raise. Its classes are
|
|
64
|
+
`btn theme-primary btn-sm btn-outline ms-3`.
|
|
65
|
+
|
|
66
|
+
## Icons on resource links
|
|
67
|
+
|
|
68
|
+
- A link to a resource is preceded by a Bootstrap Icon, using the `<i>` form:
|
|
69
|
+
`<i class='bi bi-person-rolodex'></i> Contacts`. The layout loads
|
|
70
|
+
`bootstrap-icons@1.13.1`.
|
|
71
|
+
- Pick the icon by the displayed title, from this map. It is duplicated in
|
|
72
|
+
`lib/recourse/icons.rb`, which is what the code reads — change both together.
|
|
73
|
+
- An unlisted title falls back to `circle`, so a column of links stays aligned.
|
|
74
|
+
Add a real entry rather than leaving the fallback in place.
|
|
75
|
+
|
|
76
|
+
'Agents' => 'robot', 'Answers' => 'question-circle', 'Apps' => 'window',
|
|
77
|
+
'Assessments' => 'clipboard-check', 'Bookings' => 'calendar-check',
|
|
78
|
+
'Brands' => 'buildings', 'Campaigns' => 'megaphone',
|
|
79
|
+
'Contacts' => 'person-rolodex', 'Contract' => 'file-earmark-check',
|
|
80
|
+
'Conversations' => 'chat-dots', 'Counties' => 'map', 'CRM' => 'plugin',
|
|
81
|
+
'Echoes' => 'soundwave', 'Episodes' => 'collection-play',
|
|
82
|
+
'Evaluations' => 'speedometer2', 'Franchises' => 'shop', 'Home' => 'house',
|
|
83
|
+
'Locations' => 'geo-alt', 'Logout' => 'box-arrow-right', 'Markets' => 'pin-map',
|
|
84
|
+
'Offer questions' => 'gift', 'Optimizations' => 'sliders',
|
|
85
|
+
'Platforms' => 'plugin', 'Profile' => 'person-circle',
|
|
86
|
+
'Prompts' => 'terminal', 'Providers' => 'briefcase',
|
|
87
|
+
'Satisfaction questions' => 'emoji-smile', 'Searches' => 'search',
|
|
88
|
+
'Settings' => 'gear', 'Sources' => 'signpost', 'Specialties' => 'award',
|
|
89
|
+
'Specialty matches' => 'award', 'States' => 'geo',
|
|
90
|
+
'Verticals' => 'bar-chart', 'ZIPs' => 'geo-alt-fill'
|
|
91
|
+
|
|
92
|
+
- `Home` maps to `house`: Bootstrap Icons has no `home`, so that entry would
|
|
93
|
+
have rendered an empty box.
|
|
94
|
+
- Icons go on *links*. The breadcrumb's current-page item is not a link, so it
|
|
95
|
+
carries no icon.
|
|
96
|
+
|
|
97
|
+
## The sidebar
|
|
98
|
+
|
|
99
|
+
- Below the navbar, an `<aside>` sits to the left of the content holding a
|
|
100
|
+
vertical `ul.nav.flex-column` of links — one per resource `recourses` drew.
|
|
101
|
+
- The order is the order `config/routes.rb` declares them, never sorted.
|
|
102
|
+
- The entry for the page being shown is `nav-link active` with
|
|
103
|
+
`aria-current='page'`. It is matched on the controller, not on the URL, so
|
|
104
|
+
`/contacts?page=2` still marks Contacts active.
|
|
105
|
+
- A resource appears only if its `index` action is routed. `recourses :drafts,
|
|
106
|
+
only: :new` draws no index, so it gets no link rather than a broken one.
|
|
107
|
+
- Layout is `.row` with `aside.col-auto` and `main.col`, inside the page's
|
|
108
|
+
`.container-fluid`.
|
|
109
|
+
- The aside's border runs to the bottom of the window. That takes a chain of
|
|
110
|
+
three: `body.d-flex.flex-column.min-vh-100`, then
|
|
111
|
+
`.container-fluid.flex-grow-1.d-flex`, then `.row.flex-grow-1`. The aside
|
|
112
|
+
stretches because `.row` is a flex container and Bootstrap leaves
|
|
113
|
+
`align-items` unset, so items default to `stretch`.
|
|
114
|
+
- `min-height` rather than `height`, so short pages fill the window without a
|
|
115
|
+
scrollbar and long ones still scroll.
|
|
116
|
+
|
|
117
|
+
## Forms
|
|
118
|
+
|
|
119
|
+
- The gem serves `new.html.erb`: it sets `:title` to `New <resource>` and
|
|
120
|
+
renders the `form` partial, passing the record explicitly under its own name.
|
|
121
|
+
- It serves `edit.html.erb` the same way, titled after the record instead — the
|
|
122
|
+
value of whatever its model's `recourse_label` names, so a market reads
|
|
123
|
+
`Chicago`. Both render the *same* `form` partial, so a host that writes one
|
|
124
|
+
`_fields.html.erb` gets it on both pages and never writes a second.
|
|
125
|
+
- After a rejected update the title shows what was typed, not what is stored,
|
|
126
|
+
because the record already carries the submitted values. Blanking the label
|
|
127
|
+
blanks the title.
|
|
128
|
+
- The form is `form_with model:` plus one field per *editable* column — every
|
|
129
|
+
column except `id`, `created_at` and `updated_at`. Encrypted columns are
|
|
130
|
+
editable even though the table will not display them.
|
|
131
|
+
- Each field is a `.form-label` and a `.form-control` inside
|
|
132
|
+
`.mb-3.lg:col-6`, and the whole set sits in one `.row`. Two fields to a row on
|
|
133
|
+
a large viewport, stacked below it — a name or a phone number needs nowhere
|
|
134
|
+
near the full width of a page, so a form of full-width inputs reads as a
|
|
135
|
+
column of empty space.
|
|
136
|
+
- The submit is `btn btn-solid theme-primary`. In v6 the fill is a separate class
|
|
137
|
+
from the base: `.btn` sizes, `.btn-solid` / `.btn-outline` / `.btn-subtle`
|
|
138
|
+
fill, and `theme-*` colours.
|
|
139
|
+
- A field whose attribute is not required carries `placeholder='Optional'`.
|
|
140
|
+
Required is judged by the model's validators, not by `null: false` — and a
|
|
141
|
+
`belongs_to` validates the association, so `state_id` counts as required
|
|
142
|
+
through `:state`.
|
|
143
|
+
- A required attribute also gets a required *field*: `required` on the input, so
|
|
144
|
+
the browser turns the form back before the server ever sees it. It is the same
|
|
145
|
+
judgement the placeholder makes, from the same validators — the two are
|
|
146
|
+
readings of one fact and never disagree.
|
|
147
|
+
- Worth knowing before there is an `edit` action: a required encrypted attribute
|
|
148
|
+
is a required password field, and a password field renders empty, so editing a
|
|
149
|
+
record would demand the value be retyped. Revisit the rule then, not now.
|
|
150
|
+
- A required field shows the shape it expects instead: `555-555-5555` for a
|
|
151
|
+
phone, `michael@example.com` for an email. Every other required field has no
|
|
152
|
+
placeholder, since there is nothing useful to show.
|
|
153
|
+
- An explicit `type:` picks the sample on its own, required or not. `field :email,
|
|
154
|
+
type: :email` on an optional column shows `michael@example.com` rather than
|
|
155
|
+
`Optional`: the caller has said what the field is, and the sample is the more
|
|
156
|
+
useful of the two hints.
|
|
157
|
+
- The field list is a `fields` partial of its own, so a host app can replace the
|
|
158
|
+
fields without rewriting `form_with` or the submit button. It renders one
|
|
159
|
+
`field` per editable column; a host writes the calls it wants by hand.
|
|
160
|
+
- `field` takes the column name and two options: `label:` for the heading and
|
|
161
|
+
`type:` for the input. `field :phone, type: :phone` beats every rule below,
|
|
162
|
+
including the encrypted-column one — an explicit type is an instruction.
|
|
163
|
+
- The field type otherwise follows the column, and the rules are in this order: a
|
|
164
|
+
foreign key is a combobox; an encrypted column is a password field; one named
|
|
165
|
+
`email` or `color` gets that input; a `date`, `time` or `datetime` attribute
|
|
166
|
+
gets its own field, the last of those as `datetime-local`; everything else is
|
|
167
|
+
text.
|
|
168
|
+
- Encryption wins over the name, so an encrypted `email` is masked rather than
|
|
169
|
+
typed as an email — protecting the value matters more than the keyboard.
|
|
170
|
+
- Length, format and numericality travel to the browser, and all three are read
|
|
171
|
+
from the model's *validators*, never from the column: `maxlength` and
|
|
172
|
+
`minlength` from a length validator's `maximum`, `minimum` or `is`, `pattern`
|
|
173
|
+
from a format validator with `\A` and `\z` stripped since an HTML pattern is
|
|
174
|
+
anchored already, and `inputmode: 'numeric'` from a numericality validator or
|
|
175
|
+
a digits-only pattern.
|
|
176
|
+
- A field with a `pattern` also carries a `title` showing the shape it wants:
|
|
177
|
+
`\d{5}` gives `title='Please match the format 00000'`. Without one the browser
|
|
178
|
+
says only that the value does not match, which tells nobody what would. The
|
|
179
|
+
example is read off the pattern — `\d` becomes a digit, `\w` a letter, a bracket
|
|
180
|
+
class its first character, and `{n}` repeats — so the phone's
|
|
181
|
+
`[2-9]\d{2}[2-9]\d{6}` reads as `2002000000`.
|
|
182
|
+
- Always pass `size: nil`. Rails mirrors `maxlength` into `size`, and a
|
|
183
|
+
five-character box for a ZIP code undoes the width rule above.
|
|
184
|
+
- Which input a `date`, `time` or `datetime` gets is the one thing no validator
|
|
185
|
+
can say, so it comes from `type_for_attribute` — the model's own attribute
|
|
186
|
+
type, which an `attribute` override still governs — and not from
|
|
187
|
+
`columns_hash`.
|
|
188
|
+
|
|
189
|
+
## Comboboxes for foreign keys
|
|
190
|
+
|
|
191
|
+
- A form asks for a foreign key one of two ways, and which one is the label's
|
|
192
|
+
decision. Where the label has a *length validator* it is short enough to type,
|
|
193
|
+
so the field asks for the value; otherwise it is a combobox to pick from.
|
|
194
|
+
- A typed reference names both: the label reads `ZIP code`, not `ZIP`, since a
|
|
195
|
+
code is what the field wants. It takes the shape of that attribute —
|
|
196
|
+
`maxlength`, `minlength`, `pattern`, `title`, `inputmode` — from the model the
|
|
197
|
+
attribute belongs to, but takes *required* from the association that needs it,
|
|
198
|
+
which is the page's model and not the other one's.
|
|
199
|
+
- This is what keeps a form from being enormous. `/locations/new` was 3.3 MB when
|
|
200
|
+
its ZIP was a combobox of 40,965 options; typing the code instead makes it
|
|
201
|
+
6.4 KB. A combobox is right for fifty states and wrong for forty thousand ZIPs.
|
|
202
|
+
- A value that matches no record leaves the foreign key nil, so `belongs_to`
|
|
203
|
+
reports `Must exist` beside the field, and the field keeps what was typed. That
|
|
204
|
+
value comes from `params`, not from the record — nothing was ever assigned to it.
|
|
205
|
+
- `state_id` is still a Bootstrap combobox listing each `State` by `name`, in the
|
|
206
|
+
"Search menu items" form, so a list of fifty stays usable.
|
|
207
|
+
- What each option reads is the model's own `recourse_label` — `name` by default,
|
|
208
|
+
`code` for a ZIP, `email` for an Agent. See CLAUDE.md, "Every model says how it
|
|
209
|
+
is labelled".
|
|
210
|
+
- The menu holds every row, so it is only as usable as the table is small. The
|
|
211
|
+
ZIP combobox on `/locations/new` is 40,965 options and 3.3 MB of HTML: the
|
|
212
|
+
search box finds one instantly, but the page pays for all of them up front.
|
|
213
|
+
Bootstrap filters what is already in the DOM, so there is no cheaper option
|
|
214
|
+
short of a server-side search.
|
|
215
|
+
- The markup is the toggle followed by its `.menu` **sibling** — the plugin finds
|
|
216
|
+
the menu with `SelectorEngine.next`, so anything between them breaks it:
|
|
217
|
+
|
|
218
|
+
<button class='form-control combobox-toggle' type='button' id='county_state_id'
|
|
219
|
+
data-bs-toggle='combobox' data-bs-name='county[state_id]'
|
|
220
|
+
data-bs-placeholder='Select a State…' data-bs-search='true'>
|
|
221
|
+
<span class='combobox-value'>Select a State…</span>
|
|
222
|
+
<i class='bi bi-chevron-down combobox-caret'></i>
|
|
223
|
+
</button>
|
|
224
|
+
<div class='menu'>
|
|
225
|
+
<div class='combobox-search'>
|
|
226
|
+
<input type='text' class='form-control combobox-search-input'
|
|
227
|
+
placeholder='Search…' autocomplete='off' aria-label='Search…'>
|
|
228
|
+
</div>
|
|
229
|
+
<button class='menu-item' type='button' data-bs-value='1'>Alabama</button>
|
|
230
|
+
<div class='combobox-no-results d-none'>No results found</div>
|
|
231
|
+
</div>
|
|
232
|
+
|
|
233
|
+
- `data-bs-name` is what makes it a form control: the plugin inserts a hidden
|
|
234
|
+
input of that name before the toggle and writes the chosen `data-bs-value`
|
|
235
|
+
into it. Never put `name=` on the toggle itself.
|
|
236
|
+
- `data-bs-search='true'` enables filtering, but the search input has to be in
|
|
237
|
+
the markup — the plugin only wires up a `.combobox-search-input` it finds.
|
|
238
|
+
- The toggle carries the `id` the label points at, which is legal because a
|
|
239
|
+
`<button>` is a labelable element. Use `form.field_id` and `form.field_name`
|
|
240
|
+
rather than spelling either out.
|
|
241
|
+
- Bootstrap's example uses an inline SVG caret; ours is
|
|
242
|
+
`<i class='bi bi-chevron-down combobox-caret'></i>`. The class only needs
|
|
243
|
+
`flex-shrink` and a rotation, the icon font is already loaded, and the `<i>`
|
|
244
|
+
form is what every other icon on the page uses.
|
|
245
|
+
- The placeholder doubles as the empty label: `Select a <Model>…`, from
|
|
246
|
+
`model_name.human` so a registered acronym survives. An optional association
|
|
247
|
+
says `Optional` instead, like any other optional field.
|
|
248
|
+
- A required association carries `aria-required` on the toggle rather than
|
|
249
|
+
`required`. The toggle is a `<button>`, which `required` does not apply to, and
|
|
250
|
+
the hidden input that would take it is not in the markup — the plugin writes it
|
|
251
|
+
at runtime. So the requirement is announced, not enforced: the model's
|
|
252
|
+
validation is still what rejects a blank.
|
|
253
|
+
|
|
254
|
+
## Flash messages
|
|
255
|
+
|
|
256
|
+
- A flash is a Toast, never an inline alert, in a
|
|
257
|
+
`.toast-container.position-fixed.bottom-0.end-0.p-3` at the end of `<body>`.
|
|
258
|
+
`.toast-container` is `position: absolute` in v6, so `position-fixed` is not
|
|
259
|
+
optional — without it the toast scrolls away with the page.
|
|
260
|
+
- The variant is the flash key: `toast theme-success` for a notice, `toast
|
|
261
|
+
theme-danger` for an alert, and a neutral `theme-primary` for a key a host
|
|
262
|
+
invents. The theme goes on the `.toast` itself.
|
|
263
|
+
- The message goes in the *header*, and the body is kept but hidden:
|
|
264
|
+
|
|
265
|
+
<div class='toast theme-success' role='alert' aria-live='assertive' aria-atomic='true'>
|
|
266
|
+
<div class='toast-header border-0'>
|
|
267
|
+
<span class='me-auto'>Contact was created.</span>
|
|
268
|
+
<button type='button' class='btn-close' data-bs-dismiss='toast' aria-label='Close'></button>
|
|
269
|
+
</div>
|
|
270
|
+
<div class='toast-body d-none'></div>
|
|
271
|
+
</div>
|
|
272
|
+
|
|
273
|
+
- That is what tints the whole toast. `.toast-header` takes its background from
|
|
274
|
+
`--bs-theme-bg-subtle` while `.toast` itself takes the plain body background, so
|
|
275
|
+
a message in the body would sit on white below a coloured strip. With the body
|
|
276
|
+
hidden the toast *is* the header, and the theme colours all of it.
|
|
277
|
+
- `border-0` removes the header's `border-block-end`, which would otherwise rule a
|
|
278
|
+
line under the message with nothing beneath it.
|
|
279
|
+
- `me-auto` on the message is what pushes the X to the right. Inside a header the
|
|
280
|
+
close button needs nothing else: v6 gives it margins through
|
|
281
|
+
`.toast-header .btn-close`, which a headerless toast would have had to supply
|
|
282
|
+
itself.
|
|
283
|
+
- It autohides, which is the Toast default — nothing to declare.
|
|
284
|
+
- The wording names the model, never the record: `Contact was created.` and
|
|
285
|
+
`Contact could not be created.`, both from `model_name.human`. Interpolating the
|
|
286
|
+
record instead prints `#<Contact:0x000000012b6febc8>`, because Active Record
|
|
287
|
+
leaves `to_s` as Object's.
|
|
288
|
+
- Toasts need JavaScript twice over. `.toast:not(.show)` is `display: none`, so
|
|
289
|
+
one has to be shown, and the autohide timer only starts when it is. The layout
|
|
290
|
+
imports `Toast` from the bundle and calls `show()` on every `.toast` it finds.
|
|
291
|
+
`data-bs-dismiss='toast'` needs the component loaded too, so the X is dead
|
|
292
|
+
without it.
|
|
293
|
+
|
|
294
|
+
## Validation errors
|
|
295
|
+
|
|
296
|
+
- A rejected `create` redraws the same page with `422`, never a redirect, so the
|
|
297
|
+
fields keep what was typed and the errors sit beside them.
|
|
298
|
+
- The control that failed gains `is-invalid`, and the message follows it as
|
|
299
|
+
`<small class='invalid-feedback'>`. Both are needed: Bootstrap reveals the
|
|
300
|
+
feedback with `.is-invalid ~ .invalid-feedback`, so a feedback element on its
|
|
301
|
+
own stays hidden and an `is-invalid` on its own only reddens the border.
|
|
302
|
+
- Because that selector is a *sibling* one, the feedback goes after the whole
|
|
303
|
+
control — for a combobox, after the `.menu`, not inside the toggle.
|
|
304
|
+
- Nothing writes that markup by hand. `config.action_view.field_error_proc` does
|
|
305
|
+
it for every field a form builder draws — see CLAUDE.md, "Match Bootstrap with
|
|
306
|
+
field_error_proc".
|
|
307
|
+
- The combobox is the exception, because it is a partial rather than a form
|
|
308
|
+
builder tag, so `field_error_proc` never sees it. It adds its own `is-invalid`
|
|
309
|
+
and its own `.invalid-feedback`.
|
|
310
|
+
- The message is the bare reason, sentence-cased: `Must exist`, `Can't be blank`.
|
|
311
|
+
The label above it already names the attribute, so a full message would repeat
|
|
312
|
+
it.
|
|
313
|
+
- A `belongs_to` reports its error on the association, so a field for `state_id`
|
|
314
|
+
asks the record about both `state_id` and `state` — otherwise a missing state
|
|
315
|
+
reddens nothing.
|
|
316
|
+
|
|
317
|
+
## Tables
|
|
318
|
+
|
|
319
|
+
- A `<table>` defaults to the hoverable accent, not the striped one:
|
|
320
|
+
`class='table table-hover'`. Reach for `.table-striped` only when a specific
|
|
321
|
+
table is better served by it.
|
|
322
|
+
- Always add `.sm:table-stacked`, so rows become stacked blocks once the
|
|
323
|
+
container gets narrow.
|
|
324
|
+
- Cells live in a `_row` partial, one `column` call each, with the content in a
|
|
325
|
+
block:
|
|
326
|
+
|
|
327
|
+
<%= column header: 'Phone' do %>
|
|
328
|
+
<%= number_to_phone contact.phone %>
|
|
329
|
+
<% end %>
|
|
330
|
+
|
|
331
|
+
- `column` also takes anything `tag` does — `class:`, `style:` — and passes it
|
|
332
|
+
to both the `th` and the `td`.
|
|
333
|
+
- A foreign-key column shows what the record it points at is called, not the id
|
|
334
|
+
that points at it: `/locations` heads a column `ZIP code` and fills it with
|
|
335
|
+
`00501`. The heading is the one the form uses for the same column, so a table
|
|
336
|
+
and its form never disagree about what a column is.
|
|
337
|
+
- Those names cost one query per association rather than one per row, because the
|
|
338
|
+
index eager-loads every `belongs_to` the table can name. Twenty locations still
|
|
339
|
+
cost five queries.
|
|
340
|
+
- Every table ends with an `Actions` column, and `_table` adds it rather than
|
|
341
|
+
`_row`. That is the whole point of putting it there: a host that writes its own
|
|
342
|
+
row still gets the column, appended after whatever columns that row defines, so
|
|
343
|
+
`/contacts` reads `Name | Phone | Created at | Actions`.
|
|
344
|
+
- Where the resource has an `edit` action each row links to it, and the link's
|
|
345
|
+
content is the `<i class='bi bi-pencil-square'></i>` icon rather than the word.
|
|
346
|
+
It carries `aria-label='Edit'`, since an icon alone says nothing to a screen
|
|
347
|
+
reader.
|
|
348
|
+
- The column is there either way, empty for a resource that only has an index.
|
|
349
|
+
- The record arrives under its own name, `contact:` for contacts, so a host
|
|
350
|
+
partial declares `<%# locals: (contact:) -%>`. It is rendered once for the
|
|
351
|
+
header row with that local set to nil, so never assume it is present outside
|
|
352
|
+
a `column` block.
|
|
353
|
+
- A host app overrides one table by defining
|
|
354
|
+
`app/views/<resources>/_row.html.erb`, which wins through the controller's
|
|
355
|
+
template prefixes — so keep everything cell-shaped in that partial and
|
|
356
|
+
nothing else.
|
|
357
|
+
- Stacking needs two more things, or it degrades badly. The table must sit
|
|
358
|
+
inside a `.table-responsive` wrapper, which is the container query's
|
|
359
|
+
container. And every `<td>` needs `data-cell='<heading>'` — that is where the
|
|
360
|
+
labels in the stacked layout come from, so without it a narrow screen shows
|
|
361
|
+
values with nothing naming them.
|
|
362
|
+
- A table of records shows every attribute that is not encrypted, one column
|
|
363
|
+
each — not just the id. Encrypted attributes are omitted entirely: showing
|
|
364
|
+
ciphertext helps nobody, and decrypting it into a list leaks it.
|
|
365
|
+
- Column headings come from `human_attribute_name`, so a host app can rename
|
|
366
|
+
one by translating the attribute.
|
|
367
|
+
- A `belongs_to` currently shows its raw foreign key — `/counties` renders the
|
|
368
|
+
`State` column as `1`. Rendering the associated record instead needs
|
|
369
|
+
`includes` in the controller to stay within the one-count-one-select budget,
|
|
370
|
+
and that work is deferred. Do not reach through an association from a view in
|
|
371
|
+
the meantime.
|
|
372
|
+
|
|
373
|
+
## Links
|
|
374
|
+
|
|
375
|
+
- Internal links go through Turbo, so navigation is a fetch and a swap rather
|
|
376
|
+
than a full page load. The layout loads Turbo from the CDN.
|
|
377
|
+
- Turbo prefetches a link on `mouseenter`, so a page is already on its way
|
|
378
|
+
before the click lands. This is on by default in Turbo 8 — never add
|
|
379
|
+
`<meta name='turbo-prefetch' content='true'>` to restate it.
|
|
380
|
+
- Do not put `data-turbo='false'` or `data-turbo-prefetch='false'` on an
|
|
381
|
+
internal link. Either one opts that link out of both behaviours.
|
|
382
|
+
|
|
383
|
+
## Phone numbers
|
|
384
|
+
|
|
385
|
+
- A phone number shown to a user always goes through `number_to_phone`, so
|
|
386
|
+
`5552234567` reads as `555-223-4567`. Never print the stored digits raw.
|
|
387
|
+
- Storage is unaffected: the column still holds ten bare digits, as `CLAUDE.md`
|
|
388
|
+
requires. The formatting is for reading only.
|
|
389
|
+
- In a generic table this keys off the column being named `phone`, which is
|
|
390
|
+
safe because that convention guarantees the name.
|
|
391
|
+
- A phone *field* separates as it is typed, not only once it is stored. Every
|
|
392
|
+
`<input type='phone'>` carries the Stimulus controller that does it:
|
|
393
|
+
|
|
394
|
+
data-controller='phone'
|
|
395
|
+
data-action='keydown->phone#down input->phone#input'
|
|
396
|
+
|
|
397
|
+
- The controller formats on `connect` too, so a form redrawn after a rejected
|
|
398
|
+
`create` shows the separators rather than the ten digits it was sent.
|
|
399
|
+
- Because the value now carries separators, the `pattern` has to accept them or
|
|
400
|
+
the browser refuses to submit what it just helped type. A phone's pattern is
|
|
401
|
+
therefore `[2-9]\d{2}-[2-9]\d{2}-\d{4}` — the separated form of the model's
|
|
402
|
+
`NORTH_AMERICAN_PHONES`, keeping the rule that an area or exchange code cannot
|
|
403
|
+
start with 0 or 1. The server sees bare digits regardless, since `Phonable`
|
|
404
|
+
normalizes them away.
|
|
405
|
+
- Never put a length validator on a phone. `maxlength` would come from it and cut
|
|
406
|
+
the value off at ten characters, three short of `555-555-5555`.
|
|
407
|
+
- The `title` says `Please match the format 555-555-5555`, matching the
|
|
408
|
+
placeholder. Where a field has a canonical sample the title uses it rather than
|
|
409
|
+
a shape derived from the pattern, so the two never disagree.
|
|
410
|
+
|
|
411
|
+
## Times and dates
|
|
412
|
+
|
|
413
|
+
- A time on a page reads `%b %-d at %I:%M%P %Z` — `Aug 4 at 07:16pm EDT` —
|
|
414
|
+
wrapped in a `<time>` tag carrying the machine-readable value:
|
|
415
|
+
|
|
416
|
+
<time datetime='2026-08-04T19:16:51-04:00'>Aug 4 at 07:16pm EDT</time>
|
|
417
|
+
|
|
418
|
+
- Rails' `time_tag` builds both halves: `time_tag value,
|
|
419
|
+
value.strftime(TIME_FORMAT)`. Pass the text explicitly, or the helper reaches
|
|
420
|
+
for I18n instead.
|
|
421
|
+
- The `datetime` attribute is `rfc3339`, so it carries seconds and the offset.
|
|
422
|
+
The visible text drops both; the attribute is what a machine reads.
|
|
423
|
+
- Zone comes from `config.time_zone`, so `%Z` reads `EDT` or `EST` depending on
|
|
424
|
+
the date, never `UTC`.
|
|
425
|
+
|
|
426
|
+
## Pagination
|
|
427
|
+
|
|
428
|
+
- Paginate with the `pagy` gem, never hand-rolled offsets.
|
|
429
|
+
- The page limit is 20, which is already pagy's own default — so never pass
|
|
430
|
+
`limit:` to restate it.
|
|
431
|
+
- Below the table, in this order: `info_tag` for the item count, then
|
|
432
|
+
`series_nav :bootstrap` for the links. Both need `<%==` rather than `<%=`,
|
|
433
|
+
since they return HTML.
|
|
434
|
+
- Leave `max_limit` unset. Without it pagy ignores a `?limit=` in the query
|
|
435
|
+
string, so a visitor cannot ask for a page of 100,000 rows.
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# Superclass of the controllers the gem defines when a host app has none.
|
|
2
|
+
class RecoursesController < ApplicationController
|
|
3
|
+
include Pagy::Method
|
|
4
|
+
|
|
5
|
+
helper Recourse::Helpers
|
|
6
|
+
|
|
7
|
+
# `find` raises RecordNotFound, so an id that names nothing answers 404.
|
|
8
|
+
before_action :find_resource, only: %i[edit update]
|
|
9
|
+
|
|
10
|
+
# Lists one page of the model the route is named after.
|
|
11
|
+
def index
|
|
12
|
+
@pagy, @resources = pagy resource_scope
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Builds a blank record under the name Rails would use: @contact for contacts.
|
|
16
|
+
def new
|
|
17
|
+
assign resource_class.new
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Saves a submitted record, then shows the index again or redraws the form.
|
|
21
|
+
def create
|
|
22
|
+
record = assign resource_class.new(resource_params)
|
|
23
|
+
|
|
24
|
+
if record.save
|
|
25
|
+
flash.notice = "#{human_name} was created."
|
|
26
|
+
redirect_to url_for(action: :index), status: :see_other
|
|
27
|
+
else
|
|
28
|
+
flash.now.alert = "#{human_name} could not be created."
|
|
29
|
+
render :new, status: :unprocessable_entity
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Shows the form for the record the id names, which is already known to exist.
|
|
34
|
+
def edit; end
|
|
35
|
+
|
|
36
|
+
# Saves changes to a record, then shows the index again or redraws the form.
|
|
37
|
+
def update
|
|
38
|
+
if @recourse.update resource_params
|
|
39
|
+
flash.notice = "#{human_name} was updated."
|
|
40
|
+
redirect_to url_for(action: :index), status: :see_other
|
|
41
|
+
else
|
|
42
|
+
flash.now.alert = "#{human_name} could not be updated."
|
|
43
|
+
render :edit, status: :unprocessable_entity
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
# Every table cell that names a referenced record would otherwise be a query.
|
|
50
|
+
def resource_scope
|
|
51
|
+
names = resource_class.reflect_on_all_associations(:belongs_to).map(&:name)
|
|
52
|
+
return resource_class.all if names.empty?
|
|
53
|
+
|
|
54
|
+
resource_class.includes(*names)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def find_resource
|
|
58
|
+
assign resource_class.find(params.expect(:id))
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def assign(record)
|
|
62
|
+
@recourse = record
|
|
63
|
+
instance_variable_set "@#{controller_name.singularize}", record
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def resource_class
|
|
67
|
+
controller_name.classify.constantize
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def human_name
|
|
71
|
+
resource_class.model_name.human
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def resource_params
|
|
75
|
+
permitted = Recourse.editable_columns resource_class
|
|
76
|
+
|
|
77
|
+
resolve_references params.expect(controller_name.singularize.to_sym => permitted)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# A foreign key whose label is typed arrives as that label, so it is looked up
|
|
81
|
+
# here. Nothing found leaves the key nil, and `belongs_to` reports it missing.
|
|
82
|
+
def resolve_references(attributes)
|
|
83
|
+
resource_class.reflect_on_all_associations(:belongs_to).each do |association|
|
|
84
|
+
key = association.foreign_key.to_s
|
|
85
|
+
next unless attributes.key?(key) && association.klass.recourse_typed_label?
|
|
86
|
+
|
|
87
|
+
attributes[key] = reference_id association, attributes[key]
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
attributes
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def reference_id(association, label)
|
|
94
|
+
association.klass.find_by(association.klass.recourse_label => label)&.id
|
|
95
|
+
end
|
|
96
|
+
end
|