stimulus_table_filter 0.1.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.
Files changed (46) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +16 -0
  3. data/Gemfile +17 -0
  4. data/Gemfile.lock +299 -0
  5. data/README.md +526 -0
  6. data/app/assets/javascripts/stimulus_table_filter/filter_list.js +17 -0
  7. data/app/assets/javascripts/stimulus_table_filter/page_navigator.js +25 -0
  8. data/app/assets/javascripts/stimulus_table_filter/paginator.js +29 -0
  9. data/app/assets/javascripts/stimulus_table_filter/row_matcher.js +36 -0
  10. data/app/assets/javascripts/stimulus_table_filter/row_sorter.js +33 -0
  11. data/app/assets/javascripts/stimulus_table_filter/row_stats.js +9 -0
  12. data/app/assets/javascripts/stimulus_table_filter/table_filter.js +75 -0
  13. data/app/assets/javascripts/stimulus_table_filter/table_filter_controller.js +234 -0
  14. data/app/assets/javascripts/stimulus_table_filter/table_filter_view.js +149 -0
  15. data/app/assets/javascripts/stimulus_table_filter/url_state.js +51 -0
  16. data/app/assets/stylesheets/stimulus_table_filter/table_filter.css +8 -0
  17. data/config/importmap.rb +2 -0
  18. data/eslint.config.js +18 -0
  19. data/lib/spec/shared_examples/table_filter_footer.rb +6 -0
  20. data/lib/spec/shared_examples/table_filter_rows.rb +13 -0
  21. data/lib/spec/shared_examples/table_filter_view.rb +87 -0
  22. data/lib/stimulus_table_filter/engine.rb +23 -0
  23. data/lib/stimulus_table_filter/error.rb +5 -0
  24. data/lib/stimulus_table_filter/rspec/helpers.rb +18 -0
  25. data/lib/stimulus_table_filter/rspec/matchers.rb +43 -0
  26. data/lib/stimulus_table_filter/rspec.rb +19 -0
  27. data/lib/stimulus_table_filter/version.rb +5 -0
  28. data/lib/stimulus_table_filter/view_helper/container.rb +28 -0
  29. data/lib/stimulus_table_filter/view_helper/controls.rb +42 -0
  30. data/lib/stimulus_table_filter/view_helper/rows.rb +32 -0
  31. data/lib/stimulus_table_filter/view_helper/sort.rb +31 -0
  32. data/lib/stimulus_table_filter/view_helper/stats.rb +47 -0
  33. data/lib/stimulus_table_filter/view_helper.rb +16 -0
  34. data/lib/stimulus_table_filter.rb +6 -0
  35. data/package-lock.json +1112 -0
  36. data/package.json +14 -0
  37. data/stimulus_table_filter.gemspec +29 -0
  38. data/test/javascript/controller.test.mjs +10 -0
  39. data/test/javascript/controller_stub.mjs +1 -0
  40. data/test/javascript/paginator.test.mjs +35 -0
  41. data/test/javascript/register.mjs +20 -0
  42. data/test/javascript/row_matcher.test.mjs +71 -0
  43. data/test/javascript/row_sorter.test.mjs +43 -0
  44. data/test/javascript/row_stats_and_url_state.test.mjs +69 -0
  45. data/test/javascript/stimulus_stub.mjs +6 -0
  46. metadata +107 -0
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../error'
4
+
5
+ module StimulusTableFilter
6
+ module RSpec
7
+ # Provides `html` for shared examples: `response.body` in request specs, `page.html` in system specs.
8
+ # Override with `let(:html) { ... }` in your example group if needed.
9
+ module Helpers
10
+ def html
11
+ return response.body if respond_to?(:response) && response.respond_to?(:body)
12
+ return page.html if respond_to?(:page) && page.respond_to?(:html)
13
+
14
+ raise StimulusTableFilter::Error, 'Define `let(:html) { ... }` or use inside a request or system spec'
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'nokogiri'
4
+
5
+ RSpec::Matchers.define :have_data do |name, value|
6
+ chain :on, :tag
7
+
8
+ match do |html|
9
+ values = data_values(html, name)
10
+ value.nil? ? values.any? : values.include?(value.to_s)
11
+ end
12
+
13
+ description { value.nil? ? "have a data-#{name} attribute" : "have data-#{name}=\"#{value}\"" }
14
+
15
+ failure_message do |html|
16
+ found = data_values(html, name)
17
+ if found.empty?
18
+ "expected HTML to contain a data-#{name} attribute, but no element has one\n\nHTML:\n#{html}"
19
+ else
20
+ "expected HTML to contain data-#{name}=\"#{value}\", found: #{found.uniq.inspect}\n\nHTML:\n#{html}"
21
+ end
22
+ end
23
+
24
+ def data_values(html, name)
25
+ Nokogiri::HTML.fragment(html.to_s).css(tag || '*').filter_map { |el| el["data-#{name}"] }
26
+ end
27
+ end
28
+
29
+ RSpec::Matchers.define :have_data_target do |target|
30
+ match do |html|
31
+ Nokogiri::HTML.fragment(html.to_s).css('*').any? do |el|
32
+ el['data-table-filter-target']&.split&.include?(target.to_s)
33
+ end
34
+ end
35
+
36
+ description { "have an element with data-table-filter-target=\"#{target}\"" }
37
+
38
+ failure_message do |html|
39
+ present = Nokogiri::HTML.fragment(html.to_s).css('[data-table-filter-target]')
40
+ .filter_map { |el| el['data-table-filter-target'] }.uniq
41
+ "expected HTML to contain data-table-filter-target=\"#{target}\", targets present: #{present.inspect}"
42
+ end
43
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'rspec/helpers'
4
+ require_relative 'rspec/matchers'
5
+ require_relative '../spec/shared_examples/table_filter_view'
6
+ require_relative '../spec/shared_examples/table_filter_footer'
7
+ require_relative '../spec/shared_examples/table_filter_rows'
8
+
9
+ module StimulusTableFilter
10
+ # Opt-in wiring for the shared examples.
11
+ # Call StimulusTableFilter::RSpec.install! from your spec support file.
12
+ module RSpec
13
+ def self.install!
14
+ ::RSpec.configure do |config|
15
+ config.include Helpers
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StimulusTableFilter
4
+ VERSION = '0.1.0'
5
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StimulusTableFilter
4
+ module ViewHelper
5
+ def table_filter_container_tag(sort: 'name', dir: 'asc', page: nil, page_size: nil, url_key: nil,
6
+ debounce_ms: nil, tag: :div, **opts, &)
7
+ data = container_data(sort:, dir:, page:, page_size:, url_key:,
8
+ debounce_ms:).merge(opts.delete(:data) || {})
9
+ content_tag(tag, data: data, **opts, &)
10
+ end
11
+
12
+ private
13
+
14
+ def base_container_data(sort:, dir:, page: nil)
15
+ base = { controller: 'table-filter', table_filter_sort_value: sort, table_filter_dir_value: dir }
16
+ base[:table_filter_page_value] = page.to_s if page
17
+ base
18
+ end
19
+
20
+ def container_data(sort:, dir:, page: nil, page_size: nil, url_key: nil, debounce_ms: nil)
21
+ base = base_container_data(sort:, dir:, page:)
22
+ base = base.merge(table_filter_page_size_value: page_size.to_s) if page_size
23
+ base = base.merge(table_filter_url_key_value: url_key.to_s) if url_key
24
+ base = base.merge(table_filter_debounce_ms_value: debounce_ms.to_s) if debounce_ms
25
+ base
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StimulusTableFilter
4
+ module ViewHelper
5
+ def table_filter_search_tag(**opts) = tag.input(type: 'search', data: target_data('search', opts), **opts)
6
+
7
+ def table_filter_filter_btn_tag(value, label, dimension: nil, **opts)
8
+ data = with_data({ filter_btn: value }, opts)
9
+ data[:filter_dimension] = dimension if dimension
10
+ content_tag(:button, label, data: data, **opts)
11
+ end
12
+
13
+ def table_filter_empty_row_tag(**opts, &)
14
+ data = target_data('emptyRow', opts)
15
+ klass = ['hidden', *Array(opts.delete(:class))].join(' ')
16
+ content_tag(:tr, data: data, class: klass, **opts, &)
17
+ end
18
+
19
+ def table_filter_active_filter_tag(dimension: nil, **opts, &)
20
+ data = target_data('filterDisplay', opts)
21
+ data[:filter_dimension] = dimension if dimension
22
+ content_tag(:span, data: data, hidden: true, **opts, &)
23
+ end
24
+
25
+ def table_filter_select_tag(options, dimension: nil, **opts)
26
+ data = with_data({ table_filter_target: 'filterSelect' }, opts)
27
+ data[:filter_dimension] = dimension if dimension
28
+ pairs = normalize_options(options)
29
+ content_tag(:select, data: data, **opts) { render_options(pairs) }
30
+ end
31
+
32
+ private
33
+
34
+ def option_tag(pair) = content_tag(:option, pair.first, value: pair.last)
35
+ def render_options(pairs) = safe_join(pairs.map(&method(:option_tag)))
36
+
37
+ def normalize_options(options)
38
+ pairs = options.is_a?(Hash) ? options.to_a : options
39
+ pairs.map { |pair| pair.is_a?(Array) ? pair : [pair, pair] }
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StimulusTableFilter
4
+ module ViewHelper
5
+ def table_filter_row_attrs(name:, filters: {}, sort: {}, searchable: nil, group: nil)
6
+ data = { table_filter_target: 'row', name: name.to_s.downcase, **filter_data(filters), **sort_data(sort) }
7
+ extras = { searchable: searchable&.to_s&.downcase, group: group&.to_s }.compact
8
+ { data: data.merge(extras) }
9
+ end
10
+
11
+ def table_filter_row_tag(name:, filters: {}, sort: {}, searchable: nil, group: nil,
12
+ tag: :tr, **html_options, &)
13
+ data = with_data(
14
+ table_filter_row_attrs(name: name, filters: filters, sort: sort,
15
+ searchable: searchable, group: group)[:data], html_options
16
+ )
17
+ content_tag(tag, data: data, **html_options, &)
18
+ end
19
+
20
+ def table_filter_group_header_tag(group, tag: :tr, **opts, &)
21
+ data = with_data({ table_filter_target: 'groupHeader', group: group.to_s }, opts)
22
+ content_tag(tag, data: data, **opts, &)
23
+ end
24
+
25
+ private
26
+
27
+ def filter_key(key) = :"filter_#{key}"
28
+ def filter_data(filters) = filters.transform_keys(&method(:filter_key)).transform_values(&:to_s)
29
+ def sort_key(key) = :"sort_#{key}"
30
+ def sort_data(sort) = sort.transform_keys(&method(:sort_key)).transform_values(&:to_s)
31
+ end
32
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StimulusTableFilter
4
+ module ViewHelper
5
+ def table_filter_sort_btn_tag(col, label, type: nil, **)
6
+ table_filter_build_sort_trigger(:button, col, label, type: type, **)
7
+ end
8
+
9
+ def table_filter_sort_th_tag(col, label, type: nil, **)
10
+ table_filter_build_sort_trigger(:th, col, label, type: type, **)
11
+ end
12
+
13
+ def table_filter_column_tag(col:, label:, type: nil, sortable: true, **)
14
+ return content_tag(:th, label, scope: 'col', **) unless sortable
15
+
16
+ table_filter_sort_th_tag(col, label, type: type, **)
17
+ end
18
+
19
+ private
20
+
21
+ def sort_trigger_icon = content_tag(:span, '', data: { sort_icon: '' })
22
+ def sort_btn_base(col, type) = type ? { sort_btn: col, sort_type: type } : { sort_btn: col }
23
+ def sort_trigger_data(col, type, opts) = sort_btn_base(col, type).merge(opts.delete(:data) || {})
24
+
25
+ def table_filter_build_sort_trigger(tag, col, label, type: nil, **opts)
26
+ data = sort_trigger_data(col, type, opts)
27
+ opts = { scope: 'col' }.merge(opts) if tag == :th
28
+ content_tag(tag, ERB::Util.html_escape(label) + sort_trigger_icon, data: data, **opts)
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StimulusTableFilter
4
+ module ViewHelper
5
+ def table_filter_prev_btn_tag(label = '←', **opts)
6
+ content_tag(:button, label, data: with_data({ prev_page: '', table_filter_target: 'prevPage' }, opts), **opts)
7
+ end
8
+
9
+ def table_filter_next_btn_tag(label = '→', **opts)
10
+ content_tag(:button, label, data: with_data({ next_page: '', table_filter_target: 'nextPage' }, opts), **opts)
11
+ end
12
+
13
+ def table_filter_page_info_tag(**opts) = content_tag(:span, '', data: target_data('pageInfo', opts), **opts)
14
+
15
+ def table_filter_match_count_tag(**) = table_filter_stat_span('matchCount', **)
16
+ def table_filter_total_count_tag(**) = table_filter_stat_span('totalCount', **)
17
+ def table_filter_match_pct_tag(**) = table_filter_stat_span('matchPct', **)
18
+
19
+ def table_filter_count_tag(dimension:, value:, **opts)
20
+ data = with_data({ count_dimension: dimension, count_value: value }, opts)
21
+ content_tag(:span, '', data: data, **opts)
22
+ end
23
+
24
+ def table_filter_footer_tag(colspan: nil, tag: :tfoot, **opts, &)
25
+ content_tag(tag) do
26
+ content_tag(:tr) do
27
+ td_opts = colspan ? { colspan: colspan } : {}
28
+ content_tag(:td, **td_opts, **opts) { footer_content(&) }
29
+ end
30
+ end
31
+ end
32
+
33
+ private
34
+
35
+ def footer_content(&)
36
+ return default_footer_content unless block_given?
37
+
38
+ capture(table_filter_match_count_tag, table_filter_total_count_tag, &)
39
+ end
40
+
41
+ def table_filter_stat_span(target, **opts) = content_tag(:span, '', data: target_data(target, opts), **opts)
42
+
43
+ def default_footer_content
44
+ safe_join([table_filter_match_count_tag, ' of ', table_filter_total_count_tag])
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StimulusTableFilter
4
+ module ViewHelper
5
+ private
6
+
7
+ def with_data(base, opts) = base.merge(opts.delete(:data) || {})
8
+ def target_data(target, opts) = with_data({ table_filter_target: target }, opts)
9
+ end
10
+ end
11
+
12
+ require_relative 'view_helper/container'
13
+ require_relative 'view_helper/rows'
14
+ require_relative 'view_helper/controls'
15
+ require_relative 'view_helper/sort'
16
+ require_relative 'view_helper/stats'
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'stimulus_table_filter/version'
4
+ require 'stimulus_table_filter/error'
5
+ require 'stimulus_table_filter/view_helper'
6
+ require 'stimulus_table_filter/engine' if defined?(Rails)