recourse 7.3.0 → 7.5.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: 9f6b6c7f06985a9e98ef9544e3f7494dca418d4a962fe9c1690d20e05b057104
4
- data.tar.gz: 9ea935c4fb5c7b1ad8d37f4d0001dc3c0d59e0f5b50b8d383bfa49c9441db7d1
3
+ metadata.gz: 28a482b8977d87fe680f8b1bff7502998e09202d1c5e95364746cc01b9c92596
4
+ data.tar.gz: a8fdb47ce0906854dd0164ac638d8d43155ca9d5561b3f47641c100b326113bf
5
5
  SHA512:
6
- metadata.gz: a146580b87daa86917b16a3fab8c2029f0d72d2ccf40a877550537b1c058e87caa294f6d66996909a0398585191e3d70e70a835e4dd606eca08d14afea1c5803
7
- data.tar.gz: 37a1dcaa862e424001eddfccf0018951bb62ccf6762c7c2d19995946f1fb72fca99ce7047c82f586b4154a9af080aa167e5a38edb9b4641902011b6dd12d1fa2
6
+ metadata.gz: b03910594792d293f48c138e3ebc186527fa8e08b4784a8c45924479290342ff83776775d508222b7b127b4d7dad5d19f24c5d6b08e9ad46df8be6aff418bfc3
7
+ data.tar.gz: e6951d50584a7d913f575caeaa4e141d842e990902abee2326e65080b382587ec416cdedc73bf136265a3d5902374b3b5250546c1cdddd7d0513b5b4a16bdd86
data/CHANGELOG.md CHANGED
@@ -7,6 +7,33 @@ For more information about changelogs, check [Keep a Changelog](http://keepachan
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## 7.5.0 - 2026-09-20
11
+
12
+ * [FEATURE] A model may word its own deletion
13
+
14
+ Deleting is not what every model does when a row goes: an account is disconnected, a
15
+ note is withdrawn. Both the button and the heading over the dialog now read
16
+ `recourse.models.<model>.delete` and `recourse.models.<model>.deletion_title` where a
17
+ host wrote them, and the one word the gem has where nobody did. Keyed off the record's
18
+ own class, so a subclass words it apart from its siblings — and a slash in an i18n key
19
+ is a nesting, so `integration/jobber` sits under `integration` in the locale.
20
+
21
+ ## 7.4.0 - 2026-09-20
22
+
23
+ * [CHANGE] A singular resource that has its record reads it rather than offering a form
24
+
25
+ `new` drew a form for a second record where the parent already kept one. At most one is
26
+ what singular means, so the reader is sent to the page that reads it — the mirror of the
27
+ redirect that already sent them to `new` when there was none yet. Before the action
28
+ rather than inside it, so a host writing its own `new` gets it too.
29
+
30
+ * [FIX] Every file in the gem is under a hundred lines again
31
+
32
+ Seven of them had grown past it. Each split along a seam it already had: web addresses
33
+ out of `Formats`, the trail out of `Navigation`, the counter lookups into `Counters`,
34
+ what a form sent into `ParameterResolution`, and which columns a screen uses apart from
35
+ what order they read in.
36
+
10
37
  ## 7.3.0 - 2026-09-20
11
38
 
12
39
  * [CHANGE] A boolean reads as the word a reader answers with
@@ -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
@@ -3,13 +3,6 @@ module Recourse
3
3
  # The button that deletes the record a form is showing, and the warning it puts
4
4
  # in front of whoever clicked it.
5
5
  module Deletions
6
- # `dependent:` values that take the children with the parent.
7
- DESTROYED = %i[destroy destroy_async].freeze
8
-
9
- # And the one that keeps them, holding the key open. Anything else — a bare
10
- # `has_many`, a `:restrict` — is left unsaid rather than guessed at.
11
- NULLIFIED = %i[nullify].freeze
12
-
13
6
  private
14
7
 
15
8
  # The record a page may delete: the one it is about, on its own look or its own
@@ -26,68 +19,37 @@ module Recourse
26
19
  path = record && destroy_resource_path(record)
27
20
  return unless path
28
21
 
29
- confirm_button_to t('recourse.delete', model: resource_name), path,
22
+ confirm_button_to destroy_label(record), path,
30
23
  confirm: destroy_warning(record), method: :delete,
31
24
  class: 'btn btn-sm btn-solid theme-danger ms-3',
32
25
  form_class: 'd-inline-block'
33
26
  end
34
27
 
35
- # @api private
36
- # What deleting this record takes with it, counted a level down and no further:
37
- # a state reaches counties, then ZIPs, then locations, and counting that far
38
- # would join 40,965 rows to draw one page.
39
- def destroy_warning(record)
40
- lines = [t('recourse.deletion.title', record: destroy_title(record)), nil]
41
-
42
- [*lines, *dependent_lines(record), nil, t('recourse.deletion.undone')].join "\n"
43
- end
44
-
45
- def destroy_resource_path(record)
46
- return unless routed_action? 'destroy'
47
-
48
- url_for action: :destroy, id: record
49
- end
50
-
51
- # The middle of the warning, in the order it reads: what goes, what stays, and
52
- # only then what is under what goes — the levels this stops short of counting.
53
- def dependent_lines(record)
54
- going = dependents record, DESTROYED
55
- staying = dependents record, NULLIFIED
56
- lines = []
57
- lines << t('recourse.deletion.going', list: going.to_sentence) if going.any?
58
- lines << staying_line(staying) if staying.any?
59
- lines << t('recourse.deletion.under') if going.any?
60
-
61
- lines
28
+ # The word on the button, and below it the word over the dialog. A model that
29
+ # undoes something rather than deleting it says so under its own name, and every
30
+ # other model falls through to the one word the gem has. Keyed off the record's
31
+ # class rather than the resource's, so a subclass words it apart from its
32
+ # siblings: an integration a provider authorized is disconnected where the one an
33
+ # admin picked is given up.
34
+ def destroy_label(record)
35
+ t worded(record, :delete), model: resource_name, default: :'recourse.delete'
62
36
  end
63
37
 
64
- def staying_line(staying)
65
- t 'recourse.deletion.staying', list: staying.to_sentence, model: resource_name
38
+ def destroy_heading(record)
39
+ t worded(record, :deletion_title), record: destroy_title(record),
40
+ default: :'recourse.deletion.title'
66
41
  end
67
42
 
68
- def dependents(record, kinds)
69
- record.class.reflect_on_all_associations(:has_many).filter_map do |association|
70
- next if association.through_reflection || kinds.exclude?(association.options[:dependent])
71
-
72
- dependent_count record, association
73
- end
43
+ # A slash in the key is a nesting to i18n, which is what lets a subclass sit
44
+ # under the model it inherits from rather than beside it.
45
+ def worded(record, name)
46
+ :"recourse.models.#{record.model_name.i18n_key}.#{name}"
74
47
  end
75
48
 
76
- # `association.reader` rather than a method named at runtime, and `count` rather
77
- # than loading them: the warning needs how many, never which.
78
- def dependent_count(record, association)
79
- count = record.association(association.name).reader.count
80
- return if count.zero?
81
-
82
- name = Recourse.downcase association.klass.model_name.human
83
-
84
- "#{number_with_delimiter count} #{name.pluralize count}"
85
- end
49
+ def destroy_resource_path(record)
50
+ return unless routed_action? 'destroy'
86
51
 
87
- # What the record is called, or what it is, for one that answers to no label.
88
- def destroy_title(record)
89
- record.attributes[record.class.recourse_label.to_s].presence ||
90
- record.class.model_name.human
52
+ url_for action: :destroy, id: record
91
53
  end
92
54
  end
93
55
  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.
@@ -85,25 +80,6 @@ module Recourse
85
80
  def phone_span(value) = value && tag.span(value, data: { controller: 'phone' })
86
81
 
87
82
  def enum_badge(value) = tag.span(value, class: 'badge')
88
-
89
- def web_url?(value) = value.is_a?(String) && value.match?(WEB_URL)
90
-
91
- # The words lead where the value points, in this tab, the way any link does; the
92
- # arrow after them — Bootstrap's icon link, stepping under the cursor — opens the
93
- # same address in a new tab. Either reads as the host, and an ellipsis where the
94
- # address goes further: a path is how a machine finds the page, and the href has it.
95
- def url_link(value)
96
- host, rest = value.match(WEB_URL).captures
97
- said = rest.delete_suffix('/').empty? ? host : "#{host}/…"
98
-
99
- safe_join [tag.a(said, href: value), new_tab_arrow(value)], ' '
100
- end
101
-
102
- def new_tab_arrow(value)
103
- tag.a icon_tag(:point_right), href: value, target: '_blank', rel: 'noopener',
104
- class: 'icon-link icon-link-hover',
105
- aria: { label: t('recourse.new_tab') }
106
- end
107
83
  end
108
84
  end
109
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
@@ -0,0 +1,68 @@
1
+ module Recourse
2
+ module Helpers
3
+ # What a deletion is said to cost before it is made: the heading over the dialog,
4
+ # and what the row takes with it or leaves behind.
5
+ module Warnings
6
+ # `dependent:` values that take the children with the parent.
7
+ DESTROYED = %i[destroy destroy_async].freeze
8
+
9
+ # And the one that keeps them, holding the key open. Anything else — a bare
10
+ # `has_many`, a `:restrict` — is left unsaid rather than guessed at.
11
+ NULLIFIED = %i[nullify].freeze
12
+
13
+ private
14
+
15
+ # @api private
16
+ # What deleting this record takes with it, counted a level down and no further:
17
+ # a state reaches counties, then ZIPs, then locations, and counting that far
18
+ # would join 40,965 rows to draw one page.
19
+ def destroy_warning(record)
20
+ lines = [destroy_heading(record), nil]
21
+
22
+ [*lines, *dependent_lines(record), nil, t('recourse.deletion.undone')].join "\n"
23
+ end
24
+
25
+ # The middle of the warning, in the order it reads: what goes, what stays, and
26
+ # only then what is under what goes — the levels this stops short of counting.
27
+ def dependent_lines(record)
28
+ going = dependents record, DESTROYED
29
+ staying = dependents record, NULLIFIED
30
+ lines = []
31
+ lines << t('recourse.deletion.going', list: going.to_sentence) if going.any?
32
+ lines << staying_line(staying) if staying.any?
33
+ lines << t('recourse.deletion.under') if going.any?
34
+
35
+ lines
36
+ end
37
+
38
+ def staying_line(staying)
39
+ t 'recourse.deletion.staying', list: staying.to_sentence, model: resource_name
40
+ end
41
+
42
+ def dependents(record, kinds)
43
+ record.class.reflect_on_all_associations(:has_many).filter_map do |association|
44
+ next if association.through_reflection || kinds.exclude?(association.options[:dependent])
45
+
46
+ dependent_count record, association
47
+ end
48
+ end
49
+
50
+ # `association.reader` rather than a method named at runtime, and `count` rather
51
+ # than loading them: the warning needs how many, never which.
52
+ def dependent_count(record, association)
53
+ count = record.association(association.name).reader.count
54
+ return if count.zero?
55
+
56
+ name = Recourse.downcase association.klass.model_name.human
57
+
58
+ "#{number_with_delimiter count} #{name.pluralize count}"
59
+ end
60
+
61
+ # What the record is called, or what it is, for one that answers to no label.
62
+ def destroy_title(record)
63
+ record.attributes[record.class.recourse_label.to_s].presence ||
64
+ record.class.model_name.human
65
+ end
66
+ end
67
+ end
68
+ end
@@ -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'
@@ -45,20 +47,20 @@ require_relative 'helpers/spans'
45
47
  require_relative 'helpers/tabs'
46
48
  require_relative 'helpers/times'
47
49
  require_relative 'helpers/values'
50
+ require_relative 'helpers/warnings'
48
51
  require_relative 'helpers/weeks'
49
52
  require_relative 'helpers/zones'
50
53
 
51
54
  module Recourse
52
55
  # View helpers for the pages the gem renders, and what the parts share.
53
56
  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
57
+ include Actions, Positions, Attachments, Blobs, Bookmarks, Breadcrumbs, Buttons,
58
+ Calendars, Cards, Cells, Choices, Colors, Comboboxes, Constraints, Counters,
59
+ Deletions, Densities, Details, Events, Examples, Fields, Filters, Flashes,
60
+ Formats, Inputs, Kinds, Limits, Links, Maps, Names, Navigation, Parents,
61
+ Pictures, Previews, References, Refreshes, Routing, Resources, Rows, Searches,
62
+ Shapes, Shortcuts, Sidebars, Sorts, Spans, Tabs, Themes, Times, Values,
63
+ Warnings, Weeks, Zones
62
64
 
63
65
  # The grid a record's own two pages lay an attribute out in: two columns on a large
64
66
  # 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.3.0'
3
+ VERSION = '7.5.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.3.0
4
+ version: 7.5.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
@@ -236,6 +240,7 @@ files:
236
240
  - lib/recourse/helpers/themes.rb
237
241
  - lib/recourse/helpers/times.rb
238
242
  - lib/recourse/helpers/values.rb
243
+ - lib/recourse/helpers/warnings.rb
239
244
  - lib/recourse/helpers/weeks.rb
240
245
  - lib/recourse/helpers/zones.rb
241
246
  - lib/recourse/icons.rb