katalyst-tables 2.1.3 → 2.2.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5f13db4214a34d37ef3f5cdc1ce35b92d89102f627be22b31794496dad2ca4a7
4
- data.tar.gz: 0d6f8dd7971eab72d20d3d1438e75daaf61ae1029c170e77653570312b07e150
3
+ metadata.gz: fc97f1a686a28e4542c65f076afde15d61523726efb4dcaa3586e6c945121c13
4
+ data.tar.gz: 10eee025b9e0daee5d3aeda7a78625e6491d91d02f99bf831092072845276185
5
5
  SHA512:
6
- metadata.gz: c3bc64f42389475a49357b0c30236e854a8be2bcc81034d5be4a085a7e5bba54668ac7f0231ab55f8d63feb902620fb9019c9c1040f3422e5d0fd85512102abc
7
- data.tar.gz: '09dcc5204e076d9e3e2e6d406d884b23d7bd3e8495bf3d8a6f4dfeb47c6d2109b4e8688d85de884d44d18f3cbe0c3696ce50bce8d0189ed45686c0f87c1e48fd'
6
+ metadata.gz: 2a7e06d525551918f60e7b9a225f4ddb34bb3eeaeaecde37e817d0c61ade348f52e290235b21e84bedc72c92615f81e1226a10305242ff3e95e33fbb47a21878
7
+ data.tar.gz: 2d23b372c056506b054c02165e7191eb73d00f76df054e0d70eafbd88d6979126a3803c11bef29889f9095f644c7e40b30396da8d48580239978a7c9ee795019
data/CHANGELOG.md CHANGED
@@ -1,5 +1,10 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [2.2.0]
4
+
5
+ - Add support for ordinal columns with batch updating
6
+ - See [[docs/ordinal.md]] for examples
7
+
3
8
  ## [2.1.0]
4
9
 
5
10
  - Add Collection model for building collections in a controller from params.
data/README.md CHANGED
@@ -287,6 +287,12 @@ def index
287
287
  end
