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.
- checksums.yaml +4 -4
- data/DESIGN.md +33 -3
- data/README.md +69 -3
- data/app/assets/builds/administratum.css +1 -1
- data/app/assets/javascripts/administratum.js +429 -3
- data/app/assets/tailwind/administratum/components.css +36 -3
- data/app/assets/tailwind/administratum/theme.css +13 -3
- data/app/controllers/administratum/resource.rb +24 -1
- data/app/helpers/administratum/application_helper.rb +1 -0
- data/app/helpers/administratum/form_helper.rb +56 -4
- data/app/helpers/administratum/reorder_helper.rb +55 -0
- data/app/views/administratum/fields/_belongs_to.html.erb +2 -1
- data/app/views/administratum/fields/_select.html.erb +2 -1
- data/app/views/administratum/resources/_table.html.erb +16 -3
- data/config/locales/en.yml +14 -0
- data/lib/administratum/definition.rb +2 -1
- data/lib/administratum/dsl.rb +9 -0
- data/lib/administratum/reorder.rb +33 -0
- data/lib/administratum/version.rb +1 -1
- data/lib/administratum.rb +1 -0
- metadata +3 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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>
|
data/config/locales/en.yml
CHANGED
|
@@ -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
|
data/lib/administratum/dsl.rb
CHANGED
|
@@ -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
|
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.
|
|
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
|