administratum 0.2.4 → 0.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.
@@ -56,13 +56,52 @@ module Administratum
56
56
  end
57
57
 
58
58
  # Options for select-style fields. `collection` may be an array, a
59
- # relation, or a lambda (optionally receiving the record).
59
+ # relation, or a lambda (optionally receiving the record). `group_by:` (an
60
+ # attribute name, or a lambda receiving each item) groups them into optgroups.
60
61
  def field_collection(field, record)
61
62
  collection = field.options[:collection]
62
63
  collection = collection.arity.zero? ? instance_exec(&collection) : instance_exec(record, &collection) if collection.respond_to?(:call)
63
64
  collection ||= association_collection(field, record)
64
65
  collection = collection.to_a if collection.is_a?(ActiveRecord::Relation)
65
- collection.map { |item| item.is_a?(ActiveRecord::Base) ? [record_title(item), item.id] : item }
66
+ group_by = field.options[:group_by]
67
+ return collection.map { |item| collection_choice(item) } unless group_by
68
+
69
+ collection.group_by { |item| collection_group(group_by, item) }
70
+ .map { |group, items| [group, items.map { |item| collection_choice(item) }] }
71
+ end
72
+
73
+ # The `searchable:` option of a select field, resolved: nil, or a hash with
74
+ # the search `url:` (a string, or a lambda optionally receiving the record) and `min:`.
75
+ def field_search(field, record)
76
+ option = field.options[:searchable]
77
+ return unless option
78
+
79
+ option = {} if option == true
80
+ url = option[:url]
81
+ url = url.arity.zero? ? instance_exec(&url) : instance_exec(record, &url) if url.respond_to?(:call)
82
+ { url:, min: option[:min] }
83
+ end
84
+
85
+ # Choices for a select field. One that searches remotely only renders the
86
+ # current value; the rest come from the search URL.
87
+ def select_field_choices(field, record, search = field_search(field, record))
88
+ return field_collection(field, record) unless search&.dig(:url)
89
+
90
+ value = record.try(field.name)
91
+ return [] if value.blank?
92
+
93
+ reflection = field_reflection(field, record)
94
+ return [[value.to_s, value]] unless reflection
95
+
96
+ reflection.klass.where(reflection.klass.primary_key => value).map { |item| collection_choice(item) }
97
+ end
98
+
99
+ # Data attributes that make a select searchable (see administratum.js).
100
+ # Use them on hand-written selects too: `data: adm_combobox_data(url: …)`.
101
+ def adm_combobox_data(url: nil, min: nil)
102
+ { adm_combobox: true, adm_combobox_url: url, adm_combobox_min: min,
103
+ adm_combobox_empty: t("administratum.combobox.empty"), adm_combobox_loading: t("administratum.combobox.loading"),
104
+ adm_combobox_short: t("administratum.combobox.short"), adm_combobox_error: t("administratum.combobox.error") }.compact
66
105
  end
67
106
 
68
107
  # HTML options for a field's input: the partial's defaults, then the
@@ -101,11 +140,24 @@ module Administratum
101
140
  end
102
141
 
103
142
  def association_collection(field, record)
104
- name = field.name.to_s.delete_suffix("_ids").delete_suffix("_id")
105
- reflection = record.class.reflect_on_association(name.to_sym) || record.class.reflect_on_association(name.pluralize.to_sym)
143
+ reflection = field_reflection(field, record)
106
144
  reflection ? reflection.klass.all : []
107
145
  end
108
146
 
147
+ def field_reflection(field, record)
148
+ name = field.name.to_s.delete_suffix("_ids").delete_suffix("_id")
149
+ record.class.reflect_on_association(name.to_sym) || record.class.reflect_on_association(name.pluralize.to_sym)
150
+ end
151
+
152
+ def collection_choice(item)
153
+ item.is_a?(ActiveRecord::Base) ? [record_title(item), item.id] : item
154
+ end
155
+
156
+ def collection_group(group_by, item)
157
+ group = group_by.respond_to?(:call) ? instance_exec(item, &group_by) : item.public_send(group_by)
158
+ group.is_a?(ActiveRecord::Base) ? record_title(group) : group.to_s
159
+ end
160
+
109
161
  # Dates and times are formatted; anything else (such as a submitted