288
288
  ```
289
289
 
290
+ ## Extensions
291
+
292
+ The following extensions are available:
293
+
294
+ * [Orderable](docs/orderable.md) - adds bulk-update for 'ordinal' columns via dragging rows in the table.
295
+
290
296
  ## Customization
291
297
 
292
298
  A common pattern we use is to have a cell at the end of the table for actions. For example:
@@ -334,7 +340,7 @@ class ActionTableComponent < Katalyst::TableComponent
334
340
  config.body_row = "ActionBodyRow"
335
341
  config.body_cell = "ActionBodyCell"
336
342
 
337
- def default_attributes
343
+ def default_html_attributes
338
344
  { class: "action-table" }
339
345
  end
340
346
 
@@ -0,0 +1,20 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+
3
+ export default class OrderableFormController extends Controller {
4
+ add(name, value) {
5
+ this.element.insertAdjacentHTML(
6
+ "beforeend",
7
+ `<input type="hidden" name="${name}" value="${value}" data-generated>`,
8
+ );
9
+ }
10
+
11
+ submit() {
12
+ this.element.requestSubmit();
13
+ }
14
+
15
+ clear() {
16
+ this.element
17
+ .querySelectorAll("input[data-generated]")
18
+ .forEach((input) => input.remove());
19
+ }
20
+ }
@@ -0,0 +1,8 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+
3
+ export default class OrderableRowController extends Controller {
4
+ static values = {
5
+ name: String,
6
+ value: Number,
7
+ };
8
+ }
@@ -0,0 +1,73 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+
3
+ export default class OrderableListController extends Controller {
4
+ static outlets = ["tables--orderable--item", "tables--orderable--form"];
5
+
6
+ dragstart(event) {
7
+ if (this.element !== event.target.parentElement) return;
8
+
9
+ const target = event.target;
10
+ event.dataTransfer.effectAllowed = "move";
11
+
12
+ // update element style after drag has begun
13
+ setTimeout(() => (target.dataset.dragging = ""));
14
+ }
15
+
16
+ dragover(event) {
17
+ if (!this.dragItem) return;
18
+
19
+ swap(this.dropTarget(event.target), this.dragItem);
20
+
21
+ event.preventDefault();
22
+ return true;
23
+ }
24
+
25
+ dragenter(event) {
26
+ event.preventDefault();
27
+ }
28
+
29
+ drop(event) {
30
+ if (!this.dragItem) return;
31
+
32
+ event.preventDefault();
33
+ delete this.dragItem.dataset.dragging;
34
+
35
+ this.update();
36
+ }
37
+
38
+ update() {
39
+ // clear any existing inputs to prevent duplicates
40
+ this.tablesOrderableFormOutlet.clear();
41
+
42
+ // insert any items that have changed position
43
+ this.tablesOrderableItemOutlets.forEach((item, index) => {
44
+ if (item.valueValue !== index) {
45
+ this.tablesOrderableFormOutlet.add(item.nameValue, index);
46
+ }
47
+ });
48
+
49
+ this.tablesOrderableFormOutlet.submit();
50
+ }
51
+
52
+ get dragItem() {
53
+ return this.element.querySelector("[data-dragging]");
54
+ }
55
+
56
+ dropTarget($e) {
57
+ while ($e && $e.parentElement !== this.element) {
58
+ $e = $e.parentElement;
59
+ }
60
+ return $e;
61
+ }
62
+ }
63
+
64
+ function swap(target, item) {
65
+ if (target && target !== item) {
66
+ const positionComparison = target.compareDocumentPosition(item);
67
+ if (positionComparison & Node.DOCUMENT_POSITION_FOLLOWING) {
68
+ target.insertAdjacentElement("beforebegin", item);
69
+ } else if (positionComparison & Node.DOCUMENT_POSITION_PRECEDING) {
70
+ target.insertAdjacentElement("afterend", item);
71
+ }
72
+ }
73
+ }
@@ -5,7 +5,7 @@ export default class TurboCollectionController extends Controller {
5
5
  static values = {
6
6
  url: String,
7
7
  sort: String,
8
- }
8
+ };
9
9
 
10
10
  urlValueChanged(url) {
11
11
  Turbo.navigator.history.replace(this.#url(url));
@@ -14,18 +14,39 @@ module Katalyst
14
14
 
15
15
  class_methods do
16
16
  # Define a configurable sub-component.
17
- def config_component(name, component_name: "#{name}_component", default: nil)
17
+ # Sub-components are cached on the table instance. We want to allow run
18
+ # time mixins for tables so that we can extend tables with cross-cutting
19
+ # concerns that affect multiple sub-components by including the concern
20
+ # into the top-level table class. We achieve this by subclassing the
21
+ # component as soon as it is created so that when a mixin is added to
22
+ # the table class, it can immediately retrieve and modify the
23
+ # sub-component class as well without needing to worry about affecting
24
+ # other tables.
25
+ def config_component(name, component_name: "#{name}_component", default: nil) # rubocop:disable Metrics/MethodLength
18
26
  config_accessor(name)
19
27
  config.public_send("#{name}=", default)
20
28
  define_method(component_name) do
21
- instance_variable_get("@#{component_name}") if instance_variable_defined?("@#{component_name}")
29
+ return instance_variable_get("@#{component_name}") if instance_variable_defined?("@#{component_name}")
22
30
 
23
31
  klass = config.public_send(name)
24
- component = self.class.const_get(klass) if klass
32
+ component = klass ? self.class.const_get(klass) : nil
33
+
34
+ # subclass to allow table-specific extensions
35
+ if component
36
+ component = Class.new(component)
37
+ component.extend(HiddenSubcomponent)
38
+ end
39
+
25
40
  instance_variable_set("@#{component_name}", component) if component
26
41
  end
27
42
  end
28
43
  end
44
+
45
+ # View Component uses `name` to resolve the template path, so we need to
46
+ # hide the subclass from the template resolver.
47
+ module HiddenSubcomponent
48
+ delegate :name, to: :superclass
49
+ end
29
50
  end
30
51
  end
31
52
  end
@@ -4,41 +4,57 @@ require "html_attributes_utils"
4
4
 
5
5
  module Katalyst
6
6
  module Tables
7
- module HasHtmlAttributes # :nodoc:
7
+ # Adds HTML attributes to a component.
8
+ # Accepts HTML attributes from the constructor or via `html_attributes=`.
9
+ # These are merged with the default attributes defined in the component.
10
+ # Adds support for custom html attributes for other tags, e.g.:
11
+ # define_html_attribute_methods :table_attributes, default: {}
12
+ # tag.table(**table_attributes)
13
+ module HasHtmlAttributes
8
14
  extend ActiveSupport::Concern
9
15
 
10
16
  using HTMLAttributesUtils
11
17
 
12
- DEFAULT_MERGEABLE_ATTRIBUTES = [
18
+ MERGEABLE_ATTRIBUTES = [
13
19
  *HTMLAttributesUtils::DEFAULT_MERGEABLE_ATTRIBUTES,
14
20
  %i[data controller],
15
- %i[data action]
21
+ %i[data action],
16
22
  ].freeze
17
23
 
18
- def initialize(**options)
19
- super(**options.except(:id, :aria, :class, :data, :html))
20
-
21
- self.html_attributes = options
24
+ refine Hash do
25
+ def merge_html(attributes)
26
+ deep_merge_html_attributes(attributes, mergeable_attributes: MERGEABLE_ATTRIBUTES)
27
+ end
22
28
  end
23
29
 
24
- # Add HTML options to the current component.
25
- # Public method for customizing components from within
26
- def html_attributes=(options)
27
- @html_attributes = options.slice(:id, :aria, :class, :data).merge(options.fetch(:html, {}))
28
- end
30
+ class_methods do
31
+ using HasHtmlAttributes
32
+
33
+ def define_html_attribute_methods(name, default: {})
34
+ define_method("default_#{name}") { default }
35
+ private("default_#{name}")
29
36
 
30
- # Backwards compatibility with tables 1.0
31
- alias options html_attributes=
37
+ define_method(name) do
38
+ send("default_#{name}").merge_html(instance_variable_get("@#{name}") || {})
39
+ end
32
40
 
33
- private
41
+ define_method("#{name}=") do |options|
42
+ instance_variable_set("@#{name}", options.slice(:id, :aria, :class, :data).merge(options.fetch(:html, {})))
43
+ end
44
+ end
45
+ end
46
+
47
+ included do
48
+ define_html_attribute_methods :html_attributes, default: {}
34
49
 
35
- def html_attributes
36
- default_attributes
37
- .deep_merge_html_attributes(@html_attributes, mergeable_attributes: DEFAULT_MERGEABLE_ATTRIBUTES)
50
+ # Backwards compatibility with tables 1.0
51
+ alias_method :options, :html_attributes=
38
52
  end
39
53
 
40
- def default_attributes
41
- {}
54
+ def initialize(**options)
55
+ super(**options.except(:id, :aria, :class, :data, :html))
56
+
57
+ self.html_attributes = options
42
58
  end
43
59
  end
44
60
  end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Katalyst
4
+ module Tables
5
+ # Adds drag and drop ordering to a table.
6
+ # See [documentation](/docs/orderable.md) for more details.
7
+ module Orderable
8
+ extend ActiveSupport::Concern
9
+
10
+ FORM_CONTROLLER = "tables--orderable--form"
11
+ ITEM_CONTROLLER = "tables--orderable--item"
12
+ LIST_CONTROLLER = "tables--orderable--list"
13
+
14
+ using HasHtmlAttributes
15
+
16
+ # Enhance a given table component class with orderable support.
17
+ # Supports extension via `included` and `extended` hooks.
18
+ def self.make_orderable(table_class)
19
+ # Add `orderable` columns to row components
20
+ table_class.header_row_component.include(HeaderRow)
21
+ table_class.body_row_component.include(BodyRow)
22
+
23
+ # Add `orderable` slot to table component
24
+ table_class.config_component :orderable, default: "FormComponent"
25
+ table_class.renders_one(:orderable, lambda do |**attrs|
26
+ orderable_component.new(table: self, **attrs)
27
+ end)
28
+ end
29
+
30
+ # Support for inclusion in a table component class
31
+ included do
32
+ Orderable.make_orderable(self)
33
+ end
34
+
35
+ # Support for extending a table component instance
36
+ def self.extended(table)
37
+ Orderable.make_orderable(table.class)
38
+ end
39
+
40
+ def tbody_attributes
41
+ return super unless orderable?
42
+
43
+ super.merge_html(
44
+ { data: { controller: LIST_CONTROLLER,
45
+ action: <<~ACTIONS.squish,
46
+ dragstart->#{LIST_CONTROLLER}#dragstart
47
+ dragenter->#{LIST_CONTROLLER}#dragenter
48
+ dragover->#{LIST_CONTROLLER}#dragover
49
+ drop->#{LIST_CONTROLLER}#drop
50
+ ACTIONS
51
+ "#{LIST_CONTROLLER}-#{FORM_CONTROLLER}-outlet" => "##{orderable.id}",
52
+ "#{LIST_CONTROLLER}-#{ITEM_CONTROLLER}-outlet" => "td.ordinal" } },
53
+ )
54
+ end
55
+
56
+ module HeaderRow # :nodoc:
57
+ def ordinal(attribute = :ordinal, **)
58
+ cell(attribute, class: "ordinal", label: "")
59
+ end
60
+ end
61
+
62
+ module BodyRow # :nodoc:
63
+ def ordinal(attribute = :ordinal, id: :id)
64
+ name = @table.orderable.record_scope(@record, id, attribute)
65
+ value = @record.public_send(attribute)
66
+ cell(attribute, class: "ordinal", data: {
67
+ controller: ITEM_CONTROLLER,
68
+ "#{ITEM_CONTROLLER}-name-value" => name,
69
+ "#{ITEM_CONTROLLER}-value-value" => value,
70
+ }) { t("katalyst.tables.orderable.value") }
71
+ end
72
+
73
+ def html_attributes
74
+ super.merge_html(
75
+ {
76
+ draggable: "true",
77
+ },
78
+ )
79
+ end
80
+ end
81
+
82
+ class FormComponent < ViewComponent::Base # :nodoc:
83
+ attr_reader :id, :url, :scope
84
+
85
+ def initialize(table:,
86
+ url:,
87
+ id: "#{table.id}-orderable-form",
88
+ scope: "order[#{table.collection.model_name.plural}]")
89
+ super
90
+
91
+ @table = table
92
+ @id = id
93
+ @url = url
94
+ @scope = scope
95
+ end
96
+
97
+ def record_scope(record, id, attribute)
98
+ "#{scope}[#{record.public_send(id)}][#{attribute}]"
99
+ end
100
+
101
+ def call
102
+ form_with(id: id, url: url, method: :patch, data: { controller: FORM_CONTROLLER }) do |form|
103
+ form.button(hidden: "")
104
+ end
105
+ end
106
+
107
+ def inspect
108
+ "#<#{self.class.name} id: #{id.inspect}, url: #{url.inspect}, scope: #{scope.inspect}>"
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,13 @@
1
+ <%= tag.table(**html_attributes) do %>
2
+ <%= render caption if caption? %>
3
+ <% if header? %>
4
+ <%= tag.thead(**thead_attributes) do %>
5
+ <%= header_row.render_in(view_context, &row_proc) %>
6
+ <% end %>
7
+ <% end %>
8
+ <%= tag.tbody(**tbody_attributes) do %>
9
+ <% collection.each do |record| %>
10
+ <%= body_row(record).render_in(view_context) { |r| row_proc.call(r, record) } %>
11
+ <% end %>
12
+ <% end %>
13
+ <% end %>
@@ -54,43 +54,24 @@ module Katalyst
54
54
  super(**html_attributes)
55
55
  end
56
56
 
57
- def call
58
- tag.table(**html_attributes) do
59
- concat(caption)
60
- concat(thead)
61
- concat(tbody)
62
- end
57
+ def caption?
58
+ @caption.present?
63
59
  end
64
60
 
65
61
  def caption
66
- caption_component&.new(self)&.render_in(view_context) if @caption
62
+ caption_component&.new(self)
67
63
  end
68
64
 
69
- def thead
70
- return "".html_safe unless @header
71
-
72
- tag.thead do
73
- concat(render_header)
74
- end
75
- end
76
-
77
- def tbody
78
- tag.tbody do
79
- collection.each do |record|
80
- concat(render_row(record))
81
- end
82
- end
65
+ def header?
66
+ @header.present?
83
67
  end
84
68
 
85
- def render_header
86
- # extract the column's block from the slot and pass it to the cell for rendering
87
- header_row_component.new(self, **@header_options).render_in(view_context, &row_proc)
69
+ def header_row
70
+ header_row_component.new(self, **@header_options)
88
71
  end
89
72
 
90
- def render_row(record)
91
- body_row_component.new(self, record).render_in(view_context) do |row|
92
- row_proc.call(row, record)
93
- end
73
+ def body_row(record)
74
+ body_row_component.new(self, record)
94
75
  end
95
76
 
96
77
  def sorting
@@ -98,5 +79,12 @@ module Katalyst
98
79
 
99
80
  collection.sorting if collection.respond_to?(:sorting)
100
81
  end
82
+
83
+ def inspect
84
+ "#<#{self.class.name} collection: #{collection.inspect}>"
85
+ end
86
+
87
+ define_html_attribute_methods(:thead_attributes)
88
+ define_html_attribute_methods(:tbody_attributes)
101
89
  end
102
90
  end
@@ -35,6 +35,10 @@ module Katalyst
35
35
  def value
36
36
  @record.public_send(@attribute)
37
37
  end
38
+
39
+ def inspect
40
+ "#<#{self.class.name} attribute: #{@attribute.inspect}, value: #{value.inspect}>"
41
+ end
38
42
  end
39
43
  end
40
44
  end
@@ -35,6 +35,10 @@ module Katalyst
35
35
  def body?
36
36
  true
37
37
  end
38
+
39
+ def inspect
40
+ "#<#{self.class.name} record: #{record.inspect}>"
41
+ end
38
42
  end
39
43
  end
40
44
  end
@@ -28,9 +28,13 @@ module Katalyst
28
28
  human.pluralize.downcase
29
29
  end
30
30
 
31
+ def inspect
32
+ "#<#{self.class.name}>"
33
+ end
34
+
31
35
  private
32
36
 
33
- def default_attributes
37
+ def default_html_attributes
34
38
  { align: "bottom" }
35
39
  end
36
40
  end
@@ -46,9 +46,13 @@ module Katalyst
46
46
  @attribute.to_s.humanize.capitalize
47
47
  end
48
48
 
49
+ def inspect
50
+ "#<#{self.class.name} attribute: #{@attribute.inspect}, value: #{value.inspect}>"
51
+ end
52
+
49
53
  private
50
54
 
51
- def default_attributes
55
+ def default_html_attributes
52
56
  return {} unless sorting&.supports?(collection, @attribute)
53
57
 
54
58
  { data: { sort: sorting.status(@attribute) } }
@@ -35,6 +35,10 @@ module Katalyst
35
35
  def body?
36
36
  false
37
37
  end
38
+
39
+ def inspect
40
+ "#<#{self.class.name} link_attributes: #{@link_attributes.inspect}>"
41
+ end
38
42
  end
39
43
  end
40
44
  end
@@ -23,6 +23,10 @@ module Katalyst
23
23
  def call
24
24
  pagy_nav(@pagy, **pagy_options).html_safe # rubocop:disable Rails/OutputSafety
25
25
  end
26
+
27
+ def inspect
28
+ "#<#{self.class.name} pagy: #{@pagy.inspect}>"
29
+ end
26
30
  end
27
31
  end
28
32
  end
@@ -23,13 +23,13 @@ module Katalyst
23
23
 
24
24
  private
25
25
 
26
- def default_attributes
26
+ def default_html_attributes
27
27
  {
28
28
  data: {
29
- controller: "tables--turbo-collection",
30
- tables__turbo_collection_url_value: current_path,
31
- tables__turbo_collection_sort_value: collection.sort
32
- }
29
+ controller: "tables--turbo-collection",
30
+ tables__turbo_collection_url_value: current_path,
31
+ tables__turbo_collection_sort_value: collection.sort,
32
+ },
33
33
  }
34
34
  end
35
35
 
@@ -0,0 +1,6 @@
1
+ en:
2
+ katalyst:
3
+ tables:
4
+ orderable:
5
+ value:
6
+ "⠿"
@@ -22,9 +22,9 @@ module Katalyst
22
22
  column, direction = params[:sort]&.split(" ")
23
23
  direction = "asc" unless SortForm::DIRECTIONS.include?(direction)
24
24
 
25
- SortForm.new(column: column,
25
+ SortForm.new(column: column,
26
26
  direction: direction)
27
- .apply(collection)
27
+ .apply(collection)
28
28
  end
29
29
 
30
30
  def self_referred?
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Katalyst
4
4
  module Tables
5
- VERSION = "2.1.3"
5
+ VERSION = "2.2.0"
6
6
  end
7
7
  end
@@ -7,7 +7,7 @@ require_relative "tables/engine"
7
7
  require_relative "tables/frontend"
8
8
  require_relative "tables/version"
9
9
 
10
- require_relative "tables/engine" if Object.const_defined?("Rails")
10
+ require_relative "tables/engine" if Object.const_defined?(:Rails)
11
11
 
12
12
  module Katalyst
13
13
  module Tables
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: katalyst-tables
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.1.3
4
+ version: 2.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Katalyst Interactive
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2023-08-14 00:00:00.000000000 Z
11
+ date: 2023-09-20 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: html-attributes-utils
@@ -50,12 +50,17 @@ files:
50
50
  - LICENSE.txt
51
51
  - README.md
52
52
  - app/assets/config/katalyst-tables.js
53
+ - app/assets/javascripts/controllers/tables/orderable/form_controller.js
54
+ - app/assets/javascripts/controllers/tables/orderable/item_controller.js
55
+ - app/assets/javascripts/controllers/tables/orderable/list_controller.js
53
56
  - app/assets/javascripts/controllers/tables/turbo_collection_controller.js
54
57
  - app/components/concerns/katalyst/tables/configurable_component.rb
55
58
  - app/components/concerns/katalyst/tables/has_html_attributes.rb
56
59
  - app/components/concerns/katalyst/tables/has_table_content.rb
60
+ - app/components/concerns/katalyst/tables/orderable.rb
57
61
  - app/components/concerns/katalyst/tables/sortable.rb
58
62
  - app/components/concerns/katalyst/tables/turbo_replaceable.rb
63
+ - app/components/katalyst/table_component.html.erb
59
64
  - app/components/katalyst/table_component.rb
60
65
  - app/components/katalyst/tables/body_cell_component.rb
61
66
  - app/components/katalyst/tables/body_row_component.rb
@@ -72,6 +77,7 @@ files:
72
77
  - app/models/concerns/katalyst/tables/collection/sorting.rb
73
78
  - app/models/katalyst/tables/collection.rb
74
79
  - config/importmap.rb
80
+ - config/locales/tables.en.yml
75
81
  - lib/katalyst/tables.rb
76
82
  - lib/katalyst/tables/backend.rb
77
83
  - lib/katalyst/tables/backend/sort_form.rb