recourse 3.0.3 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 -35
  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,30 @@
1
+ module Recourse
2
+ module Helpers
3
+ # The letter each sidebar link answers to, and where in its title that letter is.
4
+ module Shortcuts
5
+ private
6
+
7
+ # The first letter of the title nothing above it has claimed, so `Contacts`
8
+ # answers to C and `Counties`, declared after it, answers to O. Nil where a
9
+ # title has no letter left to give, which leaves that entry without a shortcut.
10
+ def shortcut_index(title, taken)
11
+ index = title.each_char.find_index do |char|
12
+ char.match?(/[a-z]/i) && taken.exclude?(char.downcase)
13
+ end
14
+ taken << title[index].downcase if index
15
+
16
+ index
17
+ end
18
+
19
+ # The title with that one letter marked, for the overlay to reveal. Wrapped in
20
+ # one element on purpose: `.nav-link` is a flex container with a gap, so a bare
21
+ # span would leave the letter a flex item of its own and the word would read
22
+ # `C o unties`. One element is one item, and inside it the word is just a word.
23
+ def shortcut_title(title, key)
24
+ marked = tag.span title[key], class: 'recourse-key'
25
+
26
+ tag.span safe_join([title[0...key], marked, title[(key + 1)..]])
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,39 @@
1
+ module Recourse
2
+ module Helpers
3
+ # The sidebar: one entry per resource the routes declared, in that order, and
4
+ # the way out where the host drew one.
5
+ module Sidebars
6
+ private
7
+
8
+ # Whether the host drew a way out. A route named `exit` — `resource :session,
9
+ # only: :destroy, as: :exit` — is the whole declaration: the name is the
10
+ # convention, and its helper existing is what the sidebar asks.
11
+ def exit? = respond_to? :exit_path
12
+
13
+ # Sidebar entries as [name, title, path, key], in the order routes.rb declares
14
+ # them, `key` being where in the title the letter that reaches it sits.
15
+ def sidebar_resources
16
+ taken = []
17
+
18
+ Recourse.declared.filter_map do |path|
19
+ next unless routed? path, 'index'
20
+
21
+ title = Recourse.title path
22
+ [
23
+ path, title, url_for(controller: "/#{path}", action: :index),
24
+ shortcut_index(title, taken),
25
+ ]
26
+ end
27
+ end
28
+
29
+ # True when a sidebar entry names the page being shown, or the parent a nested
30
+ # page sits under. The whole path, so a namespaced resource and its top-level
31
+ # twin are not each other.
32
+ def current_resource?(path)
33
+ here = controller.controller_path
34
+
35
+ path == here || here.start_with?("#{path}/")
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,62 @@
1
+ module Recourse
2
+ module Helpers
3
+ # The links in a table's headings, and the caret naming the order in force.
4
+ module Sorts
5
+ # Caret for the direction a column is sorted by. A column nobody sorted by
6
+ # gets none: an arrow on every heading says nothing about the order in force.
7
+ SORT_CARETS = { 'asc' => 'caret-up-fill', 'desc' => 'caret-down-fill' }.freeze
8
+
9
+ # A heading for a column: a link that sorts the table by it where the model
10
+ # allows that, and the plain title everywhere else. Only the header row draws
11
+ # the link, so the `data-cell` on every other row stays readable text.
12
+ #
13
+ # Named apart from Ransack's `sort_link`, which it calls: sharing the name
14
+ # would take that helper away from every view this gem's controllers render.
15
+ def sort_header(column, title = nil)
16
+ title ||= sort_title column
17
+ return title unless @recourse_headers && sortable_column?(column)
18
+
19
+ sort_link resource_search, column.to_sym, hide_indicator: true, page: nil do
20
+ safe_join [title, sort_caret(column)].compact, ' '
21
+ end
22
+ end
23
+
24
+ private
25
+
26
+ # What the column is called — except a counter's header row, which shows the
27
+ # counted model's icon: the cells under it are bare figures, and the icon is what
28
+ # says what they count.
29
+ #
30
+ # The column's own name even where a foreign key is typed rather than picked. A
31
+ # form names the attribute it wants typed, `ZIP code` rather than `ZIP`, because
32
+ # a box has to say what goes in it; a heading stands over what a record is called
33
+ # and nobody types anything under it — and `Location address line 1` over a column
34
+ # of addresses reads as a form's question asked where there is no form.
35
+ def sort_title(column)
36
+ counted = resource_model.recourse_counters[column.to_s]
37
+ return counter_title counted if counted && @recourse_headers
38
+
39
+ resource_column_title column.to_s
40
+ end
41
+
42
+ # Whether the model lets a heading sort by this column.
43
+ def sortable_column?(column) = resource_model.ransortable_attributes.include?(column.to_s)
44
+
45
+ def sort_caret(column)
46
+ sort = resource_search.sorts.find { |one| one.name == column.to_s }
47
+ icon = SORT_CARETS[sort&.dir]
48
+ return unless icon
49
+
50
+ tag.i class: "bi bi-#{icon}"
51
+ end
52
+
53
+ # Carried through the form as a hidden field, so searching keeps the order a
54
+ # heading asked for. Ransack's own links write one sort, and write it as a
55
+ # string; an array from anywhere else is left behind rather than mangled.
56
+ def sort_param
57
+ sort = query_params[:s]
58
+ sort if sort.is_a? String
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,82 @@
1
+ module Recourse
2
+ module Helpers
3
+ # What a nested index's tab on the parent's card reads as.
4
+ module Tabs
5
+ private
6
+
7
+ # What a nested index's tab reads as. A `has_many` of that name counts its rows
8
+ # and lends its icon; a route the parent has no association for is named after the
9
+ # path instead — a full index reached under a record. There is no model to ask
10
+ # then, so such a tab carries neither an icon nor a count.
11
+ def nested_tab_label(record, name, namespace)
12
+ association = nested_association record, name
13
+ return association_tab_label record, association, namespace if association
14
+
15
+ routed_tab_name name, namespace
16
+ end
17
+
18
+ def nested_association(record, name)
19
+ record.class.reflect_on_all_associations(:has_many).find { |one| one.name.to_s == name }
20
+ end
21
+
22
+ # `Messages`, and `Booked messages` where a namespace leads — the same shape
23
+ # the counted tab keeps. Humanized off the path, since nothing else answers.
24
+ def routed_tab_name(name, namespace)
25
+ lead = namespace_words namespace
26
+ title = name.humanize
27
+ title = Recourse.downcase title if lead.present?
28
+
29
+ [lead.presence&.upcase_first, title].compact.join ' '
30
+ end
31
+
32
+ # The icon is the counted model's own, whatever leads the words beside it.
33
+ def association_tab_label(record, association, namespace)
34
+ icon = Recourse.known_model_icon association.klass
35
+
36
+ words = [icon && tag.i(class: "bi bi-#{icon}"), tab_name(record, association, namespace)]
37
+
38
+ safe_join words.compact, ' '
39
+ end
40
+
41
+ # `8 ZIPs` where the record keeps a count — read off the record itself, no
42
+ # query, like the column — and the bare `ZIPs` where it keeps none. A namespace
43
+ # the routes drew leads either of those, so two nestings of one model read
44
+ # apart: `10 visited places` beside `4 booked places`, `Visited places` beside
45
+ # `Booked places`. Whatever leads earns the downcase, and a title that leads
46
+ # keeps its capital — which is what puts these beside Show and Edit.
47
+ def tab_name(record, association, namespace)
48
+ count = tab_count record, association
49
+ lead = [count, namespace_words(namespace)].compact_blank.join ' '
50
+ title = Recourse.model_title association.klass, count: count, lower: lead.present?
51
+
52
+ [lead.presence&.upcase_first, title].compact.join ' '
53
+ end
54
+
55
+ def tab_count(record, association)
56
+ column = counter_column_of record.class, association
57
+
58
+ record.attributes[column] if column
59
+ end
60
+
61
+ # `on_hold` reads as `on hold`, and an acronym among them keeps its capitals.
62
+ # A nested path split where the parent's ends: whatever the routes drew between
63
+ # the two — a `namespace`, usually nothing — and then the resource's own name.
64
+ # `admin/people/quick/memos` under `admin/people` is `memos`, led by `quick`.
65
+ # Both the tab and the button that stand for that route read it from here, so
66
+ # the two cannot disagree about which words belong to the namespace.
67
+ def nested_segments(nested, path)
68
+ namespace = nested.delete_prefix("#{path}/").split '/'
69
+
70
+ [namespace.pop, namespace]
71
+ end
72
+
73
+ def namespace_words(namespace)
74
+ namespace.map { |segment| Recourse.downcase segment.humanize }.join ' '
75
+ end
76
+
77
+ def counter_column_of(model, association)
78
+ model.recourse_counters.find { |_, one| one == association }&.first
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,43 @@
1
+ module Recourse
2
+ module Helpers
3
+ # A date or a time on a page that only reads it, drawn against the reader's own
4
+ # clock — which is `Time.zone` here, because `Zoning` put it there.
5
+ module Times
6
+ private
7
+
8
+ # In words and in the attribute a machine reads. `l` picks the date format or the
9
+ # time one by what it is handed, so nothing here has to ask which it has — and a
10
+ # `DateTime`, which is both, still keeps its time.
11
+ def localized(kind, value)
12
+ time_tag value, l(value, format: :recourse), **relative_naming(kind, value)
13
+ end
14
+
15
+ # Only an instant is told how far off it is. A date is a day and a time is a time
16
+ # of day, and neither is a moment for `in 9 years` to count against. Beside the
17
+ # value rather than above it, unlike the icons that head a column: a timestamp
18
+ # has a row of the table above it and a value of its own page, and either is
19
+ # something a reader may be comparing this one against.
20
+ def relative_naming(kind, value)
21
+ return {} unless kind == :datetime
22
+
23
+ {
24
+ data: {
25
+ controller: 'relative-time tooltip', bs_placement: 'left',
26
+ bs_title: relative_words(value),
27
+ },
28
+ }
29
+ end
30
+
31
+ # Rails says how far off, never which way, so the direction is supplied here — in
32
+ # the wording `Intl.RelativeTimeFormat` uses, since the browser says these words
33
+ # again on the way to the tooltip and two spellings of one phrase read as two.
34
+ # It has to say them again: these are true when the page is drawn, and the page
35
+ # may be cached for a day.
36
+ def relative_words(value)
37
+ key = value.past? ? 'recourse.ago' : 'recourse.from_now'
38
+
39
+ t key, distance: distance_of_time_in_words_to_now(value)
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,64 @@
1
+ module Recourse
2
+ module Helpers
3
+ # What one record reads as, laid out where its form's fields would be.
4
+ module Values
5
+ private
6
+
7
+ # One labelled value in the show page's grid: the heading the table gives the
8
+ # column, and under it what the record says, formatted the way the table's cell
9
+ # for the same column is — this page reads a record out, so it names its columns
10
+ # the way the page of every record does. `label:` overrides the heading.
11
+ def value(name, **options)
12
+ column = name.to_s
13
+ label = options.fetch :label, resource_column_title(column)
14
+
15
+ tag.div class: ROW do
16
+ safe_join [tag.div(label, class: 'form-label'), value_control(column)]
17
+ end
18
+ end
19
+
20
+ # What the record says for one column, or a dash where it says nothing. A
21
+ # boolean says something either way, and an icon says it, so only a value that
22
+ # formats to nothing at all reads as nothing.
23
+ def resource_value(column)
24
+ value = formatted_value column
25
+
26
+ value.to_s.empty? ? t('recourse.blank') : value
27
+ end
28
+
29
+ # Nothing to disclose is nothing to mask: an encrypted column the record has no
30
+ # value for reads as the dash, rather than as one asterisk hiding one.
31
+ def value_control(column)
32
+ return masked_value formatted_value(column) if masked? column
33
+
34
+ tag.div resource_value(column), class: 'form-control-plaintext'
35
+ end
36
+
37
+ def masked?(column)
38
+ encrypted_column?(column) && resource_record.attributes[column].present?
39
+ end
40
+
41
+ # PII is a page's to show and nobody's to leak by accident, so it arrives as one
42
+ # asterisk per character with the plaintext in an attribute the reveal reads: a
43
+ # screenshot of the page discloses nothing, and reading one value takes a click.
44
+ def masked_value(value)
45
+ options = {
46
+ class: 'form-control-plaintext d-flex gap-2 align-items-end',
47
+ data: { controller: 'reveal', reveal_plain_value: value },
48
+ }
49
+
50
+ tag.div(**options) { safe_join [masked_span(value), reveal_button] }
51
+ end
52
+
53
+ def masked_span(value)
54
+ tag.span '*' * value.length, data: { reveal_target: 'mask' }
55
+ end
56
+
57
+ def reveal_button
58
+ tag.button t('recourse.reveal'), type: :button,
59
+ class: 'btn btn-link btn-sm p-0',
60
+ data: { action: 'reveal#show', reveal_target: 'button' }
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,38 @@
1
+ module Recourse
2
+ module Helpers
3
+ # The menu a time zone is picked from, and the short list it opens on.
4
+ module Zones
5
+ private
6
+
7
+ # Every zone Rails knows, or the narrower list a host's own type nominates: an app
8
+ # serving one country has no use for the other hundred and twenty, and the type is
9
+ # already what the gem asks whether a column holds a zone at all.
10
+ #
11
+ # It opens on the few that type calls common, and the rest arrive behind
12
+ # `All time zones` the way a filter's unused options do: forty of them is a page to
13
+ # read, and four answer for most records.
14
+ def zone_combobox(form, column)
15
+ type = resource_model.type_for_attribute column
16
+ values = zone_names type, :values, ActiveSupport::TimeZone.all.map(&:name)
17
+ shown = zone_names type, :common, values
18
+
19
+ render 'recourses/combobox', **combobox_locals(form, column),
20
+ values: values, shown: shown,
21
+ all: zone_reveal(column, values, shown)
22
+ end
23
+
24
+ # What the type says, where it says anything: neither list is a question every type
25
+ # has an answer to, and what Rails knows is the answer when one has none.
26
+ def zone_names(type, name, fallback)
27
+ type.respond_to?(name) ? Array(type.public_send(name)) : fallback
28
+ end
29
+
30
+ # The line that asks for the rest of them, and nothing where there is no rest.
31
+ def zone_reveal(column, values, shown)
32
+ return unless shown.size < values.size
33
+
34
+ t 'recourse.all', models: Recourse.downcase(resource_column_title(column).pluralize)
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,81 @@
1
+ require_relative 'helpers/actions'
2
+ require_relative 'helpers/bookmarks'
3
+ require_relative 'helpers/buttons'
4
+ require_relative 'helpers/cards'
5
+ require_relative 'helpers/cells'
6
+ require_relative 'helpers/choices'
7
+ require_relative 'helpers/colors'
8
+ require_relative 'helpers/comboboxes'
9
+ require_relative 'helpers/constraints'
10
+ require_relative 'helpers/counters'
11
+ require_relative 'helpers/deletions'
12
+ require_relative 'helpers/details'
13
+ require_relative 'helpers/examples'
14
+ require_relative 'helpers/fields'
15
+ require_relative 'helpers/filters'
16
+ require_relative 'helpers/formats'
17
+ require_relative 'helpers/inputs'
18
+ require_relative 'helpers/kinds'
19
+ require_relative 'helpers/limits'
20
+ require_relative 'helpers/names'
21
+ require_relative 'helpers/navigation'
22
+ require_relative 'helpers/parents'
23
+ require_relative 'helpers/pictures'
24
+ require_relative 'helpers/references'
25
+ require_relative 'helpers/resources'
26
+ require_relative 'helpers/refreshes'
27
+ require_relative 'helpers/routing'
28
+ require_relative 'helpers/rows'
29
+ require_relative 'helpers/schemes'
30
+ require_relative 'helpers/searches'
31
+ require_relative 'helpers/shortcuts'
32
+ require_relative 'helpers/sidebars'
33
+ require_relative 'helpers/sorts'
34
+ require_relative 'helpers/tabs'
35
+ require_relative 'helpers/times'
36
+ require_relative 'helpers/values'
37
+ require_relative 'helpers/zones'
38
+
39
+ module Recourse
40
+ # View helpers for the pages the gem renders, and what the parts share.
41
+ module Helpers
42
+ include Actions, Bookmarks, Buttons, Cards,
43
+ Cells, Choices, Colors, Comboboxes, Constraints, Counters, Deletions,
44
+ Details,
45
+ Examples, Fields, Filters, Formats, Inputs, Kinds, Limits,
46
+ Names, Navigation, Parents, Pictures, References, Refreshes,
47
+ Routing,
48
+ Resources, Rows, Schemes, Searches, Shortcuts, Sidebars, Sorts, Tabs,
49
+ Times, Values, Zones
50
+
51
+ # The grid a record's own two pages lay an attribute out in: two columns on a large
52
+ # viewport, and the same padding on both, so a value and the field that edits it sit
53
+ # at the same height whichever page is open. The rule between rows belongs to the
54
+ # show page alone — `.recourse-values` in the layout draws it.
55
+ ROW = 'recourse-row pb-2 mb-3 lg:col-6'
56
+
57
+ # Bootstrap theme for each flash key, so a notice and an alert read apart.
58
+ FLASH_THEMES = { 'notice' => 'theme-success', 'alert' => 'theme-danger' }
59
+
60
+ # Theme for one flash entry, falling back to a neutral one for a host's key.
61
+ def flash_theme(key)
62
+ FLASH_THEMES.fetch key.to_s, 'theme-primary'
63
+ end
64
+
65
+ private
66
+
67
+ # What marks the row a write just landed on, and nothing at all on a page no write
68
+ # brought about — which is every page but the one after a create or an update.
69
+ def written_data
70
+ row = flash[Recourse::WRITTEN]
71
+
72
+ { controller: 'written', written_row_value: row } if row.present?
73
+ end
74
+
75
+ # `?q=anything` arrives as a String, which has no parameters to read — the
76
+ # same test `Recourse::Search` makes, spelled the same way.
77
+ def query_params
78
+ params[:q].is_a?(ActionController::Parameters) ? params[:q] : {}
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,21 @@
1
+ # Reopened for the one thing a drawn resource cannot answer alone: which picture it
2
+ # is shown with, which its model names and Unicon translates.
3
+ module Recourse
4
+ # What a resource is drawn with, by the name of the class a route resolves to, and
5
+ # nil where there is no such class -- a bare action is drawn under a word this app
6
+ # has no class for, and an icon is not worth raising over.
7
+ def self.known_icon(name)
8
+ model = name.to_s.split('/').last.classify.safe_constantize
9
+
10
+ known_model_icon model if model
11
+ end
12
+
13
+ # And for a model in hand. Nil rather than Unicon's circle where it has never heard
14
+ # the concept: a crumb or a tab then draws no picture at all, since the circle
15
+ # pictures nothing.
16
+ def self.known_model_icon(model)
17
+ concept = model.recourse_icon
18
+
19
+ Unicon[concept][:bootstrap] if Unicon.names.include? concept
20
+ end
21
+ end
@@ -0,0 +1,12 @@
1
+ # Reopened for how much of a table a page shows at once.
2
+ module Recourse
3
+ # Rows to a page, in the order the menu offers them: pagy's own default, and the one
4
+ # step up for a reader scanning a table rather than reading it. Named once — the
5
+ # controller checks a cookie against this list, and the menu is drawn from it.
6
+ LIMITS = [20, 100].freeze
7
+
8
+ # Where a reader's chosen page size is kept in their browser. A cookie rather than
9
+ # local storage, which the scheme is kept in: pagy runs on the server, and a cookie
10
+ # is the only storage the server is sent.
11
+ LIMIT_STORAGE = 'recourse-limit'
12
+ end
@@ -1,33 +1,80 @@
1
- # A module to manage administered resources.
1
+ require 'active_support'
2
+
2
3
  module Recourse
