recourse 5.2.0 → 5.3.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: 70333646b284ff615c653da393325f5ea94bb7bd04f73f46b9bff27e5d693a1c
4
- data.tar.gz: 1b386bf8262585fa5d3d5493e4297ea3521bac6105f8f9fffba64ecbdc7bd12d
3
+ metadata.gz: 0c224b5ff48a88afa37e70da5d2157d9b5229eccdc02d7d9f8ce1b85e4df31d8
4
+ data.tar.gz: 50da170bddd967baba9c46c276e61f750694465a2c2e11c5fe09c5e59cc84eff
5
5
  SHA512:
6
- metadata.gz: 88b168334941dfe01535414620b28dffea6ceedf1583e023f586b71f33c5f6eda669c900a45bd293b28c0d2b2f262c89bfd276f6bc396f08f05bc1c9a2e062c9
7
- data.tar.gz: b890abc1502680ad0fcbf1ba27bfcbca86743bbb051d2e30bc9ea171cc87f495885baa8158c3b672efb7549b64370fc2e9068883a9ecbc2c2f522d8d2ffe6c86
6
+ metadata.gz: 91bc98cadd2186f29b342da8804c0926f523656aec2149e8025393c4c3ba3ec034e1ae359732cb81368192ac00b70f484731640227692c18b4c5d1ed09adac1f
7
+ data.tar.gz: a0565fb334f552e1a403af4db94f8d878f25ae5473e6af167304785fc017d42e392e9276ece5b2fcd4475fe48511cac8216a670adc23f8269f9ee8b931573c4f
data/CHANGELOG.md CHANGED
@@ -7,6 +7,42 @@ For more information about changelogs, check [Keep a Changelog](http://keepachan
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## 5.3.0 - 2026-09-16
11
+
12
+ * [Feature] A record's files are a field, a value and a page
13
+
14
+ Back from the 4.0.0 development tree: a `has_one_attached` is a file field on the form
15
+ and a value on the record's page, a `has_many_attached :photos` beside
16
+ `recourses :photos, only: %i[index destroy]` under the record is a table of Active
17
+ Storage's blobs — searched, sorted and paged like any other — and a submitted file is
18
+ attached after the save rather than assigned, so an edit that touched only a name never
19
+ purges what the record had. New this time: a file a browser can be shown is a picture
20
+ first, a `<picture>` 100 pixels tall of a representation made 200 tall — WebP at quality
21
+ 80 with its metadata stripped where the browser takes it, the file's own kind where not
22
+ — in the link that opens the whole file inline — the image scaled, or the frame a
23
+ previewer takes of a video or a PDF where the host has `ffmpeg` or `poppler` — on the
24
+ table and on the record's page alike. And a Delete on each row of the table, which
25
+ takes the file off the record and leaves the blob to Active Storage. Files are still
26
+ added on the record's form, and a shelf is still not counted on its tab: there is no
27
+ `belongs_to` to hang a counter cache on.
28
+
29
+ * [Feature] A table routed `destroy` without `edit` offers Delete on each row
30
+
31
+ The Delete button stood on the edit page and nowhere else, so a resource with a
32
+ `destroy` and no `edit` — a file attached to a record, a row joining two — had no way to
33
+ be deleted from the gem's own screens. Its table now draws a third action column, the
34
+ trash icon in `fg-danger`, a `button_to` carrying the same confirmation the edit page's
35
+ button does. A table whose rows have an edit page is unchanged.
36
+
37
+ * [Fix] A filter narrows a table again
38
+
39
+ A combobox became a `<select>` in 5.0.0 and its picks are submitted one value each, but the
40
+ search read them the way it read the comma-joined string before it — so `q[team_id_in][]=1`
41
+ arrived as the array `["1"]`, was written out as the one value `["1"]`, and was cast to the
42
+ id 0 no row holds. Every filter menu in the gem came to an empty table, whatever was ticked.
43
+ The picks are now taken as the values they arrive as, and `All …`, which submits one empty
44
+ value, is no filter rather than a filter nothing answers.
45
+
10
46
  ## 5.2.0 - 2026-09-14
11
47
 
12
48
  * [Feature] A table of places can be read as a map
data/README.md CHANGED
@@ -47,7 +47,8 @@ Every recourse with `new` or `edit` gets a form with appropriate browser formatt
47
47
 
48
48
  <img width="3824" height="1220" alt="Image" src="https://github.com/user-attachments/assets/ebe5108b-bbd1-4cd1-a635-89eef0e6fc50" />
49
49
 
50
- Every recourse with `destroy` gets a button with a detailed confirmation message:
50
+ Every recourse with `destroy` gets a button with a detailed confirmation message — on its
51
+ edit page, or on each row of its table where no `edit` is routed:
51
52
 
52
53
  [image]
53
54
 
@@ -123,37 +124,76 @@ A controller the app already defines is left alone.
123
124
 
124
125
  ## Step 5. Enjoy the extras
125
126
 
126
- Here are some bonus features the gem provides. Customize them by editing their values in
127
- your `config/initializers/recourse.rb` file:
127
+ The rest of what the gem offers, in four parts. The first two are a line each in
128
+ `config/initializers/recourse.rb`; the last two need nothing beyond what the model
129
+ already declares.
128
130
 
129
- - [`Recourse.color=`](https://rubydoc.info/gems/recourse/Recourse#color%3D-class_method):
130
- which Bootstrap family is primary on every page.
131
- - [`Recourse.theme=`](https://rubydoc.info/gems/recourse/Recourse#theme%3D-class_method):
132
- which color scheme every page is drawn in, one of `Recourse::THEMES.keys` — eight from
133
- code editors and Bootstrap's own. Readers rotate through them, light and dark, from the
134
- moon at the foot of the sidebar; their choice stays in their browser.
135
- - [`Recourse.bookmarks=`](https://rubydoc.info/gems/recourse/Recourse#bookmarks%3D-class_method):
136
- a Proc answering the viewer's bookmark rows. Every table of a model with a `has_many`
137
- at that class then opens with a square to keep a row by, kept rows first.
131
+ ### Color and theme
132
+
133
+ [`Recourse.color=`](https://rubydoc.info/gems/recourse/Recourse#color%3D-class_method)
134
+ says which Bootstrap family is primary on every page, and
135
+ [`Recourse.theme=`](https://rubydoc.info/gems/recourse/Recourse#theme%3D-class_method)
136
+ says which color scheme the pages are drawn in one of `Recourse::THEMES.keys`: eight
137
+ palettes taken from code editors, plus Bootstrap's own.
138
138
 
139
139
  ```ruby
140
140
  Recourse.color = :orange
141
141
  Recourse.theme = :nord
142
+ ```
143
+
144
+ The theme is where a reader starts, not where they stay. The moon at the foot of the
145
+ sidebar rotates through every palette, light and dark, and the one they pick stays in
146
+ their browser.
147
+
148
+ ### Bookmarks
149
+
150
+ [`Recourse.bookmarks=`](https://rubydoc.info/gems/recourse/Recourse#bookmarks%3D-class_method)
151
+ takes a Proc answering the rows the viewer has kept:
152
+
153
+ ```ruby
142
154
  Recourse.bookmarks = -> { Keepsake.where agent: Current.agent }
143
155
  ```
144
156
 
157
+ A Proc rather than a relation, because at boot the viewer is nobody. Every model with a
158
+ `has_many` at that class then opens its table with a square to keep a row by, and kept
159
+ rows come first. A model that cannot hold a bookmark gets no column.
160
+
145
161
  <img width="3824" height="1220" alt="Image" src="https://github.com/user-attachments/assets/d1db4adb-0b5c-4e41-8bb6-cf72a35288f0" />
146
162
 
163
+ ### Attachments
164
+
165
+ What a model keeps as files needs nothing declared either. A `has_one_attached :logo` is
166
+ a file field on the form and, on the record's page, the picture Active Storage makes of
167
+ the file — 100 pixels tall, WebP where the browser takes it, opening the whole file in a
168
+ new tab — or the file's name where nothing can be drawn of it.
169
+
170
+ A `has_many_attached :photos` earns a page of its own, nested under the record:
171
+
172
+ ```ruby
173
+ recourses :photos, only: %i[index destroy]
174
+ ```
175
+
176
+ That page is a table of the files with their pictures, and a Delete on each row that
177
+ takes the file off the record. Files are added on the record's own form, where a chosen
178
+ file joins a shelf and replaces a single one, and a field nobody touched leaves
179
+ everything as it was. `recourse_hidden :photos` keeps a file off every screen the way it
180
+ keeps a column off.
181
+
182
+ Drawing a picture of an image needs `image_processing`; video and PDF also need the
183
+ host's `ffmpeg` and `poppler`.
184
+
185
+ ### Maps
186
+
147
187
  A table whose model keeps a `google_place_id`, or a `latitude` and a `longitude`, can be
148
188
  read as a Google map of the same page: the footer under it offers `Display as map`, and
149
189
  `/counties.map` draws this page's rows in the frame and over the footer the table has, so
150
- search, sort and pages work the same on either shape. A place ID is filled in as an area
151
- where the model is a geography Google draws boundaries for — a `State`, a `County`, a `City`
152
- or a `ZIP`, by name and pinned at the place for any other model; a point is a pin. The key
153
- and the map ID are the host's credentials, and the map's style has the matching **Feature
154
- layers** turned on in the Cloud console — Postal code for ZIPs, Administrative area level 2
155
- for counties. A host with a Content Security Policy allows `maps.googleapis.com` for scripts
156
- and connections and Google's tile hosts for images.
190
+ search, sort and pages work the same on either shape.
191
+
192
+ A point is a pin. A place ID is filled in as an area where the model is a geography
193
+ Google draws boundaries for a `State`, a `County`, a `City` or a `ZIP`, by name — and
194
+ pinned at the place for any other model.
195
+
196
+ The key and the map ID are the host's own credentials:
157
197
 
158
198
  ```yaml
159
199
  # config/credentials.yml.enc
@@ -162,6 +202,11 @@ google_maps:
162
202
  map_id: 4f2a…
163
203
  ```
164
204
 
205
+ The map's style has the matching **Feature layers** turned on in the Cloud console —
206
+ Postal code for ZIPs, Administrative area level 2 for counties. A host with a Content
207
+ Security Policy allows `maps.googleapis.com` for scripts and connections, and Google's
208
+ tile hosts for images.
209
+
165
210
 
166
211
  ## Development
167
212
 
@@ -0,0 +1,44 @@
1
+ module Recourse
2
+ # An index over what a record has attached rather than over a model of its own.
3
+ # `has_many_attached :photos` and `recourses :photos, only: :index` under the same
4
+ # record is the whole declaration: Rails already generates the association this
5
+ # reads, and Active Storage already has the model it lists.
6
+ module AttachmentResolution
7
+ extend ActiveSupport::Concern
8
+
9
+ private
10
+
11
+ # The attachment this page lists, or nil where the name is a model like any
12
+ # other. `has_one_attached` is not one: a single file is a value on the record's
13
+ # own page, and a table of one row says less than the field it replaced.
14
+ def attachment_reflection
15
+ return unless defined?(ActiveStorage::Reflection) && attachment_parent
16
+
17
+ reflection = attachment_parent.class.attachment_reflections[controller_name]
18
+
19
+ reflection if reflection.is_a? ActiveStorage::Reflection::HasManyAttachedReflection
20
+ end
21
+
22
+ # The record the path names, asked before the model is settled: a blob holds no key
23
+ # pointing back at what it hangs off, and what this page is about turns on which
24
+ # record that is. The same lookup `find_parent` falls back on, kept for the request.
25
+ def attachment_parent
26
+ return @attachment_parent if defined? @attachment_parent
27
+
28
+ @attachment_parent = path_parent
29
+ end
30
+
31
+ # The blobs themselves, through the association `has_many_attached` generated —
32
+ # a real relation, so the search, the sort and the page all still apply.
33
+ def attachment_relation
34
+ attachment_parent.association(:"#{controller_name}_blobs").reader
35
+ end
36
+
37
+ # The row joining the record to one of its files, which is what a delete on this
38
+ # page removes: the file stays as long as anything else still points at it, and
39
+ # Active Storage purges it once nothing does.
40
+ def attachment_of(blob)
41
+ attachment_parent.association(:"#{controller_name}_attachments").reader.find_by! blob: blob
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,70 @@
1
+ module Recourse
2
+ # The files a form submits, which are written after the record rather than with it.
3
+ # An attachment row points at an id, and a record being created has none until it is
4
+ # saved — so a write here is always two steps where a column's is one.
5
+ module AttachmentWriting
6
+ extend ActiveSupport::Concern
7
+
8
+ private
9
+
10
+ # A new record, then the files that came with it, in one transaction: a record
11
+ # half-saved is worse than one not saved.
12
+ def create_resource(record)
13
+ record.transaction { super && attach_submitted_files(record) }
14
+ end
15
+
16
+ # The same for one that already exists.
17
+ def update_resource(record)
18
+ super && attach_submitted_files(record)
19
+ end
20
+
21
+ # On a page of what a record has attached, the row between the two goes and the
22
+ # file stays for whatever else points at it; everywhere else the record itself goes.
23
+ def destroy_resource(record)
24
+ return super unless attachment_reflection
25
+
26
+ attachment_of(record).destroy!
27
+ end
28
+
29
+ # Attached rather than assigned, which is the whole reason this exists. Rails'
30
+ # generated writer replaces every attachment rather than adding to them, and reads
31
+ # a blank field as an instruction to delete the lot — so an edit that touched only
32
+ # a name would purge what the record had. `Attached::Many#attach` appends and
33
+ # `Attached::One#attach` replaces the one, which is what each means on a form.
34
+ #
35
+ # `each` answers the list it was given, which is truthy even when it is empty — so
36
+ # a model with nothing attached never turns a save that worked into a failure.
37
+ def attach_submitted_files(record)
38
+ Recourse.attachment_names(resource_class).each do |name|
39
+ files = submitted_files name
40
+ attached(record, name).attach(*files) if files.present?
41
+ end
42
+ end
43
+
44
+ # Built rather than read off the record: the reader Active Storage generates is
45
+ # reachable only by its own name, and this is the object that reader returns.
46
+ def attached(record, name)
47
+ return ActiveStorage::Attached::Many.new name, record if
48
+ Recourse.attachment_many? resource_class, name
49
+
50
+ ActiveStorage::Attached::One.new name, record
51
+ end
52
+
53
+ # What the form sent for one attachment, as a list either way. Blanks are dropped
54
+ # rather than trusted: the browser sends an empty file input as an empty string,
55
+ # and Active Storage would read a list of those as `delete everything`.
56
+ def submitted_files(name)
57
+ Array(params.dig(controller_name.singularize.to_sym, name)).compact_blank
58
+ end
59
+
60
+ # What a form may submit for one: a list of files where the model keeps several,
61
+ # and one file where it keeps one. Permitted alongside the columns, so a host
62
+ # running `action_on_unpermitted_parameters` at `:raise` is not tripped by a field
63
+ # the gem itself drew — and dropped again before anything reaches the record.
64
+ def attachment_filters
65
+ Recourse.attachment_names(resource_class).map do |name|
66
+ Recourse.attachment_many?(resource_class, name) ? { name => [] } : name
67
+ end
68
+ end
69
+ end
70
+ end
@@ -12,7 +12,7 @@ module Recourse
12
12
 
13
13
  # The record a key points at where the resource has one, and otherwise the record
14
14
  # the path names: a page nested by the path alone — the memos of a team no memo
15
- # belongs to — still sits under something.
15
+ # belongs to, the blobs a record has attached — still sits under something.
16
16
  def find_parent
17
17
  @recourse_parent_association = parent_association
18
18
  @recourse_parent = @recourse_parent_association ? parent_model.find(parent_id) : path_parent
@@ -34,7 +34,7 @@ module Recourse
34
34
  # What the route settled and every action honours: the index lists rows carrying
35
35
  # these columns, and `new` and `create` build records that do.
36
36
  def parent_columns
37
- return {} if @recourse_parent_association.nil?
37
+ return {} if attachment_reflection || @recourse_parent_association.nil?
38
38
 
39
39
  { @recourse_parent_association.foreign_key => @recourse_parent.id }
40
40
  end
@@ -22,6 +22,10 @@ module Recourse
22
22
  record.update attributes
23
23
  end
24
24
 
25
+ # And the record gone. `destroy!`, so a callback that stops one says so rather than
26
+ # leaving the page claiming it worked.
27
+ def destroy_resource(record) = record.destroy!
28
+
25
29
  # A foreign key whose label is typed arrives as that label, so it is looked up
26
30
  # here. Nothing found leaves the key nil, and `belongs_to` reports it missing.
27
31
  def resolve_references(attributes)
@@ -25,8 +25,13 @@ module Recourse
25
25
  false
26
26
  end
27
27
 
28
- # The model the route is named after.
29
- def resource_class = recourse_model
28
+ # The model the route is named after — or Active Storage's, where the name is
29
+ # something the parent has attached rather than a model of this app's own.
30
+ def resource_class
31
+ return ActiveStorage::Blob if attachment_reflection
32
+
33
+ recourse_model
34
+ end
30
35
 
31
36
  # The model this screen is about, and the second thing a host overrides to put a
32
37
  # page of its own behind a screen the gem otherwise draws whole:
@@ -39,7 +44,11 @@ module Recourse
39
44
  Recourse.model controller_name
40
45
  end
41
46
 
47
+ # What the page calls one of its rows: the model's own word, or the route's for a
48
+ # page of files — `Photo was deleted.`, never `Blob`.
42
49
  def human_name
50
+ return controller_name.singularize.humanize if attachment_reflection
51
+
43
52
  resource_class.model_name.human
44
53
  end
45
54
 
@@ -50,15 +59,17 @@ module Recourse
50
59
  # `def resource_params = params.expect(provider: %i[name cid])`.
51
60
  def resource_params
52
61
  # The parent is merged after resolving, so the one the route names is never
53
- # mistaken for a label.
54
- submitted_attributes.merge parent_columns
62
+ # mistaken for a label; the files go no further than the permit that let them
63
+ # through, being attached rather than assigned.
64
+ submitted_attributes.except(*Recourse.attachment_names(resource_class))
65
+ .merge parent_columns
55
66
  end
56
67
 
57
68
  # What the form sent, with a typed reference read back as the id it names. A bare
58
69
  # `Create` submits no attributes at all, so the key may be absent: the parent a
59
70
  # nested route names is everything such a record starts from.
60
71
  def submitted_attributes
61
- permitted = Recourse.editable_columns resource_class
72
+ permitted = Recourse.editable_columns(resource_class) + attachment_filters
62
73
  key = controller_name.singularize.to_sym
63
74
  return {} unless params.key? key
64
75
 
@@ -3,7 +3,7 @@ module Recourse
3
3
  # its own behavior above it — `class RecoursesController < Recourse::BaseController`
4
4
  # with a `before_action :authenticate!` guards every screen the gem serves.
5
5
  class BaseController < ApplicationController
6
- include Pagy::Method,
6
+ include Pagy::Method, AttachmentResolution, AttachmentWriting,
7
7
  Landing, Paging, ListResolution, ParentNaming,
8
8
  ParentResolution, ReferenceResolution, ResourceResolution,
9
9
  Zoning
@@ -64,7 +64,7 @@ module Recourse
64
64
  # Deletes the record and shows the index without it. `destroy!`, so a callback that
65
65
  # stops one says so rather than leaving the page claiming it worked.
66
66
  def destroy
67
- @recourse.destroy!
67
+ destroy_resource @recourse
68
68
  wrote t('recourse.deleted', model: human_name)
69
69
  end
70
70
 
@@ -80,6 +80,8 @@ module Recourse
80
80
  # `def recourse_relation = County.with_boosts_for(@recourse_parent)`. Private, so
81
81
  # overriding it adds a query and never an action.
82
82
  def recourse_relation
83
+ return attachment_relation if attachment_reflection
84
+
83
85
  resource_class.where parent_columns
84
86
  end
85
87
  end
@@ -1,3 +1,6 @@
1
1
  <% editable_columns.each do |name| -%>
2
2
  <%= field name %>
3
3
  <% end -%>
4
+ <% attachment_names.each do |name| -%>
5
+ <%= attachment_field name %>
6
+ <% end -%>
@@ -1,4 +1,8 @@
1
1
  <% record = local_assigns[resource_key] -%>
2
+ <%# A file is a picture before it is a row, where there is a picture to draw. -%>
3
+ <% if blob_resource? -%>
4
+ <%= column header: t('recourse.preview') do %><%= blob_preview record %><% end %>
5
+ <% end -%>
2
6
  <% resource_columns.each do |name| -%>
3
7
  <%= column header: sort_header(name), class: counter_class(name) do %><%= resource_cell record, name %><% end %>
4
8
  <% end -%>
@@ -1,3 +1,6 @@
1
1
  <% shown_columns.each do |name| -%>
2
2
  <%= value name %>
3
3
  <% end -%>
4
+ <% shown_attachments.each do |name| -%>
5
+ <%= attachment_value name %>
6
+ <% end -%>
@@ -13,6 +13,13 @@ en:
13
13
  all_values: All
14
14
  as_map: Display as map
15
15
  as_table: Display as table
16
+ # One message in four spellings: what a record is holding, under the field that
17
+ # adds to it.
18
+ attached:
19
+ many: "%{count} files attached (%{files})"
20
+ none: No file attached
21
+ none_many: No files attached
22
+ one: 1 file attached (%{files})
16
23
  blank: "—"
17
24
  bookmark: Bookmark
18
25
  bookmark_added: Bookmark added
@@ -27,6 +34,7 @@ en:
27
34
  darken: Dark
28
35
  delete: Delete %{model}
29
36
  deleted: "%{model} was deleted."
37
+ destroy: Delete
30
38
  exit: Exit
31
39
  displaying:
32
40
  one: Displaying 1 item
@@ -54,6 +62,7 @@ en:
54
62
  none: "No %{models}."
55
63
  optional: Optional
56
64
  per_page: "%{limit} per page"
65
+ preview: Preview
57
66
  ambiguous: "matches more than one record, so it does not say which: %{label}"
58
67
  reference: "%{model} %{attribute}"
59
68
  reveal: Show
@@ -0,0 +1,28 @@
1
+ # Reopened for what a record keeps beside its columns, which a form, a show page and
2
+ # the params a write permits each have to ask a model about.
3
+ module Recourse
4
+ # What Active Storage was told a model has attached. Extended onto `Recourse`, so
5
+ # every one of these is `Recourse.something` wherever it is called from.
6
+ module Attachments
7
+ # The attachments a screen offers, in the order the model declared them, less
8
+ # whatever it asked to keep off one: `recourse_hidden :photos` hides a file exactly
9
+ # as it hides a column. Empty for a class Active Storage never reached — a host
10
+ # that installed none of it — which is the same guard `AttachmentResolution`
11
+ # already carries as `defined?(ActiveStorage::Reflection)`.
12
+ def attachment_names(model)
13
+ return [] unless model.respond_to? :attachment_reflections
14
+
15
+ model.attachment_reflections.keys - hidden_columns(model)
16
+ end
17
+
18
+ # Whether that name holds several files or one, which is the only thing a field and
19
+ # a write each need to know about it: a field offers `multiple` for the one and not
20
+ # the other, and a write appends to the one where it replaces the other. Read off
21
+ # the reflection's own `macro`, so neither of Active Storage's classes is named.
22
+ def attachment_many?(model, name)
23
+ model.attachment_reflections[name]&.macro == :has_many_attached
24
+ end
25
+ end
26
+
27
+ extend Attachments
28
+ end
@@ -0,0 +1,22 @@
1
+ module Recourse
2
+ # What a table of attachments reads off Active Storage's own model, which was never
3
+ # asked how it wanted drawing. Extended onto the blob through the load hook Rails
4
+ # publishes for it, so nothing here is a patch.
5
+ module Blobs
6
+ # A blob is known by the name it was uploaded under. Every other model answers
7
+ # `:name`, and Active Storage has no such column — point a combobox at one
8
+ # without this and the select raises.
9
+ def recourse_label = :filename
10
+
11
+ # The service's business rather than the reader's: where the file sits, what it
12
+ # is called there, what it hashes to, and whatever the analyzer wrote down.
13
+ def recourse_hidden = %i[key checksum service_name metadata]
14
+
15
+ # Newest first, which is the order somebody looking at what was attached wants.
16
+ def recourse_order = { created_at: :desc }
17
+ end
18
+ end
19
+
20
+ ActiveSupport.on_load :active_storage_blob do
21
+ extend Recourse::Blobs
22
+ end
@@ -1,16 +1,18 @@
1
1
  module Recourse
2
2
  module Helpers
3
- # The action columns a row opens with: a look at a record, then a change to it.
3
+ # The action columns a row opens with: a look at a record, a change to it, and —
4
+ # where the routes drew nowhere else to put it — the end of it.
4
5
  module Actions
5
- # The two pages a record has, named as the concepts an icon set knows rather
6
- # than as one set's own word for them, so what draws them is Unicon's business
7
- # here as everywhere else. A row's links and a card's tabs read the same map,
8
- # so the two cannot drift apart — and its order is the order a row opens with.
9
- ICONS = { show: :view, edit: :edit }.freeze
6
+ # The pages a record has and the one thing done to it without a page, named as
7
+ # the concepts an icon set knows rather than as one set's own word for them, so
8
+ # what draws them is Unicon's business here as everywhere else. A row's links and
9
+ # a card's tabs read the same map, so the two cannot drift apart — and its order
10
+ # is the order a row opens with.
11
+ ICONS = { show: :view, edit: :edit, destroy: :delete }.freeze
10
12
 
11
13
  private
12
14
 
13
- # Which of those two this table draws a column for. Read into a local by the
15
+ # Which of those this table draws a column for. Read into a local by the
14
16
  # table, so the routes are asked once per render rather than twice for the
15
17
  # heading and twice more for every row.
16
18
  def resource_actions
@@ -29,14 +31,33 @@ module Recourse
29
31
 
30
32
  # Whether a record's own page is there to be linked to, wherever its routes
31
33
  # were drawn: a nested table's rows lead to the resource's own pages, the ones
32
- # a nested route leaves to it, so the columns are the same either way.
34
+ # a nested route leaves to it, so the columns are the same either way. A delete
35
+ # is the exception, standing on the table only where no page would carry it.
33
36
  def resource_action?(action)
37
+ return destroy_action_path.present? if action == :destroy
38
+
34
39
  routed_action? action.to_s, resource_controller_path
35
40
  end
36
41
 
37
- # The icon linking to one of those pages, or nothing where the page is not
38
- # routed. Which of the two it is, is the whole difference between them.
42
+ # Where a row is deleted from, or nil. The Delete button stands on the edit page
43
+ # where the routes drew one; a resource routed `destroy` and no `edit` — a file
44
+ # attached to a record, a row joining two — has no page of its own to carry it,
45
+ # so the table does, at whichever of the two paths drew the route: the nesting's
46
+ # own, or the resource's. Remembered per render, since the table asks per row.
47
+ def destroy_action_path
48
+ return @recourse_destroy_path if defined? @recourse_destroy_path
49
+
50
+ paths = [controller.controller_path, resource_controller_path].uniq
51
+ @recourse_destroy_path = if paths.none? { |path| routed_action? 'edit', path }
52
+ paths.find { |path| routed_action? 'destroy', path }
53
+ end
54
+ end
55
+
56
+ # The icon linking to one of those pages, or the button that deletes the row, or
57
+ # nothing where the page is not routed.
39
58
  def resource_action_link(action, record)
59
+ return destroy_button record if action == :destroy
60
+
40
61
  path = resource_action_path action, record
41
62
  return unless path
42
63
 
@@ -51,6 +72,17 @@ module Recourse
51
72
 
52
73
  url_for controller: "/#{resource_controller_path}", action: action, id: record
53
74
  end
75
+
76
+ # The icon alone, red for what it does, and the same warning the edit page's button
77
+ # carries in front of it, which the bundle draws as its dialog.
78
+ def destroy_button(record)
79
+ path = url_for controller: "/#{destroy_action_path}", action: :destroy, id: record
80
+
81
+ button_to icon_tag(ICONS[:destroy], class: 'fg-danger'), path,
82
+ method: :delete, class: 'btn btn-sm btn-link btn-icon p-0',
83
+ aria: { label: t('recourse.delete', model: resource_name) },
84
+ form: { data: { turbo_confirm: destroy_warning(record) } }
85
+ end
54
86
  end
55
87
  end
56
88
  end
@@ -0,0 +1,66 @@
1
+ module Recourse
2
+ module Helpers
3
+ # The field a form offers for putting a file on a record, and the note under it
4
+ # saying what the record is holding already.
5
+ module Attachments
6
+ private
7
+
8
+ # The attachments this screen's model keeps, which the form draws after every
9
+ # column it has: a file input is the widest control on the page.
10
+ def attachment_names
11
+ Recourse.attachment_names resource_model
12
+ end
13
+
14
+ # One labelled file input, in the grid a column's field sits in. `multiple` where
15
+ # the model keeps several, and no hidden blank beside it: nothing here is ever
16
+ # assigned, so a field nobody touched is one the write passes over.
17
+ def attachment_field(name)
18
+ label = resource_model.human_attribute_name name
19
+
20
+ tag.div class: ROW do
21
+ safe_join [
22
+ @recourse_form.label(name, label, class: 'form-label'),
23
+ @recourse_form.file_field(name, **attachment_options(name)),
24
+ attached_note(name),
25
+ ].compact
26
+ end
27
+ end
28
+
29
+ # A file input carries none of a column's constraints, so its options are the
30
+ # class, the note under it, and whether it takes several files at once.
31
+ def attachment_options(name)
32
+ described = field_note_id name if resource_record&.persisted?
33
+ options = { class: 'form-control', aria: { describedby: described } }
34
+ return options unless Recourse.attachment_many? resource_model, name
35
+
36
+ options.merge multiple: true, include_hidden: false
37
+ end
38
+
39
+ # What the record is holding, under the field that adds to it: choosing a file
40
+ # joins what is there or replaces it, and which of the two it is depends on what
41
+ # is there. Only where there is a record to ask — a form making one has nothing
42
+ # attached yet, so it has nothing to report and says nothing.
43
+ def attached_note(name)
44
+ return unless resource_record&.persisted?
45
+
46
+ field_note attached_reading(name), field_note_id(name)
47
+ end
48
+
49
+ def attached_reading(name)
50
+ files = attached_filenames name
51
+ return attached_nothing name if files.empty?
52
+ return t 'recourse.attached.one', files: files.first if files.one?
53
+
54
+ t 'recourse.attached.many', count: files.size, files: files.to_sentence
55
+ end
56
+
57
+ # A field for one file reads `No file attached` where a field for several reads
58
+ # `No files attached`, since which it is decides what choosing one will do.
59
+ def attached_nothing(name)
60
+ many = Recourse.attachment_many? resource_model, name
61
+
62
+ t "recourse.attached.#{many ? 'none_many' : 'none'}"
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,80 @@
1
+ module Recourse
2
+ module Helpers
3
+ # What a page reads off Active Storage's own objects: the row behind a file, the way
4
+ # to open the file itself, and the picture a browser can make of it. Paired with
5
+ # `Recourse::Blobs`, which is what tells the blob model how it wants drawing.
6
+ module Blobs
7
+ # How tall a preview stands on the page. The picture behind it is made twice as
8
+ # tall, so a screen with two pixels to the point has every one of them.
9
+ PREVIEW_HEIGHT = 100
10
+
11
+ # How a picture is saved for the trip: lossy at a quality the eye forgives at this
12
+ # size, and stripped of whatever the camera wrote — neither is worth the bytes.
13
+ PREVIEW_SAVER = { quality: 80, strip: true }.freeze
14
+
15
+ private
16
+
17
+ # True for the filename of a blob, and for nothing else — a host model with a
18
+ # column of that name is drawing its own value, not Active Storage's.
19
+ def blob_filename?(column) = column == 'filename' && blob_resource?
20
+
21
+ # By name, so an app with no Active Storage never mentions the constant.
22
+ def blob_resource? = resource_model.name == 'ActiveStorage::Blob'
23
+
24
+ # The file itself, in a tab of its own: an admin opening one is leaving the page
25
+ # they were reading, and a file that replaced it would lose their place. Inline,
26
+ # so the browser shows a picture and plays a video rather than saving either.
27
+ # Whatever the caller hands over is what the link reads as.
28
+ def blob_link(blob, read)
29
+ link_to read, main_app.rails_blob_path(blob, disposition: :inline),
30
+ target: '_blank', rel: 'noopener'
31
+ end
32
+
33
+ # A picture of the file where Active Storage can make one — the image itself
34
+ # scaled down, or the frame a previewer takes of a video or a PDF — inside the link
35
+ # that opens the whole file. Nothing for a file nothing can draw. A `<picture>`
36
+ # offering WebP first, the smallest a browser can be sent, and the file's own kind
37
+ # under it for one that cannot take WebP; either at half the pixels it was made
38
+ # with, and the height is the attribute's, so no class here may say `height: auto`,
39
+ # as `img-thumbnail` does.
40
+ def blob_preview(blob)
41
+ return unless blob.representable?
42
+
43
+ blob_link blob, tag.picture(safe_join([preview_source(blob), preview_image(blob)]))
44
+ end
45
+
46
+ def preview_source(blob)
47
+ tag.source srcset: preview_path(blob, format: :webp, saver: PREVIEW_SAVER),
48
+ type: 'image/webp'
49
+ end
50
+
51
+ def preview_image(blob)
52
+ image_tag preview_path(blob), alt: blob.filename.to_s, height: PREVIEW_HEIGHT,
53
+ class: 'border rounded', loading: 'lazy'
54
+ end
55
+
56
+ # The address of one scaling of the file, made on first request and kept.
57
+ def preview_path(blob, **saving)
58
+ representation = blob.representation resize_to_limit: [nil, PREVIEW_HEIGHT * 2], **saving
59
+
60
+ main_app.rails_representation_path representation
61
+ end
62
+
63
+ # The blobs one attachment holds, as a list whichever kind it is. Read through
64
+ # the associations Active Storage generated rather than through the reader it
65
+ # named, which is the trade `AttachmentResolution` already makes next door.
66
+ def attached_blobs(name)
67
+ return resource_record.association(:"#{name}_blobs").reader.to_a if
68
+ Recourse.attachment_many? resource_model, name
69
+
70
+ Array resource_record.association(:"#{name}_blob").reader
71
+ end
72
+
73
+ # And the names they were uploaded under, which is what a reader is told a
74
+ # record is holding.
75
+ def attached_filenames(name)
76
+ attached_blobs(name).map { |blob| blob.filename.to_s }
77
+ end
78
+ end
79
+ end
80
+ end
@@ -79,6 +79,8 @@ module Recourse
79
79
  return search_highlight named_cell(resource, association), column if association
80
80
 
81
81
  value = resource.attributes[column]
82
+ return blob_link resource, value if blob_filename? column
83
+
82
84
  counted = resource_model.recourse_counters[column]
83
85
 
84
86
  # A count is the bare number — the icon in the heading already says what it
@@ -0,0 +1,40 @@
1
+ module Recourse
2
+ module Helpers
3
+ # An attached file on a record's own page: shown where a browser can make a picture
4
+ # of it, and named where it cannot, either opening the file.
5
+ module Previews
6
+ private
7
+
8
+ # The attachments a record's own page reads out, which are the ones it keeps a
9
+ # single file under. A `has_many_attached` is a table of its own — a page a host
10
+ # nests under the record — and a row of pictures says more there than a list
11
+ # squeezed into a value's row would.
12
+ def shown_attachments
13
+ attachment_names.reject { |name| Recourse.attachment_many? resource_model, name }
14
+ end
15
+
16
+ # One labelled file, in the grid a column's value sits in.
17
+ def attachment_value(name)
18
+ label = resource_model.human_attribute_name name
19
+
20
+ tag.div class: ROW do
21
+ safe_join [tag.div(label, class: 'form-label'), attachment_control(name)]
22
+ end
23
+ end
24
+
25
+ # Nothing attached reads as the dash every other empty value reads as.
26
+ def attachment_control(name)
27
+ blob = attached_blobs(name).first
28
+ read = blob ? attached_file(blob) : t('recourse.blank')
29
+
30
+ tag.div read, class: 'form-control-plaintext'
31
+ end
32
+
33
+ # The picture where there is one to draw, and the name where there is not — a
34
+ # spreadsheet is a download and nothing else. Either opens the file.
35
+ def attached_file(blob)
36
+ blob_preview(blob) || blob_link(blob, blob.filename.to_s)
37
+ end
38
+ end
39
+ end
40
+ end
@@ -3,13 +3,22 @@ module Recourse
3
3
  # What the page is about and what it is called: the model behind it, the record
4
4
  # on it, and the words both are read out under.
5
5
  module Resources
6
- # Human, plural name of the resource on the page, e.g. `Contacts`. `known_title`,
7
- # since a host page wearing this layout may be named after no model at all — a
8
- # contact's home is a `Location`, and the path is the only word for it.
9
- def resources_name = Recourse.known_title(controller.controller_name)
6
+ # Human, plural name of the resource on the page, e.g. `Contacts`. A page of
7
+ # attachments is named after what the record calls them rather than after Active
8
+ # Storage's own word for the row: `Photos`, never `Blobs`. `known_title`, since a
9
+ # host page wearing this layout may be named after no model at all — a contact's
10
+ # home is a `Location`, and the path is the only word for it.
11
+ def resources_name
12
+ return controller.controller_name.humanize if blob_resource?
10
13
 
11
- # Singular, lowercase name of the resource, e.g. 'contact'.
14
+ Recourse.known_title controller.controller_name
15
+ end
16
+
17
+ # Singular, lowercase name of the resource, e.g. 'contact' — and 'photo' on a page
18
+ # of files, for the same reason as above.
12
19
  def resource_name
20
+ return Recourse.downcase controller.controller_name.singularize.humanize if blob_resource?
21
+
13
22
  Recourse.downcase resource_model.model_name.human
14
23
  end
15
24
 
@@ -38,7 +47,8 @@ module Recourse
38
47
 
39
48
  private
40
49
 
41
- # Resolved by the controller, where a host may have named a model the route does not.
50
+ # Resolved by the controller, which is the one that knows whether the name is a
51
+ # model of this app's, something a record has attached, or a model a host named.
42
52
  def resource_model
43
53
  controller_assign('recourse_model') || Recourse.model(controller.controller_name)
44
54
  end
@@ -1,4 +1,6 @@
1
1
  require_relative 'helpers/actions'
2
+ require_relative 'helpers/attachments'
3
+ require_relative 'helpers/blobs'
2
4
  require_relative 'helpers/bookmarks'
3
5
  require_relative 'helpers/buttons'
4
6
  require_relative 'helpers/cards'
@@ -24,6 +26,7 @@ require_relative 'helpers/names'
24
26
  require_relative 'helpers/navigation'
25
27
  require_relative 'helpers/parents'
26
28
  require_relative 'helpers/pictures'
29
+ require_relative 'helpers/previews'
27
30
  require_relative 'helpers/references'
28
31
  require_relative 'helpers/resources'
29
32
  require_relative 'helpers/refreshes'
@@ -42,11 +45,11 @@ require_relative 'helpers/zones'
42
45
  module Recourse
43
46
  # View helpers for the pages the gem renders, and what the parts share.
44
47
  module Helpers
45
- include Actions, Bookmarks, Buttons, Cards,
48
+ include Actions, Attachments, Blobs, Bookmarks, Buttons, Cards,
46
49
  Cells, Choices, Colors, Comboboxes, Constraints, Counters, Deletions, Densities,
47
50
  Details,
48
51
  Examples, Fields, Filters, Flashes, Formats, Inputs, Kinds, Limits, Maps,
49
- Names, Navigation, Parents, Pictures, References, Refreshes,
52
+ Names, Navigation, Parents, Pictures, Previews, References, Refreshes,
50
53
  Routing,
51
54
  Resources, Rows, Searches, Shortcuts, Sidebars, Sorts, Tabs, Themes,
52
55
  Times, Values, Zones
@@ -56,18 +56,26 @@ module Recourse
56
56
 
57
57
  # Ransack reads nothing it has not been shown — `ransackable_attributes` is the
58
58
  # allowlist — so what arrives here needs no permitting, only untangling: a list
59
- # predicate is split back into values, and a filter nobody set is dropped,
60
- # since `IN ()` would match no row rather than every one.
59
+ # predicate is gathered into the values it was picked as, and a filter nobody set
60
+ # is dropped, since `IN ()` would match no row rather than every one.
61
61
  def conditions(params)
62
62
  # `?q=anything` reaches here as a String rather than as parameters of its own,
63
63
  # and a search nobody asked for reaches here as nil. Neither is a condition.
64
64
  return {} unless params.is_a? ActionController::Parameters
65
65
 
66
66
  params.to_unsafe_h.filter_map do |key, value|
67
+ value = list_values value if key.match? LIST_PREDICATES
67
68
  next if value.blank?
68
69
 
69
- [key, key.match?(LIST_PREDICATES) ? value.to_s.split(',') : value]
70
+ [key, value]
70
71
  end.to_h
71
72
  end
73
+
74
+ # A multiple select submits one value per pick, so a list predicate arrives as an
75
+ # array — and as a lone value where a link carried one. Never split on a comma:
76
+ # that was the shape before a combobox became a `<select>`, and it read `["48"]`,
77
+ # the array written out, as one value, which Ransack cast to the 0 no row holds.
78
+ # Emptied of its blanks after, since the way back to no filter submits one.
79
+ def list_values(value) = Array(value).compact_blank
72
80
  end
73
81
  end
@@ -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 = '5.2.0'
3
+ VERSION = '5.3.0'
4
4
  end
data/lib/recourse.rb CHANGED
@@ -4,6 +4,8 @@ require 'ransack'
4
4
  require 'unicon'
5
5
 
6
6
  require_relative 'recourse/version'
7
+ require_relative 'recourse/attachments'
8
+ require_relative 'recourse/blobs'
7
9
  require_relative 'recourse/bookmarks'
8
10
  require_relative 'recourse/colors'
9
11
  require_relative 'recourse/columns'
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: 5.2.0
4
+ version: 5.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - claudiob
@@ -121,6 +121,8 @@ files:
121
121
  - MIT-LICENSE
122
122
  - README.md
123
123
  - app/controllers/bookmarks_controller.rb
124
+ - app/controllers/concerns/recourse/attachment_resolution.rb
125
+ - app/controllers/concerns/recourse/attachment_writing.rb
124
126
  - app/controllers/concerns/recourse/landing.rb
125
127
  - app/controllers/concerns/recourse/list_resolution.rb
126
128
  - app/controllers/concerns/recourse/paging.rb
@@ -156,6 +158,8 @@ files:
156
158
  - app/views/recourses/show.html.erb
157
159
  - config/locales/recourse.en.yml
158
160
  - lib/recourse.rb
161
+ - lib/recourse/attachments.rb
162
+ - lib/recourse/blobs.rb
159
163
  - lib/recourse/bookmarks.rb
160
164
  - lib/recourse/broadcasting.rb
161
165
  - lib/recourse/colors.rb
@@ -165,6 +169,8 @@ files:
165
169
  - lib/recourse/engine.rb
166
170
  - lib/recourse/helpers.rb
167
171
  - lib/recourse/helpers/actions.rb
172
+ - lib/recourse/helpers/attachments.rb
173
+ - lib/recourse/helpers/blobs.rb
168
174
  - lib/recourse/helpers/bookmarks.rb
169
175
  - lib/recourse/helpers/buttons.rb
170
176
  - lib/recourse/helpers/cards.rb
@@ -190,6 +196,7 @@ files:
190
196
  - lib/recourse/helpers/navigation.rb
191
197
  - lib/recourse/helpers/parents.rb
192
198
  - lib/recourse/helpers/pictures.rb
199
+ - lib/recourse/helpers/previews.rb
193
200
  - lib/recourse/helpers/references.rb
194
201
  - lib/recourse/helpers/refreshes.rb
195
202
  - lib/recourse/helpers/resources.rb