recourse 3.0.4 → 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 -36
- 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
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
require_relative 'routes/nested'
|
|
2
|
+
|
|
3
|
+
module Recourse
|
|
4
|
+
# Extends the config/routes.rb DSL, so `recourses` works anywhere `resources` does.
|
|
5
|
+
module Routes
|
|
6
|
+
include Nested
|
|
7
|
+
|
|
8
|
+
# Draws what `resources` draws, after supplying any controller the host lacks. A
|
|
9
|
+
# block nests what it declares under each resource — ZIPs at
|
|
10
|
+
# `/counties/:county_id/zips` — with the nested controller namespaced after the
|
|
11
|
+
# parent, so it and the top-level `ZIPsController` stay two controllers. Only a
|
|
12
|
+
# `recourses` block adds that namespace, which every nested page relies on, so
|
|
13
|
+
# nesting inside a plain `resources` block raises here rather than serving a
|
|
14
|
+
# broken page. A `namespace` may sit in between — what is refused is a nesting
|
|
15
|
+
# that adds no namespace at all. A nested resource defaults to
|
|
16
|
+
# `only: %i[index new create]`, and an explicit `only:` or `except:` is the
|
|
17
|
+
# host's word, which wins.
|
|
18
|
+
def recourses(*names, **options, &block)
|
|
19
|
+
refuse_unscoped_nesting names
|
|
20
|
+
# Asked before the block, where every resource is its own parent. Top level
|
|
21
|
+
# only: a resource nested under another is reached through its parent, and its
|
|
22
|
+
# rows are kept at the resource's own route rather than at a second one drawn
|
|
23
|
+
# under every parent that happens to list them.
|
|
24
|
+
keepable = Recourse.bookmarks? && parent_resource.nil?
|
|
25
|
+
|
|
26
|
+
names.each { |name| declare_resource name }
|
|
27
|
+
options = default_nested_actions options
|
|
28
|
+
return resources(*names, **options) unless block || keepable
|
|
29
|
+
|
|
30
|
+
resources(*names, **options) { draw_within keepable, block }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
# The module is the namespace being drawn in, so a resource is declared and its
|
|
36
|
+
# controller defined under the path Rails will route to.
|
|
37
|
+
def declare_resource(name)
|
|
38
|
+
path = [current_module, name].compact.join '/'
|
|
39
|
+
record_declaration path
|
|
40
|
+
Controllers.define_missing path
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def default_nested_actions(options)
|
|
44
|
+
return options if !parent_resource || options.key?(:only) || options.key?(:except)
|
|
45
|
+
|
|
46
|
+
options.merge only: %i[index new create]
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def refuse_unscoped_nesting(names)
|
|
50
|
+
parent = parent_resource
|
|
51
|
+
return if parent.nil? || current_module.to_s.split('/').include?(parent.name)
|
|
52
|
+
|
|
53
|
+
raise Error, I18n.t('recourse.nested', names: names.map(&:inspect).join(', '),
|
|
54
|
+
parent: parent.name)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
data/lib/recourse/routing.rb
CHANGED
|
@@ -1,38 +1,17 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Reopened for the one question a controller and a view both ask of the router.
|
|
2
2
|
module Recourse
|
|
3
|
-
#
|
|
3
|
+
# What the router will answer. A view asks it through `Helpers::Routing`, which keeps
|
|
4
|
+
# a set of its own for the table that asks four times a row; a controller asks here,
|
|
5
|
+
# at most once a request, so this scans rather than remembering — a set kept between
|
|
6
|
+
# requests would outlive the routes file it was built from.
|
|
4
7
|
module Routing
|
|
5
|
-
#
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
resources(*args, **kwargs, &scoped(args, &block))
|
|
10
|
-
end
|
|
11
|
-
|
|
12
|
-
private
|
|
13
|
-
|
|
14
|
-
def store_recourses(args, kwargs)
|
|
15
|
-
if @scope[:scope_level_resource]
|
|
16
|
-
Recourse.resources[@scope[:scope_level_resource].name.to_sym][:nested] = args
|
|
17
|
-
else
|
|
18
|
-
routes = Array(kwargs.fetch :only, self.class::Resource.default_actions(false))
|
|
19
|
-
routes-= Array(kwargs.fetch :except, [])
|
|
20
|
-
|
|
21
|
-
Recourse.resources.merge! args.to_h { |arg| [arg, { module: @scope[:module], routes: routes }] }
|
|
8
|
+
# True where a route is drawn to this controller and action.
|
|
9
|
+
def routed?(controller_path, action)
|
|
10
|
+
Rails.application.routes.routes.any? do |route|
|
|
11
|
+
route.defaults[:controller] == controller_path && route.defaults[:action] == action.to_s
|
|
22
12
|
end
|
|
23
13
|
end
|
|
24
|
-
|
|
25
|
-
def scoped(args, &block)
|
|
26
|
-
return unless block_given?
|
|
27
|
-
|
|
28
|
-
parent_module = args.sole
|
|
29
|
-
Proc.new { scope module: parent_module, &block }
|
|
30
|
-
# :nocov:
|
|
31
|
-
rescue Enumerable::SoleItemExpectedError
|
|
32
|
-
raise ArgumentError, 'recourses accepts only one resource if a block is given'
|
|
33
|
-
# :nocov:
|
|
34
|
-
end
|
|
35
14
|
end
|
|
36
|
-
end
|
|
37
15
|
|
|
38
|
-
|
|
16
|
+
extend Routing
|
|
17
|
+
end
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# Reopened for the one thing a reader says about how every page looks.
|
|
2
|
+
module Recourse
|
|
3
|
+
# Where a reader's chosen mode is kept in their browser. Named once: the sidebar's
|
|
4
|
+
# controller writes it and the layout's own script reads it back before the first paint.
|
|
5
|
+
SCHEME_STORAGE = 'recourse-scheme'
|
|
6
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
module Recourse
|
|
2
|
+
# What the routes DSL reads back out of the Mapper as it draws, and what it records
|
|
3
|
+
# from that: the module a resource is being declared in, and the resource a block is
|
|
4
|
+
# nesting under.
|
|
5
|
+
module Scopes
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
# The Mapper keeps its scope in an internal frame with no reader, so the two
|
|
9
|
+
# facts this DSL needs — the module being drawn in, and the resource a block
|
|
10
|
+
# nests under — are read in these two methods and nowhere else: a Rails
|
|
11
|
+
# upgrade that moves the frame edits one file, twice.
|
|
12
|
+
def current_module
|
|
13
|
+
@scope[:module]
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def parent_resource
|
|
17
|
+
@scope[:scope_level_resource]
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def record_declaration(path)
|
|
21
|
+
# A nested resource is reached through its parent rather than the sidebar,
|
|
22
|
+
# so it is recorded under the parent's path — that order is its tabs' order.
|
|
23
|
+
return Recourse.declare path unless parent_resource
|
|
24
|
+
|
|
25
|
+
Recourse.nest parent_path, path
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# The parent's own controller path: the module being drawn in, cut off after the
|
|
29
|
+
# parent resource's own segment, so a `namespace` between the two is left out
|
|
30
|
+
# rather than mistaken for the parent itself.
|
|
31
|
+
def parent_path
|
|
32
|
+
parts = current_module.to_s.split '/'
|
|
33
|
+
|
|
34
|
+
parts[0..parts.rindex(parent_resource.name)].join '/'
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
module Recourse
|
|
2
|
+
# The Ransack search behind an index: what a heading asked to sort by, what the
|
|
3
|
+
# search box asked to match, and the relation those add up to.
|
|
4
|
+
class Search
|
|
5
|
+
# Predicates whose value is a list, which is how a multiple combobox submits:
|
|
6
|
+
# one input holding every chosen value, comma-joined.
|
|
7
|
+
LIST_PREDICATES = /_(not_)?in\z/
|
|
8
|
+
|
|
9
|
+
# The Ransack object the form and the sort links read.
|
|
10
|
+
attr_reader :query
|
|
11
|
+
|
|
12
|
+
# A search of `relation` for the `q` parameters the request carried. A relation
|
|
13
|
+
# rather than a model, so a host that narrowed the index is searched inside what
|
|
14
|
+
# it narrowed to rather than around it — the model is still what answers for the
|
|
15
|
+
# order, the eager loads and the allowlist, and a relation knows its own.
|
|
16
|
+
#
|
|
17
|
+
def initialize(relation, params)
|
|
18
|
+
@model = relation.klass
|
|
19
|
+
@query = relation.ransack conditions(params)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# The relation the index lists. Ransack has already ordered it where a heading
|
|
23
|
+
# asked, so the model's own order only applies when nothing did.
|
|
24
|
+
def scope
|
|
25
|
+
scope = @query.result
|
|
26
|
+
scope = scope.order(*kept_first, @model.recourse_order) if @query.sorts.empty?
|
|
27
|
+
includes = @model.recourse_includes
|
|
28
|
+
return scope if includes.blank?
|
|
29
|
+
|
|
30
|
+
scope.includes includes
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
# The rows this viewer has kept, ahead of whatever the model orders by — but only
|
|
36
|
+
# where nobody clicked a heading, which is the same word `recourse_order` answers
|
|
37
|
+
# to. A semi-join rather than an outer one: it cannot multiply a row, and it
|
|
38
|
+
# leaves the count pagy runs over this relation well-formed.
|
|
39
|
+
def kept_first
|
|
40
|
+
reflection = Recourse.bookmarks_for @model
|
|
41
|
+
return [] unless reflection
|
|
42
|
+
|
|
43
|
+
kept = Recourse.bookmarks_of(reflection).select reflection.foreign_key
|
|
44
|
+
# `true` before `false` in PostgreSQL and `1` before `0` in the other two, so
|
|
45
|
+
# descending puts the kept rows first wherever this runs.
|
|
46
|
+
[@model.arel_table[@model.primary_key].in(kept.arel).desc]
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Ransack reads nothing it has not been shown — `ransackable_attributes` is the
|
|
50
|
+
# allowlist — so what arrives here needs no permitting, only untangling: a list
|
|
51
|
+
# predicate is split back into values, and a filter nobody set is dropped,
|
|
52
|
+
# since `IN ()` would match no row rather than every one.
|
|
53
|
+
def conditions(params)
|
|
54
|
+
# `?q=anything` reaches here as a String rather than as parameters of its own,
|
|
55
|
+
# and a search nobody asked for reaches here as nil. Neither is a condition.
|
|
56
|
+
return {} unless params.is_a? ActionController::Parameters
|
|
57
|
+
|
|
58
|
+
params.to_unsafe_h.filter_map do |key, value|
|
|
59
|
+
next if value.blank?
|
|
60
|
+
|
|
61
|
+
[key, key.match?(LIST_PREDICATES) ? value.to_s.split(',') : value]
|
|
62
|
+
end.to_h
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
module Recourse
|
|
2
|
+
module Searchable
|
|
3
|
+
# What a model's own schema offers a search: which of its columns are worth
|
|
4
|
+
# looking through, and which of its foreign keys are worth looking past.
|
|
5
|
+
module Columns
|
|
6
|
+
# Column types a search box can match on containment. An enum is a Postgres
|
|
7
|
+
# type of its own, and what it holds is a word, so it reads as a string too.
|
|
8
|
+
SEARCHABLE_TYPES = %i[string text citext enum].freeze
|
|
9
|
+
|
|
10
|
+
# Columns worth searching: the indexed strings a table also shows. An index is
|
|
11
|
+
# the only signal a schema carries about which column identifies a row rather
|
|
12
|
+
# than describes it, and a column no page draws is not one to search by — a row
|
|
13
|
+
# would arrive with nothing on it explaining why. That is also what keeps a
|
|
14
|
+
# `recourse_hidden` column out: hidden from every screen means hidden here.
|
|
15
|
+
def recourse_searchable_columns
|
|
16
|
+
recourse_indexed_strings - recourse_encrypted_names - Recourse.hidden_columns(self)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# The attributes Active Record Encryption holds, as column names.
|
|
20
|
+
def recourse_encrypted_names
|
|
21
|
+
Array(encrypted_attributes).map(&:to_s)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Foreign keys a search reaches through rather than filters by: the ones whose
|
|
25
|
+
# other model is too long to list, since a menu is only a control while every row
|
|
26
|
+
# fits in one. The label has to be a word to match, and the key one a screen
|
|
27
|
+
# draws -- a row matched through a hidden key arrives saying nothing about why.
|
|
28
|
+
def recourse_searchable_associations
|
|
29
|
+
hidden = Recourse.hidden_columns self
|
|
30
|
+
|
|
31
|
+
recourse_references.select do |association|
|
|
32
|
+
next false if hidden.include? association.foreign_key.to_s
|
|
33
|
+
|
|
34
|
+
!association.klass.recourse_listable? && association.klass.recourse_searchable_label?
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# True where the label is a column a `cont` can match. Reaching through a foreign
|
|
39
|
+
# key to compare an id or a date against typed text says nothing -- and neither
|
|
40
|
+
# does naming a column Ransack will not answer to. Encryption leaves a column's
|
|
41
|
+
# type alone, so a label the search could never match reads as a word here and is
|
|
42
|
+
# caught by the allowlist instead: a term built from one raises on the reader the
|
|
43
|
+
# box asks for, taking down every index that reaches this model through a key.
|
|
44
|
+
def recourse_searchable_label?
|
|
45
|
+
label = recourse_label.to_s
|
|
46
|
+
|
|
47
|
+
SEARCHABLE_TYPES.include?(type_for_attribute(label).type) &&
|
|
48
|
+
ransackable_attributes.include?(label)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
# The same columns where they are encrypted, which a search matches whole
|
|
54
|
+
# rather than by containment: a LIKE reads ciphertext and matches nothing,
|
|
55
|
+
# while a deterministic value encrypts to the same bytes every time, so `=`
|
|
56
|
+
# still finds it. Rails' own `deterministic_encrypted_attributes` is that
|
|
57
|
+
# list — a column encrypted any other way never compares equal twice.
|
|
58
|
+
def recourse_encrypted_searchable_columns
|
|
59
|
+
deterministic = Array(deterministic_encrypted_attributes).map(&:to_s)
|
|
60
|
+
|
|
61
|
+
(recourse_indexed_strings & deterministic) -
|
|
62
|
+
Recourse.hidden_columns(self)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Every indexed column whose value is a word, whether or not it is encrypted.
|
|
66
|
+
# Asked through `type_for_attribute` — the one door the whole gem uses — so
|
|
67
|
+
# an `attribute` override counts here the way it counts on a form.
|
|
68
|
+
def recourse_indexed_strings
|
|
69
|
+
column_names.intersection(recourse_indexed_columns).select do |column|
|
|
70
|
+
type = type_for_attribute column
|
|
71
|
+
# An array column answers with its subtype's own name — `text[]` reads as
|
|
72
|
+
# `:text` — and a LIKE against an array is SQL that never runs. A type
|
|
73
|
+
# wrapping a subtype is a collection, not a word, so it stays out.
|
|
74
|
+
SEARCHABLE_TYPES.include?(type.type) && !type.respond_to?(:subtype)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Those foreign keys as Ransack names them: `zip_code`, for the ZIP that
|
|
79
|
+
# `/locations` asks to be typed rather than picked out of 40,965 options.
|
|
80
|
+
# `except:` leaves out the one a nested route has already answered.
|
|
81
|
+
def recourse_searchable_references(except: nil)
|
|
82
|
+
(recourse_searchable_associations - [except]).map do |association|
|
|
83
|
+
"#{association.name}_#{association.klass.recourse_label}"
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Columns an index covers, the primary key among them. Read from the schema
|
|
88
|
+
# cache, so asking costs nothing after the first look — and the gem's one
|
|
89
|
+
# reach into that cache, whose spelling moves across Rails majors, is fenced
|
|
90
|
+
# in this method so an upgrade edits one place.
|
|
91
|
+
def recourse_indexed_columns
|
|
92
|
+
indexes = connection_pool.schema_cache.indexes table_name
|
|
93
|
+
# An expression index reports a string rather than a list of columns, and it
|
|
94
|
+
# names no column an ORDER BY could use, so intersecting drops it.
|
|
95
|
+
column_names.intersection [primary_key, *indexes.flat_map { |one| Array one.columns }]
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
module Recourse
|
|
2
|
+
module Searchable
|
|
3
|
+
# What a model offers as a filter beside the search box: a menu for every column
|
|
4
|
+
# whose values are a short known list, and one for every key pointing at a table
|
|
5
|
+
# short enough to list.
|
|
6
|
+
module Filters
|
|
7
|
+
# Filters offered beside the search box, as a Ransack predicate to the options
|
|
8
|
+
# that draw it — `label:` for its heading, `scope:` for the records it offers.
|
|
9
|
+
# One per enum, one per boolean, then one per belongs_to, less the ones the
|
|
10
|
+
# search box reaches through instead: a typed label says the other model is too
|
|
11
|
+
# big to list. The model's own columns come first, before the menus that name
|
|
12
|
+
# other tables.
|
|
13
|
+
def filter_fields
|
|
14
|
+
enum_filter_fields.merge(boolean_filter_fields).merge reference_filter_fields
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
# Columns a filter may name at all. A menu narrowing the table by a column no
|
|
20
|
+
# screen shows asks a reader to choose by something they cannot see, which is the
|
|
21
|
+
# reason `recourse_searchable_columns` leaves the same columns out of the search
|
|
22
|
+
# box: hidden from every screen means hidden here.
|
|
23
|
+
def filterable_columns
|
|
24
|
+
column_names - Recourse.hidden_columns(self)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Every one of the three comes to the same shape, and `_in` is what lets a
|
|
28
|
+
# request tick more than one of the values a menu offers.
|
|
29
|
+
def filters_for(names)
|
|
30
|
+
names.index_with({}).transform_keys { |name| "#{name}_in" }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# One per enum: a dozen words a column admits are a menu whatever else is on the
|
|
34
|
+
# page.
|
|
35
|
+
def enum_filter_fields
|
|
36
|
+
filters_for defined_enums.keys & filterable_columns
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# One per boolean: a column admitting two values is a menu for the same reason a
|
|
40
|
+
# dozen words are, and the two are the type's own rather than anything declared.
|
|
41
|
+
def boolean_filter_fields
|
|
42
|
+
booleans = filterable_columns.select { |one| type_for_attribute(one).type == :boolean }
|
|
43
|
+
|
|
44
|
+
filters_for booleans
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# One per belongs_to the search box does not reach through instead.
|
|
48
|
+
def reference_filter_fields
|
|
49
|
+
searched = recourse_searchable_associations
|
|
50
|
+
keys = recourse_references.filter_map do |one|
|
|
51
|
+
one.foreign_key.to_s unless searched.include? one
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
filters_for keys & filterable_columns
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
module Recourse
|
|
2
|
+
module Searchable
|
|
3
|
+
# What the search box above a table submits, and what it says while it is empty.
|
|
4
|
+
module Terms
|
|
5
|
+
# The predicate a search box submits: everything it looks through at once,
|
|
6
|
+
# joined by `or`. Nil where a model has nothing worth looking through, which
|
|
7
|
+
# is also what leaves that model's index without the form — filters and all.
|
|
8
|
+
# `except:` takes the association a nested route already answered, so a page
|
|
9
|
+
# pinned to one provider offers no box to search them all.
|
|
10
|
+
def search_field(except: nil)
|
|
11
|
+
fields, predicate = recourse_search_terms(except:)
|
|
12
|
+
return if fields.empty?
|
|
13
|
+
|
|
14
|
+
"#{fields.join '_or_'}_#{predicate}"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# What the search box says while it is empty, naming what it looks through.
|
|
18
|
+
def search_prompt(except: nil)
|
|
19
|
+
fields, predicate = recourse_search_terms(except:)
|
|
20
|
+
return if fields.empty?
|
|
21
|
+
|
|
22
|
+
list = recourse_search_names(except:).join ' or '
|
|
23
|
+
I18n.t "recourse.searched_#{predicate}", list: list
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
# What a search box looks through, and how it matches: the plaintext columns
|
|
29
|
+
# and the labels behind foreign keys — less the one `except:` names — on
|
|
30
|
+
# containment; or, for a model that keeps nothing in plaintext worth
|
|
31
|
+
# searching, its encrypted columns, whole.
|
|
32
|
+
def recourse_search_terms(except: nil)
|
|
33
|
+
plain = recourse_searchable_columns + recourse_searchable_references(except:)
|
|
34
|
+
return [plain, 'cont'] if plain.any?
|
|
35
|
+
|
|
36
|
+
[recourse_encrypted_searchable_columns, 'eq']
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Those same terms as words, lower case but for the acronyms among them.
|
|
40
|
+
def recourse_search_names(except: nil)
|
|
41
|
+
fields, = recourse_search_terms(except:)
|
|
42
|
+
|
|
43
|
+
fields.map { |field| Recourse.downcase recourse_term_name(field) }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# A term is a column of this model, or a `zip_code` reaching through one of its
|
|
47
|
+
# foreign keys — which a form and a table already have a name for.
|
|
48
|
+
def recourse_term_name(field)
|
|
49
|
+
reached = recourse_searchable_associations.find { |one| field.start_with? "#{one.name}_" }
|
|
50
|
+
return reached.klass.recourse_reference_name if reached
|
|
51
|
+
|
|
52
|
+
human_attribute_name field
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
data/lib/recourse/searchable.rb
CHANGED
|
@@ -1,38 +1,63 @@
|
|
|
1
|
-
|
|
1
|
+
require 'active_support'
|
|
2
|
+
|
|
3
|
+
require_relative 'searchable/columns'
|
|
4
|
+
require_relative 'searchable/filters'
|
|
5
|
+
require_relative 'searchable/terms'
|
|
6
|
+
|
|
2
7
|
module Recourse
|
|
3
|
-
# Extends
|
|
8
|
+
# Extends every Active Record model with what Ransack asks of it, so an index
|
|
9
|
+
# sorts, searches and filters before a model has said anything at all.
|
|
4
10
|
module Searchable
|
|
5
|
-
|
|
6
|
-
def ransackable_attributes(auth_object = nil)
|
|
7
|
-
%w[ ]
|
|
8
|
-
end
|
|
11
|
+
include Columns, Filters, Terms
|
|
9
12
|
|
|
10
|
-
#
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
end
|
|
13
|
+
# How many rows a menu may hold before it stops being a menu. Fifty states are a
|
|
14
|
+
# list to pick from; three thousand counties are a page of HTML nobody reads.
|
|
15
|
+
MENU_LIMIT = 100
|
|
14
16
|
|
|
15
|
-
#
|
|
16
|
-
|
|
17
|
-
|
|
17
|
+
# Attributes Ransack may read: every column that is not encrypted, plus the
|
|
18
|
+
# encrypted ones a search can still match whole — `cont` reads ciphertext and
|
|
19
|
+
# finds nothing, but a deterministic `eq` compares the same bytes every time.
|
|
20
|
+
def ransackable_attributes(_auth_object = nil)
|
|
21
|
+
(column_names - recourse_encrypted_names) + recourse_encrypted_searchable_columns
|
|
18
22
|
end
|
|
19
23
|
|
|
20
|
-
#
|
|
21
|
-
|
|
24
|
+
# Associations Ransack may reach through: the ones the search box looks into,
|
|
25
|
+
# and no others, so a predicate joins another table only where one is searched.
|
|
26
|
+
def ransackable_associations(_auth_object = nil)
|
|
27
|
+
recourse_searchable_associations.map { |association| association.name.to_s }
|
|
22
28
|
end
|
|
23
29
|
|
|
24
|
-
#
|
|
25
|
-
|
|
30
|
+
# Columns a heading may sort by: the timestamps, whatever an index covers, and
|
|
31
|
+
# every counter cache — a count is a number a reader ranks by, and the one column
|
|
32
|
+
# whose heading says what it counts. Never a foreign key: its cell shows a label
|
|
33
|
+
# from the other table, and the id under it is not the order that label reads in.
|
|
34
|
+
# And never one the model hides, for the reason `recourse_searchable_columns` gives
|
|
35
|
+
# for leaving those out too: hidden from every screen means hidden here.
|
|
36
|
+
def ransortable_attributes(_auth_object = nil)
|
|
37
|
+
readable = ransackable_attributes - recourse_encrypted_names
|
|
38
|
+
indexed = recourse_indexed_columns + recourse_counters.keys + Recourse::TIMESTAMPS
|
|
39
|
+
|
|
40
|
+
keys = recourse_references.map { |one| one.foreign_key.to_s }
|
|
41
|
+
|
|
42
|
+
(readable & indexed) - keys - Recourse.hidden_columns(self)
|
|
26
43
|
end
|
|
27
44
|
|
|
28
|
-
#
|
|
29
|
-
|
|
45
|
+
# True where a foreign key pointing here is typed rather than picked, which a
|
|
46
|
+
# form and the controller reading its parameters back have to agree on: the label
|
|
47
|
+
# is bounded, or the table is too long to list.
|
|
48
|
+
def recourse_typed_reference? = recourse_typed_label? || !recourse_listable?
|
|
30
49
|
|
|
31
|
-
#
|
|
32
|
-
|
|
50
|
+
# True where every row of this model could be listed in one menu. The count is
|
|
51
|
+
# bounded, so the question costs the same on ten rows as on ten million, and it
|
|
52
|
+
# is asked once per class — a table that crosses the line is noticed at boot.
|
|
53
|
+
def recourse_listable?
|
|
54
|
+
return @recourse_listable unless @recourse_listable.nil?
|
|
55
|
+
|
|
56
|
+
@recourse_listable = limit(MENU_LIMIT + 1).count <= MENU_LIMIT
|
|
57
|
+
end
|
|
33
58
|
end
|
|
34
59
|
end
|
|
35
60
|
|
|
36
|
-
ActiveSupport.on_load
|
|
61
|
+
ActiveSupport.on_load :active_record do
|
|
37
62
|
extend Recourse::Searchable
|
|
38
63
|
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# Reopened for what a page calls things: the words a resource, a model and a column
|
|
2
|
+
# are read out under, which several helpers were each spelling for themselves.
|
|
3
|
+
module Recourse
|
|
4
|
+
# What a resource and a model are called on a page. Extended onto `Recourse`, so
|
|
5
|
+
# every one of these is `Recourse.something` wherever it is called from.
|
|
6
|
+
module Titles
|
|
7
|
+
# Lower case, but for the words Rails was told are acronyms: `ZIP code` reads as
|
|
8
|
+
# `ZIP code` and never `zip code`, while `Code or Name` becomes `code or name`.
|
|
9
|
+
# The plural of one counts as one, so `8 ZIPs` survives a host that registered
|
|
10
|
+
# `ZIP` alone — pluralizing an acronym is the gem's job, not the host's.
|
|
11
|
+
def downcase(text)
|
|
12
|
+
acronyms = ActiveSupport::Inflector.inflections.acronyms
|
|
13
|
+
|
|
14
|
+
text.split.map do |word|
|
|
15
|
+
known = [word, word.singularize].any? { |one| acronyms.key? one.downcase }
|
|
16
|
+
|
|
17
|
+
known ? word : word.downcase
|
|
18
|
+
end.join ' '
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# The plural title a resource is shown under, read off its model rather than out
|
|
22
|
+
# of its path: `ZIP` pluralizes to `ZIPs`, where humanizing `zips` says `Zips`
|
|
23
|
+
# unless the host registers that word as an acronym of its own. It also follows a
|
|
24
|
+
# model renamed in a locale file, which humanizing a path never would.
|
|
25
|
+
def title(name)
|
|
26
|
+
model_title model(name)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# The same for a name that may resolve to no model at all: an action drawn under
|
|
30
|
+
# a record still needs a word for its button, and `pause` is a verb this app never
|
|
31
|
+
# made a class for.
|
|
32
|
+
def known_title(name)
|
|
33
|
+
segment = name.to_s.split('/').last
|
|
34
|
+
model = segment.classify.safe_constantize
|
|
35
|
+
|
|
36
|
+
model ? model_title(model) : segment.humanize
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# And the singular of that, for the button a bare action earns: `Add sweep` is one
|
|
40
|
+
# sweep, and the same split that names the tab beside it.
|
|
41
|
+
def known_singular(name)
|
|
42
|
+
known_title(name).singularize
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# And the same for a model already in hand — the far side of an association, the
|
|
46
|
+
# model a filter lists. `count:` picks the singular where one is meant, and
|
|
47
|
+
# `lower:` is for a title that follows a word rather than opening the line.
|
|
48
|
+
def model_title(model, count: nil, lower: false)
|
|
49
|
+
title = model.model_name.human.pluralize count
|
|
50
|
+
|
|
51
|
+
lower ? downcase(title) : title
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
extend Titles
|
|
56
|
+
end
|
data/lib/recourse/version.rb
CHANGED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Reopened for what a page is told about the write that brought it here.
|
|
2
|
+
module Recourse
|
|
3
|
+
# The flash key naming the row a write just landed on. Data for the page rather than
|
|
4
|
+
# a message for the reader, so `_flash` keeps it out of the loop that draws the rest:
|
|
5
|
+
# every other key there becomes a toast, whoever invented it.
|
|
6
|
+
WRITTEN = 'recourse_written'
|
|
7
|
+
|
|
8
|
+
# The name a row goes by in the page it is drawn on, which a write reports itself by
|
|
9
|
+
# and a square keeps its own row by. Read by a controller and by a view, which is why
|
|
10
|
+
# it lives here rather than in either.
|
|
11
|
+
def self.row_id(record) = ActionView::RecordIdentifier.dom_id(record)
|
|
12
|
+
end
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# Reopened for whose clock the gem's own pages are read against.
|
|
2
|
+
module Recourse
|
|
3
|
+
# Where the reader's own time zone is kept in their browser, for the server to read
|
|
4
|
+
# back. An IANA name — `America/Los_Angeles` — as the browser itself reports it.
|
|
5
|
+
ZONE_STORAGE = 'recourse-zone'
|
|
6
|
+
end
|