3
- # Ensures every Active Record object is compatible with recourse.
4
+ # Extends every Active Record model, so each one says how it is labelled, which
5
+ # of its columns a screen draws, what its index eager-loads and how it is sorted.
4
6
  module Recoursive
5
- # Return the associations to .include when loading all the resources.
6
- def recourse_includes
7
- %i[]
8
- end
7
+ # Column a combobox shows for a record, and selects alongside its id.
8
+ def recourse_label = :name
9
9
 
10
- # Return the fields to .order when loading all the models.
11
- def recourse_order
12
- end
10
+ # The concept a resource is drawn with, which Unicon names in each icon set it
11
+ # knows. A model's own name by default — `contact` draws a rolodex, `job` a hammer
12
+ # — and Unicon answers with a circle for a name it has never heard of.
13
+ def recourse_icon = model_name.singular.to_sym
14
+
15
+ # Columns the model keeps off its screens — the table, the show page, the
16
+ # form and the search box — none by default. One name or a list:
17
+ # `def recourse_hidden = :name` reads as well as `%i[name title]`.
18
+ def recourse_hidden = []
19
+
20
+ # And the columns a table draws whatever would otherwise keep them off it —
21
+ # ciphertext, the id, the inheritance column, and the two
22
+ # timestamps. Every one of those is a default the gem picks, and a host is what
23
+ # answers for its own screens: `def recourse_displayed = :phone` puts a number
24
+ # back on a table that recognises its rows by nothing else, and
25
+ # `%i[created_at updated_at]` asks for the two Rails keeps rather than the two
26
+ # a record is about. A timestamp named here still lands last, and in that order,
27
+ # whichever way round it was written.
28
+ def recourse_displayed = []
13
29
 
