recourse 5.5.0 → 5.6.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: 90b3afa1fb3432ce14d6b23b67dfbd972c9887db8077b03eef5a58e43359d079
4
- data.tar.gz: 69c7047b6f440ca4228d19fc2418deb769a51a63890b0c98cf1289059a80195c
3
+ metadata.gz: 8d2b5251cca5129bfd2c634cb82e8cb8951e9363507762ec1efab7263bce419f
4
+ data.tar.gz: 25630ad43f83b9a650e8a4d52fc01628a0032e087d23f7285f420d683db4895c
5
5
  SHA512:
6
- metadata.gz: 1c72dd551e37c712fdf67f2beb4dd0cbcb3c44c234bec7cf8423ddd30a510efbb1ff00151f3820709b58d9a00e59efaf7759ed32cd15b5cc16d22cc46169192d
7
- data.tar.gz: 8528213a6a673cabc608d45be06311439f317d6eb56f90f5d9e36b4b25298bd7e0d29acfa8a6d28dd38c9b0cd61b40c6e5cd4e4e5436c864eeac7da308aa1665
6
+ metadata.gz: 8fa002c794c334526bdc692ba5bcc5cc47f40521501c2dd945f9aa04638013a6af5d5fcae20b57960ab06fdb34adb77cf63dfb7bd8dae4e4908bf369b8d86d56
7
+ data.tar.gz: 8dc080edb46c2273cf4685a5a98c1bf82c58411acec408c1a9cf030ed9c09ff9219a55741df512028f9065f0b09b0f27983dcf58c7dd5dcf7b93e8f72546d5a2
data/CHANGELOG.md CHANGED
@@ -7,6 +7,70 @@ For more information about changelogs, check [Keep a Changelog](http://keepachan
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## 5.6.0 - 2026-09-18
11
+
12
+ * [Feature] A table can be dragged into an order
13
+
14
+ Back from the 4.0.0 development tree, and opting in the way the map and the calendar do:
15
+ a model keeping an integer `position` is one a reader positions by hand. A grip opens each
16
+ row, dragging it moves the row, and `resource :position, only: :update` is where the place
17
+ it landed is written. That route is drawn where the host asks for it —
18
+ `recourses :steps, positionable: true` — and nowhere else: a table is put in order from
19
+ its index, so the keyword is refused on a resource that draws none. The column still
20
+ decides everything else, `recourse_order` among it, because the order a table is read in
21
+ and the order somebody put it in are one fact.
22
+
23
+ The type is load-bearing rather than belt-and-braces, and asked through
24
+ `type_for_attribute` as a calendar asks about its two ends: `contacts.position` holding a
25
+ job title is a word about the row, not a place among rows. A map and a calendar only
26
+ read, so a column mistaken for one costs a link; this writes, and writes at the first
27
+ save.
28
+
29
+ Two things follow the column rather than the routes. `Recourse::Positionable` fills the
30
+ column on create and closes the gap on destroy, so a row made in a console is numbered
31
+ like one made behind a form — what a drop reports is a row's place on the *page*, which
32
+ is a position only while the numbers run 1, 2, 3 with no holes in them. A model pointing
33
+ several ways answers `recourse_siblings` with the rows one of its own is counted among;
34
+ one pointing a single way needs no answer, and one pointing nowhere is positioned among
35
+ the whole table.
36
+
37
+ Whether a page is positioned is asked per page, not per model: a place means something
38
+ under the parent it is counted within, so a flat model's own index is positioned and a
39
+ listing of every row across every parent — where the positions run 1, 2, 3, 1, 2, 3 side
40
+ by side — is not. And the search box and the sorted headings stand down while the grips
41
+ are drawn, a `q` typed by hand refused with them: a filter shortens the page, and a drop
42
+ on a shortened page reports a place among the rows that are left.
43
+
44
+ A host keeping the order itself writes `def recourse_position = nil`. A host with a
45
+ second listing of a positioned model names the second column on its own controller, which
46
+ then owns the write as well as the page — `Recourse::PositionsController` is public to
47
+ subclass, and `Recourse::Positioning` is public for the column such a host maintains.
48
+
49
+ * [Fix] A word that names a class which is not a model
50
+
51
+ `known_title` guarded against a name resolving to nothing — `pause` is a verb no app
52
+ makes a class for — but not against one resolving to a class with no `model_name`. A
53
+ host may keep a plain `Message` that gathers rows rather than a table of them, and a
54
+ breadcrumb over such a page raised instead of reading. It asks whether what it found
55
+ answers `model_name`, the way `known_icon` beside it already does.
56
+
57
+ * [Feature] `Recourse.assets` says where the house bundle is served from
58
+
59
+ The stylesheet, the script and the palettes were three URLs carrying one pinned version,
60
+ moved together by hand every release — the 5.5.0 entry below says as much. They are one
61
+ line now: `Recourse::BUNDLE` is which release, `Recourse.assets` is where it is read
62
+ from, and jsdelivr's copy of the published package stays the default.
63
+
64
+ A host that sets `Recourse.assets = ''` serves the bundle itself, out of the
65
+ `houseaccount` gem mounted by path: its engine answers `/css`, `/js` and `/theme` from
66
+ its own `public/`. That is how a stylesheet or a controller gets tried on a real page
67
+ before the version carrying it is published, which is what this release needed.
68
+
69
+ * [Change] The pinned bundle is `houseaccount@0.14.0`
70
+
71
+ What carries the `sortable` controller the feature above names, and the two rules beside
72
+ it: the grip's cursor, and the tint on the space a dragged row will land in.
73
+
10
74
  ## 5.5.0 - 2026-09-17
11
75
 
12
76
  * [Fix] A sorted heading has a mark again
data/README.md CHANGED
@@ -66,7 +66,8 @@ To change the content displayed in the `index` table of a model, override any of
66
66
  | [`recourse_label`](https://rubydoc.info/gems/recourse/Recourse/Recoursive#recourse_label-instance_method) | `:name` | the column a combobox shows and a foreign-key cell reads; typed rather than picked where it has a length validator |
67
67
  | [`recourse_hidden`](https://rubydoc.info/gems/recourse/Recourse/Recoursive#recourse_hidden-instance_method) | `[]` | columns kept off the table, the page, the form and the search |
68
68
  | [`recourse_displayed`](https://rubydoc.info/gems/recourse/Recourse/Recoursive#recourse_displayed-instance_method) | `[]` | columns a table draws that it would leave off: encrypted ones, the id, timestamps, JSON |
69
- | [`recourse_order`](https://rubydoc.info/gems/recourse/Recourse/Recoursive#recourse_order-instance_method) | `:id` | the index's order, a Symbol or a Hash; rows with nothing in the column come last |
69
+ | [`recourse_order`](https://rubydoc.info/gems/recourse/Recourse/Recoursive#recourse_order-instance_method) | `:id`, or the positioned column | the index's order, a Symbol or a Hash; rows with nothing in the column come last |
70
+ | [`recourse_position`](https://rubydoc.info/gems/recourse/Recourse/Recoursive#recourse_position-instance_method) | `'position'` where the model keeps an integer one | the column a reader drags the rows into order by, or `nil` for a table nobody positions |
70
71
  | [`recourse_icon`](https://rubydoc.info/gems/recourse/Recourse/Recoursive#recourse_icon-instance_method) | the model's name | the icon on the sidebar, the crumbs and the tabs |
71
72
 
72
73
  For instance, this would yield a more compact `index` than the default configuration:
@@ -239,6 +240,69 @@ Both columns have to be datetimes, asked through `type_for_attribute`, so an
239
240
  `starts_at` earns nothing.
240
241
 
241
242
 
243
+ ### Positionable tables
244
+
245
+ A table whose model keeps an integer `position` is one a reader puts in order by hand: a
246
+ grip opens each row, dragging it moves the row, and the place it lands in is written to a
247
+ route drawn under the index. Say where that route belongs, since a page nobody drags needs
248
+ none:
249
+
250
+ ```ruby
251
+ recourses :steps, positionable: true
252
+ ```
253
+
254
+ A table is put in order from its index and from nowhere else, so the keyword is refused on
255
+ a resource that draws none. The column decides the rest — the grips, the callbacks, and
256
+ `recourse_order`, since the order a table is read in and the order somebody put it in are
257
+ one fact.
258
+
259
+ ```ruby
260
+ class Step < ActiveRecord::Base
261
+ belongs_to :team
262
+ belongs_to :person
263
+
264
+ # A step points two ways, so which of them its place is counted within is the model's
265
+ # to say. One `belongs_to` needs no answer, and a model pointing nowhere is positioned
266
+ # among the whole table.
267
+ def recourse_siblings = team.steps
268
+ end
269
+ ```
270
+
271
+ Two things follow the column rather than the routes. A new row lands last among its own
272
+ and the gap closes behind one that goes, whether it was made behind a form or in a
273
+ console: what a drop reports is a row's place on the *page*, which is a position only
274
+ while the numbers run 1, 2, 3 with no holes in them. And a table being positioned is asked
275
+ per page — a position means something under the parent it is counted within, so a flat
276
+ model's own index is positioned and a listing of every row across every parent is not.
277
+
278
+ The search box and the sorted headings stand down while the grips are drawn, and a `q`
279
+ typed by hand is refused for the same reason: a filter shortens the page, and a drop on a
280
+ shortened page reports a place among the rows that are left.
281
+
282
+ A host that keeps the order itself writes `def recourse_position = nil`, which takes the
283
+ grips off the table and the two callbacks off the model together. A host with a *second*
284
+ listing of a positioned model — a plan's place among its service's plans, and another
285
+ among every plan of its department — names the second column on the controller instead,
286
+ and owns the write as well as the page:
287
+
288
+ ```ruby
289
+ module Departmental
290
+ extend ActiveSupport::Concern
291
+
292
+ private
293
+
294
+ def recourse_position = 'ordering'
295
+ end
296
+
297
+ class Administered::Departments::Plans::PositionsController < Recourse::PositionsController
298
+ include Departmental
299
+ end
300
+ ```
301
+
302
+ `Recourse::Positioning` is public for exactly that case: `new(relation, column).close` is
303
+ what such a host calls to keep its own column contiguous.
304
+
305
+
242
306
  ## Development
243
307
 
244
308
  ```bash
@@ -249,6 +313,23 @@ cd test/dummy && bin/rails server
249
313
 
250
314
  The dummy app under `test/dummy` runs on SQLite; the gem names no adapter.
251
315
 
316
+ ### Reading an unpublished bundle
317
+
318
+ The pages link `houseaccount` from a CDN, pinned to `Recourse::BUNDLE`, so a stylesheet or
319
+ a Stimulus controller that is not published yet cannot be read on a page. Two lines change
320
+ that — one in this Gemfile, kept out of it because CI checks out this repo alone:
321
+
322
+ ```bash
323
+ echo "gem 'houseaccount', path: '../design'" >> Gemfile && bundle install
324
+ cd test/dummy && bin/rails db:migrate && HOUSE_ASSETS=1 bin/rails server
325
+ ```
326
+
327
+ `HOUSE_ASSETS` is what loads that gem and sets `Recourse.assets = ''`, so the page asks
328
+ this app for `/css`, `/js` and `/theme` and the gem's engine answers them out of its own
329
+ `public/`. Behind a variable because what a deployed app links is what the suite asserts,
330
+ and because the dummy is also the stand-in for a host with no such gem at all. The dummy's
331
+ development database is migrated on its own, which the line above is why.
332
+
252
333
  ## License
253
334
 
254
335
  [MIT](MIT-LICENSE).
@@ -0,0 +1,40 @@
1
+ module Recourse
2
+ # Which column a listing is dragged into order by, and so whether it is positioned at
3
+ # all. Two questions with one answer, asked by the table that draws the grips, by the
4
+ # search and the headings that stand down where they are drawn, and by the write a
5
+ # drop lands on.
6
+ module Positioned
7
+ extend ActiveSupport::Concern
8
+
9
+ included do
10
+ # After the parent is found, which is what the default turns on.
11
+ before_action { @recourse_position = recourse_position }
12
+ end
13
+
14
+ private
15
+
16
+ # The model's own column by default — the `position` it keeps — and only on a page
17
+ # where a place among rows means something: the rows one is counted within are the
18
+ # rows the page lists, which the parent above it settles.
19
+ #
20
+ # A host overrides it where a listing is positioned by an order the model does not
21
+ # keep. A plan holds a place among its service's plans and another among every plan
22
+ # of its department, and which of the two a page is in is the page's answer rather
23
+ # than the model's: a model keeps one column, and a second listing is a second
24
+ # column that only its own controller knows about.
25
+ #
26
+ # Such a host owns the write as well as the listing. The gem draws a positions
27
+ # controller under every index, and that one has to be told the same two things —
28
+ # this column, and the rows to renumber — or a drop would move a row among rows the
29
+ # page never showed.
30
+ def recourse_position
31
+ return unless resource_model?
32
+ return unless Recourse.positioned? resource_class, @recourse_parent_association
33
+
34
+ Recourse.position_column resource_class
35
+ end
36
+
37
+ # Whether these rows are ones a reader positions, which is the same question again.
38
+ def positioned? = @recourse_position.present?
39
+ end
40
+ end
@@ -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, AttachmentResolution, AttachmentWriting,
6
+ include Pagy::Method, Positioned, AttachmentResolution, AttachmentWriting,
7
7
  Landing, Paging, ListResolution, ParentNaming,
8
8
  ParentResolution, ReferenceResolution, ResourceResolution,
9
9
  Weeks, Zoning
@@ -22,7 +22,7 @@ module Recourse
22
22
  # Lists one page of the model the route is named after, or one week of it where the
23
23
  # calendar was asked for — a week is how much that shape shows. `@q` is Ransack's own.
24
24
  def index
25
- search = Search.new recourse_relation, params[:q]
25
+ search = Search.new recourse_relation, params[:q], positioned: @recourse_position
26
26
  @q = search.query
27
27
 
28
28
  @pagy, @resources = week_or_page search.scope
@@ -0,0 +1,43 @@
1
+ module Recourse
2
+ # The place one row holds in a table somebody positioned: written by a drag, and never
3
+ # read back here. The path names the row and the route names the rows it is counted
4
+ # among, so a position is all there is to submit.
5
+ class PositionsController < ::RecoursesController
6
+ # `update` is a member action everywhere else, so the inherited callback would
7
+ # look for an `:id` a singular resource never carries.
8
+ skip_before_action :find_resource
9
+
10
+ # Puts the row the path names where the request says, among the rows the route
11
+ # named, and answers with as little as the caller can do with: a drag is answered
12
+ # with nothing at all, the row being already where it was dropped, and redrawing
13
+ # the table under the cursor that dropped it is what this avoids.
14
+ def update
15
+ positioning = Positioning.new recourse_relation, positioned_column
16
+ positioning.move moved_record, params.expect(:position)
17
+
18
+ head :no_content
19
+ end
20
+
21
+ private
22
+
23
+ # The model this positions, read off the listing the route was nested under rather
24
+ # than off this controller's own name, which is always `positions`.
25
+ def recourse_model = Recourse.model(listing_path)
26
+
27
+ def moved_record = resource_class.find(params.expect(:"#{listing_name}_id"))
28
+
29
+ # A table nobody positions draws no handle, so the only way here is by hand — which
30
+ # earns a 404 rather than a 500 from somewhere below.
31
+ def positioned_column
32
+ raise ActiveRecord::RecordNotFound unless positioned?
33
+
34
+ @recourse_position
35
+ end
36
+
37
+ # One segment up, which is where the routes drew it — the gem draws this path
38
+ # itself, so unlike a host's nesting there is nothing to look up.
39
+ def listing_path = controller_path.rpartition('/').first
40
+
41
+ def listing_name = listing_path.split('/').last.singularize
42
+ end
43
+ end
@@ -18,15 +18,16 @@
18
18
 
19
19
  <%# Everything the page is styled by, from the one place every HouseAccount app draws
20
20
  on: Bootstrap 6, its icons and the house's own rules for this gem's markup. Served
21
- from the `houseaccount` package on a CDN, pinned to a version, so a change there
22
- reaches these pages when somebody moves the pin here and not before; the same
23
- version stands in the script below and in `Recourse::THEMES_PATH`. A published
24
- version is never written again, so the bytes behind that URL cannot move under us.
25
- Two connections opened before the parser finds either tag: the stylesheet's plain
26
- one and the CORS one a module script and a font are fetched over. -%>
21
+ from the `houseaccount` package on a CDN, pinned to a version `Recourse.assets`
22
+ is where, and `Recourse::BUNDLE` is which so a change there reaches these pages
23
+ when somebody moves that line and not before. Two connections opened before the
24
+ parser finds either tag: the stylesheet's plain one and the CORS one a module
25
+ script and a font are fetched over. They name the CDN outright, being a hint
26
+ about it and nothing more, so a host serving the bundle itself simply never uses
27
+ them. -%>
27
28
  <link rel='preconnect' href='https://cdn.jsdelivr.net'>
28
29
  <link rel='preconnect' href='https://cdn.jsdelivr.net' crossorigin>
29
- <link rel='stylesheet' href='https://cdn.jsdelivr.net/npm/houseaccount@0.13.0/public/css/houseaccount.css'>
30
+ <link rel='stylesheet' href='<%= Recourse.asset 'css/houseaccount.css' %>'>
30
31
  <%# The one thing these screens set that the house's stylesheet does not: system faces
31
32
  at a compact size, for pages dense with figures. 14px as rem, so a browser's own
32
33
  text-size setting still scales it. After the stylesheet, since both are `:root`. -%>
@@ -50,7 +51,7 @@
50
51
  <%# Turbo, Bootstrap and a Stimulus application with every controller this markup
51
52
  names, started once under `window.Stimulus`. In the head, so a controller exists
52
53
  before the body's first `data-controller`. -%>
53
- <script type='module' src='https://cdn.jsdelivr.net/npm/houseaccount@0.13.0/public/js/houseaccount.js'></script>
54
+ <script type='module' src='<%= Recourse.asset 'js/houseaccount.js' %>'></script>
54
55
  </head>
55
56
 
56
57
  <%# The reader's zone is told to the server here rather than from a page of its
@@ -23,6 +23,6 @@
23
23
  link.dataset.recourseTheme = ''
24
24
  document.head.appendChild(link)
25
25
  }
26
- link.href = '<%= Recourse::THEMES_PATH %>/' + stored.theme + '.css'
26
+ link.href = '<%= Recourse.themes_path %>/' + stored.theme + '.css'
27
27
  })()
28
28
  </script>
@@ -17,11 +17,16 @@
17
17
  bookmark_digest, Time.zone.name] -%>
18
18
  <% cache_if cacheable_table?, key do %>
19
19
  <% actions = resource_actions %>
20
+ <%# Read once for the whole table: every row of it is positioned or none is. -%>
21
+ <% positioned = positioned? %>
20
22
  <div class='table-responsive'>
21
23
  <table class='table caption-top table-hover align-middle sm:table-stacked'>
22
24
  <thead>
23
25
  <tr>
24
26
  <% @recourse_headers = true %>
27
+ <%# The grip leads, because dragging it moves the whole row: what a reader
28
+ takes hold of belongs at the edge they reach for it from. -%>
29
+ <%= column header: position_header, class: 'recourse-actions' if positioned %>
25
30
  <%# The square a row is kept by opens the table, ahead of the pages it
26
31
  leads to: whether this row is one of mine, before what to do with it. -%>
27
32
  <%= column header: bookmark_header, class: 'recourse-actions' if resource_bookmarks %>
@@ -31,10 +36,15 @@
31
36
  <%= render 'row', resource_key => nil %>
32
37
  </tr>
33
38
  </thead>
34
- <tbody>
39
+ <tbody <%= tag.attributes data: position_data(pagy) if positioned %>>
35
40
  <% @recourse_headers = false %>
36
41
  <% recourses.each do |recourse| %>
37
- <tr <%= tag.attributes id: Recourse.row_id(recourse), class: bookmark_row_class(recourse) %>>
42
+ <tr <%= tag.attributes id: Recourse.row_id(recourse),
43
+ class: bookmark_row_class(recourse),
44
+ data: ({ sortable_update_url: position_url(recourse) } if positioned) %>>
45
+ <% if positioned -%>
46
+ <%= column header: position_header, class: 'recourse-actions recourse-handle' do %><%= position_handle %><% end %>
47
+ <% end -%>
38
48
  <% if resource_bookmarks -%>
39
49
  <%= column header: bookmark_header, class: 'recourse-actions' do %><%= bookmark_button recourse %><% end %>
40
50
  <% end -%>
@@ -18,6 +18,11 @@ en:
18
18
  ago: "%{distance} ago"
19
19
  all: All %{models}
20
20
  all_values: All
21
+ # Why a model pointing several ways cannot be positioned without a word from it.
22
+ ambiguous_position: "%{model} points several ways, so which of them its position is
23
+ counted within is not the gem's to guess. Answer `recourse_siblings` with the rows
24
+ one of these is positioned among."
25
+ position: Reorder
21
26
  as_calendar: Display as calendar
22
27
  as_map: Display as map
23
28
  as_table: Display as table
@@ -62,6 +67,9 @@ en:
62
67
  has no %{model} model.
63
68
  nested: You nested `recourses %{names}` inside `resources :%{parent}`, which does
64
69
  not namespace what it holds. Nest it under `recourses :%{parent}` instead.
70
+ unindexed: "You asked for `recourses %{names}, positionable: true`, which draws no
71
+ index to put in order. A row is dragged from the table that lists it, so there is
72
+ nowhere for a drop to be reported from."
65
73
  # What a menu with several picks reads as, the first named and the rest counted.
66
74
  more: "%{first} + %{count} more"
67
75
  new: New %{model}
@@ -72,6 +80,7 @@ en:
72
80
  none: "No %{models}."
73
81
  optional: Optional
74
82
  per_page: "%{limit} per page"
83
+ position_updated: Position updated
75
84
  preview: Preview
76
85
  previous_week: Previous week
77
86
  previous_weeks: Previous four weeks
@@ -0,0 +1,32 @@
1
+ # Reopened for where the house bundle these pages are drawn from is served from.
2
+ module Recourse
3
+ # The release of `houseaccount` they are drawn from, named once — the layout links its
4
+ # stylesheet and its script, and the sidebar's toggle swaps a palette out of the same
5
+ # release. A published version is never written again, so the bytes behind these URLs
6
+ # cannot move under us, and a change to the bundle reaches these pages when somebody
7
+ # moves this line and not before.
8
+ BUNDLE = '0.14.0'
9
+
10
+ class << self
11
+ # Where that release is served from, with no trailing slash. jsdelivr's copy of the
12
+ # published package by default, which is what every deployed app reads and the one
13
+ # place all five of them draw from.
14
+ #
15
+ # A host that sets it to `''` serves the bundle itself, out of the `houseaccount`
16
+ # gem: its engine answers `/css`, `/js` and `/theme` from its own `public/`. That is
17
+ # how the bundle is worked on — a stylesheet or a controller can be tried on a real
18
+ # page before the version carrying it is published — and it is the whole of what
19
+ # such a host writes.
20
+ attr_accessor :assets
21
+ end
22
+
23
+ @assets = "https://cdn.jsdelivr.net/npm/houseaccount@#{BUNDLE}/public"
24
+
25
+ # One file of the bundle.
26
+ def self.asset(path) = "#{assets}/#{path}"
27
+
28
+ # And the folder one palette is read from, which three places ask for: the layout
29
+ # links the one a host named, the sidebar's toggle rotates through the rest, and the
30
+ # layout's own script puts back whichever the reader chose last time.
31
+ def self.themes_path = asset('theme')
32
+ end
@@ -21,22 +21,22 @@ module Recourse
21
21
  Recourse.ordered resource_model, resource_model.column_names - hidden_columns
22
22
  end
23
23
 
24
- # What no table shows, less whatever the model asked to draw anyway. Each of
25
- # the four below is a default the gem picks, and a host is what answers for
26
- # its own screens — so naming one overrules it.
24
+ # What no table shows, less whatever the model asked to draw anyway. Each below
25
+ # is a default the gem picks, and a host answers for its own screens.
27
26
  def hidden_columns
28
27
  columns_hidden_by_default - Array(resource_model.recourse_displayed).map(&:to_s)
29
28
  end
30
29
 
31
30
  # Ciphertext, the id that addresses the row, the parent a nested route already
32
31
  # names, the timestamps and every JSON payload — what a machine keeps rather than
33
- # what a row is about — and whatever the model asked to hide, the one of these a
34
- # host decides without the override above.
32
+ # what a row is about — whatever the model asked to hide, the one of these a host
33
+ # decides without the override above, and the column *this page* is dragged into
34
+ # order by, where a host positioned a second listing by one the model never keeps.
35
35
  def columns_hidden_by_default
36
36
  [
37
37
  resource_model.recourse_encrypted_names, resource_model.primary_key, TIMESTAMPS,
38
38
  resource_parent_association&.foreign_key, json_columns,
39
- Recourse.hidden_columns(resource_model),
39
+ Recourse.hidden_columns(resource_model), position_column,
40
40
  ].flatten.compact
41
41
  end
42
42
 
@@ -0,0 +1,54 @@
1
+ module Recourse
2
+ module Helpers
3
+ # What a table somebody positions by hand draws, and what it stops drawing.
4
+ module Positions
5
+ # The concept the grip beside each row is drawn as, named for an icon set to
6
+ # answer for rather than spelled as one set's own class: Bootstrap draws it
7
+ # `grip-vertical`, iOS as the three bars it has used for a reorder grabber
8
+ # since the first table view, and Material as `drag_indicator`.
9
+ HANDLE = :drag
10
+
11
+ private
12
+
13
+ # The column these rows are dragged into order by, which the controller settled
14
+ # before the page began: a listing may be positioned by a column the model never
15
+ # nominated, and only the controller knows which.
16
+ def position_column = controller_assign('recourse_position')
17
+
18
+ # Whether these rows are ones a reader positions, which is the same question.
19
+ def positioned? = position_column.present?
20
+
21
+ # The grip's heading: the icon on the header row, since the column is as narrow
22
+ # as what sits in it, and the word in every other for the `data-cell` a stacked
23
+ # table labels itself with.
24
+ def position_header
25
+ label = t 'recourse.position'
26
+ return label unless @recourse_headers
27
+
28
+ icon_heading HANDLE, label
29
+ end
30
+
31
+ # The grip itself. A method rather than the constant, so a template reaches for
32
+ # nothing a view context cannot resolve.
33
+ def position_handle = icon_tag(HANDLE)
34
+
35
+ # What the body carries so a drop knows what it is counting from: the rows are a
36
+ # page rather than the table, so the index a drag reports is short by whatever
37
+ # the pages before it hold.
38
+ def position_data(pagy)
39
+ {
40
+ controller: 'sortable', sortable_offset_value: pagy&.offset.to_i,
41
+ sortable_message_value: t('recourse.position_updated'),
42
+ }
43
+ end
44
+
45
+ # And what each row carries: where to write the place it ends up. One URL per
46
+ # row rather than one template on the body, so a host's own row partial and a
47
+ # nested table alike are addressed by the routes rather than by string-building.
48
+ def position_url(record)
49
+ url_for controller: "/#{controller.controller_path}/positions", action: :update,
50
+ "#{resource_key}_id": record
51
+ end
52
+ end
53
+ end
54
+ end
@@ -20,6 +20,11 @@ module Recourse
20
20
  # earns it — a table with nothing worth searching may still be worth filtering,
21
21
  # and a model whose only columns are an enum and a foreign key is the case.
22
22
  def search_form
23
+ # Nor on a table somebody positions, for the reason a heading stands down: a
24
+ # search or a filter shortens the page, and a drop on a shortened one reports
25
+ # a place among the rows that are left rather than a position in the table.
26
+ return if positioned?
27
+
23
28
  field = resource_search_field
24
29
  filters = resource_filter_fields
25
30
  return if field.blank? && filters.empty?
@@ -43,8 +43,16 @@ module Recourse
43
43
  resource_column_title column.to_s
44
44
  end
45
45
 
46
- # Whether the model lets a heading sort by this column.
47
- def sortable_column?(column) = resource_model.ransortable_attributes.include?(column.to_s)
46
+ # Whether the model lets a heading sort by this column — and whether this table
47
+ # is one a heading may re-sort at all. A positioned table is read in the order
48
+ # somebody put it in: a drop reports a row's place on the page, which is a
49
+ # position only while the page runs 1, 2, 3, so a heading that re-sorted it would
50
+ # leave the next drag renumbering by the wrong index.
51
+ def sortable_column?(column)
52
+ return false if positioned?
53
+
54
+ resource_model.ransortable_attributes.include? column.to_s
55
+ end
48
56
 
49
57
  def sort_mark(column)
50
58
  sort = resource_search.sorts.find { |one| one.name == column.to_s }
@@ -19,7 +19,7 @@ module Recourse
19
19
  end
20
20
 
21
21
  # Where one palette is served from.
22
- def theme_stylesheet_path(theme) = "#{Recourse::THEMES_PATH}/#{theme}.css"
22
+ def theme_stylesheet_path(theme) = "#{Recourse.themes_path}/#{theme}.css"
23
23
 
24
24
  # What the sidebar's toggle needs to rotate: every palette there is, where they
25
25
  # are served from, and where to keep what the reader picked.
@@ -27,7 +27,7 @@ module Recourse
27
27
  {
28
28
  controller: 'scheme', action: 'scheme#rotate',
29
29
  scheme_themes_value: Recourse::THEMES.keys.to_json,
30
- scheme_path_value: Recourse::THEMES_PATH,
30
+ scheme_path_value: Recourse.themes_path,
31
31
  scheme_storage_value: Recourse::SCHEME_STORAGE,
32
32
  }
33
33
  end
@@ -1,4 +1,5 @@
1
1
  require_relative 'helpers/actions'
2
+ require_relative 'helpers/positions'
2
3
  require_relative 'helpers/attachments'
3
4
  require_relative 'helpers/blobs'
4
5
  require_relative 'helpers/bookmarks'
@@ -50,7 +51,7 @@ require_relative 'helpers/zones'
50
51
  module Recourse
51
52
  # View helpers for the pages the gem renders, and what the parts share.
52
53
  module Helpers
53
- include Actions, Attachments, Blobs, Bookmarks, Buttons, Calendars, Cards,
54
+ include Actions, Positions, Attachments, Blobs, Bookmarks, Buttons, Calendars, Cards,
54
55
  Cells, Choices, Colors, Comboboxes, Constraints, Counters, Deletions, Densities,
55
56
  Details,
56
57
  Events, Examples, Fields, Filters, Flashes, Formats, Inputs, Kinds, Limits, Maps,
@@ -0,0 +1,75 @@
1
+ require 'active_support'
2
+
3
+ module Recourse
4
+ # Keeps a positioned table numbered 1, 2, 3: a new row lands last among its own, and
5
+ # the gap closes behind one that goes. Both are the gem's own screens' business. The
6
+ # form it draws never asks for a position — a reader sets one by dragging a row, and
7
+ # a box beside the handle would be a second way to say a thing already said — so a
8
+ # column the schema insists on has to be filled from somewhere. And what a drop
9
+ # reports is a row's place on the page, which means a position only while the two run
10
+ # together: leave a hole and every later drag lands beside where it was aimed.
11
+ #
12
+ # Every model carries this, and it acts on the ones that keep a `position`. Nothing
13
+ # to include and nothing to remember: the column is the declaration, so a table with
14
+ # one is numbered whether its rows are ever dragged or only ever made in a console.
15
+ # A host maintaining its own order says `def recourse_position = nil` and gets
16
+ # neither callback. What it writes otherwise is `recourse_siblings`, and only where
17
+ # more than one key could be the parent.
18
+ module Positionable
19
+ extend ActiveSupport::Concern
20
+
21
+ included do
22
+ before_validation :recourse_place_last, on: :create
23
+ after_destroy :recourse_close_gap
24
+ end
25
+
26
+ # The rows this one's position is counted among: those under the same parent, or
27
+ # the whole table where the model points nowhere. The parent is named rather than
28
+ # its key, so a key that names no one table is answered by the record it holds and
29
+ # the two halves are matched together.
30
+ #
31
+ # Override where two keys could be the parent and only one is: a picture belongs to
32
+ # a department and to the file it shows, and its place is among the department's.
33
+ def recourse_siblings
34
+ name = recourse_position_reference
35
+ return self.class.all unless name
36
+
37
+ self.class.where name => public_send(name)
38
+ end
39
+
40
+ private
41
+
42
+ # A model pointing one way is positioned within what it points at, and one pointing
43
+ # nowhere is a whole table in one order. Which of several is the parent is the
44
+ # model's to say: guessing would number a picture among every picture there is,
45
+ # quietly and at the first write.
46
+ def recourse_position_reference
47
+ references = self.class.reflect_on_all_associations :belongs_to
48
+ return if references.empty?
49
+ raise Error, I18n.t('recourse.ambiguous_position', model: self.class.name) unless
50
+ references.one?
51
+
52
+ references.first.name
53
+ end
54
+
55
+ def recourse_place_last
56
+ column = Recourse.position_column self.class
57
+ return unless column && self[column].nil?
58
+
59
+ self[column] = recourse_siblings.maximum(column).to_i + 1
60
+ end
61
+
62
+ def recourse_close_gap
63
+ column = Recourse.position_column self.class
64
+ return unless column
65
+
66
+ Positioning.new(recourse_siblings, column).close self[column]
67
+ end
68
+ end
69
+ end
70
+
71
+ # Beside `Recoursive`'s own: a model is positioned by keeping the column, and there is no
72
+ # second place to say it again.
73
+ ActiveSupport.on_load :active_record do
74
+ include Recourse::Positionable
75
+ end
@@ -0,0 +1,60 @@
1
+ module Recourse
2
+ # Moves one row of a positioned table to a place in it, and closes the gap it leaves
3
+ # by shifting whatever it displaced one step the other way.
4
+ class Positioning
5
+ # The rows a position is counted within, and the column it is counted in. A
6
+ # relation rather than a model, because which rows those are is the route's answer
7
+ # on a drag and the record's on a delete, and each caller has already asked.
8
+ def initialize(relation, column)
9
+ @relation = relation
10
+ @column = column
11
+ end
12
+
13
+ # Puts the record at `position`, counting from one and never past the end — a drag
14
+ # reports where a row was dropped, and a page is not the whole table. That the two
15
+ # agree at all is what `Positionable` is for: a drop names a row's place on the page,
16
+ # which is a position only while the table runs 1, 2, 3 with no gaps in it.
17
+ def move(record, position)
18
+ target = position.to_i.clamp 1, @relation.count
19
+ current = record[@column]
20
+ return if target == current
21
+
22
+ @relation.transaction do
23
+ displace record, current, target
24
+ record.update! @column => target
25
+ end
26
+ end
27
+
28
+ # Closes the gap a row left behind it: whatever stood after it moves one step up.
29
+ # The row itself is already gone, so the block starts where it was standing.
30
+ def close(from)
31
+ @relation.where(@column => from..).update_all shift(-1)
32
+ end
33
+
34
+ private
35
+
36
+ # Everything between where the row was and where it is going moves one step
37
+ # towards the space it left: moving up, the block beneath it shifts down; moving
38
+ # down, the block above it shifts up. That is what the two ranges say, the
39
+ # half-open one compensating for the row itself being left out of the count.
40
+ def displace(record, current, target)
41
+ delta = current <=> target
42
+ between = delta.positive? ? target...current : current..target
43
+
44
+ @relation.excluding(record).where(@column => between).update_all shift(delta)
45
+ end
46
+
47
+ # One statement however many rows it moves, and it touches them in the same
48
+ # breath: `update_all` runs no callbacks, so nothing else would tell the relation
49
+ # a table caches on that its version has changed, and the old order would be
50
+ # served straight back.
51
+ def shift(delta)
52
+ column = @relation.klass.connection.quote_column_name @column
53
+ moved = "#{column} = #{column} + ?"
54
+
55
+ return [moved, delta] unless @relation.klass.column_names.include? 'updated_at'
56
+
57
+ ["#{moved}, updated_at = ?", delta, Time.current]
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,50 @@
1
+ # Reopened for the order somebody put a table's rows in, which is neither the order
2
+ # they were made in nor one a heading sorts them into.
3
+ module Recourse
4
+ # The column that order is kept in. The whole of the opt-in, the way a
5
+ # `google_place_id` is the whole of a map's: a table keeping a place in each row has
6
+ # said its rows are in an order, and there is nothing else for a model to declare.
7
+ POSITION_COLUMN = 'position'
8
+
9
+ # Whether a model's rows are positioned by hand. Asked of the type the model reports,
10
+ # as a calendar asks about the two ends of its events — a `position` holding a job
11
+ # title is a word about the row rather than a place among rows, and a table of those
12
+ # is one this must leave alone.
13
+ #
14
+ # The guard earns more here than it does there. A map and a calendar only read, so a
15
+ # column mistaken for one costs a link nobody follows; positioning writes, and it
16
+ # writes at the first save — a new row takes a number and a deleted one closes the
17
+ # gap behind it — so a wrong guess here is a column overwritten rather than a page
18
+ # drawn wrong.
19
+ def self.positionable?(model)
20
+ model.column_names.include?(POSITION_COLUMN) &&
21
+ model.type_for_attribute(POSITION_COLUMN).type == :integer
22
+ end
23
+
24
+ # The column a model is positioned by, or nil where nobody positions it. The model's
25
+ # own word rather than the convention, since that is what a host overrides — and nil
26
+ # for a page whose rows answer no model at all.
27
+ def self.position_column(model)
28
+ model.recourse_position&.to_s if model.respond_to? :recourse_position
29
+ end
30
+
31
+ # The same as a list, for a caller subtracting it from a set of column names: a
32
+ # position is nobody's to type and no table's to draw, since the row's own place in
33
+ # the table already says it.
34
+ def self.position_columns(model) = Array(position_column(model))
35
+
36
+ # Whether *this* page is one the positioning means anything on, which is the same
37
+ # question as whether the rows it lists are the rows a position is counted within.
38
+ #
39
+ # The association rather than the record it found: a key is what makes every row on
40
+ # the page share the parent, and `parent_columns` narrows the relation by the same
41
+ # one. A model nothing points away from — a flat list — is its own whole table, so
42
+ # its index is that level too. What is neither is a page listing every row across
43
+ # every parent, where the positions run 1, 2, 3, 1, 2, 3 and mean nothing side by
44
+ # side: that page sorts and searches like any other, and offers no handle.
45
+ def self.positioned?(model, association)
46
+ return false unless position_column model
47
+
48
+ association.present? || model.recourse_references.empty?
49
+ end
50
+ end
@@ -62,9 +62,25 @@ module Recourse
62
62
  # belongs_to it can follow, since each cell naming one would be a query of its own.
63
63
  def recourse_includes = recourse_references.map(&:name)
64
64
 
65
- # How the index sorts its rows, in any shape `order` accepts. By id by default,
66
- # which is the one column every table has and the order rows were created in.
67
- def recourse_order = :id
65
+ # The column this model's rows are positioned by dragged into an order somebody
66
+ # chose rather than sorted into one or nil for a table nobody positions. The
67
+ # column is the whole of the declaration, so a model keeping a `position` has said
68
+ # this already and says nothing here.
69
+ #
70
+ # Overridden two ways. `def recourse_position = :ordering` where the column is
71
+ # named otherwise, and `def recourse_position = nil` where a host keeps the order
72
+ # itself: that one is the way out of the convention, and it takes the handle off
73
+ # the table and both callbacks off the model together.
74
+ def recourse_position
75
+ Recourse::POSITION_COLUMN if Recourse.positionable? self
76
+ end
77
+
78
+ # How the index sorts its rows, in any shape `order` accepts. The column it is
79
+ # positioned by where there is one — the order a table is read in and the order
80
+ # somebody put it in are one fact, and a table read in another order would have a
81
+ # drop reporting a place that is no position at all — and by id otherwise, which is
82
+ # the one column every table has and the order rows were created in.
83
+ def recourse_order = recourse_position&.to_sym || :id
68
84
 
69
85
  # What a column is for, said where the table itself documents it — a form draws it
70
86
  # under the field that sets it. Read from the schema, which is the one thing here no
@@ -1,27 +1,42 @@
1
1
  module Recourse
2
2
  module Routes
3
3
  # What a `recourses` block draws around whatever the host wrote inside it: the
4
- # square that keeps one of its rows.
4
+ # square that keeps one of its rows, and the place one of them holds.
5
5
  module Nested
6
6
  private
7
7
 
8
- # What a resource holds: the square that keeps one of its rows, and whatever the
9
- # host's own block declared each under the resource's own module, which is what
10
- # every nested page relies on.
11
- def draw_within(keepable, block)
8
+ # What a resource holds: the square that keeps one of its rows, the place a row
9
+ # holds in a table somebody positioned, and whatever the host's own block declared
10
+ # each under the resource's own module, which is what every nested page relies on.
11
+ def draw_within(keepable, positionable, block)
12
+ addressable = addressable_rows?
13
+
12
14
  scope module: parent_resource.name do
13
- draw_bookmark if keepable && addressable_rows?
15
+ draw_bookmark if keepable && addressable
16
+ draw_position if positionable && addressable
14
17
  instance_exec(&block) if block
15
18
  end
16
19
  end
17
20
 
18
21
  # Whether this resource has rows to address one at a time. A bookmark names one
19
- # row, which means nothing for a name with no class behind it at all — and one was
20
- # drawn anyway, at `/placeholders/:placeholder_id/bookmark`, where nothing linked
21
- # to it and anything reaching it raised. Asked of the constant rather than the
22
- # class: loading a model while the routes draw is what Rails 8.2 warns about.
22
+ # row and a position names one row, so neither means anything for a name with no
23
+ # class behind it at all and one was drawn anyway, at
24
+ # `/placeholders/:placeholder_id/bookmark`, where nothing linked to it and
25
+ # anything reaching it raised. Asked of the constant rather than the class:
26
+ # loading a model while the routes draw is what Rails 8.2 warns about.
23
27
  def addressable_rows? = Object.const_defined? Recourse.model_name(parent_resource.name)
24
28
 
29
+ # The place a row of a positioned table holds, at `/teams/5/position`. Recorded
30
+ # nowhere, for the reason the bookmark gives: a tab and a bare-action button both
31
+ # look under a resource, and this is neither. Drawn wherever an index is —
32
+ # whether a model keeps a position column is a question for a request, and asking
33
+ # it here would reach for a database before the routes are even finished.
34
+ def draw_position
35
+ path = [current_module, 'positions'].compact.join '/'
36
+ Controllers.define_missing(path) { PositionsController }
37
+ resource :position, only: :update
38
+ end
39
+
25
40
  # The row a table's bookmark square writes: one record kept by whoever is looking,
26
41
  # at `/places/5/bookmark`. Deliberately not recorded through `Recourse.nest` — it
27
42
  # would sit directly under the resource, where a tab and a bare-action button both
@@ -24,10 +24,15 @@ module Recourse
24
24
  keepable = Recourse.bookmarks? && parent_resource.nil?
25
25
 
26
26
  names.each { |name| declare_resource name }
27
+ # The host's word, and taken out before Rails sees the rest: `resources` would
28
+ # refuse a keyword it does not know.
29
+ positionable = options.delete(:positionable) { false }
27
30
  options = default_nested_actions options
28
- return resources(*names, **options) unless block || keepable
31
+ refuse_unindexed_positioning names, positionable, options
29
32
 
30
- resources(*names, **options) { draw_within keepable, block }
33
+ return resources(*names, **options) unless block || keepable || positionable
34
+
35
+ resources(*names, **options) { draw_within keepable, positionable, block }
31
36
  end
32
37
 
33
38
  private
@@ -40,6 +45,23 @@ module Recourse
40
45
  Controllers.define_missing path
41
46
  end
42
47
 
48
+ # A table is put in order from its index and from nowhere else, so asking for the
49
+ # route without drawing one is a host saying two things that cannot both hold.
50
+ def refuse_unindexed_positioning(names, positionable, options)
51
+ return unless positionable && !indexed?(options)
52
+
53
+ raise Error, I18n.t('recourse.unindexed', names: names.map(&:inspect).join(', '))
54
+ end
55
+
56
+ # Whether `index` survived the `only:` or `except:` the host wrote. Neither of
57
+ # them is the common case, and both name the action plainly.
58
+ def indexed?(options)
59
+ return Array(options[:except]).map(&:to_sym).exclude?(:index) if options.key? :except
60
+ return Array(options[:only]).map(&:to_sym).include?(:index) if options.key? :only
61
+
62
+ true
63
+ end
64
+
43
65
  def default_nested_actions(options)
44
66
  return options if !parent_resource || options.key?(:only) || options.key?(:except)
45
67
 
@@ -14,8 +14,14 @@ module Recourse
14
14
  # it narrowed to rather than around it — the model is still what answers for the
15
15
  # order, the eager loads and the allowlist, and a relation knows its own.
16
16
  #
17
- def initialize(relation, params)
17
+ # `positioned` is the column this page is dragged into order by, or nil where it is
18
+ # no such page. The controller's word rather than a question asked here: whether a
19
+ # place among rows means anything depends on the level the page was reached at,
20
+ # which the route knows and a relation does not — and a listing may be positioned by
21
+ # a column the model never keeps, which only its own controller knows.
22
+ def initialize(relation, params, positioned: nil)
18
23
  @model = relation.klass
24
+ @positioned = positioned
19
25
  @query = relation.ransack conditions(params)
20
26
  end
21
27
 
@@ -33,19 +39,27 @@ module Recourse
33
39
  # A heading's sort or the model's own order, each column with its empty rows last.
34
40
  # Ransack has ordered the relation already where a heading asked, but as the bare
35
41
  # column; the same sort is written again here so it ends the way every order does.
42
+ #
43
+ # And on a page somebody positions, the column they positioned it by — which is the
44
+ # model's own order for a table positioned by the column it keeps, and is not for the
45
+ # second listing of one, where a host named another. The order a table is read in
46
+ # and the order somebody put it in have to be the same order, or a drop reports a
47
+ # place that is no position at all.
36
48
  def ordering
37
49
  sorts = @query.sorts.map { |sort| sort.attr.public_send(sort.dir).nulls_last }
38
50
  return sorts if sorts.any?
39
51
 
40
- kept_first + Recourse.nulls_last(@model, @model.recourse_order)
52
+ kept_first + Recourse.nulls_last(@model, @positioned&.to_sym || @model.recourse_order)
41
53
  end
42
54
 
43
55
  # The rows this viewer has kept, ahead of whatever the model orders by — but only
44
56
  # where nobody clicked a heading, which is the same word `recourse_order` answers
45
57
  # to. A semi-join rather than an outer one: it cannot multiply a row, and it
46
- # leaves the count pagy runs over this relation well-formed.
58
+ # leaves the count pagy runs over this relation well-formed. Never on a positioned
59
+ # table, where it would put one reader's kept rows ahead of the order everybody
60
+ # else set by hand — and leave the numbers no longer running 1, 2, 3 down the page.
47
61
  def kept_first
48
- reflection = Recourse.bookmarks_for @model
62
+ reflection = Recourse.bookmarks_for @model unless @positioned
49
63
  return [] unless reflection
50
64
 
51
65
  kept = Recourse.bookmarks_of(reflection).select reflection.foreign_key
@@ -60,8 +74,12 @@ module Recourse
60
74
  # is dropped, since `IN ()` would match no row rather than every one.
61
75
  def conditions(params)
62
76
  # `?q=anything` reaches here as a String rather than as parameters of its own,
63
- # and a search nobody asked for reaches here as nil. Neither is a condition.
64
- return {} unless params.is_a? ActionController::Parameters
77
+ # and a search nobody asked for reaches here as nil. Neither is a condition — and
78
+ # neither is anything at all where the table is positioned by hand, since a search
79
+ # or a filter shortens the page and a drop on a shortened one reports a place
80
+ # among the rows that are left. Refused here rather than by leaving the form off
81
+ # the page, because an address is typed as readily as it is clicked.
82
+ return {} if @positioned || !params.is_a?(ActionController::Parameters)
65
83
 
66
84
  params.to_unsafe_h.filter_map do |key, value|
67
85
  value = list_values value if key.match? LIST_PREDICATES
@@ -22,11 +22,6 @@ module Recourse
22
22
  tokyo_night: %i[blue brown cyan gray green orange pink purple red yellow],
23
23
  }.freeze
24
24
 
25
- # Where the palettes are served from, named once: the layout links one, the layout's
26
- # own script puts back the one a reader chose, and the sidebar's controller swaps it.
27
- # The version is the one the layout links its stylesheet and script at.
28
- THEMES_PATH = 'https://cdn.jsdelivr.net/npm/houseaccount@0.13.0/public/theme'
29
-
30
25
  # Where a reader's chosen palette and mode are kept in their browser. Named once for
31
26
  # the same reason: the sidebar's controller writes it and the layout reads it back.
32
27
  SCHEME_STORAGE = 'recourse-scheme'
@@ -26,14 +26,16 @@ module Recourse
26
26
  model_title model(name)
27
27
  end
28
28
 
29
- # The same for a name that may resolve to no model at all: an action drawn under
30
- # a record still needs a word for its button, and `pause` is a verb this app never
31
- # made a class for.
29
+ # The same for a name that may name no model at all: an action drawn under a record
30
+ # still needs a word for its button, and `pause` is a verb this app never made a
31
+ # class for. A word that does resolve is not a model either where what it found has
32
+ # no `model_name` — `Message` may be a host's own class for gathering rows rather
33
+ # than a table of them, and asking it would raise rather than read.
32
34
  def known_title(name)
33
35
  segment = name.to_s.split('/').last
34
36
  model = segment.classify.safe_constantize
35
37
 
36
- model ? model_title(model) : segment.humanize
38
+ model.respond_to?(:model_name) ? model_title(model) : segment.humanize
37
39
  end
38
40
 
39
41
  # And the singular of that, for the button a bare action earns: `Add sweep` is one
@@ -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.5.0'
3
+ VERSION = '5.6.0'
4
4
  end
data/lib/recourse.rb CHANGED
@@ -4,6 +4,7 @@ require 'ransack'
4
4
  require 'unicon'
5
5
 
6
6
  require_relative 'recourse/version'
7
+ require_relative 'recourse/assets'
7
8
  require_relative 'recourse/attachments'
8
9
  require_relative 'recourse/blobs'
9
10
  require_relative 'recourse/bookmarks'
@@ -18,8 +19,11 @@ require_relative 'recourse/helpers'
18
19
  require_relative 'recourse/limits'
19
20
  require_relative 'recourse/maps'
20
21
  require_relative 'recourse/orders'
22
+ require_relative 'recourse/positions'
23
+ require_relative 'recourse/positioning'
21
24
  require_relative 'recourse/writes'
22
25
  require_relative 'recourse/zones'
26
+ require_relative 'recourse/positionable'
23
27
  require_relative 'recourse/broadcasting'
24
28
  require_relative 'recourse/recoursive'
25
29
  require_relative 'recourse/registry'
@@ -36,9 +40,8 @@ module Recourse
36
40
  # them. Named once: three places ask which columns these are.
37
41
  TIMESTAMPS = %w[created_at updated_at].freeze
38
42
 
39
- # The shapes a page of rows takes besides the table, by the format each is asked
40
- # for. Every one of them is the same HTML drawn another way, which is why none has
41
- # a template of its own and all three answer to one controller action.
43
+ # The shapes a page of rows takes besides the table, by the format each is asked for.
44
+ # Both are the same HTML drawn another way: no template of their own, and one action.
42
45
  SHAPES = %i[map cal].freeze
43
46
 
44
47
  class << self
@@ -59,12 +62,14 @@ module Recourse
59
62
  hidden_columns(model)
60
63
  end
61
64
 
62
- # Columns no screen shows: whatever the model asked to hide through
63
- # `recourse_hidden` — one name or a list, taken either way — and the column Rails
64
- # reserves for single table inheritance. A class name is machinery, not something
65
- # to read out or type over.
65
+ # Columns no screen shows: whatever the model asked to hide through `recourse_hidden`
66
+ # — one name or a list, taken either way — the column Rails reserves for single table
67
+ # inheritance, and the place a row holds where somebody positioned the table. A class
68
+ # name is machinery rather than something to read out, and a position is set by
69
+ # dragging the row rather than typed beside it.
66
70
  def self.hidden_columns(model)
67
- Array(model.recourse_hidden).map(&:to_s) + [model.inheritance_column]
71
+ Array(model.recourse_hidden).map(&:to_s) +
72
+ [model.inheritance_column, *position_columns(model)]
68
73
  end
69
74
 
70
75
  # The names a column is validated under: its own, and — where it is a foreign key
@@ -75,17 +80,16 @@ module Recourse
75
80
  [column, column.delete_suffix('_id')].uniq
76
81
  end
77
82
 
78
- # The model a resource is named after. A controller the gem defined has nothing
79
- # else to go on, so a name that resolves to no model is a routes file to fix
80
- # rather than a `NameError` from somewhere inside a view.
83
+ # The model a resource is named after. A controller the gem defined has nothing else
84
+ # to go on, so a name resolving to no model is a routes file to fix rather than a
85
+ # `NameError` from somewhere inside a view.
81
86
  def self.model(name)
82
87
  model?(name) || raise(Error, I18n.t('recourse.missing_model', name:, model: model_name(name)))
83
88
  end
84
89
 
85
90
  # The same, answering nil where there is no such model rather than raising. A bare
86
- # action is a verb — `recourses :sweeps, only: :create` — and the gem labels its button
87
- # from the path alone, so whether a name has a model behind it has to be a question
88
- # and not an accusation.
91
+ # action is a verb — `recourses :sweeps, only: :create` — labelled from the path alone,
92
+ # so whether a name has a model behind it is a question and not an accusation.
89
93
  def self.model?(name) = model_name(name).safe_constantize
90
94
 
91
95
  # A namespaced resource is `admin/sources`, and the model it lists is a Source.
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.5.0
4
+ version: 5.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - claudiob
@@ -128,12 +128,14 @@ files:
128
128
  - app/controllers/concerns/recourse/paging.rb
129
129
  - app/controllers/concerns/recourse/parent_naming.rb
130
130
  - app/controllers/concerns/recourse/parent_resolution.rb
131
+ - app/controllers/concerns/recourse/positioned.rb
131
132
  - app/controllers/concerns/recourse/reference_resolution.rb
132
133
  - app/controllers/concerns/recourse/resource_resolution.rb
133
134
  - app/controllers/concerns/recourse/weeks.rb
134
135
  - app/controllers/concerns/recourse/zoning.rb
135
136
  - app/controllers/recourse/base_controller.rb
136
137
  - app/controllers/recourse/bookmarks_controller.rb
138
+ - app/controllers/recourse/positions_controller.rb
137
139
  - app/controllers/recourses_controller.rb
138
140
  - app/views/layouts/recourses.html.erb
139
141
  - app/views/recourses/_breadcrumb.html.erb
@@ -161,6 +163,7 @@ files:
161
163
  - app/views/recourses/show.html.erb
162
164
  - config/locales/recourse.en.yml
163
165
  - lib/recourse.rb
166
+ - lib/recourse/assets.rb
164
167
  - lib/recourse/attachments.rb
165
168
  - lib/recourse/blobs.rb
166
169
  - lib/recourse/bookmarks.rb
@@ -202,6 +205,7 @@ files:
202
205
  - lib/recourse/helpers/navigation.rb
203
206
  - lib/recourse/helpers/parents.rb
204
207
  - lib/recourse/helpers/pictures.rb
208
+ - lib/recourse/helpers/positions.rb
205
209
  - lib/recourse/helpers/previews.rb
206
210
  - lib/recourse/helpers/references.rb
207
211
  - lib/recourse/helpers/refreshes.rb
@@ -224,6 +228,9 @@ files:
224
228
  - lib/recourse/limits.rb
225
229
  - lib/recourse/maps.rb
226
230
  - lib/recourse/orders.rb
231
+ - lib/recourse/positionable.rb
232
+ - lib/recourse/positioning.rb
233
+ - lib/recourse/positions.rb
227
234
  - lib/recourse/recoursive.rb
228
235
  - lib/recourse/registry.rb
229
236
  - lib/recourse/routes.rb