110
162
  # string that failed validation) is shown as typed.
111
163
  def iso_value(value, format)
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Administratum
4
+ # Controls for putting records in order. Mark the list's container with
5
+ # data-adm-sortable and each record's element with data-adm-sortable-item,
6
+ # put reorder_controls in each, and submit them with reorder_form and
7
+ # reorder_save_button:
8
+ #
9
+ # <%= reorder_form id: "chapter-order", url: action_path(:reorder) %>
10
+ # <ol data-adm-sortable>
11
+ # <% chapters.each do |chapter| %>
12
+ # <li data-adm-sortable-item><%= reorder_controls chapter, form: "chapter-order" %> <%= chapter.title %></li>
13
+ # <% end %>
14
+ # </ol>
15
+ # <%= reorder_save_button form: "chapter-order" %>
16
+ module ReorderHelper
17
+ # An empty form. The position fields and the save button join it through
18
+ # their form="…" attribute, so the list can sit anywhere, even around rows
19
+ # that hold forms of their own.
20
+ def reorder_form(id:, url:, method: :patch)
21
+ form_with(url:, method:, id:, class: "adm-reorder-form") { "" }
22
+ end
23
+
24
+ # A drag handle and move up/down buttons, shown by administratum.js, and
25
+ # the positions[id] number field they keep up to date. Without JS only the
26
+ # number field shows.
27
+ def reorder_controls(record, form:, column: :position, position: record.public_send(column))
28
+ input_id = "#{form}-position-#{record.id}"
29
+ fallback = tag.span(data: { adm_sortable_fallback: true }) do
30
+ label_tag(input_id, t("administratum.reorder.position"), class: "adm-sr-only") +
31
+ number_field_tag("positions[#{record.id}]", position, id: input_id, form:, min: 1,
32
+ class: "adm-input adm-sortable-position", data: { adm_sortable_position: true })
33
+ end
34
+
35
+ tag.span(class: "adm-sortable-controls", data: { adm_sortable_moved: t("administratum.reorder.moved") }) do
36
+ safe_join([
37
+ reorder_button("⠿", :handle, class: "adm-icon-btn adm-sortable-handle", data: { adm_sortable_handle: true }),
38
+ reorder_button("↑", :up, class: "adm-icon-btn", data: { adm_sortable_up: true }),
39
+ reorder_button("↓", :down, class: "adm-icon-btn", data: { adm_sortable_down: true }),
40
+ fallback
41
+ ])
42
+ end
43
+ end
44
+
45
+ def reorder_save_button(form:, label: t("administratum.reorder.save"), **options)
46
+ tag.button(label, **options.except(:class), type: "submit", form:, class: class_names("adm-btn adm-btn-primary", options[:class]))
47
+ end
48
+
49
+ private
50
+
51
+ def reorder_button(symbol, name, **)
52
+ tag.button(symbol, type: "button", hidden: true, aria: { label: t("administratum.reorder.#{name}") }, **)
53
+ end
54
+ end
55
+ end
@@ -1 +1,2 @@
1
- <%= form.select field.name, field_collection(field, resource), { include_blank: field.options[:placeholder] || field.options.fetch(:include_blank, true) }, **input_options(field, form:, class: "adm-input") %>
1
+ <% search = field_search(field, resource) %>
2
+ <%= form.select field.name, select_field_choices(field, resource, search), { include_blank: field.options[:placeholder] || field.options.fetch(:include_blank, true) }, **input_options(field, form:, class: "adm-input", data: search ? adm_combobox_data(**search) : {}) %>
@@ -1 +1,2 @@
1
- <%= form.select field.name, field_collection(field, resource), { include_blank: field.options[:placeholder] || field.options.fetch(:include_blank, false) }, **input_options(field, form:, class: "adm-input") %>
1
+ <% search = field_search(field, resource) %>
2
+ <%= form.select field.name, select_field_choices(field, resource, search), { include_blank: field.options[:placeholder] || field.options.fetch(:include_blank, false) }, **input_options(field, form:, class: "adm-input", data: search ? adm_combobox_data(**search) : {}) %>
@@ -1,17 +1,24 @@
1
+ <% reorderable = index_reorderable? %>
1
2
  <section class="adm-card">