14
- # Return whether the content to display all the models can be cached.
15
- def recourse_cachable?
16
- true
30
+ # Columns holding a counter cache, each mapped to the association it counts. Read
31
+ # from the `belongs_to` on the other side, which is where `counter_cache` is
32
+ # declared: a column merely named `quote_count` is not one of these. Worked out
33
+ # once per class, the way `recourse_listable?` is: associations are declared at
34
+ # load and a reloaded class is a new one, with a memo of its own.
35
+ def recourse_counters
36
+ @recourse_counters ||= reflect_on_all_associations(:has_many).filter_map do |association|
37
+ column = association.inverse_of&.counter_cache_column
38
+ [column, association] if column
39
+ end.to_h
17
40
  end
18
41
 
19
- # @return [Boolean] whether Ransack search attributes are defined on the resource.
20
- def recourse_searchable?
21
- ransackable_attributes.any? || ransackable_associations.any?
42
+ # `ZIP code`: what to call a foreign key pointing here. A form's label, a table's
43
+ # heading and a search prompt all name the same thing, so they name it once.
44
+ def recourse_reference_name
45
+ attribute = Recourse.downcase human_attribute_name(recourse_label)
46
+
47
+ I18n.t 'recourse.reference', model: model_name.human, attribute: attribute
22
48
  end
