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.
Files changed (131) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +234 -0
  3. data/README.md +150 -20
  4. data/app/controllers/bookmarks_controller.rb +7 -0
  5. data/app/controllers/concerns/recourse/landing.rb +49 -0
  6. data/app/controllers/concerns/recourse/list_resolution.rb +28 -0
  7. data/app/controllers/concerns/recourse/paging.rb +17 -0
  8. data/app/controllers/concerns/recourse/parent_naming.rb +24 -0
  9. data/app/controllers/concerns/recourse/parent_resolution.rb +63 -0
  10. data/app/controllers/concerns/recourse/reference_resolution.rb +70 -0
  11. data/app/controllers/concerns/recourse/resource_resolution.rb +68 -0
  12. data/app/controllers/concerns/recourse/zoning.rb +29 -0
  13. data/app/controllers/recourse/base_controller.rb +85 -0
  14. data/app/controllers/recourse/bookmarks_controller.rb +76 -0
  15. data/app/controllers/recourses_controller.rb +6 -25
  16. data/app/javascript/recourse/bookmark_controller.js +100 -0
  17. data/app/javascript/recourse/clear_controller.js +22 -0
  18. data/app/javascript/recourse/combobox_controller.js +68 -0
  19. data/app/javascript/recourse/confirm.js +46 -0
  20. data/app/javascript/recourse/deselect_controller.js +37 -0
  21. data/app/javascript/recourse/flash.js +35 -0
  22. data/app/javascript/recourse/limit_controller.js +23 -0
  23. data/app/javascript/recourse/phone_controller.js +33 -0
  24. data/app/javascript/recourse/relative_time_controller.js +45 -0
  25. data/app/javascript/recourse/reveal_controller.js +16 -0
  26. data/app/javascript/recourse/scheme_controller.js +51 -0
  27. data/app/javascript/recourse/search_controller.js +78 -0
  28. data/app/javascript/recourse/shortcuts_controller.js +42 -0
  29. data/app/javascript/recourse/timezone_controller.js +27 -0
  30. data/app/javascript/recourse/toast_controller.js +35 -0
  31. data/app/javascript/recourse/tooltip_controller.js +16 -0
  32. data/app/javascript/recourse/written.js +29 -0
  33. data/app/javascript/recourse/written_controller.js +28 -0
  34. data/app/views/layouts/recourses.html.erb +442 -0
  35. data/app/views/recourses/_breadcrumb.html.erb +35 -0
  36. data/app/views/recourses/_card.html.erb +33 -0
  37. data/app/views/recourses/_color.html.erb +20 -0
  38. data/app/views/recourses/_combobox.html.erb +68 -0
  39. data/app/views/recourses/_confirm.html.erb +14 -0
  40. data/app/views/recourses/_fields.html.erb +3 -0
  41. data/app/views/recourses/_flash.html.erb +26 -0
  42. data/app/views/recourses/_footer.html.erb +22 -0
  43. data/app/views/recourses/_form.html.erb +11 -0
  44. data/app/views/recourses/_none.html.erb +1 -0
  45. data/app/views/recourses/_results.html.erb +10 -0
  46. data/app/views/recourses/_row.html.erb +4 -10
  47. data/app/views/recourses/_scheme.html.erb +14 -0
  48. data/app/views/recourses/_search.html.erb +24 -0
  49. data/app/views/recourses/_sidebar.html.erb +40 -0
  50. data/app/views/recourses/_table.html.erb +47 -42
  51. data/app/views/recourses/_values.html.erb +3 -0
  52. data/app/views/recourses/edit.html.erb +10 -0
  53. data/app/views/recourses/index.html.erb +27 -24
  54. data/app/views/recourses/new.html.erb +3 -0
  55. data/app/views/recourses/show.html.erb +10 -0
  56. data/config/locales/recourse.en.yml +72 -0
  57. data/lib/recourse/bookmarks.rb +51 -0
  58. data/lib/recourse/broadcasting.rb +33 -0
  59. data/lib/recourse/colors.rb +32 -0
  60. data/lib/recourse/columns.rb +80 -0
  61. data/lib/recourse/controllers.rb +26 -0
  62. data/lib/recourse/engine.rb +33 -13
  63. data/lib/recourse/helpers/actions.rb +56 -0
  64. data/lib/recourse/helpers/bookmarks.rb +88 -0
  65. data/lib/recourse/helpers/buttons.rb +65 -0
  66. data/lib/recourse/helpers/cards.rb +67 -0
  67. data/lib/recourse/helpers/cells.rb +89 -0
  68. data/lib/recourse/helpers/choices.rb +49 -0
  69. data/lib/recourse/helpers/colors.rb +20 -0
  70. data/lib/recourse/helpers/comboboxes.rb +87 -0
  71. data/lib/recourse/helpers/constraints.rb +92 -0
  72. data/lib/recourse/helpers/counters.rb +75 -0
  73. data/lib/recourse/helpers/deletions.rb +87 -0
  74. data/lib/recourse/helpers/details.rb +41 -0
  75. data/lib/recourse/helpers/examples.rb +35 -0
  76. data/lib/recourse/helpers/fields.rb +78 -0
  77. data/lib/recourse/helpers/filters.rb +91 -0
  78. data/lib/recourse/helpers/formats.rb +94 -0
  79. data/lib/recourse/helpers/inputs.rb +92 -0
  80. data/lib/recourse/helpers/kinds.rb +87 -0
  81. data/lib/recourse/helpers/limits.rb +37 -0
  82. data/lib/recourse/helpers/names.rb +44 -0
  83. data/lib/recourse/helpers/navigation.rb +75 -0
  84. data/lib/recourse/helpers/parents.rb +82 -0
  85. data/lib/recourse/helpers/pictures.rb +33 -0
  86. data/lib/recourse/helpers/references.rb +99 -0
  87. data/lib/recourse/helpers/refreshes.rb +27 -0
  88. data/lib/recourse/helpers/resources.rb +47 -0
  89. data/lib/recourse/helpers/routing.rb +28 -0
  90. data/lib/recourse/helpers/rows.rb +33 -0
  91. data/lib/recourse/helpers/schemes.rb +14 -0
  92. data/lib/recourse/helpers/searches.rb +57 -0
  93. data/lib/recourse/helpers/shortcuts.rb +30 -0
  94. data/lib/recourse/helpers/sidebars.rb +39 -0
  95. data/lib/recourse/helpers/sorts.rb +62 -0
  96. data/lib/recourse/helpers/tabs.rb +82 -0
  97. data/lib/recourse/helpers/times.rb +43 -0
  98. data/lib/recourse/helpers/values.rb +64 -0
  99. data/lib/recourse/helpers/zones.rb +38 -0
  100. data/lib/recourse/helpers.rb +81 -0
  101. data/lib/recourse/icons.rb +21 -0
  102. data/lib/recourse/limits.rb +12 -0
  103. data/lib/recourse/recoursive.rb +66 -19
  104. data/lib/recourse/registry.rb +36 -0
  105. data/lib/recourse/routes/nested.rb +36 -0
  106. data/lib/recourse/routes.rb +57 -0
  107. data/lib/recourse/routing.rb +11 -32
  108. data/lib/recourse/schemes.rb +6 -0
  109. data/lib/recourse/scopes.rb +37 -0
  110. data/lib/recourse/search.rb +65 -0
  111. data/lib/recourse/searchable/columns.rb +99 -0
  112. data/lib/recourse/searchable/filters.rb +58 -0
  113. data/lib/recourse/searchable/terms.rb +56 -0
  114. data/lib/recourse/searchable.rb +47 -22
  115. data/lib/recourse/titles.rb +56 -0
  116. data/lib/recourse/version.rb +2 -1
  117. data/lib/recourse/writes.rb +12 -0
  118. data/lib/recourse/zones.rb +6 -0
  119. data/lib/recourse.rb +79 -7
  120. data/vendor/recourse/bootstrap-icons.min.css +5 -0
  121. data/vendor/recourse/bootstrap.bundle.min.js +9 -0
  122. data/vendor/recourse/bootstrap.min.css +2 -0
  123. data/vendor/recourse/fonts/bootstrap-icons.woff +0 -0
  124. data/vendor/recourse/fonts/bootstrap-icons.woff2 +0 -0
  125. data/vendor/recourse/stimulus.js +2563 -0
  126. metadata +169 -30
  127. data/Rakefile +0 -6
  128. data/app/helpers/navigable_helper.rb +0 -36
  129. data/app/helpers/recoursive_helper.rb +0 -10
  130. data/app/helpers/searchable_helper.rb +0 -54
  131. 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