2
3
  <div class="adm-table-wrap">
3
4
  <table class="adm-table">
4
5
  <thead>
5
6
  <tr>
7
+ <% if reorderable %>
8
+ <th scope="col" class="w-px whitespace-nowrap"><%= t("administratum.reorder.column") %></th>
9
+ <% end %>
6
10
  <% definition.index_columns.each do |column| %>
7
11
  <th scope="col"><%= column.sortable ? sort_link(@search, column.name, column_label(column)) : column_label(column) %></th>
8
12
  <% end %>
9
13
  <th scope="col" class="adm-table-actions"><span class="adm-sr-only"><%= t("administratum.actions.title") %></span></th>
10
14
  </tr>
11
15
  </thead>
12
- <tbody>
16
+ <tbody<% if reorderable %> data-adm-sortable<% end %>>
13
17
  <% @resources.each do |record| %>
14
- <tr>
18
+ <tr<% if reorderable %> data-adm-sortable-item<% end %>>
19
+ <% if reorderable %>
20
+ <td class="w-px whitespace-nowrap"><%= reorder_controls record, form: "adm-reorder", column: definition.reorder_column %></td>
21
+ <% end %>
15
22
  <% definition.index_columns.each_with_index do |column, index| %>
16
23
  <td>
17
24
  <% if index.zero? && !column.block && definition.action_enabled?(:show) %>
@@ -25,7 +32,7 @@
25
32
  </tr>
26
33
  <% end %>
27
34
  <% if @resources.empty? %>
28
- <tr><td colspan="<%= definition.index_columns.size + 1 %>" class="adm-empty"><%= t("administratum.empty", resources: resource_class.plural_label.downcase) %></td></tr>
35
+ <tr><td colspan="<%= definition.index_columns.size + (reorderable ? 2 : 1) %>" class="adm-empty"><%= t("administratum.empty", resources: resource_class.plural_label.downcase) %></td></tr>
29
36
  <% end %>
30
37
  </tbody>
31
38
  </table>
@@ -37,6 +44,12 @@
37
44
  · <%= link_to t("administratum.download_csv"), url_for(format: :csv, q: request.query_parameters[:q], scope: current_index_scope&.name), class: "adm-link" %>
38
45
  <% end %>
39
46
  </div>
47
+ <% if reorderable %>
48
+ <div class="adm-form-footer">
49
+ <%= reorder_form id: "adm-reorder", url: action_path(:reorder) %>
50
+ <%= reorder_save_button form: "adm-reorder", class: "adm-btn-sm" %>
51
+ </div>
52
+ <% end %>
40
53
  <div class="adm-pagy"><%== pagy_nav(@pagy) if @pagy.pages > 1 %></div>
41
54
  </footer>
42
55
  </section>
@@ -21,6 +21,7 @@ en:
21
21
  created: "%{resource} was created."
22
22
  updated: "%{resource} was updated."
23
23
  destroyed: "%{resource} was deleted."
24
+ reordered: "%{resources} were reordered."
24
25
  errors:
25
26
  title:
26
27
  one: 1 error prevented saving
@@ -33,6 +34,19 @@ en:
33
34
  any: Any
34
35
  from: "%{field} from"
35
36
  to: "%{field} to"
37
+ combobox:
38
+ empty: No matches
39
+ loading: Searching…
40
+ short: Type at least %{count} characters
41
+ error: Search failed
42
+ reorder:
43
+ handle: Reorder (drag, or use the arrow keys)
44
+ up: Move up
45
+ down: Move down
46
+ position: Position
47
+ moved: Moved to position %{position} of %{count}
48
+ save: Save order
49
+ column: Order
36
50
  upload:
37
51
  file: File
38
52
  submit: Upload
@@ -101,7 +101,7 @@ module Administratum
101
101
  end
102
102
 
103
103
  attr_accessor :model, :title_attribute, :permitted, :parent, :form_template, :scope_block, :route_key, :label,
104
- :plural_label, :per_page, :default_sort, :csv
104
+ :plural_label, :per_page, :default_sort, :csv, :reorder_column
105
105
  attr_reader :fields, :columns, :custom_actions, :buttons, :show_attributes, :enabled_actions, :menu, :index_scopes
