recourse 7.2.1 → 7.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b5902f16def7afce17cc0aec2d67eb1255ffc504af58cbd766146a4f3e7f3077
4
- data.tar.gz: 3e917516c8a76bdc0fe30497e02d7181a886d96f500cd765dfbe6202f4785452
3
+ metadata.gz: da821317fa8c02a37911dd03b62aaeb072bb3cacbc40cfb15c29eb51c3ffbd8b
4
+ data.tar.gz: 276e8d9570b080d81dbefc6b304ba6d4a169e783191587cbe534059a2ccd3c0b
5
5
  SHA512:
6
- metadata.gz: d1a6d35fa1bd005290644f5f8981ac7a7837fda2f6bb9919b3225518ab41bacdf497d261827116b43928b1cf1f918eea3b5030de38faed3bd195aa257db6f798
7
- data.tar.gz: e242d7c656755f01f53c1e2f3c1f9b75c89bbc5654b50a3dc44e6bb7f768c64321572c024e7d52fe036dcfc9c65bed2eaee7464815617e2294bfcbecc64350dc
6
+ metadata.gz: 172bd06e4fc69264846c29ed4c12123290aab2532c98033076c1fce4f0ab7829931cfeac6d16e16765c7d25117eec969f624b2fd3a66120878d85fc3f56f57ee
7
+ data.tar.gz: f7ab6fd22033642867259dc840ec3da125c9914c08af2a9617a72ebf67fa4cf7e70c7b5930f928ef8bac45855449111621ef4565b323da2631958e5a9af70392
data/CHANGELOG.md CHANGED
@@ -7,6 +7,31 @@ For more information about changelogs, check [Keep a Changelog](http://keepachan
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## 7.4.0 - 2026-09-20
11
+
12
+ * [CHANGE] A singular resource that has its record reads it rather than offering a form
13
+
14
+ `new` drew a form for a second record where the parent already kept one. At most one is
15
+ what singular means, so the reader is sent to the page that reads it — the mirror of the
16
+ redirect that already sent them to `new` when there was none yet. Before the action
17
+ rather than inside it, so a host writing its own `new` gets it too.
18
+
19
+ * [FIX] Every file in the gem is under a hundred lines again
20
+
21
+ Seven of them had grown past it. Each split along a seam it already had: web addresses
22
+ out of `Formats`, the trail out of `Navigation`, the counter lookups into `Counters`,
23
+ what a form sent into `ParameterResolution`, and which columns a screen uses apart from
24
+ what order they read in.
25
+
26
+ ## 7.3.0 - 2026-09-20
27
+
28
+ * [CHANGE] A boolean reads as the word a reader answers with
29
+
30
+ A cell and a value read `true` and `false`, which is what the database keeps and not
31
+ what anybody asked. Both now read `Yes` and `No` — the two the filter menu beside the
32
+ column already offered — and a nullable column nobody answered reads as the dash every
33
+ other empty value reads as, rather than as a no.
34
+
10
35
  ## 7.2.1 - 2026-09-20
11
36
 
12
37
  * [FIX] A refused change keeps the card it was made in
@@ -0,0 +1,31 @@
1
+ module Recourse
2
+ # Resolves what a form sent: the attributes a record may be written with, read back
3
+ # from what the route names and what the model lets a screen set.
4
+ module ParameterResolution
5
+ private
6
+
7
+ # What a form may submit: every column a user may set, less the ones the model
8
+ # keeps off its screens. The other thing a host overrides, and for the same
9
+ # reason as `recourse_relation` — a form of its own asks for what it asks for,
10
+ # which may be a hidden column or an attribute that is no column at all:
11
+ # `def resource_params = params.expect(provider: %i[name cid])`.
12
+ def resource_params
13
+ # The parent is merged after resolving, so the one the route names is never
14
+ # mistaken for a label; the files go no further than the permit that let them
15
+ # through, being attached rather than assigned.
16
+ submitted_attributes.except(*Recourse.attachment_names(resource_class))
17
+ .merge parent_columns
18
+ end
19
+
20
+ # What the form sent, with a typed reference read back as the id it names. A bare
21
+ # `Create` submits no attributes at all, so the key may be absent: the parent a
22
+ # nested route names is everything such a record starts from.
23
+ def submitted_attributes
24
+ permitted = Recourse.editable_columns(resource_class) + attachment_filters
25
+ key = controller_name.singularize.to_sym
26
+ return {} unless params.key? key
27
+
28
+ resolve_lists resolve_references(params.expect(key => permitted))
29
+ end
30
+ end
31
+ end
@@ -33,6 +33,18 @@ module Recourse
33
33
  redirect_to url_for(action: :new) if Recourse.routed? controller_path, 'new'
34
34
  end
35
35
 
36
+ # And the other way, before a form is drawn: a singular resource holds at most one,
37
+ # so where the parent already keeps it there is nothing for `new` to make and the
38
+ # page that reads it answers instead. The mirror of `missing_record`, which sends a
39
+ # reader here when there is none yet. Where no `show` is drawn the form stands, that
40
+ # being the only page the routes gave this record.
41
+ def redirect_to_existing_record
42
+ return unless singular_reflection && Recourse.routed?(controller_path, 'show')
43
+ return unless @recourse_parent.association(singular_reflection.name).reader
44
+
45
+ redirect_to url_for(action: :show)
46
+ end
47
+
36
48
  def assign(record)
37
49
  @recourse = record
38
50
  instance_variable_set "@#{controller_name.singularize}", record
@@ -76,29 +88,5 @@ module Recourse
76
88
 
77
89
  resource_class.model_name.human
78
90
  end
79
-
80
- # What a form may submit: every column a user may set, less the ones the model
81
- # keeps off its screens. The other thing a host overrides, and for the same
82
- # reason as `recourse_relation` — a form of its own asks for what it asks for,
83
- # which may be a hidden column or an attribute that is no column at all:
84
- # `def resource_params = params.expect(provider: %i[name cid])`.
85
- def resource_params
86
- # The parent is merged after resolving, so the one the route names is never
87
- # mistaken for a label; the files go no further than the permit that let them
88
- # through, being attached rather than assigned.
89
- submitted_attributes.except(*Recourse.attachment_names(resource_class))
90
- .merge parent_columns
91
- end
92
-
93
- # What the form sent, with a typed reference read back as the id it names. A bare
94
- # `Create` submits no attributes at all, so the key may be absent: the parent a
95
- # nested route names is everything such a record starts from.
96
- def submitted_attributes
97
- permitted = Recourse.editable_columns(resource_class) + attachment_filters
98
- key = controller_name.singularize.to_sym
99
- return {} unless params.key? key
100
-
101
- resolve_lists resolve_references(params.expect(key => permitted))
102
- end
103
91
  end
104
92
  end
@@ -4,7 +4,7 @@ module Recourse
4
4
  # with a `before_action :authenticate!` guards every screen the gem serves.
5
5
  class BaseController < ApplicationController
6
6
  include Pagy::Method, Positioned, AttachmentResolution, AttachmentWriting,
7
- Landing, Paging, ListResolution, ParentNaming,
7
+ Landing, Paging, ListResolution, ParameterResolution, ParentNaming,
8
8
  ParentResolution, ReferenceResolution, ResourceResolution,
9
9
  Weeks, Zoning
10
10
 
@@ -13,6 +13,10 @@ module Recourse
13
13
  # `find` raises RecordNotFound, so an id that names nothing answers 404.
14
14
  before_action :find_resource, only: %i[show edit update destroy]
15
15
 
16
+ # Before the form rather than inside `new`, so a host that writes its own still
17
+ # sends a reader to the record a singular resource already has.
18
+ before_action :redirect_to_existing_record, only: :new
19
+
16
20
  # The model behind the page, assigned once, and only where the route names one.
17
21
  before_action { @recourse_model = resource_class if resource_model? }
18
22
 
@@ -0,0 +1,80 @@
1
+ # Reopened for the order a row reads in, which a table, a show page and a form all ask
2
+ # for the same way.
3
+ module Recourse
4
+ # Which part of a row a column belongs to. What a column holds is the gem's to know
5
+ # and where the schema put it is the host's, so both have a say: the kind picks the
6
+ # band, and the order inside the band is the one the table already has. Extended onto
7
+ # `Recourse`, so this is `Recourse.ordered` wherever it is called from.
8
+ module Bands
9
+ # The bands, in the order a row reads. What kind of row this is and what state it is
10
+ # in, whose it is, what it says, its flags, the long values a narrow column suits
11
+ # least, when it happened, the two Rails keeps — and last of all the counts, which
12
+ # say nothing about the row itself, only how much hangs off it. So they close the
13
+ # row at the far edge rather than opening it beside the buttons they resemble.
14
+ #
15
+ # A flag follows what the row says rather than leading it. A yes or a no is narrow
16
+ # enough to have led on width alone, but it reads as a note *about* the row, and a
17
+ # reader scanning a table is looking for the name it belongs to first.
18
+ BANDS = %i[state reference scalar boolean long date timestamp counter].freeze
19
+
20
+ # Values that are paragraphs rather than words, under every name an adapter has for
21
+ # them: PostgreSQL reports `jsonb` where SQLite and MySQL report `json`.
22
+ LONG_KINDS = %i[text json jsonb].freeze
23
+
24
+ # And the ones that are a point in time, whichever part of one they keep.
25
+ DATE_KINDS = %i[date datetime time].freeze
26
+
27
+ # Whether a column holds a list of values rather than one. A PostgreSQL array
28
+ # reports a type wrapping a subtype, and so does a `serialize` of an Array; asked
29
+ # that way rather than by an adapter's own class, which only exists where that
30
+ # adapter is loaded. But wrapping a subtype is not enough on its own: an enum and a
31
+ # range always answered the same way, and since Rails 8.2 so does every decorator
32
+ # a column may wear — time zone conversion on a timestamp, `normalizes`, a lock. What
33
+ # sets a list apart is that its value is changed in place, which `Mutable` marks and
34
+ # none of those are.
35
+ def list_column?(model, column)
36
+ type = model.type_for_attribute column
37
+
38
+ type.respond_to?(:subtype) && type.is_a?(ActiveModel::Type::Helpers::Mutable)
39
+ end
40
+
41
+ # The columns of a model in the order a row reads them, given whichever of them the
42
+ # caller is drawing. Grouped rather than sorted: `group_by` keeps the order it was
43
+ # given inside each group, which is what leaves an order the schema already carries
44
+ # standing, and `sort_by` would not — Ruby's sort is not stable.
45
+ def ordered(model, names)
46
+ keys = reference_keys model
47
+
48
+ names.group_by { |name| BANDS.index band(model, name, keys) }.sort.flat_map(&:last)
49
+ end
50
+
51
+ private
52
+
53
+ # Asked in the order that settles it. A counter is one whatever it is stored as; the
54
+ # column Rails keeps a subclass in says what kind of row this is, as an enum says
55
+ # what state it is in; and a key is an integer, so it has to be recognised as a key
56
+ # before its type is asked about at all.
57
+ def band(model, name, keys)
58
+ return :counter if Recourse.counters(model).key? name
59
+ return :state if name == model.inheritance_column || model.defined_enums.key?(name)
60
+ return :timestamp if TIMESTAMPS.include? name
61
+ return :reference if keys.include? name
62
+
63
+ band_of model.type_for_attribute(name).type
64
+ end
65
+
66
+ def band_of(kind)
67
+ return :boolean if kind == :boolean
68
+ return :long if LONG_KINDS.include? kind
69
+ return :date if DATE_KINDS.include? kind
70
+
71
+ :scalar
72
+ end
73
+
74
+ def reference_keys(model)
75
+ model.recourse_references.map { |reference| reference.foreign_key.to_s }
76
+ end
77
+ end
78
+
79
+ extend Bands
80
+ end
@@ -1,5 +1,5 @@
1
- # Reopened for the order a row reads in, which a table, a show page and a form all ask
2
- # for the same way.
1
+ # Reopened for which columns a screen uses at all, which the table, the show page and
2
+ # the form each ask before they ask what order to read them in.
3
3
  module Recourse
4
4
  # Columns the database writes itself out of the others, which a form never offers: a
5
5
  # stored generated column takes no value, and Postgres refuses the one a form would send.
@@ -9,80 +9,30 @@ module Recourse
9
9
  model.columns.select(&:virtual?).map(&:name)
10
10
  end
11
11
 
12
- # Which part of a row a column belongs to. What a column holds is the gem's to know
13
- # and where the schema put it is the host's, so both have a say: the kind picks the
14
- # band, and the order inside the band is the one the table already has. Extended onto
15
- # `Recourse`, so this is `Recourse.ordered` wherever it is called from.
16
- module Columns
17
- # The bands, in the order a row reads. What kind of row this is and what state it is
18
- # in, whose it is, what it says, its flags, the long values a narrow column suits
19
- # least, when it happened, the two Rails keeps — and last of all the counts, which
20
- # say nothing about the row itself, only how much hangs off it. So they close the
21
- # row at the far edge rather than opening it beside the buttons they resemble.
22
- #
23
- # A flag follows what the row says rather than leading it. A yes or a no is narrow
24
- # enough to have led on width alone, but it reads as a note *about* the row, and a
25
- # reader scanning a table is looking for the name it belongs to first.
26
- BANDS = %i[state reference scalar boolean long date timestamp counter].freeze
27
-
28
- # Values that are paragraphs rather than words, under every name an adapter has for
29
- # them: PostgreSQL reports `jsonb` where SQLite and MySQL report `json`.
30
- LONG_KINDS = %i[text json jsonb].freeze
31
-
32
- # And the ones that are a point in time, whichever part of one they keep.
33
- DATE_KINDS = %i[date datetime time].freeze
34
-
35
- # Whether a column holds a list of values rather than one. A PostgreSQL array
36
- # reports a type wrapping a subtype, and so does a `serialize` of an Array; asked
37
- # that way rather than by an adapter's own class, which only exists where that
38
- # adapter is loaded. But wrapping a subtype is not enough on its own: an enum and a
39
- # range always answered the same way, and since Rails 8.2 so does every decorator
40
- # a column may wear — time zone conversion on a timestamp, `normalizes`, a lock. What
41
- # sets a list apart is that its value is changed in place, which `Mutable` marks and
42
- # none of those are.
43
- def list_column?(model, column)
44
- type = model.type_for_attribute column
45
-
46
- type.respond_to?(:subtype) && type.is_a?(ActiveModel::Type::Helpers::Mutable)
47
- end
48
-
49
- # The columns of a model in the order a row reads them, given whichever of them the
50
- # caller is drawing. Grouped rather than sorted: `group_by` keeps the order it was
51
- # given inside each group, which is what leaves an order the schema already carries
52
- # standing, and `sort_by` would not — Ruby's sort is not stable.
53
- def ordered(model, names)
54
- keys = reference_keys model
55
-
56
- names.group_by { |name| BANDS.index band(model, name, keys) }.sort.flat_map(&:last)
57
- end
58
-
59
- private
60
-
61
- # Asked in the order that settles it. A counter is one whatever it is stored as; the
62
- # column Rails keeps a subclass in says what kind of row this is, as an enum says
63
- # what state it is in; and a key is an integer, so it has to be recognised as a key
64
- # before its type is asked about at all.
65
- def band(model, name, keys)
66
- return :counter if Recourse.counters(model).key? name
67
- return :state if name == model.inheritance_column || model.defined_enums.key?(name)
68
- return :timestamp if TIMESTAMPS.include? name
69
- return :reference if keys.include? name
70
-
71
- band_of model.type_for_attribute(name).type
72
- end
73
-
74
- def band_of(kind)
75
- return :boolean if kind == :boolean
76
- return :long if LONG_KINDS.include? kind
77
- return :date if DATE_KINDS.include? kind
78
-
79
- :scalar
80
- end
12
+ # Columns a user may set: the form offers these, the show page reads these out, and
13
+ # `create` permits these. A counter cache is none of a user's business Rails keeps
14
+ # it, so a form that offered one would let it be typed over, and neither is a column
15
+ # the database generates: it takes no value at all.
16
+ def self.editable_columns(model)
17
+ ordered model, model.column_names - ['id', *TIMESTAMPS] - counters(model).keys -
18
+ hidden_columns(model) - virtual_columns(model)
19
+ end
81
20
 
82
- def reference_keys(model)
83
- model.recourse_references.map { |reference| reference.foreign_key.to_s }
84
- end
21
+ # Columns no screen shows: whatever the model asked to hide through `recourse_hidden`
22
+ # one name or a list, taken either way — the column Rails reserves for single table
23
+ # inheritance, and the place a row holds where somebody positioned the table. A class
24
+ # name is machinery rather than something to read out, and a position is set by
25
+ # dragging the row rather than typed beside it.
26
+ def self.hidden_columns(model)
27
+ Array(model.recourse_hidden).map(&:to_s) +
28
+ [model.inheritance_column, *position_columns(model)]
85
29
  end
86
30
 
87
- extend Columns
31
+ # The names a column is validated under: its own, and — where it is a foreign key
32
+ # — the association's, since `belongs_to` validates the record it points at rather
33
+ # than the number pointing there. Two questions where a column is a key, one
34
+ # everywhere else.
35
+ def self.validated_names(column)
36
+ [column, column.delete_suffix('_id')].uniq
37
+ end
88
38
  end
@@ -0,0 +1,58 @@
1
+ module Recourse
2
+ module Helpers
3
+ # The trail across the navbar: what each crumb reads, and which of them lead
4
+ # anywhere.
5
+ module Breadcrumbs
6
+ private
7
+
8
+ # Trail to the current page as [resource, title, path] triples, opening with
9
+ # the parent a nested page sits under; a nil path is not a link.
10
+ def resource_breadcrumbs
11
+ crumbs = parent_breadcrumbs
12
+ leaf = breadcrumb_leaf
13
+ here = controller.controller_path
14
+ return crumbs << [here, breadcrumb_name, nil] unless leaf
15
+
16
+ crumbs << [here, breadcrumb_name, index_url] << [nil, leaf, nil]
17
+ end
18
+
19
+ # What the crumb naming this resource reads: its plural, or its singular where
20
+ # the routes drew one record rather than a list. Rails routes a singular resource
21
+ # to a plural controller, so the path says `properties` for the one property a
22
+ # location keeps -- and the crumb over it would read `HouseCanaries` for a page
23
+ # there is only ever one of. The same word the tab leading here took, from the
24
+ # same place, since the two stand for one page.
25
+ def breadcrumb_name
26
+ return resources_name unless idless_route? controller.controller_path, 'show'
27
+
28
+ Recourse.known_singular controller.controller_name
29
+ end
30
+
31
+ # Where this resource's index is, or nil where it has none: a singular resource
32
+ # is one record reached with no id, so there is no list of it to go back to and
33
+ # the crumb naming it is read out rather than linked.
34
+ def index_url
35
+ url_for action: :index if routed_action? 'index'
36
+ end
37
+
38
+ # A crumb's words, which a phone drops where the icon stands for them: the row at
39
+ # the top has a search box and a button to fit beside the trail.
40
+ def crumb_label(resource, title)
41
+ icon = Recourse.known_icon resource
42
+ return title unless icon
43
+
44
+ word = tag.span title, class: 'recourse-crumb-word'
45
+
46
+ safe_join [tag.i(class: "bi bi-#{icon}"), word], ' '
47
+ end
48
+
49
+ # Only a page beneath the index names itself, and names what it is showing.
50
+ def breadcrumb_leaf
51
+ case controller.action_name
52
+ when 'new', 'create' then t 'recourse.new', model: resource_name
53
+ when 'show', 'edit', 'update' then resource_record_label
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -68,7 +68,7 @@ module Recourse
68
68
  # `Created`, not `Created at`, since when it happened is what the column holds.
69
69
  def dated_title(column)
70
70
  title = resource_model.human_attribute_name column
71
- dated = column.end_with?('_at', '_on') && Columns::DATE_KINDS.include?(attribute_type(column))
71
+ dated = column.end_with?('_at', '_on') && Bands::DATE_KINDS.include?(attribute_type(column))
72
72
 
73
73
  dated ? title.sub(/ (at|on)\z/, '') : title
74
74
  end
@@ -74,6 +74,24 @@ module Recourse
74
74
 
75
75
  safe_join [icon, tag.span(title, class: 'recourse-counter-word')]
76
76
  end
77
+
78
+ # The counter a tab reads. A `has_many through:` keeps none of its own, and the
79
+ # join it goes through usually does: a market's ZIPs are reached through its
80
+ # territories, and `territories_count` is the number of them it holds. The figure
81
+ # is of the join rows, which is what the record actually keeps a count of.
82
+ def counter_column_of(model, association)
83
+ counter_of(model, association) || counter_of(model, join_of(model, association))
84
+ end
85
+
86
+ def counter_of(model, association)
87
+ Recourse.counters(model).find { |_, one| one == association }&.first if association
88
+ end
89
+
90
+ def join_of(model, association)
91
+ name = association.options[:through]
92
+
93
+ model.reflect_on_association name if name
94
+ end
77
95
  end
78
96
  end
79
97
  end
@@ -2,11 +2,6 @@ module Recourse
2
2
  module Helpers
3
3
  # How one attribute reads on a page that only reads it.
4
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
5
  private
11
6
 
12
7
  # What the record says for one column, formatted by what the column holds.
@@ -18,12 +13,13 @@ module Recourse
18
13
  end
19
14
 
20
15
  # 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
16
+ # cell and a show page's value both come down, so a boolean is the same word
22
17
  # and an enum the same badge on either. A block, where the caller has one,
23
18
  # marks the search terms inside whichever arm ends up as words.
24
19
  def formatted_attribute(column, value, &)
25
20
  kind = attribute_kind column
26
21
  return listed Array(value) if kind == :list
22
+ return affirmed value if kind == :boolean
27
23
  return formatted_number kind, column, value if numeric_kind? kind
28
24
 
29
25
  formatted_text kind, value, &
@@ -51,6 +47,15 @@ module Recourse
51
47
  end
52
48
  end
53
49
 
50
+ # The word a reader answers with, which is the word the filter beside the column
51
+ # already offers. A column that was never answered holds neither, so it reads as
52
+ # the dash every other empty value does rather than as a no.
53
+ def affirmed(value)
54
+ return if value.nil?
55
+
56
+ t value ? 'recourse.affirmative' : 'recourse.negative'
57
+ end
58
+
54
59
  # One whole web address is a value to follow rather than to read, and anything
55
60
  # else is words — which the caller may have marking of its own for.
56
61
  def linked_or_marked(value, &)
@@ -75,25 +80,6 @@ module Recourse
75
80
  def phone_span(value) = value && tag.span(value, data: { controller: 'phone' })
76
81
 
77
82
  def enum_badge(value) = tag.span(value, class: 'badge')
78
-
79
- def web_url?(value) = value.is_a?(String) && value.match?(WEB_URL)
80
-
81
- # The words lead where the value points, in this tab, the way any link does; the
82
- # arrow after them — Bootstrap's icon link, stepping under the cursor — opens the
83
- # same address in a new tab. Either reads as the host, and an ellipsis where the
84
- # address goes further: a path is how a machine finds the page, and the href has it.
85
- def url_link(value)
86
- host, rest = value.match(WEB_URL).captures
87
- said = rest.delete_suffix('/').empty? ? host : "#{host}/…"
88
-
89
- safe_join [tag.a(said, href: value), new_tab_arrow(value)], ' '
90
- end
91
-
92
- def new_tab_arrow(value)
93
- tag.a icon_tag(:point_right), href: value, target: '_blank', rel: 'noopener',
94
- class: 'icon-link icon-link-hover',
95
- aria: { label: t('recourse.new_tab') }
96
- end
97
83
  end
98
84
  end
99
85
  end
@@ -0,0 +1,33 @@
1
+ module Recourse
2
+ module Helpers
3
+ # A value that is a web address rather than words: how one is recognised, and how
4
+ # it reads once it is.
5
+ module Links
6
+ # One absolute web address and nothing else: a value to follow, not to read.
7
+ # Anything around it — words, a second address — reads as text instead. What it
8
+ # captures is what a link says: the host, less any `www.`, and whatever follows.
9
+ WEB_URL = %r{\Ahttps?://(?:www\.)?([^/?#\s]+)(\S*)\z}
10
+
11
+ private
12
+
13
+ def web_url?(value) = value.is_a?(String) && value.match?(WEB_URL)
14
+
15
+ # The words lead where the value points, in this tab, the way any link does; the
16
+ # arrow after them — Bootstrap's icon link, stepping under the cursor — opens the
17
+ # same address in a new tab. Either reads as the host, and an ellipsis where the
18
+ # address goes further: a path is how a machine finds the page, and the href has it.
19
+ def url_link(value)
20
+ host, rest = value.match(WEB_URL).captures
21
+ said = rest.delete_suffix('/').empty? ? host : "#{host}/…"
22
+
23
+ safe_join [tag.a(said, href: value), new_tab_arrow(value)], ' '
24
+ end
25
+
26
+ def new_tab_arrow(value)
27
+ tag.a icon_tag(:point_right), href: value, target: '_blank', rel: 'noopener',
28
+ class: 'icon-link icon-link-hover',
29
+ aria: { label: t('recourse.new_tab') }
30
+ end
31
+ end
32
+ end
33
+ end
@@ -1,39 +1,9 @@
1
1
  module Recourse
2
2
  module Helpers
3
- # Helpers for the navbar: the trail across it, and the links and forms it draws.
3
+ # The links and forms the navbar draws, and where each of them goes.
4
4
  module Navigation
5
5
  private
6
6
 
7
- # Trail to the current page as [resource, title, path] triples, opening with
8
- # the parent a nested page sits under; a nil path is not a link.
9
- def resource_breadcrumbs
10
- crumbs = parent_breadcrumbs
11
- leaf = breadcrumb_leaf
12
- here = controller.controller_path
13
- return crumbs << [here, breadcrumb_name, nil] unless leaf
14
-
15
- crumbs << [here, breadcrumb_name, index_url] << [nil, leaf, nil]
16
- end
17
-
18
- # What the crumb naming this resource reads: its plural, or its singular where
19
- # the routes drew one record rather than a list. Rails routes a singular resource
20
- # to a plural controller, so the path says `properties` for the one property a
21
- # location keeps -- and the crumb over it would read `HouseCanaries` for a page
22
- # there is only ever one of. The same word the tab leading here took, from the
23
- # same place, since the two stand for one page.
24
- def breadcrumb_name
25
- return resources_name unless idless_route? controller.controller_path, 'show'
26
-
27
- Recourse.known_singular controller.controller_name
28
- end
29
-
30
- # Where this resource's index is, or nil where it has none: a singular resource
31
- # is one record reached with no id, so there is no list of it to go back to and
32
- # the crumb naming it is read out rather than linked.
33
- def index_url
34
- url_for action: :index if routed_action? 'index'
35
- end
36
-
37
7
  # A link out of a table. Every cell is inside the results frame, and the page a
38
8
  # cell links to has no frame of that name, so Turbo would replace the table with
39
9
  # `Content missing` rather than leaving the page. `_top` is what leaves it.
@@ -77,25 +47,6 @@ module Recourse
77
47
 
78
48
  safe_join [tag.i(class: "bi bi-#{icon}"), tag.span(name, class: 'recourse-nav-word')], ' '
79
49
  end
80
-
81
- # The same for a crumb, whose words a phone drops where the icon stands for them:
82
- # the row at the top has a search box and a button to fit beside the trail.
83
- def crumb_label(resource, title)
84
- icon = Recourse.known_icon resource
85
- return title unless icon
86
-
87
- word = tag.span title, class: 'recourse-crumb-word'
88
-
89
- safe_join [tag.i(class: "bi bi-#{icon}"), word], ' '
90
- end
91
-
92
- # Only a page beneath the index names itself, and names what it is showing.
93
- def breadcrumb_leaf
94
- case controller.action_name
95
- when 'new', 'create' then t 'recourse.new', model: resource_name
96
- when 'show', 'edit', 'update' then resource_record_label
97
- end
98
- end
99
50
  end
100
51
  end
101
52
  end
@@ -90,24 +90,6 @@ module Recourse
90
90
  def namespace_words(namespace)
91
91
  namespace.map { |segment| Recourse.downcase segment.humanize }.join ' '
92
92
  end
93
-
94
- # The counter a tab reads. A `has_many through:` keeps none of its own, and the
95
- # join it goes through usually does: a market's ZIPs are reached through its
96
- # territories, and `territories_count` is the number of them it holds. The figure
97
- # is of the join rows, which is what the record actually keeps a count of.
98
- def counter_column_of(model, association)
99
- counter_of(model, association) || counter_of(model, join_of(model, association))
100
- end
101
-
102
- def counter_of(model, association)
103
- Recourse.counters(model).find { |_, one| one == association }&.first if association
104
- end
105
-
106
- def join_of(model, association)
107
- name = association.options[:through]
108
-
109
- model.reflect_on_association name if name
110
- end
111
93
  end
112
94
  end
113
95
  end
@@ -18,7 +18,7 @@ module Recourse
18
18
  end
19
19
 
20
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
21
+ # boolean says something either way, and a word says it, so only a value that
22
22
  # formats to nothing at all reads as nothing.
23
23
  def resource_value(column)
24
24
  value = formatted_value column
@@ -3,6 +3,7 @@ require_relative 'helpers/positions'
3
3
  require_relative 'helpers/attachments'
4
4
  require_relative 'helpers/blobs'
5
5
  require_relative 'helpers/bookmarks'
6
+ require_relative 'helpers/breadcrumbs'
6
7
  require_relative 'helpers/buttons'
7
8
  require_relative 'helpers/calendars'
8
9
  require_relative 'helpers/cards'
@@ -24,6 +25,7 @@ require_relative 'helpers/formats'
24
25
  require_relative 'helpers/inputs'
25
26
  require_relative 'helpers/kinds'
26
27
  require_relative 'helpers/limits'
28
+ require_relative 'helpers/links'
27
29
  require_relative 'helpers/maps'
28
30
  require_relative 'helpers/names'
29
31
  require_relative 'helpers/navigation'
@@ -51,14 +53,13 @@ require_relative 'helpers/zones'
51
53
  module Recourse
52
54
  # View helpers for the pages the gem renders, and what the parts share.
53
55
  module Helpers
54
- include Actions, Positions, Attachments, Blobs, Bookmarks, Buttons, Calendars, Cards,
55
- Cells, Choices, Colors, Comboboxes, Constraints, Counters, Deletions, Densities,
56
- Details,
57
- Events, Examples, Fields, Filters, Flashes, Formats, Inputs, Kinds, Limits, Maps,
58
- Names, Navigation, Parents, Pictures, Previews, References, Refreshes,
59
- Routing,
60
- Resources, Rows, Searches, Shapes, Shortcuts, Sidebars, Sorts, Spans, Tabs,
61
- Themes, Times, Values, Weeks, Zones
56
+ include Actions, Positions, Attachments, Blobs, Bookmarks, Breadcrumbs, Buttons,
57
+ Calendars, Cards, Cells, Choices, Colors, Comboboxes, Constraints, Counters,
58
+ Deletions, Densities, Details, Events, Examples, Fields, Filters, Flashes,
59
+ Formats, Inputs, Kinds, Limits, Links, Maps, Names, Navigation, Parents,
60
+ Pictures, Previews, References, Refreshes, Routing, Resources, Rows, Searches,
61
+ Shapes, Shortcuts, Sidebars, Sorts, Spans, Tabs, Themes, Times, Values, Weeks,
62
+ Zones
62
63
 
63
64
  # The grid a record's own two pages lay an attribute out in: two columns on a large
64
65
  # viewport, and the same padding on both, so a value and the field that edits it sit
@@ -1,4 +1,4 @@
1
1
  module Recourse
2
2
  # Version of the gem, read by the gemspec and by hosts checking compatibility.
3
- VERSION = '7.2.1'
3
+ VERSION = '7.4.0'
4
4
  end
data/lib/recourse.rb CHANGED
@@ -11,6 +11,7 @@ require_relative 'recourse/blobs'
11
11
  require_relative 'recourse/bookmarks'
12
12
  require_relative 'recourse/calendars'
13
13
  require_relative 'recourse/colors'
14
+ require_relative 'recourse/bands'
14
15
  require_relative 'recourse/columns'
15
16
  require_relative 'recourse/counters'
16
17
  require_relative 'recourse/searches'
@@ -57,33 +58,6 @@ module Recourse
57
58
  @parents = {}
58
59
  @declared_bookmarks = nil
59
60
 
60
- # Columns a user may set: the form offers these, the show page reads these out, and
61
- # `create` permits these. A counter cache is none of a user's business — Rails keeps
62
- # it, so a form that offered one would let it be typed over, and neither is a column
63
- # the database generates: it takes no value at all.
64
- def self.editable_columns(model)
65
- ordered model, model.column_names - ['id', *TIMESTAMPS] - counters(model).keys -
66
- hidden_columns(model) - virtual_columns(model)
67
- end
68
-
69
- # Columns no screen shows: whatever the model asked to hide through `recourse_hidden`
70
- # — one name or a list, taken either way — the column Rails reserves for single table
71
- # inheritance, and the place a row holds where somebody positioned the table. A class
72
- # name is machinery rather than something to read out, and a position is set by
73
- # dragging the row rather than typed beside it.
74
- def self.hidden_columns(model)
75
- Array(model.recourse_hidden).map(&:to_s) +
76
- [model.inheritance_column, *position_columns(model)]
77
- end
78
-
79
- # The names a column is validated under: its own, and — where it is a foreign key
80
- # — the association's, since `belongs_to` validates the record it points at rather
81
- # than the number pointing there. Two questions where a column is a key, one
82
- # everywhere else.
83
- def self.validated_names(column)
84
- [column, column.delete_suffix('_id')].uniq
85
- end
86
-
87
61
  # The model a resource is named after. A controller the gem defined has nothing else
88
62
  # to go on, so a name resolving to no model is a routes file to fix rather than a
89
63
  # `NameError` from somewhere inside a view.
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: recourse
3
3
  version: !ruby/object:Gem::Version
4
- version: 7.2.1
4
+ version: 7.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - claudiob
@@ -140,6 +140,7 @@ files:
140
140
  - app/controllers/concerns/recourse/landing.rb
141
141
  - app/controllers/concerns/recourse/list_resolution.rb
142
142
  - app/controllers/concerns/recourse/paging.rb
143
+ - app/controllers/concerns/recourse/parameter_resolution.rb
143
144
  - app/controllers/concerns/recourse/parent_naming.rb
144
145
  - app/controllers/concerns/recourse/parent_resolution.rb
145
146
  - app/controllers/concerns/recourse/positioned.rb
@@ -178,6 +179,7 @@ files:
178
179
  - lib/recourse.rb
179
180
  - lib/recourse/assets.rb
180
181
  - lib/recourse/attachments.rb
182
+ - lib/recourse/bands.rb
181
183
  - lib/recourse/blobs.rb
182
184
  - lib/recourse/bookmarks.rb
183
185
  - lib/recourse/broadcasting.rb
@@ -193,6 +195,7 @@ files:
193
195
  - lib/recourse/helpers/attachments.rb
194
196
  - lib/recourse/helpers/blobs.rb
195
197
  - lib/recourse/helpers/bookmarks.rb
198
+ - lib/recourse/helpers/breadcrumbs.rb
196
199
  - lib/recourse/helpers/buttons.rb
197
200
  - lib/recourse/helpers/calendars.rb
198
201
  - lib/recourse/helpers/cards.rb
@@ -214,6 +217,7 @@ files:
214
217
  - lib/recourse/helpers/inputs.rb
215
218
  - lib/recourse/helpers/kinds.rb
216
219
  - lib/recourse/helpers/limits.rb
220
+ - lib/recourse/helpers/links.rb
217
221
  - lib/recourse/helpers/maps.rb
218
222
  - lib/recourse/helpers/names.rb
219
223
  - lib/recourse/helpers/navigation.rb