23
49
 
24
- # @return [Boolean] whether Ransack sort attributes are defined on the resource.
25
- def recourse_sortable?
26
- ransortable_attributes.any?
50
+ # True when the label has a length, so it is short enough to be typed and a
51
+ # form can ask for the value instead of listing every record to pick from.
52
+ def recourse_typed_label?
53
+ validators_on(recourse_label).any? ActiveModel::Validations::LengthValidator
27
54
  end
55
+
56
+ # The belongs_to associations this gem can follow. A polymorphic key names no
57
+ # one table, so nothing can label it, list it, filter by it or search through
58
+ # it — its column reads and edits as the number it holds, like any other.
59
+ def recourse_references = reflect_on_all_associations(:belongs_to).reject(&:polymorphic?)
60
+
61
+ # Associations the index eager-loads, in any shape `includes` accepts. Every
62
+ # belongs_to it can follow, since each cell naming one would be a query of its own.
63
+ def recourse_includes = recourse_references.map(&:name)
64
+
65
+ # How the index sorts its rows, in any shape `order` accepts. By id by default,
66
+ # which is the one column every table has and the order rows were created in.
67
+ def recourse_order = :id
68
+
69
+ # What a column is for, said where the table itself documents it — a form draws it
70
+ # under the field that sets it. Read from the schema, which is the one thing here no
71
+ # validator can answer: a comment has nothing to disagree with. Nil where the schema
72
+ # says nothing, and nil on every adapter that keeps no comments at all — SQLite is
73
+ # one — which is why a host may answer it instead.
74
+ def recourse_comment(column) = columns_hash[column]&.comment
28
75
  end
