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,75 @@
1
+ module Recourse
2
+ module Helpers
3
+ # A counter cache's column in a table: an icon heading over bare figures, or —
4
+ # where the table is wide enough to read them — the counted model's own words.
5
+ module Counters
6
+ private
7
+
8
+ # The class a counter's cells carry, which is what sizes the column like the
9
+ # action columns beside it rather than like the columns carrying text.
10
+ def counter_class(column)
11
+ 'recourse-counter' if resource_model.recourse_counters.key? column
12
+ end
13
+
14
+ # A figure, and the word saying what it counts. The `aria-label` carries both,
15
+ # since a link whose whole text is a number announces as `10` and nothing else.
16
+ # A span where there is no index to link to, so an unlinked count is named the
17
+ # same as a linked one.
18
+ def counter_cell(resource, value, association)
19
+ # Delimited like every other count on the page: the filter menu beside the
20
+ # table already reads 38,405, and one figure in two spellings reads as two.
21
+ count = number_with_delimiter value
22
+ counted = counter_counted count, value, association
23
+ named = counter_naming count, association
24
+ path = resource_controller_path
25
+ nested = nested_path_of path, association
26
+ return tag.span(counted, **named) unless nested && routed?(nested, 'index')
27
+
28
+ turbo_link_to counted, nested_url(resource, path, nested), **named
29
+ end
30
+
31
+ # The two forms of one count, of which a stylesheet ever shows one: the bare
32
+ # figure where the column is a square, and the figure with the counted model's
33
+ # own word where the table is wide enough to read it. `count:` is what makes it
34
+ # `1 place` rather than `1 places`, and `lower:` what leaves it reading as a
35
+ # phrase. Written out twice rather than as a figure and a suffix, so that each
36
+ # is a whole thing to show or hide — which is what lets the tooltip ride on the
37
+ # first of them and keep quiet beside the second.
38
+ def counter_counted(count, value, association)
39
+ title = Recourse.model_title association.klass
40
+ word = Recourse.model_title association.klass, count: value, lower: true
41
+
42
+ safe_join [
43
+ tag.span(count, class: 'recourse-counter-figure', data: tooltip_on_top(title)),
44
+ tag.span("#{count} #{word}", class: 'recourse-counter-word'),
45
+ ]
46
+ end
47
+
48
+ # The label reads the figure and the word, which is the order somebody hearing it
49
+ # needs them in — and it is on the link either way, since nothing is hidden from
50
+ # a screen reader by a width.
51
+ def counter_naming(count, association)
52
+ { aria: { label: "#{count} #{Recourse.model_title association.klass}" } }
53
+ end
54
+
55
+ # Where the counted rows were nested under this resource, read off the routes
56
+ # rather than joined onto the parent's path: a `namespace` between the two is
57
+ # part of the address and nothing here would know to put it back.
58
+ def nested_path_of(path, association)
59
+ Recourse.nested_under(path).find { |one| one.split('/').last == association.name.to_s }
60
+ end
61
+
62
+ # The icon the sidebar and the breadcrumb already draw for the counted model,
63
+ # speaking the heading's word to a screen reader — and that word itself, for the
64
+ # table with room to read it. Exactly one of the two is ever shown, and the icon's
65
+ # tooltip leaves with the icon: what cannot be hovered repeats nothing.
66
+ def counter_title(association)
67
+ title = Recourse.model_title association.klass
68
+ icon = icon_heading association.klass.recourse_icon, title,
69
+ class: 'recourse-counter-icon'
70
+
71
+ safe_join [icon, tag.span(title, class: 'recourse-counter-word')]
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,87 @@
1
+ module Recourse
2
+ module Helpers
3
+ # The button that deletes the record a form is showing, and the warning it puts
4
+ # in front of whoever clicked it.
5
+ module Deletions
6
+ # `dependent:` values that take the children with the parent.
7
+ DESTROYED = %i[destroy destroy_async].freeze
8
+
9
+ # And the one that keeps them, holding the key open. Anything else — a bare
10
+ # `has_many`, a `:restrict` — is left unsaid rather than guessed at.
11
+ NULLIFIED = %i[nullify].freeze
12
+
13
+ private
14
+
15
+ # Deletes the record on the page, or nothing at all where no action is routed
16
+ # to delete it with — the same two guards the edit link answers to.
17
+ def destroy_resource_button(record)
18
+ path = destroy_resource_path record
19
+ return unless path
20
+
21
+ options = {
22
+ method: :delete, class: 'btn btn-sm btn-solid theme-danger ms-3',
23
+ form_class: 'd-inline-block', data: { turbo_confirm: destroy_warning(record) },
24
+ }
25
+
26
+ button_to t('recourse.delete', model: resource_name), path, **options
27
+ end
28
+
29
+ # What deleting this record takes with it, counted a level down and no further:
30
+ # a state reaches counties, then ZIPs, then locations, and counting that far
31
+ # would join 40,965 rows to draw one page.
32
+ def destroy_warning(record)
33
+ lines = [t('recourse.deletion.title', record: destroy_title(record)), nil]
34
+
35
+ [*lines, *dependent_lines(record), nil, t('recourse.deletion.undone')].join "\n"
36
+ end
37
+
38
+ def destroy_resource_path(record)
39
+ return unless routed_action? 'destroy'
40
+
41
+ url_for action: :destroy, id: record
42
+ end
43
+
44
+ # The middle of the warning, in the order it reads: what goes, what stays, and
45
+ # only then what is under what goes — the levels this stops short of counting.
46
+ def dependent_lines(record)
47
+ going = dependents record, DESTROYED
48
+ staying = dependents record, NULLIFIED
49
+ lines = []
50
+ lines << t('recourse.deletion.going', list: going.to_sentence) if going.any?
51
+ lines << staying_line(staying) if staying.any?
52
+ lines << t('recourse.deletion.under') if going.any?
53
+
54
+ lines
55
+ end
56
+
57
+ def staying_line(staying)
58
+ t 'recourse.deletion.staying', list: staying.to_sentence, model: resource_name
59
+ end
60
+
61
+ def dependents(record, kinds)
62
+ record.class.reflect_on_all_associations(:has_many).filter_map do |association|
63
+ next if association.through_reflection || kinds.exclude?(association.options[:dependent])
64
+
65
+ dependent_count record, association
66
+ end
67
+ end
68
+
69
+ # `association.reader` rather than a method named at runtime, and `count` rather
70
+ # than loading them: the warning needs how many, never which.
71
+ def dependent_count(record, association)
72
+ count = record.association(association.name).reader.count
73
+ return if count.zero?
74
+
75
+ name = Recourse.downcase association.klass.model_name.human
76
+
77
+ "#{number_with_delimiter count} #{name.pluralize count}"
78
+ end
79
+
80
+ # What the record is called, or what it is, for one that answers to no label.
81
+ def destroy_title(record)
82
+ record.attributes[record.class.recourse_label.to_s].presence ||
83
+ record.class.model_name.human
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,41 @@
1
+ module Recourse
2
+ module Helpers
3
+ # What a value is drawn behind when it is worth more room than a page will give it
4
+ # unasked: closed to begin with, so a column of values stays a column of values.
5
+ module Details
6
+ private
7
+
8
+ # The shape itself, said once: what the thing is, and the thing under it. A
9
+ # picture and a list are both this, and neither should be spelling it out.
10
+ def detailed(summary, body)
11
+ tag.details { safe_join [tag.summary(summary), body] }
12
+ end
13
+
14
+ # A list reads as how many first — the values are what opening it is for — and
15
+ # then as the values themselves. Nil rather than an empty `<details>` where there
16
+ # is nothing in it, so an empty list reads as the dash every other empty value
17
+ # reads as rather than as a summary with nothing behind it.
18
+ def listed(values)
19
+ count = listed_count values
20
+ return unless count
21
+
22
+ detailed count, listed_items(values)
23
+ end
24
+
25
+ # How many a list holds, and nothing else: what a table shows in place of the
26
+ # values, and what the summary above reads before anybody opens it. Nil for an
27
+ # empty one, which reads as blank in a cell and as the dash on a record's page.
28
+ def listed_count(values)
29
+ return if values.blank?
30
+
31
+ t 'recourse.items', count: values.size
32
+ end
33
+
34
+ # `mb-0`, because the row below it draws the rule between them and a list's own
35
+ # bottom margin would push that rule away from the values it closes.
36
+ def listed_items(values)
37
+ tag.ul safe_join(values.map { |one| tag.li one }), class: 'mb-0 mt-2'
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,35 @@
1
+ module Recourse
2
+ module Helpers
3
+ # Reads an example of an accepted value off the pattern that accepts it.
4
+ module Examples
5
+ # One token of a pattern and the `{n}` that may repeat it, so `\d{5}` reads
6
+ # as a single digit five times over rather than as five separate characters.
7
+ # `\d`, a bracket class and a literal are all it knows; nothing else is used.
8
+ PATTERN_TOKENS = /(\\d|\[[^\]]*\]|[^{])(?:\{(\d+),?\d*\})?/
9
+
10
+ # A token that is a bracket class, so `[2-9]` counts as one rather than five.
11
+ CLASS_TOKEN = /\A\[/
12
+
13
+ # First character a bracket class offers, past the bracket and any negation.
14
+ CLASS_SAMPLE = /[^\[^]/
15
+
16
+ private
17
+
18
+ # What the pattern accepts, so a field can name the shape it wants instead of
19
+ # only reporting that what was typed is wrong.
20
+ def pattern_example(pattern)
21
+ tokens = pattern.scan PATTERN_TOKENS
22
+
23
+ tokens.map { |token, count| pattern_sample(token) * (count || 1).to_i }.join
24
+ end
25
+
26
+ def pattern_sample(token)
27
+ case token
28
+ when '\d' then '0'
29
+ when CLASS_TOKEN then token[CLASS_SAMPLE]
30
+ else token
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,78 @@
1
+ module Recourse
2
+ module Helpers
3
+ # Chooses the form field a column deserves, and labels it.
4
+ module Fields
5
+ private
6
+
7
+ # One labelled field in the form's grid. `label:` overrides the heading and
8
+ # `type:` overrides the input the column would otherwise have chosen.
9
+ def field(name, **options)
10
+ column = name.to_s
11
+ label = options.fetch :label, reference_title(column, belongs_to_association(column))
12
+
13
+ tag.div class: ROW do
14
+ safe_join [
15
+ @recourse_form.label(column, label, class: 'form-label'),
16
+ resource_field(@recourse_form, column, type: options[:type]),
17
+ field_comment(column),
18
+ ].compact
19
+ end
20
+ end
21
+
22
+ # What the database says the column is for, under the field that sets it.
23
+ def field_comment(column)
24
+ field_note resource_model.recourse_comment(column), field_note_id(column)
25
+ end
26
+
27
+ # What the control points at, where it should point at anything. Only where there
28
+ # is a note to point at — and only where the field has nothing more urgent to say,
29
+ # since an error outranks a hint. `field_error_proc` is the host's and writes its
30
+ # own `aria-describedby` for an invalid field, so rather than leave a second one
31
+ # for the browser to throw away, this one stands down and says so here.
32
+ def field_described(column)
33
+ return if resource_model.recourse_comment(column).blank?
34
+ return if errors_on(column).any?
35
+
36
+ field_note_id column
37
+ end
38
+
39
+ # Named off the field's own id, the way Rails names everything else about it.
40
+ def field_note_id(column)
41
+ id = @recourse_form.field_id column
42
+
43
+ "#{id}_help"
44
+ end
45
+
46
+ # The line under a field saying what somebody wants to know before filling it in:
47
+ # what the column is for. Nothing at all where there is nothing to say.
48
+ #
49
+ # `mt-1` because Bootstrap's `.form-text` declares `--bs-form-text-margin-top` and
50
+ # never applies it; `.25rem` is what that variable holds, so this is the gap the
51
+ # class already meant. `fg-secondary` for a line that answers a question nobody
52
+ # asked — quieter than the value it sits under, and quieter than `.form-text`'s
53
+ # own `--bs-fg-2`, which a utility later in the cascade is what overrides.
54
+ def field_note(text, id)
55
+ tag.div text, class: 'form-text mt-1 fg-secondary', id: id if text.present?
56
+ end
57
+
58
+ # A field typed by what the column holds, not merely a text box.
59
+ def resource_field(form, column, type: nil)
60
+ association = belongs_to_association column
61
+ return reference_field form, column, association if association
62
+
63
+ # Rails mirrors `maxlength` into `size`, which would shrink the box to it.
64
+ aria = { describedby: field_described(column) }
65
+ options = { class: 'form-control', size: nil, aria: }.merge field_html(column, type)
66
+
67
+ return form.text_field column, **options, type: type if type
68
+ return form.email_field column, **options if column == 'email'
69
+
70
+ kind_field form, column, **options
71
+ end
72
+
73
+ def encrypted_column?(column)
74
+ resource_model.recourse_encrypted_names.include? column
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,91 @@
1
+ module Recourse
2
+ module Helpers
3
+ # The menus beside a search box: one per foreign key a table can be narrowed by,
4
+ # and one per column whose values are a known few.
5
+ module Filters
6
+ private
7
+
8
+ # The filters this page draws, as the markup each one is. A declared filter
9
+ # that draws nothing falls out here — a foreign key whose label is typed
10
+ # rather than picked — so what is left is what a form would hold, which is
11
+ # what decides whether there is a form at all.
12
+ def resource_filter_fields
13
+ resource_filters.filter_map { |predicate, options| filter_field predicate, **options }
14
+ end
15
+
16
+ # The model's filters, less the one a nested route already answered:
17
+ # /markets/1/sectors is filtered by market_id in the URL itself, and a menu
18
+ # for it would only offer to re-ask — or to contradict — the address.
19
+ def resource_filters
20
+ parent = resource_parent_association
21
+ filters = resource_model.filter_fields
22
+ return filters unless parent
23
+
24
+ filters.except "#{parent.foreign_key}_in"
25
+ end
26
+
27
+ # One filter: the values a column of its own admits, or a menu of the records a
28
+ # foreign key points at, holding whichever the request already asked for.
29
+ def filter_field(predicate, label: nil, scope: nil)
30
+ column = predicate.to_s.sub Search::LIST_PREDICATES, ''
31
+
32
+ choice_filter(predicate, column, label) || reference_filter(predicate, column, label, scope)
33
+ end
34
+
35
+ # A menu of the records a foreign key points at. Nothing where that key is typed
36
+ # rather than picked, which is the same question the field beside it asks: the
37
+ # label is bounded, or the table is too long to list. Either way the menu would
38
+ # be a table of its own. A `scope:` draws one anyway.
39
+ def reference_filter(predicate, column, label, scope)
40
+ association = belongs_to_association column
41
+ return if association.nil? ||
42
+ (scope.nil? && association.klass.recourse_typed_reference?)
43
+
44
+ filter_combobox predicate, label || reference_title(column, association),
45
+ (scope || association.klass).all
46
+ end
47
+
48
+ def filter_combobox(predicate, title, recourses)
49
+ label = recourses.klass.recourse_label
50
+ counter = filter_counter recourses.klass
51
+ # What it reads as when nothing is ticked, so the way back is a line in the menu.
52
+ models = Recourse.model_title recourses.klass, lower: true
53
+
54
+ filter_menu predicate, title, nil, t('recourse.all', models: models),
55
+ label: label.to_s, counter: counter,
56
+ recourses: filter_options(recourses, label, counter)
57
+ end
58
+
59
+ # The commonest choice first where the model keeps a count, since a menu is read
60
+ # from the top and most requests want the option most rows are behind — and by
61
+ # name where it keeps none. The name breaks ties, or two markets on the same
62
+ # number would swap places between one request and the next.
63
+ def filter_options(recourses, label, counter)
64
+ return recourses.select(:id, label).order label unless counter
65
+
66
+ recourses.select(:id, label, counter).order counter => :desc, label => :asc
67
+ end
68
+
69
+ # The column on the model a filter lists that counts the rows being filtered —
70
+ # `markets.zips_count` on `/zips`. Read from the counter caches that model keeps
71
+ # rather than from a column named after this one, so a `zips_count` nobody
72
+ # maintains is not a count.
73
+ def filter_counter(klass)
74
+ klass.recourse_counters.find { |_, one| one.klass == resource_model }&.first
75
+ end
76
+
77
+ # Never invalid and never required: a filter narrows rather than sets.
78
+ def filter_menu(predicate, title, values, all, **)
79
+ render('recourses/combobox', name: "q[#{predicate}]", id: "q_#{predicate}",
80
+ placeholder: title, multiple: true, aria_label: title,
81
+ selected: filter_values(predicate), small: true,
82
+ all: all, values: Array(values), **)
83
+ end
84
+
85
+ # A multiple combobox submits one comma-joined input, read back out the same way.
86
+ def filter_values(predicate)
87
+ query_params[predicate].to_s.split ','
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,94 @@
1
+ module Recourse
2
+ module Helpers
3
+ # How one attribute reads on a page that only reads it.
4
+ module Formats
5
+ # One absolute web address and nothing else: a value to follow, not to read.
6
+ # Anything around it — words, a second address — reads as text instead. What it
7
+ # captures is what a link says: the host, less any `www.`, and whatever follows.
8
+ WEB_URL = %r{\Ahttps?://(?:www\.)?([^/?#\s]+)(\S*)\z}
9
+
10
+ private
11
+
12
+ # What the record says for one column, formatted by what the column holds.
13
+ def formatted_value(column)
14
+ association = belongs_to_association column
15
+ return named_cell resource_record, association if association
16
+
17
+ formatted_attribute column, resource_record.attributes[column]
18
+ end
19
+
20
+ # One value, formatted by the kind its column holds — the one ladder a table
21
+ # cell and a show page's value both come down, so a boolean is the same icon
22
+ # and an enum the same badge on either. A block, where the caller has one,
23
+ # marks the search terms inside whichever arm ends up as words.
24
+ def formatted_attribute(column, value, &)
25
+ kind = attribute_kind column
26
+ return listed Array(value) if kind == :list
27
+ return formatted_number kind, column, value if numeric_kind? kind
28
+
29
+ formatted_text kind, value, &
30
+ end
31
+
32
+ def formatted_number(kind, column, value)
33
+ return uncounted kind, value if Kinds::UNCOUNTED_KINDS.include? kind
34
+
35
+ case kind
36
+ when :integer then number_with_delimiter value
37
+ when :phone then number_to_phone value
38
+ when :monetary then number_to_currency value, **precision_option(column)
39
+ when :percentage then number_to_percentage value, **precision_option(column)
40
+ when :decimal then number_with_precision value, **precision_option(column)
41
+ else number_with_precision value
42
+ end
43
+ end
44
+
45
+ def formatted_text(kind, value, &)
46
+ case kind
47
+ when :enum then value && enum_badge(marked(value, &))
48
+ when :date, :datetime, :time then value && localized(kind, value)
49
+ when *Kinds::JSON_KINDS then value.presence && json_block(value)
50
+ else linked_or_marked(value, &)
51
+ end
52
+ end
53
+
54
+ # One whole web address is a value to follow rather than to read, and anything
55
+ # else is words — which the caller may have marking of its own for.
56
+ def linked_or_marked(value, &)
57
+ web_url?(value) ? url_link(value) : marked(value, &)
58
+ end
59
+
60
+ # The caller's own marking, where it has one: a table marks what a search
61
+ # matched, and a show page, which no search reached, has nothing to mark.
62
+ def marked(value, &)
63
+ block_given? ? yield(value) : value
64
+ end
65
+
66
+ # A payload read as the JSON it is rather than as the Hash Ruby prints. In a block
67
+ # of its own that scrolls rather than grows: one of these is as tall as a page, and
68
+ # nothing else on a record's page should have to move aside for it. `presence`, so
69
+ # an empty payload reads as the dash every other empty value does.
70
+ def json_block(value)
71
+ tag.pre JSON.pretty_generate(value), class: 'recourse-payload'
72
+ end
73
+
74
+ def enum_badge(value)
75
+ tag.span value, class: 'badge'
76
+ end
77
+
78
+ def web_url?(value)
79
+ value.is_a?(String) && value.match?(WEB_URL)
80
+ end
81
+
82
+ # Bootstrap's icon link, saying the value leads somewhere the way text cannot.
83
+ # What it reads is the host, and an ellipsis where the address goes further --
84
+ # a path is how a machine finds the page, and the href is already carrying it.
85
+ def url_link(value)
86
+ host, rest = value.match(WEB_URL).captures
87
+ said = rest.delete_suffix('/').empty? ? host : "#{host}/…"
88
+
89
+ tag.a safe_join([said, icon_tag(:point_right)], ' '),
90
+ href: value, class: 'icon-link icon-link-hover'
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,92 @@
1
+ module Recourse
2
+ module Helpers
3
+ # The control an attribute is typed into, chosen by what the attribute holds.
4
+ module Inputs
5
+ # Numbers a field takes whole. A month and a year are counted in no smaller unit
6
+ # than themselves, so neither admits the decimals a `step` would otherwise allow.
7
+ WHOLE_KINDS = %i[integer month year].freeze
8
+
9
+ private
10
+
11
+ # A field for a column whose kind is what decides it, the fallback being a text
12
+ # box for anything that never said what it holds. The checkbox is handed one
13
+ # option by name rather than the whole hash: the rest is `maxlength`, `pattern`,
14
+ # `placeholder`, `inputmode` and `required`, none of which a box that is either
15
+ # ticked or not has any use for.
16
+ def kind_field(form, column, **options)
17
+ kind = attribute_kind column
18
+ return numeric_field(form, column, kind, **options) if numeric_kind? kind
19
+ return menu_field form, column, kind if menu_kind? kind
20
+
21
+ case kind
22
+ when :boolean then tag.div form.check_box(column, class: 'check', aria: options[:aria])
23
+ when :date then form.date_field(column, **options)
24
+ when :datetime then form.datetime_local_field(column, **options)
25
+ when :text, :list then area_field(form, column, kind, **options)
26
+ else form.text_field(column, **options)
27
+ end
28
+ end
29
+
30
+ # A list is typed one value to a line, a newline being the one separator a value
31
+ # cannot itself contain. Handed its value, since a box left to read an Array off
32
+ # the record would print the brackets.
33
+ def area_field(form, column, kind, **)
34
+ return form.text_area(column, **, rows: 1) if kind == :text
35
+
36
+ values = Array form.object&.attributes&.fetch(column, nil)
37
+ form.text_area column, **, rows: 3, value: values.join("\n")
38
+ end
39
+
40
+ # `step` is what limits a field to whole numbers, and what admits the decimals a
41
+ # column keeps: two of them for a `scale: 2`, any number at all for a float,
42
+ # which says how precise it is nowhere.
43
+ def numeric_field(form, column, kind, **)
44
+ return form.telephone_field(column, **) if kind == :phone
45
+ return adorned_field(form, column, **) if %i[monetary percentage].include? kind
46
+
47
+ form.number_field(column, **, **step_options(column, kind))
48
+ end
49
+
50
+ def step_options(column, kind)
51
+ return { step: 1 } if WHOLE_KINDS.include? kind
52
+
53
+ scale = attribute_scale column
54
+ return { step: :any } unless scale
55
+
56
+ { step: 10.0**-scale, max: digit_ceiling(column, scale) }
57
+ end
58
+
59
+ # Bootstrap adorns a control by wrapping it: the border and the padding are the
60
+ # wrapper's, the input inside is a `.form-ghost` with neither, and
61
+ # `.form-adorn-end` is what moves the unit to the far side of it.
62
+ def adorned_field(form, column, **options)
63
+ end_ = attribute_kind(column) == :percentage
64
+ classes = ['form-control form-adorn d-flex', ('form-adorn-end' if end_)].compact
65
+ unit = tag.span adorn_unit(end_), class: 'form-adorn-text'
66
+
67
+ tag.div class: classes.join(' ') do
68
+ safe_join [unit, adorned_input(form, column, **options)]
69
+ end
70
+ end
71
+
72
+ def adorned_input(form, column, **)
73
+ form.number_field(column, **, class: 'form-ghost',
74
+ **step_options(column, attribute_type(column)))
75
+ end
76
+
77
+ # The currency the app counts in, which is what its number formats already say.
78
+ def adorn_unit(percentage)
79
+ percentage ? '%' : I18n.t('number.currency.format.unit', default: '$')
80
+ end
81
+
82
+ # The largest value the column has room for: `precision: 4, scale: 2` keeps four
83
+ # digits of which two are decimals, so 99.99 and nothing above it.
84
+ def digit_ceiling(column, scale)
85
+ precision = attribute_precision column
86
+ return unless precision
87
+
88
+ (10.0**(precision - scale)) - (10.0**-scale)
89
+ end
90
+ end
91
+ end
92
+ end