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.
Files changed (45) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +6 -0
  3. data/CLAUDE.md +690 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +77 -0
  6. data/Rakefile +32 -0
  7. data/STYLE.md +435 -0
  8. data/app/controllers/recourses_controller.rb +96 -0
  9. data/app/javascript/recourse/phone_controller.js +33 -0
  10. data/app/views/layouts/application.html.erb +69 -0
  11. data/app/views/recourses/_breadcrumb.html.erb +18 -0
  12. data/app/views/recourses/_combobox.html.erb +23 -0
  13. data/app/views/recourses/_fields.html.erb +3 -0
  14. data/app/views/recourses/_flash.html.erb +13 -0
  15. data/app/views/recourses/_form.html.erb +9 -0
  16. data/app/views/recourses/_none.html.erb +1 -0
  17. data/app/views/recourses/_row.html.erb +4 -0
  18. data/app/views/recourses/_sidebar.html.erb +10 -0
  19. data/app/views/recourses/_table.html.erb +30 -0
  20. data/app/views/recourses/edit.html.erb +3 -0
  21. data/app/views/recourses/index.html.erb +14 -0
  22. data/app/views/recourses/new.html.erb +3 -0
  23. data/lib/drive.rb +3 -0
  24. data/lib/recourse/controllers.rb +13 -0
  25. data/lib/recourse/engine.rb +27 -0
  26. data/lib/recourse/helpers/cells.rb +44 -0
  27. data/lib/recourse/helpers/comboboxes.rb +33 -0
  28. data/lib/recourse/helpers/constraints.rb +94 -0
  29. data/lib/recourse/helpers/examples.rb +35 -0
  30. data/lib/recourse/helpers/fields.rb +54 -0
  31. data/lib/recourse/helpers/navigation.rb +68 -0
  32. data/lib/recourse/helpers/references.rb +89 -0
  33. data/lib/recourse/helpers.rb +60 -0
  34. data/lib/recourse/icons.rb +21 -0
  35. data/lib/recourse/recoursive.rb +19 -0
  36. data/lib/recourse/routes.rb +14 -0
  37. data/lib/recourse/version.rb +4 -0
  38. data/lib/recourse.rb +31 -0
  39. data/vendor/recourse/bootstrap-icons.min.css +5 -0
  40. data/vendor/recourse/bootstrap.bundle.min.js +9 -0
  41. data/vendor/recourse/bootstrap.min.css +2 -0
  42. data/vendor/recourse/fonts/bootstrap-icons.woff +0 -0
  43. data/vendor/recourse/fonts/bootstrap-icons.woff2 +0 -0
  44. data/vendor/recourse/stimulus.js +2563 -0
  45. metadata +144 -0
