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
|
@@ -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,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,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,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 %>
|
data/lib/drive.rb
ADDED
|
@@ -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
|