recourse 3.0.3 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +234 -0
- data/README.md +150 -20
- data/app/controllers/bookmarks_controller.rb +7 -0
- data/app/controllers/concerns/recourse/landing.rb +49 -0
- data/app/controllers/concerns/recourse/list_resolution.rb +28 -0
- data/app/controllers/concerns/recourse/paging.rb +17 -0
- data/app/controllers/concerns/recourse/parent_naming.rb +24 -0
- data/app/controllers/concerns/recourse/parent_resolution.rb +63 -0
- data/app/controllers/concerns/recourse/reference_resolution.rb +70 -0
- data/app/controllers/concerns/recourse/resource_resolution.rb +68 -0
- data/app/controllers/concerns/recourse/zoning.rb +29 -0
- data/app/controllers/recourse/base_controller.rb +85 -0
- data/app/controllers/recourse/bookmarks_controller.rb +76 -0
- data/app/controllers/recourses_controller.rb +6 -25
- data/app/javascript/recourse/bookmark_controller.js +100 -0
- data/app/javascript/recourse/clear_controller.js +22 -0
- data/app/javascript/recourse/combobox_controller.js +68 -0
- data/app/javascript/recourse/confirm.js +46 -0
- data/app/javascript/recourse/deselect_controller.js +37 -0
- data/app/javascript/recourse/flash.js +35 -0
- data/app/javascript/recourse/limit_controller.js +23 -0
- data/app/javascript/recourse/phone_controller.js +33 -0
- data/app/javascript/recourse/relative_time_controller.js +45 -0
- data/app/javascript/recourse/reveal_controller.js +16 -0
- data/app/javascript/recourse/scheme_controller.js +51 -0
- data/app/javascript/recourse/search_controller.js +78 -0
- data/app/javascript/recourse/shortcuts_controller.js +42 -0
- data/app/javascript/recourse/timezone_controller.js +27 -0
- data/app/javascript/recourse/toast_controller.js +35 -0
- data/app/javascript/recourse/tooltip_controller.js +16 -0
- data/app/javascript/recourse/written.js +29 -0
- data/app/javascript/recourse/written_controller.js +28 -0
- data/app/views/layouts/recourses.html.erb +442 -0
- data/app/views/recourses/_breadcrumb.html.erb +35 -0
- data/app/views/recourses/_card.html.erb +33 -0
- data/app/views/recourses/_color.html.erb +20 -0
- data/app/views/recourses/_combobox.html.erb +68 -0
- data/app/views/recourses/_confirm.html.erb +14 -0
- data/app/views/recourses/_fields.html.erb +3 -0
- data/app/views/recourses/_flash.html.erb +26 -0
- data/app/views/recourses/_footer.html.erb +22 -0
- data/app/views/recourses/_form.html.erb +11 -0
- data/app/views/recourses/_none.html.erb +1 -0
- data/app/views/recourses/_results.html.erb +10 -0
- data/app/views/recourses/_row.html.erb +4 -10
- data/app/views/recourses/_scheme.html.erb +14 -0
- data/app/views/recourses/_search.html.erb +24 -0
- data/app/views/recourses/_sidebar.html.erb +40 -0
- data/app/views/recourses/_table.html.erb +47 -42
- data/app/views/recourses/_values.html.erb +3 -0
- data/app/views/recourses/edit.html.erb +10 -0
- data/app/views/recourses/index.html.erb +27 -24
- data/app/views/recourses/new.html.erb +3 -0
- data/app/views/recourses/show.html.erb +10 -0
- data/config/locales/recourse.en.yml +72 -0
- data/lib/recourse/bookmarks.rb +51 -0
- data/lib/recourse/broadcasting.rb +33 -0
- data/lib/recourse/colors.rb +32 -0
- data/lib/recourse/columns.rb +80 -0
- data/lib/recourse/controllers.rb +26 -0
- data/lib/recourse/engine.rb +33 -13
- data/lib/recourse/helpers/actions.rb +56 -0
- data/lib/recourse/helpers/bookmarks.rb +88 -0
- data/lib/recourse/helpers/buttons.rb +65 -0
- data/lib/recourse/helpers/cards.rb +67 -0
- data/lib/recourse/helpers/cells.rb +89 -0
- data/lib/recourse/helpers/choices.rb +49 -0
- data/lib/recourse/helpers/colors.rb +20 -0
- data/lib/recourse/helpers/comboboxes.rb +87 -0
- data/lib/recourse/helpers/constraints.rb +92 -0
- data/lib/recourse/helpers/counters.rb +75 -0
- data/lib/recourse/helpers/deletions.rb +87 -0
- data/lib/recourse/helpers/details.rb +41 -0
- data/lib/recourse/helpers/examples.rb +35 -0
- data/lib/recourse/helpers/fields.rb +78 -0
- data/lib/recourse/helpers/filters.rb +91 -0
- data/lib/recourse/helpers/formats.rb +94 -0
- data/lib/recourse/helpers/inputs.rb +92 -0
- data/lib/recourse/helpers/kinds.rb +87 -0
- data/lib/recourse/helpers/limits.rb +37 -0
- data/lib/recourse/helpers/names.rb +44 -0
- data/lib/recourse/helpers/navigation.rb +75 -0
- data/lib/recourse/helpers/parents.rb +82 -0
- data/lib/recourse/helpers/pictures.rb +33 -0
- data/lib/recourse/helpers/references.rb +99 -0
- data/lib/recourse/helpers/refreshes.rb +27 -0
- data/lib/recourse/helpers/resources.rb +47 -0
- data/lib/recourse/helpers/routing.rb +28 -0
- data/lib/recourse/helpers/rows.rb +33 -0
- data/lib/recourse/helpers/schemes.rb +14 -0
- data/lib/recourse/helpers/searches.rb +57 -0
- data/lib/recourse/helpers/shortcuts.rb +30 -0
- data/lib/recourse/helpers/sidebars.rb +39 -0
- data/lib/recourse/helpers/sorts.rb +62 -0
- data/lib/recourse/helpers/tabs.rb +82 -0
- data/lib/recourse/helpers/times.rb +43 -0
- data/lib/recourse/helpers/values.rb +64 -0
- data/lib/recourse/helpers/zones.rb +38 -0
- data/lib/recourse/helpers.rb +81 -0
- data/lib/recourse/icons.rb +21 -0
- data/lib/recourse/limits.rb +12 -0
- data/lib/recourse/recoursive.rb +66 -19
- data/lib/recourse/registry.rb +36 -0
- data/lib/recourse/routes/nested.rb +36 -0
- data/lib/recourse/routes.rb +57 -0
- data/lib/recourse/routing.rb +11 -32
- data/lib/recourse/schemes.rb +6 -0
- data/lib/recourse/scopes.rb +37 -0
- data/lib/recourse/search.rb +65 -0
- data/lib/recourse/searchable/columns.rb +99 -0
- data/lib/recourse/searchable/filters.rb +58 -0
- data/lib/recourse/searchable/terms.rb +56 -0
- data/lib/recourse/searchable.rb +47 -22
- data/lib/recourse/titles.rb +56 -0
- data/lib/recourse/version.rb +2 -1
- data/lib/recourse/writes.rb +12 -0
- data/lib/recourse/zones.rb +6 -0
- data/lib/recourse.rb +79 -7
- 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 +169 -30
- data/Rakefile +0 -6
- data/app/helpers/navigable_helper.rb +0 -35
- data/app/helpers/recoursive_helper.rb +0 -10
- data/app/helpers/searchable_helper.rb +0 -54
- data/db/migrate/20260323234318_add_baby_to_posts.rb +0 -5
data/lib/recourse/engine.rb
CHANGED
|
@@ -1,20 +1,40 @@
|
|
|
1
|
-
#
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
# rails/engine alone raises NoMethodError: railtie.rb calls delegate_missing_to too early.
|
|
2
|
+
require 'rails'
|
|
3
|
+
|
|
4
|
+
require_relative 'routes'
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
module Recourse
|
|
7
|
+
# Hooks the gem into a host app's boot so its controllers and views are found.
|
|
7
8
|
class Engine < ::Rails::Engine
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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 Scopes, Routes
|
|
12
16
|
end
|
|
13
17
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
+
# Turbo answers first, by exact path — matched on the prefix alone, the statics
|
|
21
|
+
# below would swallow it with a 404 instead of passing it on. Then vendored
|
|
22
|
+
# files, cascading so our own JavaScript shares the URL. All of it sits before
|
|
23
|
+
# `Rails::Rack::Logger`, so fetching a stylesheet writes no `Started GET` line:
|
|
24
|
+
# a file is not what a log is about, and placement says so without the gem
|
|
25
|
+
# touching a host's logging.
|
|
26
|
+
initializer 'recourse.assets' do |app|
|
|
27
|
+
# turbo-rails' own bundle: the same Turbo plus the cable element, in the
|
|
28
|
+
# version that gem signs its streams for. A dependency rather than something
|
|
29
|
+
# to find, since a page here cannot do without it.
|
|
30
|
+
app.middleware.insert_before Rails::Rack::Logger, Rack::Static,
|
|
31
|
+
urls: { '/recourse/turbo.min.js' => 'turbo.min.js' },
|
|
32
|
+
root: Turbo::Engine.root.join('app/assets/javascripts').to_s
|
|
33
|
+
app.middleware.insert_before Rails::Rack::Logger, Rack::Static,
|
|
34
|
+
urls: STATIC_URLS, root: Engine.root.join('vendor'),
|
|
35
|
+
cascade: true
|
|
36
|
+
app.middleware.insert_before Rails::Rack::Logger, Rack::Static,
|
|
37
|
+
urls: STATIC_URLS, root: Engine.root.join('app/javascript')
|
|
18
38
|
end
|
|
19
39
|
end
|
|
20
40
|
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
module Recourse
|
|
2
|
+
module Helpers
|
|
3
|
+
# The action columns a row opens with: a look at a record, then a change to it.
|
|
4
|
+
module Actions
|
|
5
|
+
# The two pages a record has, named as the concepts an icon set knows rather
|
|
6
|
+
# than as one set's own word for them, so what draws them is Unicon's business
|
|
7
|
+
# here as everywhere else. A row's links and a card's tabs read the same map,
|
|
8
|
+
# so the two cannot drift apart — and its order is the order a row opens with.
|
|
9
|
+
ICONS = { show: :view, edit: :edit }.freeze
|
|
10
|
+
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
# Which of those two this table draws a column for. Read into a local by the
|
|
14
|
+
# table, so the routes are asked once per render rather than twice for the
|
|
15
|
+
# heading and twice more for every row.
|
|
16
|
+
def resource_actions
|
|
17
|
+
ICONS.keys.select { |action| resource_action? action }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# An action column's heading: the icon on the header row — the column is as
|
|
21
|
+
# narrow as the icon in it, with no room for a word — and the action's own
|
|
22
|
+
# word in every other, which is what each `data-cell` labels itself with.
|
|
23
|
+
def action_header(action)
|
|
24
|
+
label = t "recourse.#{action}"
|
|
25
|
+
return label unless @recourse_headers
|
|
26
|
+
|
|
27
|
+
icon_heading ICONS[action], label
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Whether a record's own page is there to be linked to, wherever its routes
|
|
31
|
+
# were drawn: a nested table's rows lead to the resource's own pages, the ones
|
|
32
|
+
# a nested route leaves to it, so the columns are the same either way.
|
|
33
|
+
def resource_action?(action)
|
|
34
|
+
routed_action? action.to_s, resource_controller_path
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# The icon linking to one of those pages, or nothing where the page is not
|
|
38
|
+
# routed. Which of the two it is, is the whole difference between them.
|
|
39
|
+
def resource_action_link(action, record)
|
|
40
|
+
path = resource_action_path action, record
|
|
41
|
+
return unless path
|
|
42
|
+
|
|
43
|
+
turbo_link_to icon_tag(ICONS[action]), path, aria: { label: t("recourse.#{action}") }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Named by controller rather than by action alone: on a nested page the two
|
|
47
|
+
# differ, and a bare `action:` would look for the member route the nesting
|
|
48
|
+
# does not draw.
|
|
49
|
+
def resource_action_path(action, record)
|
|
50
|
+
return unless resource_action? action
|
|
51
|
+
|
|
52
|
+
url_for controller: "/#{resource_controller_path}", action: action, id: record
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
module Recourse
|
|
2
|
+
module Helpers
|
|
3
|
+
# The bookmark a table's first column carries: one square per row, hollow where
|
|
4
|
+
# the viewer has not kept it and filled where they have.
|
|
5
|
+
module Bookmarks
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
# How this model's bookmarks point back at it, or nil where it keeps none and
|
|
9
|
+
# the column is not drawn at all. Looked up once per render, like the model.
|
|
10
|
+
def resource_bookmarks
|
|
11
|
+
return @resource_bookmarks if defined? @resource_bookmarks
|
|
12
|
+
|
|
13
|
+
@resource_bookmarks = Recourse.bookmarks_for resource_model
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# The rows this viewer has kept, read once for the page rather than a query a
|
|
17
|
+
# row: a page is twenty rows and the answer is one integer column.
|
|
18
|
+
def bookmarked_ids
|
|
19
|
+
@bookmarked_ids ||= Recourse.bookmarks_of(resource_bookmarks)
|
|
20
|
+
.pluck(resource_bookmarks.foreign_key).to_set
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# What the table's cache key carries for the bookmarks, and nil where there are
|
|
24
|
+
# none. The rows are the model's own and never change when a bookmark does, so
|
|
25
|
+
# without this a fragment drawn before the last click outlives it — and the
|
|
26
|
+
# ids are the viewer's, so this is also what keeps one agent's icons off
|
|
27
|
+
# another's page. The name leads because an expanded key renders `nil` and `[]`
|
|
28
|
+
# alike.
|
|
29
|
+
def bookmark_digest
|
|
30
|
+
[:bookmarked, *bookmarked_ids.to_a.sort] if resource_bookmarks
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# The class that tints a kept row, and nothing at all where the viewer has not
|
|
34
|
+
# kept it — or where this model keeps none, since `bookmarked_ids` has no column
|
|
35
|
+
# to read then. The square already says which rows are theirs, and says it to a
|
|
36
|
+
# reader who cannot see a tint; this is what a page of twenty is scanned by.
|
|
37
|
+
def bookmark_row_class(record)
|
|
38
|
+
'recourse-kept' if resource_bookmarks && bookmarked_ids.include?(record.id)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# The column's heading: the hollow square on the header row — the column is as
|
|
42
|
+
# narrow as the icon in it — and the word on every other, which is what each
|
|
43
|
+
# `data-cell` labels itself with, the way an action column's does.
|
|
44
|
+
def bookmark_header
|
|
45
|
+
label = t 'recourse.bookmark'
|
|
46
|
+
return label unless @recourse_headers
|
|
47
|
+
|
|
48
|
+
icon_heading :bookmark, label
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Kept or not, one square either way: the same path with the verb reversed, so
|
|
52
|
+
# the button toggles by flipping the method Rails already wrote into the form.
|
|
53
|
+
def bookmark_button(record)
|
|
54
|
+
kept = bookmarked_ids.include? record.id
|
|
55
|
+
label = t "recourse.#{kept ? 'unbookmark' : 'bookmark'}"
|
|
56
|
+
|
|
57
|
+
button_to bookmark_icon(kept, label), bookmark_url(record),
|
|
58
|
+
method: kept ? :delete : :post, form_class: 'd-inline-block',
|
|
59
|
+
class: 'btn btn-sm btn-link p-0 border-0 lh-1',
|
|
60
|
+
**bookmark_data(kept, label)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def bookmark_icon(kept, label)
|
|
64
|
+
icon_tag kept ? :bookmarked : :bookmark, label:
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# What the Stimulus controller flips, and the wording it cannot look up itself:
|
|
68
|
+
# a `.js` file has no `t`, so the message it may have to show travels with it.
|
|
69
|
+
def bookmark_data(kept, label)
|
|
70
|
+
# No tooltip, unlike every other icon here. This one is in every row rather
|
|
71
|
+
# than in a heading, and a label that follows the cursor down a column of
|
|
72
|
+
# squares is noise — the `aria-label` still names it for a reader who cannot
|
|
73
|
+
# see which way the square is filled.
|
|
74
|
+
data = {
|
|
75
|
+
controller: 'bookmark', bookmark_kept_value: kept,
|
|
76
|
+
bookmark_error_value: t('recourse.bookmark_error'),
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
{ aria: { label:, pressed: kept }, data: }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def bookmark_url(record)
|
|
83
|
+
url_for controller: "/#{resource_controller_path}/bookmarks", action: :create,
|
|
84
|
+
"#{resource_model.model_name.singular}_id": record.id
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
module Recourse
|
|
2
|
+
module Helpers
|
|
3
|
+
# The buttons a record's pages carry: an action the routes drew under it that has
|
|
4
|
+
# no page of its own for a link to sit on.
|
|
5
|
+
module Buttons
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
# A nested resource routed `create` without an `index` is reached from nowhere, so
|
|
9
|
+
# its button lives on the record it hangs off — beside the breadcrumbs. Which of
|
|
10
|
+
# that record's pages is the host's business as much as the button is: the
|
|
11
|
+
# routes say where it goes as well as whether it exists.
|
|
12
|
+
def bare_action_buttons(record)
|
|
13
|
+
Recourse.nested_under(card_path).filter_map do |nested|
|
|
14
|
+
next unless bare_action_page? nested
|
|
15
|
+
|
|
16
|
+
bare_action_button record, nested
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Whether this is that page. An index is a page to reach the action from and a
|
|
21
|
+
# `new` is a form to fill in on the way: either one means there is more to this
|
|
22
|
+
# than a button. A nesting with no page anywhere stands on the record's own page,
|
|
23
|
+
# since every other page of the record is about something else — a place's memos
|
|
24
|
+
# are not where a sweep of the place is offered.
|
|
25
|
+
def bare_action_page?(nested)
|
|
26
|
+
return false if routed?(nested, 'index') || routed?(nested, 'new')
|
|
27
|
+
|
|
28
|
+
record_page?
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# The one page every record has, which is where a pageless action's button goes.
|
|
32
|
+
def record_page?
|
|
33
|
+
!resource_parent && controller.action_name == 'show'
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# The `create` the routes drew on the collection, which needs no id: a member
|
|
37
|
+
# action wants the row it acts on, and a row is what a table is for.
|
|
38
|
+
def bare_action_button(record, nested)
|
|
39
|
+
return unless routed? nested, 'create'
|
|
40
|
+
|
|
41
|
+
[bare_action_label(nested), bare_action_url(record, nested), :post]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# The resource's own word, which a host renames in a locale like any other
|
|
45
|
+
# model: `Add Jobber retrieval` rather than `Add booking exchange`. Led by
|
|
46
|
+
# whatever namespace the routes put between the record and the action, because
|
|
47
|
+
# that is the only thing telling two routes to the same model apart — without it
|
|
48
|
+
# a `quick/memos` action and the `memos` beside it both read `Add memo`. The tab
|
|
49
|
+
# for a nested index is named from the same split, so the two agree.
|
|
50
|
+
def bare_action_label(nested)
|
|
51
|
+
name, namespace = nested_segments nested, card_path
|
|
52
|
+
model = Recourse.downcase Recourse.known_singular(name)
|
|
53
|
+
lead = namespace_words namespace
|
|
54
|
+
|
|
55
|
+
t 'recourse.add', model: [lead.presence, model].compact.join(' ')
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def bare_action_url(record, nested)
|
|
59
|
+
parent = card_path.split('/').last.singularize
|
|
60
|
+
|
|
61
|
+
url_for controller: "/#{nested}", action: :create, "#{parent}_id": record.id
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
module Recourse
|
|
2
|
+
module Helpers
|
|
3
|
+
# The tabs on the card a record's pages share: a look, a change, and each
|
|
4
|
+
# nested index under the record — counted where the record keeps a count.
|
|
5
|
+
module Cards
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
# The pages of the record the card is about, as `[label, path, current]` — a
|
|
9
|
+
# look first, a change second, then one tab per nested index: `8 ZIPs` where
|
|
10
|
+
# a counter cache answers, the bare `Settings` where none does. On a nested
|
|
11
|
+
# index the card is the parent record's, so the tabs are too, and the nested
|
|
12
|
+
# tab is the current one.
|
|
13
|
+
def resource_tabs(record)
|
|
14
|
+
path = card_path
|
|
15
|
+
actions = %i[show edit].filter_map { |action| action_tab record, path, action }
|
|
16
|
+
|
|
17
|
+
actions + nested_tabs(record, path)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# The resource the card belongs to: the parent's, on a page nested under it —
|
|
21
|
+
# read off the routes rather than chopped off this page's own path, which a
|
|
22
|
+
# `namespace` between the two would leave pointing at the namespace.
|
|
23
|
+
def card_path
|
|
24
|
+
return Recourse.parent_of controller.controller_path if resource_parent
|
|
25
|
+
|
|
26
|
+
controller.controller_path
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def action_tab(record, path, action)
|
|
30
|
+
return unless routed? path, action.to_s
|
|
31
|
+
|
|
32
|
+
[
|
|
33
|
+
tab_label(action), url_for(controller: "/#{path}", action:, id: record),
|
|
34
|
+
current_action_tab?(action),
|
|
35
|
+
]
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# `update` redraws the form it rejected, so the edit tab is current there too.
|
|
39
|
+
# On a nested page neither is: the count tab is the page being read.
|
|
40
|
+
def current_action_tab?(action)
|
|
41
|
+
return false if resource_parent
|
|
42
|
+
|
|
43
|
+
action == (controller.action_name == 'show' ? :show : :edit)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# One tab per resource nested under the record, in the order routes.rb nested
|
|
47
|
+
# them — the order the sidebar already keeps. The nested index is the whole
|
|
48
|
+
# requirement: a `has_many` of that name decides how the tab reads, and a counter
|
|
49
|
+
# cache whether it carries a number, but neither is what puts it there. A nesting
|
|
50
|
+
# with no index is an action, and its button stands beside the breadcrumbs instead.
|
|
51
|
+
def nested_tabs(record, path)
|
|
52
|
+
Recourse.nested_under(path).filter_map do |nested|
|
|
53
|
+
next unless routed? nested, 'index'
|
|
54
|
+
|
|
55
|
+
name, namespace = nested_segments nested, path
|
|
56
|
+
|
|
57
|
+
[nested_tab_label(record, name, namespace), nested_url(record, path, nested),
|
|
58
|
+
nested == controller.controller_path,]
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def tab_label(action)
|
|
63
|
+
safe_join [icon_tag(Actions::ICONS[action]), t("recourse.#{action}")], ' '
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
module Recourse
|
|
2
|
+
module Helpers
|
|
3
|
+
# Helpers for the cells of a table and the fields of a form.
|
|
4
|
+
module Cells
|
|
5
|
+
# One cell: a heading in the header row, the block's output in every other.
|
|
6
|
+
# Public because a row partial of a host's own is written out of these, and
|
|
7
|
+
# is rendered once for the header row and once for each row after it.
|
|
8
|
+
def column(header:, **, &)
|
|
9
|
+
return tag.th(header, scope: :col, **) if @recourse_headers
|
|
10
|
+
|
|
11
|
+
tag.td(capture(&), 'data-cell': header, **)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
# Columns the table shows: every attribute that is not encrypted and not
|
|
17
|
+
# read-only, less the primary key — an id is how a row is addressed, not
|
|
18
|
+
# something to read about it — in the order `Recourse.ordered` reads a row,
|
|
19
|
+
# which is what leaves the counts last of all, past even the timestamps.
|
|
20
|
+
def resource_columns
|
|
21
|
+
Recourse.ordered resource_model, resource_model.column_names - hidden_columns
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# What no table shows, less whatever the model asked to draw anyway. Each of
|
|
25
|
+
# the four below is a default the gem picks, and a host is what answers for
|
|
26
|
+
# its own screens — so naming one overrules it.
|
|
27
|
+
def hidden_columns
|
|
28
|
+
columns_hidden_by_default - Array(resource_model.recourse_displayed).map(&:to_s)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Ciphertext, the id that addresses the row, the parent a nested route already
|
|
32
|
+
# names, the timestamps and every JSON payload — what a machine keeps rather than
|
|
33
|
+
# what a row is about — and whatever the model asked to hide, the one of these a
|
|
34
|
+
# host decides without the override above.
|
|
35
|
+
def columns_hidden_by_default
|
|
36
|
+
[
|
|
37
|
+
resource_model.recourse_encrypted_names, resource_model.primary_key, TIMESTAMPS,
|
|
38
|
+
resource_parent_association&.foreign_key, json_columns,
|
|
39
|
+
Recourse.hidden_columns(resource_model),
|
|
40
|
+
].flatten.compact
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Columns a form offers — less the parent a nested route has already
|
|
44
|
+
# answered: a comment under `/posts/2` is for post 2, not for one picked
|
|
45
|
+
# from a menu, so no field asks.
|
|
46
|
+
def editable_columns
|
|
47
|
+
Recourse.editable_columns(resource_model) - Array(resource_parent_association&.foreign_key)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Columns the show page reads out: what the form offers, what the model draws
|
|
51
|
+
# anyway, and the timestamps last — a computed column is read where it is not typed.
|
|
52
|
+
def shown_columns
|
|
53
|
+
drawn = Array(resource_model.recourse_displayed).map(&:to_s) - TIMESTAMPS
|
|
54
|
+
(editable_columns + drawn).uniq + (TIMESTAMPS & resource_model.column_names)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Heading for a column, which a host app can translate like any attribute. A
|
|
58
|
+
# counter is headed with what it counts — `ZIPs`, not `ZIPs count` — since the
|
|
59
|
+
# column holds a number and the heading says what the number is of.
|
|
60
|
+
def resource_column_title(column)
|
|
61
|
+
counted = resource_model.recourse_counters[column]
|
|
62
|
+
return resource_model.human_attribute_name column unless counted
|
|
63
|
+
|
|
64
|
+
Recourse.model_title counted.klass
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Value for one cell, formatted according to what the column holds.
|
|
68
|
+
def resource_cell(resource, column)
|
|
69
|
+
association = belongs_to_association column
|
|
70
|
+
return search_highlight named_cell(resource, association), column if association
|
|
71
|
+
|
|
72
|
+
value = resource.attributes[column]
|
|
73
|
+
counted = resource_model.recourse_counters[column]
|
|
74
|
+
|
|
75
|
+
# A count is the bare number — the icon in the heading already says what it
|
|
76
|
+
# counts — linking to the counted rows where a block nested their index here.
|
|
77
|
+
return counter_cell resource, value, counted if counted
|
|
78
|
+
|
|
79
|
+
# A list is counted rather than drawn: the values are the record's own page to
|
|
80
|
+
# read out, and a column of them inside a column of them is not a table.
|
|
81
|
+
return listed_count value if attribute_kind(column) == :list
|
|
82
|
+
|
|
83
|
+
# The same ladder the show page comes down, with the search's own marking
|
|
84
|
+
# handed in: a table is the only page a search ever reached.
|
|
85
|
+
formatted_attribute(column, value) { |text| search_highlight text, column }
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
module Recourse
|
|
2
|
+
module Helpers
|
|
3
|
+
# The filter menus whose options are values rather than records: what a column
|
|
4
|
+
# itself admits, read off the model rather than out of another table.
|
|
5
|
+
module Choices
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
# The values a column admits of itself, where it admits a known set of them:
|
|
9
|
+
# an enum's words, or a boolean's two. Nil for anything else, which is what
|
|
10
|
+
# hands the question on to the key it might be.
|
|
11
|
+
def choice_filter(predicate, column, label)
|
|
12
|
+
return enum_filter predicate, column, label if resource_model.defined_enums.key? column
|
|
13
|
+
|
|
14
|
+
boolean_filter predicate, column, label if boolean_column? column
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# The two a boolean admits, as the words a table already prints for them. The
|
|
18
|
+
# way back is the bare `All`: a column called `signed` pluralizes to nothing
|
|
19
|
+
# anybody would write, so the line says what it does rather than what it is of.
|
|
20
|
+
def boolean_filter(predicate, column, label)
|
|
21
|
+
title = label || resource_model.human_attribute_name(column)
|
|
22
|
+
|
|
23
|
+
filter_menu predicate, title, %w[true false], t('recourse.all_values')
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def boolean_column?(column)
|
|
27
|
+
resource_model.column_names.include?(column) &&
|
|
28
|
+
resource_model.type_for_attribute(column).type == :boolean
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# A menu of the words the column admits, which are the words the form's own menu
|
|
32
|
+
# offers and the badge on a show page reads. Headed by the attribute rather than
|
|
33
|
+
# by a model, since a status is the table's own and not another table's.
|
|
34
|
+
def enum_filter(predicate, column, label)
|
|
35
|
+
title = label || resource_model.human_attribute_name(column)
|
|
36
|
+
values = resource_model.defined_enums[column].keys
|
|
37
|
+
|
|
38
|
+
filter_menu predicate, title, values, choices_all(title)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# The way back to no filter at all, named after what the menu is of: `All
|
|
42
|
+
# statuses`, `All CRMs`. An acronym keeps its capitals, the way every title
|
|
43
|
+
# the gem lower-cases does.
|
|
44
|
+
def choices_all(title)
|
|
45
|
+
t 'recourse.all', models: Recourse.downcase(title).pluralize
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
module Recourse
|
|
2
|
+
module Helpers
|
|
3
|
+
# The primary color, where a host has named one.
|
|
4
|
+
module Colors
|
|
5
|
+
private
|
|
6
|
+
|
|
7
|
+
# The `:root` block that makes `Recourse.color` the primary one, or nothing at all
|
|
8
|
+
# where no color is named — Bootstrap's own blue is already there, and a palette
|
|
9
|
+
# names its own lead accent in its own file. It wins on being later rather than on
|
|
10
|
+
# being more specific, every selector being `:root`, so it belongs after both the
|
|
11
|
+
# stylesheet link and the palette's, and never before either.
|
|
12
|
+
def primary_color_style
|
|
13
|
+
color = Recourse.color
|
|
14
|
+
return unless color
|
|
15
|
+
|
|
16
|
+
render 'recourses/color', color: color, ink: Recourse.ink(color)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
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
|
+
private
|
|
6
|
+
|
|
7
|
+
# A combobox of labels. The query fetches the two columns the menu shows and
|
|
8
|
+
# nothing else, and the errors are its own work: `field_error_proc` only ever
|
|
9
|
+
# sees the tags a form builder drew, and this is a partial.
|
|
10
|
+
def combobox(form, column, association)
|
|
11
|
+
label = association.klass.recourse_label
|
|
12
|
+
|
|
13
|
+
render 'recourses/combobox', **combobox_locals(form, column),
|
|
14
|
+
label: label.to_s,
|
|
15
|
+
recourses: combobox_options(association.klass, label)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Whichever menu a kind whose values are known in advance is drawn as.
|
|
19
|
+
def menu_field(form, column, kind)
|
|
20
|
+
kind == :enum ? enum_combobox(form, column) : zone_combobox(form, column)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# The words an enum admits, as a menu of one. The values are the model's own, so
|
|
24
|
+
# nothing here has to know what any of them mean.
|
|
25
|
+
def enum_combobox(form, column)
|
|
26
|
+
render 'recourses/combobox', **combobox_locals(form, column),
|
|
27
|
+
values: resource_model.defined_enums[column].keys,
|
|
28
|
+
selected: [form.object.attributes[column]].compact
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Whether a menu's rows can be kept, which turns on the one thing a relation is
|
|
32
|
+
# versioned by: `cache` reads `MAX(updated_at)` off the table without asking
|
|
33
|
+
# whether there is such a column, and a model Rails keeps no timestamps on has
|
|
34
|
+
# none to read. Reference data is exactly where that happens — a table of states
|
|
35
|
+
# or of postal codes is written by a migration and never again — so those menus
|
|
36
|
+
# are drawn each time rather than kept under a key nothing can version.
|
|
37
|
+
def keepable_menu?(recourses)
|
|
38
|
+
recourses.klass.column_names.include? 'updated_at'
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# What every combobox needs to know about the column it sets, whatever it offers
|
|
42
|
+
# as choices: the enum one asks for these too, and gives `values:` instead.
|
|
43
|
+
def combobox_locals(form, column)
|
|
44
|
+
messages = errors_on column
|
|
45
|
+
required = required? resource_model, column
|
|
46
|
+
|
|
47
|
+
{
|
|
48
|
+
name: form.field_name(column), id: form.field_id(column), invalid: messages.any?,
|
|
49
|
+
feedback: messages.to_sentence.upcase_first.presence,
|
|
50
|
+
described: combobox_described(form, column, messages),
|
|
51
|
+
placeholder: combobox_placeholder(column), required: required,
|
|
52
|
+
# A menu of records can say which one; only this can say none of them, and
|
|
53
|
+
# a key that may be nothing has to be settable back to it.
|
|
54
|
+
none: (t 'recourse.unset' unless required),
|
|
55
|
+
selected: combobox_selected(form, column),
|
|
56
|
+
}
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# What the toggle points at: its own error where it has one, and otherwise the
|
|
60
|
+
# note under it. The same order of precedence every other control follows.
|
|
61
|
+
def combobox_described(form, column, messages)
|
|
62
|
+
id = form.field_id column
|
|
63
|
+
return "#{id}_error" if messages.any?
|
|
64
|
+
|
|
65
|
+
field_described column
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# What the record already holds, as the menu spells its values: an id for a
|
|
69
|
+
# foreign key and the word itself for an enum. Without it a form opens on the
|
|
70
|
+
# placeholder however full the record is — and the plugin's hidden input opens
|
|
71
|
+
# empty with it, so saving would write that emptiness back.
|
|
72
|
+
def combobox_selected(form, column)
|
|
73
|
+
value = form.object&.attributes&.fetch column.to_s, nil
|
|
74
|
+
|
|
75
|
+
Array(value).map(&:to_s)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def combobox_options(klass, label)
|
|
79
|
+
klass.select(:id, label).order label
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def combobox_placeholder(column)
|
|
83
|
+
placeholder(resource_model, column, nil) || t('recourse.select')
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
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
|
+
private
|
|
18
|
+
|
|
19
|
+
# Browser-side constraints, every one read from the validators of `model` —
|
|
20
|
+
# which is the page's model unless a field asks about another one's attribute.
|
|
21
|
+
def field_html(column, type = nil, model = resource_model)
|
|
22
|
+
key = (type || column).to_s
|
|
23
|
+
pattern = DISPLAY_PATTERNS[key] || column_pattern(model, column)
|
|
24
|
+
|
|
25
|
+
{
|
|
26
|
+
maxlength: length_option(model, column, :maximum),
|
|
27
|
+
minlength: length_option(model, column, :minimum), pattern:,
|
|
28
|
+
title: title(key, pattern), placeholder: placeholder(model, column, type),
|
|
29
|
+
inputmode: (:numeric if numeric? model, column, pattern),
|
|
30
|
+
required: (true if required? model, column),
|
|
31
|
+
data: (PHONE_CONTROLLER if key == 'phone'),
|
|
32
|
+
}.compact
|
|
33
|
+
end
|
|
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
|
+
t 'recourse.format', example: 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
|
+
t 'recourse.optional' unless required? model, column
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def required?(model, column)
|
|
70
|
+
Recourse.validated_names(column).any? { |name| presence_validated? model, name }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def presence_validated?(model, attribute)
|
|
74
|
+
validator(model, attribute, ActiveModel::Validations::PresenceValidator).present?
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def numeric?(model, column, pattern)
|
|
78
|
+
digits_only?(pattern) ||
|
|
79
|
+
validator(model, column, ActiveModel::Validations::NumericalityValidator).present?
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# `\d` carries a letter, so it has to go before looking for real ones.
|
|
83
|
+
def digits_only?(pattern)
|
|
84
|
+
pattern.present? && !pattern.gsub('\d', '').match?(/[A-Za-z]/)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def validator(model, attribute, kind)
|
|
88
|
+
model.validators_on(attribute).find { |one| one.is_a? kind }
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|