106
106
 
107
107
  def initialize
@@ -206,6 +206,7 @@ module Administratum
206
206
  names = filters.map { |filter| filter.name.to_s }
207
207
  names += index_columns.select(&:sortable).map { |column| column.name.to_s }
208
208
  names << title.to_s if title
209
+ names << reorder_column.to_s if reorder_column
209
210
  names += Array(default_sort).map { |sort| sort.to_s.split.first }
210
211
  names.uniq & model.column_names
211
212
  end
@@ -152,6 +152,15 @@ module Administratum
152
152
  definition.enable_actions(only:, except:)
153
153
  end
154
154
 
155
+ # Records are put in order by an integer column. Adds a PATCH `reorder`
156
+ # collection action taking positions[id]=n, sorts the index by the column,
157
+ # and shows reorder controls there while every record is on the page.
158
+ def reorderable(column = :position)
159
+ definition.reorder_column = column.to_sym
160
+ definition.default_sort ||= "#{column} asc"
161
+ collection_action :reorder, method: :patch
162
+ end
163
+
155
164
  def member_action(name, method: :get)
156
165
  definition.add_action(name, http_method: method, scope: :member)
157
166
  end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Administratum
4
+ # Puts records in order from a positions[id]=n payload.
5
+ module Reorder
6
+ module_function
7
+
8
+ # Only records in `relation` move. Each submitted record is put at its
9
+ # number among the others (ties keep the current order); records left out
10
+ # keep their order around them. Positions are then renumbered from 1, and
11
+ # changed records saved with update!, so validations and callbacks run.
12
+ # Returns the records in their new order.
13
+ def call(relation, positions, column: :position)
14
+ positions = positions.to_unsafe_h if positions.respond_to?(:to_unsafe_h)
15
+ positions = {} unless positions.is_a?(Hash)
16
+ wanted = positions.to_h { |id, value| [id.to_s, Integer(value.to_s, exception: false)] }.compact
17
+
18
+ moved, sorted = relation.reorder(column, relation.klass.primary_key).to_a.partition { |record| wanted.key?(record.id.to_s) }
19
+ index = -1
20
+ moved.sort_by.with_index { |record, current| [wanted[record.id.to_s], current] }.each do |record|
21
+ index = (wanted[record.id.to_s] - 1).clamp(index + 1, sorted.size)
22
+ sorted.insert(index, record)
23
+ end
24
+
25
+ relation.klass.transaction do
26
+ sorted.each.with_index(1) do |record, position|
27
+ record.update!(column => position) unless record.public_send(column) == position
28
+ end
29
+ end
30
+ sorted
31
+ end
32
+ end
33
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Administratum
4
- VERSION = "0.2.4"
4
+ VERSION = "0.3.0"
5
5
  end
data/lib/administratum.rb CHANGED
@@ -12,6 +12,7 @@ require "administratum/definition"
12
12
  require "administratum/dsl"
13
13
  require "administratum/page_dsl"
14
14
  require "administratum/paths"
15
+ require "administratum/reorder"
15
16
  require "administratum/registry"
16
17
  require "administratum/ransackable"
17
18
  require "administratum/routing"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: administratum
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.4
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - buffpojken
@@ -114,6 +114,7 @@ files:
114
114
  - app/helpers/administratum/form_helper.rb
115
115
  - app/helpers/administratum/island_helper.rb
116
116
  - app/helpers/administratum/layout_helper.rb
117
+ - app/helpers/administratum/reorder_helper.rb
117
118
  - app/helpers/administratum/resource_helper.rb
118
119
  - app/mailers/administratum/application_mailer.rb
119
120
  - app/mailers/administratum/user_mailer.rb
@@ -172,6 +173,7 @@ files:
172
173
  - lib/administratum/paths.rb
173
174
  - lib/administratum/ransackable.rb
174
175
  - lib/administratum/registry.rb
176
+ - lib/administratum/reorder.rb
175
177
  - lib/administratum/routing.rb
176
178
  - lib/administratum/tailwind.rb
177
179
  - lib/administratum/version.rb