@@ -0,0 +1,33 @@
1
+ import { Controller } from '/recourse/stimulus.js'
2
+
3
+ export default class extends Controller {
4
+ connect() {
5
+ this.#format(this.element)
6
+ }
7
+
8
+ down(event) {
9
+ if (!event.key) { return }
10
+ if (event.ctrlKey) { return }
11
+ if (event.metaKey) { return }
12
+ if (event.key.length > 1) { return }
13
+ if (/[0-9.]/.test(event.key)) { return }
14
+ event.preventDefault()
15
+ }
16
+
17
+ input(event) {
18
+ if (event.inputType === 'deleteContentBackward') { return }
19
+ if (/[\d-]{12}/.test(event.target.value)) { event.preventDefault() }
20
+ this.#format(event.target)
21
+ }
22
+
23
+ #format(element) {
24
+ const digits = element.value.replace(/\D/g,'').substring(0, 10)
25
+ const areaCode = digits.substring(0, 3)
26
+ const prefix = digits.substring(3, 6)
27
+ const suffix = digits.substring(6, 10)
28
+
29
+ if (digits.length > 5) { element.value = `${areaCode}-${prefix}-${suffix}` }
30
+ else if (digits.length > 2) { element.value = `${areaCode}-${prefix}` }
31
+ else if (digits.length > 0) { element.value = `${areaCode}` }
32
+ }
33
+ }
@@ -0,0 +1,69 @@
1
+ <!DOCTYPE html>
2
+ <html lang='en'>
3
+ <head>
4
+ <meta charset='utf-8'>
5
+ <title><%= content_for(:title) || 'Recourse' %></title>
6
+ <meta name='viewport' content='width=device-width, initial-scale=1'>
7
+ <%= csrf_meta_tags %>
8
+ <%= csp_meta_tag %>
9
+
10
+ <%= yield :head %>
11
+
12
+ <link rel='preconnect' href='https://fonts.googleapis.com'>
13
+ <link rel='preconnect' href='https://fonts.gstatic.com' crossorigin>
14
+ <link rel='stylesheet' href='https://fonts.googleapis.com/css2?family=Geist+Mono:wght@100..900&family=Geist:wght@100..900&display=swap'>
15
+ <link rel='stylesheet' href='/recourse/bootstrap.min.css'>
16
+ <link rel='stylesheet' href='/recourse/bootstrap-icons.min.css'>
17
+
18
+ <script type='module'>
19
+ import { Application } from '/recourse/stimulus.js'
20
+ import PhoneController from '/recourse/phone_controller.js'
21
+
22
+ // In the head, and guarded: Turbo re-runs body scripts on every visit, and a
23
+ // second application would connect every controller twice.
24
+ if (!window.Stimulus) {
25
+ window.Stimulus = Application.start()
26
+ window.Stimulus.register('phone', PhoneController)
27
+ }
28
+ </script>
29
+
30
+ <style>
31
+ :root {
32
+ --bs-body-font-family: 'Geist', system-ui, sans-serif;
33
+ --bs-font-mono: 'Geist Mono', ui-monospace, monospace;
34
+ }
35
+ </style>
36
+ </head>
37
+
38
+ <body class='d-flex flex-column min-vh-100'>
39
+ <nav class='navbar border-bottom'>
40
+ <div class='container-fluid px-3 justify-content-start'>
41
+ <%= render 'breadcrumb' %>
42
+ <%= yield :actions %>
43
+ </div>
44
+ </nav>
45
+
46
+ <div class='container-fluid flex-grow-1 d-flex'>
47
+ <div class='row flex-grow-1'>
48
+ <aside class='col-auto border-end py-3 px-3 mx-0'>
49
+ <%= render 'sidebar' %>
50
+ </aside>
51
+
52
+ <main class='col py-4 px-3'>
53
+ <%= yield %>
54
+ </main>
55
+ </div>
56
+ </div>
57
+
58
+ <%= render 'flash' %>
59
+
60
+ <script type='module' src='https://cdn.jsdelivr.net/npm/@hotwired/turbo@8.0.23/dist/turbo.es2017-esm.js'></script>
61
+ <script type='module' src='/recourse/bootstrap.bundle.min.js'></script>
62
+ <script type='module'>
63
+ import { Toast } from '/recourse/bootstrap.bundle.min.js'
64
+
65
+ // A toast stays hidden until shown, and only then starts its autohide timer.
66
+ for (const toast of document.querySelectorAll('.toast')) Toast.getOrCreateInstance(toast).show()
67
+ </script>
68
+ </body>
69
+ </html>
@@ -0,0 +1,18 @@
1
+ <nav aria-label='breadcrumb'>
2
+ <ol class='breadcrumb mb-0'>
3
+ <% resource_breadcrumbs.each_with_index do |(title, path), index| %>
4
+ <% unless index.zero? %>
5
+ <li class='breadcrumb-divider'></li>
6
+ <% end %>
7
+ <% if path %>
8
+ <li class='breadcrumb-item'>
9
+ <%= link_to resource_label(title), path, class: 'breadcrumb-link gap-2' %>
10
+ </li>
11
+ <% else %>
12
+ <li class='breadcrumb-item' aria-current='page'>
13
+ <span class='breadcrumb-link active'><%= title %></span>
14
+ </li>
15
+ <% end %>
16
+ <% end %>
17
+ </ol>
18
+ </nav>
@@ -0,0 +1,23 @@
1
+ <%# locals: (name:, id:, invalid:, feedback:, label:, placeholder:, required:, recourses:) -%>
2
+ <button class='form-control<%= ' is-invalid' if invalid %> combobox-toggle' type='button' id='<%= id %>'
3
+ data-bs-toggle='combobox' data-bs-name='<%= name %>'
4
+ data-bs-placeholder='<%= placeholder %>' data-bs-search='true'
5
+ aria-required='<%= required %>' <%= tag.attributes 'aria-describedby': feedback && "#{id}_error" %>>
6
+ <span class='combobox-value'><%= placeholder %></span>
7
+ <i class='bi bi-chevron-down combobox-caret'></i>
8
+ </button>
9
+ <div class='menu'>
10
+ <div class='combobox-search'>
11
+ <input type='text' class='form-control combobox-search-input' placeholder='Search…'
12
+ autocomplete='off' aria-label='Search…'>
13
+ </div>
14
+ <% cache recourses do %>
15
+ <% recourses.each do |recourse| %>
16
+ <button class='menu-item' type='button' data-bs-value='<%= recourse.id %>'><%= recourse.attributes[label] %></button>
17
+ <% end %>
18
+ <% end %>
19
+ <div class='combobox-no-results d-none'>No results found</div>
20
+ </div>
21
+ <% if feedback -%>
22
+ <small class='invalid-feedback' id='<%= id %>_error'><%= feedback %></small>
23
+ <% end -%>
@@ -0,0 +1,3 @@
1
+ <% editable_columns.each do |name| -%>
2
+ <%= field name %>
3
+ <% end -%>
@@ -0,0 +1,13 @@
1
+ <% if flash.any? -%>
2
+ <div class='toast-container position-fixed bottom-0 end-0 p-3'>
3
+ <% flash.each do |key, message| -%>
4
+ <div class='toast <%= flash_theme key %>' role='alert' aria-live='assertive' aria-atomic='true'>
5
+ <div class='toast-header border-0'>
6
+ <span class='me-auto'><%= message %></span>
7
+ <button type='button' class='btn-close' data-bs-dismiss='toast' aria-label='Close'></button>
8
+ </div>
9
+ <div class='toast-body d-none'></div>
10
+ </div>
11
+ <% end -%>
12
+ </div>
13
+ <% end -%>
@@ -0,0 +1,9 @@
1
+ <% record = local_assigns[resource_key] -%>
2
+ <%= form_with model: record do |form| %>
3
+ <% @recourse_form = form -%>
4
+ <div class='row'>
5
+ <%= render 'fields', resource_key => record %>
6
+ </div>
7
+
8
+ <%= form.submit class: 'btn btn-solid theme-primary' %>
9
+ <% end %>
@@ -0,0 +1 @@
1
+ <p class='fg-2'>No <%= resources_name.downcase %>.</p>
@@ -0,0 +1,4 @@
1
+ <% record = local_assigns[resource_key] -%>
2
+ <% resource_columns.each do |name| -%>
3
+ <%= column header: reference_title(name, belongs_to_association(name)) do %><%= resource_cell record, name %><% end %>
4
+ <% end -%>
@@ -0,0 +1,10 @@
1
+ <ul class='nav flex-column'>
2
+ <% sidebar_resources.each do |name, title, path| %>
3
+ <% current = current_resource? name %>
4
+ <li class='nav-item'>
5
+ <%= link_to resource_label(title), path,
6
+ class: class_names('nav-link', active: current),
7
+ aria: { current: ('page' if current) } %>
8
+ </li>
9
+ <% end %>
10
+ </ul>
@@ -0,0 +1,30 @@
1
+ <%# locals: (recourses:, pagy:) %>
2
+ <% cache recourses do %>
3
+ <div class='table-responsive'>
4
+ <table class='table caption-top table-hover align-middle sm:table-stacked'>
5
+ <thead>
6
+ <tr>
7
+ <% @recourse_headers = true %>
8
+ <%= render 'row', resource_key => nil %>
9
+ <%= column header: 'Actions' %>
10
+ </tr>
11
+ </thead>
12
+ <tbody>
13
+ <% @recourse_headers = false %>
14
+ <% recourses.each do |recourse| %>
15
+ <tr>
16
+ <%= render 'row', resource_key => recourse %>
17
+ <%= column header: 'Actions' do %><%= edit_resource_link recourse %><% end %>
18
+ </tr>
19
+ <% end %>
20
+ </tbody>
21
+ </table>
22
+ </div>
23
+ <% end %>
24
+
25
+ <% if pagy %>
26
+ <div class='d-flex align-items-center'>
27
+ <div class='ps-2 flex-grow-1'><%== pagy.info_tag.gsub(/of (\d*?) in total/) { "of #{number_with_delimiter $1} in total" } %></div>
28
+ <div class='pe-2'><%== pagy.series_nav(:bootstrap, classes: 'pagination mb-0') if pagy.last > 1 %></div>
29
+ </div>
30
+ <% end %>
@@ -0,0 +1,3 @@
1
+ <% content_for :title, resource_record_label %>
2
+
3
+ <%= render 'form', resource_key => resource_record %>
@@ -0,0 +1,14 @@
1
+ <% content_for :title, resources_name %>
2
+
3
+ <% if (new_path = new_resource_path) %>
4
+ <% content_for :actions do %>
5
+ <%= link_to "Add #{resource_name}", new_path,
6
+ class: 'btn theme-primary btn-sm btn-outline ms-3' %>
7
+ <% end %>
8
+ <% end %>
9
+
10
+ <% if @resources.blank? %>
11
+ <%= render 'none' %>
12
+ <% else %>
13
+ <%= render 'table', recourses: @resources, pagy: @pagy %>
14
+ <% end %>
@@ -0,0 +1,3 @@
1
+ <% content_for :title, "New #{resource_name}" %>
2
+
3
+ <%= render 'form', resource_key => resource_record %>
data/lib/drive.rb ADDED
@@ -0,0 +1,3 @@
1
+ # Entry point named after the gem, so `require 'drive'` reaches the library that
2
+ # still calls itself Recourse — which is what Bundler does for a gem of this name.
3
+ require_relative 'recourse'
@@ -0,0 +1,13 @@
1
+ module Recourse
2
+ # Defines the controller classes a host app has not written for itself.
3
+ module Controllers
4
+ # Creates a controller for the named resource unless the host app has one.
5
+ def self.define_missing(name)
6
+ class_name = "#{name.to_s.camelize}Controller"
7
+ # const_defined? is true for a Zeitwerk autoload, so files on disk count too.
8
+ return if Object.const_defined? class_name
9
+
10
+ Object.const_set class_name, Class.new(RecoursesController)
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,27 @@
1
+ # rails/engine alone raises NoMethodError: railtie.rb calls delegate_missing_to too early.
2
+ require 'rails'
3
+
4
+ require_relative 'routes'
5
+
6
+ module Recourse
7
+ # Hooks the gem into a host app's boot so its controllers and views are found.
8
+ class Engine < ::Rails::Engine
9
+ # Prefix the gem answers for with a file. `Rack::Static` matches on
10
+ # `start_with?`, so dropping the slash would swallow `/recourses` itself.
11
+ STATIC_URLS = %w[/recourse/].freeze
12
+
13
+ # Initializers all run before routes are drawn, so `recourses` exists in time.
14
+ initializer 'recourse.routes' do
15
+ ActionDispatch::Routing::Mapper.include Routes
16
+ end
17
+
18
+ # Serves what a page needs, since a host may run no asset pipeline at all. The
19
+ # prefix keeps its slash: without it `/recourses` would be served as a file.
20
+ # Vendored files answer first and cascade, so our own JavaScript shares the URL.
21
+ initializer 'recourse.assets' do |app|
22
+ app.middleware.use Rack::Static, urls: STATIC_URLS, root: Engine.root.join('vendor'),
23
+ cascade: true
24
+ app.middleware.use Rack::Static, urls: STATIC_URLS, root: Engine.root.join('app/javascript')
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,44 @@
1
+ module Recourse
2
+ module Helpers
3
+ # Helpers for the cells of a table and the fields of a form.
4
+ module Cells
5
+ # How a time reads on a page, e.g. 'Aug 4 at 03:47pm EDT'.
6
+ TIME_FORMAT = '%b %-d at %I:%M%P %Z'
7
+
8
+ # Columns the table shows: every attribute that is not encrypted.
9
+ def resource_columns
10
+ resource_model.column_names - Array(resource_model.encrypted_attributes).map(&:to_s)
11
+ end
12
+
13
+ # Columns a form offers, the same list `create` permits.
14
+ def editable_columns
15
+ Recourse.editable_columns resource_model
16
+ end
17
+
18
+ # Heading for a column, which a host app can translate like any attribute.
19
+ def resource_column_title(column)
20
+ resource_model.human_attribute_name column
21
+ end
22
+
23
+ # Value for one cell, formatted according to what the column holds.
24
+ def resource_cell(resource, column)
25
+ association = belongs_to_association column
26
+ return reference_cell resource, association if association
27
+
28
+ value = resource.attributes[column]
29
+
30
+ return time_tag value, value.strftime(TIME_FORMAT) if value.is_a? Time
31
+ return number_to_phone value if column == 'phone'
32
+
33
+ value
34
+ end
35
+
36
+ # One cell: a heading in the header row, the block's output in every other.
37
+ def column(header:, **, &)
38
+ return tag.th(header, scope: :col, **) if @recourse_headers
39
+
40
+ tag.td(capture(&), 'data-cell': header, **)
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,33 @@
1
+ module Recourse
2
+ module Helpers
3
+ # The menu a foreign key offers when its label is too long to be typed.
4
+ module Comboboxes
5
+ # A combobox of labels. The query fetches the two columns the menu shows and
6
+ # nothing else, and the errors are its own work: `field_error_proc` only ever
7
+ # sees the tags a form builder drew, and this is a partial.
8
+ def combobox(form, column, association)
9
+ messages = errors_on column
10
+ label = association.klass.recourse_label
11
+ render 'recourses/combobox', name: form.field_name(column),
12
+ id: form.field_id(column),
13
+ invalid: messages.any?,
14
+ feedback: messages.to_sentence.upcase_first.presence,
15
+ label: label.to_s,
16
+ placeholder: combobox_placeholder(column, association),
17
+ required: required?(resource_model, column),
18
+ recourses: combobox_options(association.klass, label)
19
+ end
20
+
21
+ private
22
+
23
+ def combobox_options(klass, label)
24
+ klass.select(:id, label).order label
25
+ end
26
+
27
+ def combobox_placeholder(column, association)
28
+ placeholder(resource_model, column, nil) ||
29
+ "Select a #{association.klass.model_name.human}…"
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,94 @@
1
+ module Recourse
2
+ module Helpers
3
+ # Turns a model's validators into the HTML that constrains a form field.
4
+ module Constraints
5
+ # Shown for a field whose shape has one canonical example.
6
+ SAMPLE_PLACEHOLDERS = { 'phone' => '555-555-5555', 'email' => 'michael@example.com' }.freeze
7
+
8
+ # What the browser checks where the typed shape differs from the stored one: a
9
+ # phone is ten digits in the database but is typed with its separators.
10
+ DISPLAY_PATTERNS = { 'phone' => '[2-9]\d{2}-[2-9]\d{2}-\d{4}' }.freeze
11
+
12
+ # Hands a phone field to the Stimulus controller that types its separators.
13
+ PHONE_CONTROLLER = {
14
+ controller: 'phone', action: 'keydown->phone#down input->phone#input',
15
+ }.freeze
16
+
17
+ # Browser-side constraints, every one read from the validators of `model` —
18
+ # which is the page's model unless a field asks about another one's attribute.
19
+ def field_html(column, type = nil, model = resource_model)
20
+ key = (type || column).to_s
21
+ pattern = DISPLAY_PATTERNS[key] || column_pattern(model, column)
22
+
23
+ {
24
+ maxlength: length_option(model, column, :maximum),
25
+ minlength: length_option(model, column, :minimum), pattern:,
26
+ title: title(key, pattern), placeholder: placeholder(model, column, type),
27
+ inputmode: (:numeric if numeric? model, column, pattern),
28
+ required: (true if required? model, column),
29
+ data: (PHONE_CONTROLLER if key == 'phone'),
30
+ }.compact
31
+ end
32
+
33
+ private
34
+
35
+ # The canonical sample where the field has one, so the title agrees with the
36
+ # placeholder; otherwise a shape read off the pattern itself.
37
+ def title(key, pattern)
38
+ return unless pattern
39
+
40
+ "Please match the format #{SAMPLE_PLACEHOLDERS[key] || pattern_example(pattern)}"
41
+ end
42
+
43
+ def length_option(model, column, bound)
44
+ length_options(model, column).values_at(bound, :is).compact.first
45
+ end
46
+
47
+ def length_options(model, column)
48
+ validator(model, column, ActiveModel::Validations::LengthValidator)&.options || {}
49
+ end
50
+
51
+ # An HTML pattern is anchored already, so \A and \z come off the Ruby one.
52
+ def column_pattern(model, column)
53
+ shape = validator model, column, ActiveModel::Validations::FormatValidator
54
+ regexp = shape&.options&.dig :with
55
+ return unless regexp
56
+
57
+ regexp.source.delete_prefix('\A').delete_suffix '\z'
58
+ end
59
+
60
+ # An explicit type states what the field is, so it picks the sample; without
61
+ # one a required field shows the shape it expects and an optional one says so.
62
+ def placeholder(model, column, type)
63
+ sample = SAMPLE_PLACEHOLDERS[(type || column).to_s]
64
+ return sample if sample && (type || required?(model, column))
65
+
66
+ 'Optional' unless required? model, column
67
+ end
68
+
69
+ # A belongs_to validates its association, not the column, so both are asked.
70
+ def required?(model, column)
71
+ presence_validated?(model, column) ||
72
+ presence_validated?(model, column.delete_suffix('_id'))
73
+ end
74
+
75
+ def presence_validated?(model, attribute)
76
+ validator(model, attribute, ActiveModel::Validations::PresenceValidator).present?
77
+ end
78
+
79
+ def numeric?(model, column, pattern)
80
+ digits_only?(pattern) ||
81
+ validator(model, column, ActiveModel::Validations::NumericalityValidator).present?
82
+ end
83
+
84
+ # `\d` carries a letter, so it has to go before looking for real ones.
85
+ def digits_only?(pattern)
86
+ pattern.present? && !pattern.gsub('\d', '').match?(/[A-Za-z]/)
87
+ end
88
+
89
+ def validator(model, attribute, kind)
90
+ model.validators_on(attribute).find { |one| one.is_a? kind }
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,35 @@
1
+ module Recourse
2
+ module Helpers
3
+ # Reads an example of an accepted value off the pattern that accepts it.
4
+ module Examples
5
+ # One token of a pattern and the `{n}` that may repeat it, so `\d{5}` reads
6
+ # as a single digit five times over rather than as five separate characters.
7
+ # `\d`, a bracket class and a literal are all it knows; nothing else is used.
8
+ PATTERN_TOKENS = /(\\d|\[[^\]]*\]|[^{])(?:\{(\d+),?\d*\})?/
9
+
10
+ # A token that is a bracket class, so `[2-9]` counts as one rather than five.
11
+ CLASS_TOKEN = /\A\[/
12
+
13
+ # First character a bracket class offers, past the bracket and any negation.
14
+ CLASS_SAMPLE = /[^\[^]/
15
+
16
+ # What the pattern accepts, so a field can name the shape it wants instead of
17
+ # only reporting that what was typed is wrong.
18
+ def pattern_example(pattern)
19
+ tokens = pattern.scan PATTERN_TOKENS
20
+
21
+ tokens.map { |token, count| pattern_sample(token) * (count || 1).to_i }.join
22
+ end
23
+
24
+ private
25
+
26
+ def pattern_sample(token)
27
+ case token
28
+ when '\d' then '0'
29
+ when CLASS_TOKEN then token[CLASS_SAMPLE]
30
+ else token
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,54 @@
1
+ module Recourse
2
+ module Helpers
3
+ # Chooses the form field a column deserves, and labels it.
4
+ module Fields
5
+ # One labelled field in the form's grid. `label:` overrides the heading and
6
+ # `type:` overrides the input the column would otherwise have chosen.
7
+ def field(name, **options)
8
+ column = name.to_s
9
+ label = options.fetch :label, reference_title(column, belongs_to_association(column))
10
+
11
+ tag.div class: 'mb-3 lg:col-6' do
12
+ safe_join [@recourse_form.label(column, label, class: 'form-label'),
13
+ resource_field(@recourse_form, column, type: options[:type])]
14
+ end
15
+ end
16
+
17
+ # A field typed by what the column holds, not merely a text box.
18
+ def resource_field(form, column, type: nil)
19
+ association = belongs_to_association column
20
+ return reference_field form, column, association if association
21
+
22
+ # Rails mirrors `maxlength` into `size`, which would shrink the box to it.
23
+ options = { class: 'form-control', size: nil }.merge field_html(column, type)
24
+
25
+ return form.text_field column, **options, type: type if type
26
+ return form.password_field column, **options if encrypted_column? column
27
+ return form.email_field column, **options if column == 'email'
28
+ return form.color_field column, **options if column == 'color'
29
+
30
+ dated_field form, column, **options
31
+ end
32
+
33
+ private
34
+
35
+ def dated_field(form, column, **)
36
+ case attribute_type column
37
+ when :date then form.date_field column, **
38
+ when :time then form.time_field column, **
39
+ when :datetime then form.datetime_local_field column, **
40
+ else form.text_field column, **
41
+ end
42
+ end
43
+
44
+ def encrypted_column?(column)
45
+ Array(resource_model.encrypted_attributes).map(&:to_s).include? column
46
+ end
47
+
48
+ # The model's own attribute type, so an `attribute` override still counts.
49
+ def attribute_type(column)
50
+ resource_model.type_for_attribute(column).type
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,68 @@
1
+ module Recourse
2
+ module Helpers
3
+ # Helpers for the navbar and the sidebar.
4
+ module Navigation
5
+ # Trail to the current page as [title, path] pairs; a nil path is this page.
6
+ def resource_breadcrumbs
7
+ leaf = breadcrumb_leaf
8
+ return [[resources_name, nil]] unless leaf
9
+
10
+ [[resources_name, url_for(action: :index)], [leaf, nil]]
11
+ end
12
+
13
+ # Pencil linking to a record's edit page, or nothing when there is not one.
14
+ def edit_resource_link(record)
15
+ path = edit_resource_path record
16
+ return unless path
17
+
18
+ link_to tag.i(class: 'bi bi-pencil-square'), path, aria: { label: 'Edit' }
19
+ end
20
+
21
+ # Path to this resource's new page, or nil when there is not one to link to.
22
+ def new_resource_path
23
+ return unless controller.class.action_methods.include? 'new'
24
+ return unless routed? controller.controller_path, 'new'
25
+
26
+ url_for action: :new
27
+ end
28
+
29
+ # Label for a link to a resource: its icon, then its title.
30
+ def resource_label(title)
31
+ icon = NAVIGATION_ICONS.fetch title, FALLBACK_ICON
32
+
33
+ safe_join [tag.i(class: "bi bi-#{icon}"), title], ' '
34
+ end
35
+
36
+ # Sidebar entries as [name, title, path], in the order routes.rb declares them.
37
+ def sidebar_resources
38
+ Recourse.declared.filter_map do |name|
39
+ next unless routed? name, 'index'
40
+
41
+ [name, name.humanize, url_for(controller: "/#{name}", action: :index)]
42
+ end
43
+ end
44
+
45
+ # True when a sidebar entry names the resource the page is showing.
46
+ def current_resource?(name)
47
+ name == controller.controller_name
48
+ end
49
+
50
+ private
51
+
52
+ # Only a page beneath the index names itself, and names what it is showing.
53
+ def breadcrumb_leaf
54
+ case controller.action_name
55
+ when 'new', 'create' then "New #{resource_name}"
56
+ when 'edit', 'update' then resource_record_label
57
+ end
58
+ end
59
+
60
+ def edit_resource_path(record)
61
+ return unless controller.class.action_methods.include? 'edit'
62
+ return unless routed? controller.controller_path, 'edit'
63
+
64
+ url_for action: :edit, id: record
65
+ end
66
+ end
67
+ end
68
+ end