@@ -1,38 +1,17 @@
1
- # A module to manage administered resources.
1
+ # Reopened for the one question a controller and a view both ask of the router.
2
2
  module Recourse
3
- # Makes Recourse methods like `recourses` available in config/routes.rb
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
- # This method is equivalent to Rails `resources` with the added bonus that we store
6
- # the name of these administered resources so we can display them to admins in the navbar.
7
- def recourses(*args, **kwargs, &block)
8
- store_recourses args, kwargs
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
- ActionDispatch::Routing::Mapper.include Recourse::Routing
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
@@ -1,38 +1,63 @@
1
- # A module to manage administered resources.
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 any model to be searchable with Ransack.
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
- # Returns the attributes that can be searched.
6
- def ransackable_attributes(auth_object = nil)
7
- %w[ ]
8
- end
11
+ include Columns, Filters, Terms
9
12
 
10
- # Returns the associations that can be searched (by default: none).
11
- def ransackable_associations(auth_object = nil)
12
- %w[ ]
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
- # Returns the associations that can be sorted by (by default: none).
16
- def ransortable_attributes(auth_object = nil)
17
- %w[ ]
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
- # @note to be overriden by subclasses -- the name of the field to search with.
21
- def search_field
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
- # @note to be overriden by subclasses -- the placeholder for the field to search with.
25
- def search_prompt
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
- # @note to be overriden by subclasses -- the name of the field to filter by.
29
- def filter_fields = {}
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
- # Returns the names of all the search and filter fields.
32
- def searchable_fields = [search_field, filter_fields.keys]
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(:active_record) do
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
@@ -1,3 +1,4 @@
1
1
  module Recourse
2
- VERSION = '3.0.4'
2
+ # Version of the gem, read by the gemspec and by hosts checking compatibility.
3
+ VERSION = '4.0.0'
3
4
  end
@@ -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