katalyst-tables 2.1.3 → 2.2.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: 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