29
76
  end
30
77
 
31
- ActiveSupport.on_load(:active_record) do
78
+ ActiveSupport.on_load :active_record do
32
79
  extend Recourse::Recoursive
33
80
  end
@@ -0,0 +1,36 @@
1
+ # Reopened for what the routes file said, which several helpers each need read back.
2
+ module Recourse
3
+ # What the routes file said, remembered: which resources were drawn, and which were
4
+ # nested under which.
5
+ # Extended onto `Recourse`, so every one of these is `Recourse.something` wherever
6
+ # it is called from, and every ivar below is that module's own.
7
+ module Registry
8
+ # Records a resource as declared, keeping order and ignoring a repeated draw.
9
+ def declare(name)
10
+ @declared << name.to_s unless @declared.include? name.to_s
11
+ end
12
+
13
+ # Records a resource nested under a parent, in routes.rb order like the sidebar's
14
+ # — which is what the parent record's tabs follow. Both sides are whole controller
15
+ # paths, so a `namespace` drawn between the two is carried rather than guessed at.
16
+ def nest(parent, child)
17
+ children = @nested[parent.to_s] ||= []
18
+ children << child.to_s unless children.include? child.to_s
19
+ @parents[child.to_s] = parent.to_s
20
+ end
21
+
22
+ # The resources nested under one parent path, in the order they were drawn.
23
+ def nested_under(parent)
24
+ @nested.fetch parent.to_s, []
25
+ end
26
+
27
+ # The path a nested resource hangs off, or nil where it hangs off nothing. Read
28
+ # back rather than chopped off the controller's own path: how many segments a
29
+ # nesting added is something the routes knew and a path no longer says.
30
+ def parent_of(child)
31
+ @parents[child.to_s]
32
+ end
33
+ end
34
+
35
+ extend Registry
36
+ end
@@ -0,0 +1,36 @@
1
+ module Recourse
2
+ module Routes
3
+ # What a `recourses` block draws around whatever the host wrote inside it: the
4
+ # square that keeps one of its rows.
5
+ module Nested
6
+ private
7
+
8
+ # What a resource holds: the square that keeps one of its rows, and whatever the
9
+ # host's own block declared — each under the resource's own module, which is what
10
+ # every nested page relies on.
11
+ def draw_within(keepable, block)
12
+ scope module: parent_resource.name do
13
+ draw_bookmark if keepable && addressable_rows?
14
+ instance_exec(&block) if block
15
+ end
16
+ end
17
+
18
+ # Whether this resource has rows to address one at a time. A bookmark names one
19
+ # row, which means nothing for a name with no class behind it at all — and one was
20
+ # drawn anyway, at `/placeholders/:placeholder_id/bookmark`, where nothing linked
21
+ # to it and anything reaching it raised.
22
+ def addressable_rows? = Recourse.model?(parent_resource.name).present?
23
+
24
+ # The row a table's bookmark square writes: one record kept by whoever is looking,
25
+ # at `/places/5/bookmark`. Deliberately not recorded through `Recourse.nest` — it
26
+ # would sit directly under the resource, where a tab and a bare-action button both
27
+ # look, and this is neither. Nothing has to remember where it hangs off either,
28
+ # since the gem drew it: one segment under the resource, always.
29
+ def draw_bookmark
30
+ path = [current_module, 'bookmarks'].compact.join '/'
31
+ Controllers.define_missing path, ::BookmarksController
32
+ resource :bookmark, only: %i[create destroy]
33
+ end
34
+ end
35
+ end
36
+ end