recourse 3.0.3 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +234 -0
- data/README.md +150 -20
- data/app/controllers/bookmarks_controller.rb +7 -0
- data/app/controllers/concerns/recourse/landing.rb +49 -0
- data/app/controllers/concerns/recourse/list_resolution.rb +28 -0
- data/app/controllers/concerns/recourse/paging.rb +17 -0
- data/app/controllers/concerns/recourse/parent_naming.rb +24 -0
- data/app/controllers/concerns/recourse/parent_resolution.rb +63 -0
- data/app/controllers/concerns/recourse/reference_resolution.rb +70 -0
- data/app/controllers/concerns/recourse/resource_resolution.rb +68 -0
- data/app/controllers/concerns/recourse/zoning.rb +29 -0
- data/app/controllers/recourse/base_controller.rb +85 -0
- data/app/controllers/recourse/bookmarks_controller.rb +76 -0
- data/app/controllers/recourses_controller.rb +6 -25
- data/app/javascript/recourse/bookmark_controller.js +100 -0
- data/app/javascript/recourse/clear_controller.js +22 -0
- data/app/javascript/recourse/combobox_controller.js +68 -0
- data/app/javascript/recourse/confirm.js +46 -0
- data/app/javascript/recourse/deselect_controller.js +37 -0
- data/app/javascript/recourse/flash.js +35 -0
- data/app/javascript/recourse/limit_controller.js +23 -0
- data/app/javascript/recourse/phone_controller.js +33 -0
- data/app/javascript/recourse/relative_time_controller.js +45 -0
- data/app/javascript/recourse/reveal_controller.js +16 -0
- data/app/javascript/recourse/scheme_controller.js +51 -0
- data/app/javascript/recourse/search_controller.js +78 -0
- data/app/javascript/recourse/shortcuts_controller.js +42 -0
- data/app/javascript/recourse/timezone_controller.js +27 -0
- data/app/javascript/recourse/toast_controller.js +35 -0
- data/app/javascript/recourse/tooltip_controller.js +16 -0
- data/app/javascript/recourse/written.js +29 -0
- data/app/javascript/recourse/written_controller.js +28 -0
- data/app/views/layouts/recourses.html.erb +442 -0
- data/app/views/recourses/_breadcrumb.html.erb +35 -0
- data/app/views/recourses/_card.html.erb +33 -0
- data/app/views/recourses/_color.html.erb +20 -0
- data/app/views/recourses/_combobox.html.erb +68 -0
- data/app/views/recourses/_confirm.html.erb +14 -0
- data/app/views/recourses/_fields.html.erb +3 -0
- data/app/views/recourses/_flash.html.erb +26 -0
- data/app/views/recourses/_footer.html.erb +22 -0
- data/app/views/recourses/_form.html.erb +11 -0
- data/app/views/recourses/_none.html.erb +1 -0
- data/app/views/recourses/_results.html.erb +10 -0
- data/app/views/recourses/_row.html.erb +4 -10
- data/app/views/recourses/_scheme.html.erb +14 -0
- data/app/views/recourses/_search.html.erb +24 -0
- data/app/views/recourses/_sidebar.html.erb +40 -0
- data/app/views/recourses/_table.html.erb +47 -42
- data/app/views/recourses/_values.html.erb +3 -0
- data/app/views/recourses/edit.html.erb +10 -0
- data/app/views/recourses/index.html.erb +27 -24
- data/app/views/recourses/new.html.erb +3 -0
- data/app/views/recourses/show.html.erb +10 -0
- data/config/locales/recourse.en.yml +72 -0
- data/lib/recourse/bookmarks.rb +51 -0
- data/lib/recourse/broadcasting.rb +33 -0
- data/lib/recourse/colors.rb +32 -0
- data/lib/recourse/columns.rb +80 -0
- data/lib/recourse/controllers.rb +26 -0
- data/lib/recourse/engine.rb +33 -13
- data/lib/recourse/helpers/actions.rb +56 -0
- data/lib/recourse/helpers/bookmarks.rb +88 -0
- data/lib/recourse/helpers/buttons.rb +65 -0
- data/lib/recourse/helpers/cards.rb +67 -0
- data/lib/recourse/helpers/cells.rb +89 -0
- data/lib/recourse/helpers/choices.rb +49 -0
- data/lib/recourse/helpers/colors.rb +20 -0
- data/lib/recourse/helpers/comboboxes.rb +87 -0
- data/lib/recourse/helpers/constraints.rb +92 -0
- data/lib/recourse/helpers/counters.rb +75 -0
- data/lib/recourse/helpers/deletions.rb +87 -0
- data/lib/recourse/helpers/details.rb +41 -0
- data/lib/recourse/helpers/examples.rb +35 -0
- data/lib/recourse/helpers/fields.rb +78 -0
- data/lib/recourse/helpers/filters.rb +91 -0
- data/lib/recourse/helpers/formats.rb +94 -0
- data/lib/recourse/helpers/inputs.rb +92 -0
- data/lib/recourse/helpers/kinds.rb +87 -0
- data/lib/recourse/helpers/limits.rb +37 -0
- data/lib/recourse/helpers/names.rb +44 -0
- data/lib/recourse/helpers/navigation.rb +75 -0
- data/lib/recourse/helpers/parents.rb +82 -0
- data/lib/recourse/helpers/pictures.rb +33 -0
- data/lib/recourse/helpers/references.rb +99 -0
- data/lib/recourse/helpers/refreshes.rb +27 -0
- data/lib/recourse/helpers/resources.rb +47 -0
- data/lib/recourse/helpers/routing.rb +28 -0
- data/lib/recourse/helpers/rows.rb +33 -0
- data/lib/recourse/helpers/schemes.rb +14 -0
- data/lib/recourse/helpers/searches.rb +57 -0
- data/lib/recourse/helpers/shortcuts.rb +30 -0
- data/lib/recourse/helpers/sidebars.rb +39 -0
- data/lib/recourse/helpers/sorts.rb +62 -0
- data/lib/recourse/helpers/tabs.rb +82 -0
- data/lib/recourse/helpers/times.rb +43 -0
- data/lib/recourse/helpers/values.rb +64 -0
- data/lib/recourse/helpers/zones.rb +38 -0
- data/lib/recourse/helpers.rb +81 -0
- data/lib/recourse/icons.rb +21 -0
- data/lib/recourse/limits.rb +12 -0
- data/lib/recourse/recoursive.rb +66 -19
- data/lib/recourse/registry.rb +36 -0
- data/lib/recourse/routes/nested.rb +36 -0
- data/lib/recourse/routes.rb +57 -0
- data/lib/recourse/routing.rb +11 -32
- data/lib/recourse/schemes.rb +6 -0
- data/lib/recourse/scopes.rb +37 -0
- data/lib/recourse/search.rb +65 -0
- data/lib/recourse/searchable/columns.rb +99 -0
- data/lib/recourse/searchable/filters.rb +58 -0
- data/lib/recourse/searchable/terms.rb +56 -0
- data/lib/recourse/searchable.rb +47 -22
- data/lib/recourse/titles.rb +56 -0
- data/lib/recourse/version.rb +2 -1
- data/lib/recourse/writes.rb +12 -0
- data/lib/recourse/zones.rb +6 -0
- data/lib/recourse.rb +79 -7
- data/vendor/recourse/bootstrap-icons.min.css +5 -0
- data/vendor/recourse/bootstrap.bundle.min.js +9 -0
- data/vendor/recourse/bootstrap.min.css +2 -0
- data/vendor/recourse/fonts/bootstrap-icons.woff +0 -0
- data/vendor/recourse/fonts/bootstrap-icons.woff2 +0 -0
- data/vendor/recourse/stimulus.js +2563 -0
- metadata +169 -30
- data/Rakefile +0 -6
- data/app/helpers/navigable_helper.rb +0 -35
- data/app/helpers/recoursive_helper.rb +0 -10
- data/app/helpers/searchable_helper.rb +0 -54
- data/db/migrate/20260323234318_add_baby_to_posts.rb +0 -5
|
@@ -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
|
data/lib/recourse/recoursive.rb
CHANGED
|
@@ -1,33 +1,80 @@
|
|
|
1
|
-
|
|
1
|
+
require 'active_support'
|
|
2
|
+
|
|
2
3
|
module Recourse
|
|
3
|
-
#
|
|
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
|
-
#
|
|
6
|
-
def
|
|
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
|
-
#
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
#
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
#
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
#
|
|
25
|
-
|
|
26
|
-
|
|
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
|
|
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
|