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
data/package.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "name": "stimulus-table-filter",
3
+ "private": true,
4
+ "type": "module",
5
+ "scripts": {
6
+ "lint": "eslint .",
7
+ "test": "node --import ./test/javascript/register.mjs --test test/javascript/*.test.mjs"
8
+ },
9
+ "devDependencies": {
10
+ "@eslint/js": "^9.0.0",
11
+ "eslint": "^9.0.0",
12
+ "globals": "^15.0.0"
13
+ }
14
+ }
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ lib = File.expand_path('lib', __dir__)
4
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
5
+ require 'stimulus_table_filter/version'
6
+
7
+ Gem::Specification.new do |s|
8
+ s.name = 'stimulus_table_filter'
9
+ s.version = StimulusTableFilter::VERSION
10
+ s.platform = Gem::Platform::RUBY
11
+ s.authors = ['icoluccio']
12
+ s.email = ['ignacio.coluccio@gmail.com']
13
+ s.homepage = 'https://github.com/icoluccio/stimulus-table-filter'
14
+ s.summary = 'Client-side table filtering, sorting and search for Rails'
15
+ s.description = 'Stimulus controller and view helpers that add instant search, filter ' \
16
+ 'dimensions, multi-column sort, pagination and live footer stats to any ' \
17
+ 'Rails table. Everything is wired through data attributes, with no ' \
18
+ 'JavaScript dependencies or configuration required.'
19
+ s.license = 'MIT'
20
+ s.required_ruby_version = '>= 3.2'
21
+
22
+ s.files = `git ls-files -z`.split("\x0").reject do |f|
23
+ f.start_with?('spec/', 'gemfiles/', '.github/', 'coverage/', '.bundle/') ||
24
+ %w[Appraisals .overcommit.yml .rubocop.yml .gitignore Rakefile].include?(f)
25
+ end
26
+ s.require_paths = ['lib']
27
+
28
+ s.add_dependency 'railties', '>= 7.0', '< 9'
29
+ end
@@ -0,0 +1,10 @@
1
+ import { test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import controller from '../../app/assets/javascripts/stimulus_table_filter/table_filter_controller.js'
4
+ import { TableView } from '../../app/assets/javascripts/stimulus_table_filter/table_filter_view.js'
5
+
6
+ test('the controller and view modules load with their full import graphs', () => {
7
+ assert.equal(typeof controller, 'function')
8
+ assert.equal(controller.targets.length, 12)
9
+ assert.equal(typeof TableView, 'function')
10
+ })
@@ -0,0 +1 @@
1
+ export class Controller {}
@@ -0,0 +1,35 @@
1
+ import { test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { Paginator } from '../../app/assets/javascripts/stimulus_table_filter/paginator.js'
4
+
5
+ const PAGE_SIZE = 25
6
+ const TOTAL = 87
7
+ const LAST_PAGE = 4 // ceil(87 / 25)
8
+ const paginator = (size = PAGE_SIZE) => new Paginator(size)
9
+
10
+ test('is disabled when the page size is 0 or missing', () => {
11
+ assert.equal(paginator(0).enabled, false)
12
+ assert.equal(new Paginator(undefined).enabled, false)
13
+ })
14
+
15
+ test('page count rounds up and never returns zero', () => {
16
+ assert.equal(paginator().pageCount(TOTAL), LAST_PAGE)
17
+ assert.equal(paginator().pageCount(PAGE_SIZE), 1)
18
+ assert.equal(paginator().pageCount(0), 1)
19
+ })
20
+
21
+ test('clamp caps the page at the last one with rows', () => {
22
+ assert.equal(paginator().clamp(99, TOTAL), LAST_PAGE)
23
+ assert.equal(paginator().clamp(2, TOTAL), 2)
24
+ })
25
+
26
+ test('window returns the slice bounds for a page', () => {
27
+ assert.deepEqual(paginator().window(3), { start: 50, end: 75 })
28
+ assert.deepEqual(paginator().window(1), { start: 0, end: PAGE_SIZE })
29
+ })
30
+
31
+ test('info formats the visible range for the matched rows', () => {
32
+ assert.equal(paginator().info(2, TOTAL), '26–50 of 87')
33
+ assert.equal(paginator().info(LAST_PAGE, TOTAL), '76–87 of 87')
34
+ assert.equal(paginator().info(1, 0), '')
35
+ })
@@ -0,0 +1,20 @@
1
+ import { registerHooks } from 'node:module'
2
+ import { pathToFileURL } from 'node:url'
3
+
4
+ // Maps the gem's importmap names and the Stimulus dependency to real files so
5
+ // Node can load the browser modules directly.
6
+ const MODULES = new URL('../../app/assets/javascripts/stimulus_table_filter/', import.meta.url)
7
+
8
+ registerHooks({
9
+ resolve(specifier, context, nextResolve) {
10
+ if (specifier.startsWith('stimulus_table_filter/')) {
11
+ const name = specifier.slice('stimulus_table_filter/'.length)
12
+ return { shortCircuit: true, url: pathToFileURL(new URL(`${name}.js`, MODULES).pathname).href }
13
+ }
14
+ if (specifier === '@hotwired/stimulus') {
15
+ const stub = pathToFileURL(new URL('./controller_stub.mjs', import.meta.url).pathname).href
16
+ return { shortCircuit: true, url: stub }
17
+ }
18
+ return nextResolve(specifier, context)
19
+ }
20
+ })
@@ -0,0 +1,71 @@
1
+ import { test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { RowMatcher } from '../../app/assets/javascripts/stimulus_table_filter/row_matcher.js'
4
+
5
+ test('search matches data-name case-insensitively as a substring', () => {
6
+ const matcher = new RowMatcher('LPH', {})
7
+ assert.equal(matcher.matches({ dataset: { name: 'alpha' } }), true)
8
+ assert.equal(matcher.matches({ dataset: { name: 'beta' } }), false)
9
+ })
10
+
11
+ test('search prefers data-searchable over data-name', () => {
12
+ const matcher = new RowMatcher('tag', {})
13
+ assert.equal(matcher.matches({ dataset: { name: 'alpha', searchable: 'Alpha Tag' } }), true)
14
+ assert.equal(matcher.matches({ dataset: { name: 'alpha', searchable: 'Other' } }), false)
15
+ })
16
+
17
+ test('empty searchable falls back to data-name', () => {
18
+ const matcher = new RowMatcher('alpha', {})
19
+ assert.equal(matcher.matches({ dataset: { name: 'alpha', searchable: '' } }), true)
20
+ })
21
+
22
+ test('filter matches any value in the dimension list', () => {
23
+ const matcher = new RowMatcher('', { status: 'active,draft' })
24
+ assert.equal(matcher.matches({ dataset: { filterStatus: 'active' } }), true)
25
+ assert.equal(matcher.matches({ dataset: { filterStatus: 'draft' } }), true)
26
+ assert.equal(matcher.matches({ dataset: { filterStatus: 'archived' } }), false)
27
+ })
28
+
29
+ test('an "all" dimension value matches every row', () => {
30
+ const matcher = new RowMatcher('', { status: 'all' })
31
+ assert.equal(matcher.matches({ dataset: { filterStatus: 'anything' } }), true)
32
+ })
33
+
34
+ test('blank query and empty dimensions match every row', () => {
35
+ const matcher = new RowMatcher(' ', {})
36
+ assert.equal(matcher.matches({ dataset: { name: 'alpha', filterStatus: 'active' } }), true)
37
+ })
38
+
39
+ test('dimensions AND: the row must match every dimension with active values', () => {
40
+ const matcher = new RowMatcher('', { status: 'active', payment: 'paid' })
41
+ assert.equal(matcher.matches({ dataset: { filterStatus: 'active', filterPayment: 'paid' } }), true)
42
+ assert.equal(matcher.matches({ dataset: { filterStatus: 'active', filterPayment: 'unpaid' } }), false)
43
+ assert.equal(matcher.matches({ dataset: { filterStatus: 'archived', filterPayment: 'paid' } }), false)
44
+ })
45
+
46
+ test('rows without a value for an active dimension never match it', () => {
47
+ const matcher = new RowMatcher('', { payment: 'paid' })
48
+ assert.equal(matcher.matches({ dataset: {} }), false)
49
+ })
50
+
51
+ test('an "all" dimension value matches every row regardless of the row value', () => {
52
+ const matcher = new RowMatcher('', { payment: 'all' })
53
+ assert.equal(matcher.matches({ dataset: { filterPayment: 'whatever' } }), true)
54
+ })
55
+
56
+ test('toggledFilter replaces "all" with the clicked value', () => {
57
+ assert.equal(RowMatcher.toggledFilter('all', 'active'), 'active')
58
+ })
59
+
60
+ test('toggledFilter toggles values in and out of the active list', () => {
61
+ assert.equal(RowMatcher.toggledFilter('active', 'draft'), 'active,draft')
62
+ assert.equal(RowMatcher.toggledFilter('active,draft', 'draft'), 'active')
63
+ })
64
+
65
+ test('toggledFilter returns "all" when the last value is removed', () => {
66
+ assert.equal(RowMatcher.toggledFilter('active', 'active'), 'all')
67
+ })
68
+
69
+ test('toggledFilter resets to "all"', () => {
70
+ assert.equal(RowMatcher.toggledFilter('active,draft', 'all'), 'all')
71
+ })
@@ -0,0 +1,43 @@
1
+ import { test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { RowSorter } from '../../app/assets/javascripts/stimulus_table_filter/row_sorter.js'
4
+
5
+ // Sorts a single column through RowSorter and returns the values in sorted order.
6
+ const sortColumn = (column, values, type = 'string', dir = 'asc') => {
7
+ const key = `sort${column.charAt(0).toUpperCase()}${column.slice(1)}`
8
+ const rows = values.map(value => ({ dataset: { [key]: value } }))
9
+ return new RowSorter(column, type, dir).sort(rows).map(r => r.dataset[key])
10
+ }
11
+
12
+ test('string sort compares lexicographically in the given direction', () => {
13
+ assert.deepEqual(sortColumn('title', ['b', 'a', 'c']), ['a', 'b', 'c'])
14
+ assert.deepEqual(sortColumn('title', ['b', 'a', 'c'], 'string', 'desc'), ['c', 'b', 'a'])
15
+ })
16
+
17
+ test('numeric sort orders numerically, not lexicographically', () => {
18
+ assert.deepEqual(sortColumn('amount', ['10', '2'], 'numeric'), ['2', '10'])
19
+ })
20
+
21
+ test('numeric sort puts missing values last in both directions', () => {
22
+ assert.deepEqual(sortColumn('amount', ['', '10', '2'], 'numeric'), ['2', '10', ''])
23
+ assert.deepEqual(sortColumn('amount', ['', '10', '2'], 'numeric', 'desc'), ['10', '2', ''])
24
+ })
25
+
26
+ test('numeric sort falls back to the plain column attribute', () => {
27
+ const rows = [{ dataset: { amount: '5' } }, { dataset: { amount: '1' } }]
28
+ const sorted = new RowSorter('amount', 'numeric', 'asc').sort(rows)
29
+ assert.deepEqual(sorted.map(r => r.dataset.amount), ['1', '5'])
30
+ })
31
+
32
+ test('date-dmy sorts by actual date and puts missing values last', () => {
33
+ const values = ['03/01/2024', '15/01/2024', '20/12/2023', '']
34
+ assert.deepEqual(sortColumn('created', values, 'date-dmy'), ['20/12/2023', '03/01/2024', '15/01/2024', ''])
35
+ })
36
+
37
+ test('date-mdy parses month-first dates', () => {
38
+ assert.deepEqual(sortColumn('created', ['01/15/2024', '01/03/2024'], 'date-mdy'), ['01/03/2024', '01/15/2024'])
39
+ })
40
+
41
+ test('ISO dates sort without an explicit type suffix', () => {
42
+ assert.deepEqual(sortColumn('created', ['2024-02-01', '2024-01-15'], 'date'), ['2024-01-15', '2024-02-01'])
43
+ })
@@ -0,0 +1,69 @@
1
+ import { test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { RowStats } from '../../app/assets/javascripts/stimulus_table_filter/row_stats.js'
4
+ import { UrlState } from '../../app/assets/javascripts/stimulus_table_filter/url_state.js'
5
+
6
+ const row = (status) => ({ dataset: { filterStatus: status } })
7
+
8
+ test('computes matched and total counts over the filtered set', () => {
9
+ const rows = [row('active'), row('draft'), row('archived')]
10
+ const matchedRows = [rows[0], rows[1]]
11
+ assert.deepEqual(RowStats.compute(rows, matchedRows), { matched: 2, total: 3 })
12
+ })
13
+
14
+ test('percentage rounds to the nearest whole number and survives empty tables', () => {
15
+ assert.equal(RowStats.percentage(1, 3), 33)
16
+ assert.equal(RowStats.percentage(0, 0), 0)
17
+ })
18
+
19
+ const withLocation = (search, pathname = '/items') => {
20
+ globalThis.location = { search, pathname }
21
+ const urls = []
22
+ globalThis.history = { replaceState: (_state, _title, url) => urls.push(url) }
23
+ return urls
24
+ }
25
+
26
+ test('read returns present params and drops the rest', () => {
27
+ withLocation('?tf_sort=grade&tf_dir=desc')
28
+ assert.deepEqual(UrlState.read('tf'), { sort: 'grade', dir: 'desc' })
29
+ })
30
+
31
+ test('read collects per-dimension filter params', () => {
32
+ withLocation('?tf_filter_state=active&tf_filter_payment=paid')
33
+ assert.deepEqual(UrlState.read('tf'), { filterDimensions: { state: 'active', payment: 'paid' } })
34
+ })
35
+
36
+ test('read parses the page as an integer', () => {
37
+ withLocation('?tf_page=3')
38
+ assert.deepEqual(UrlState.read('tf'), { page: 3 })
39
+ })
40
+
41
+ test('write keeps params that differ from their defaults', () => {
42
+ const urls = withLocation('')
43
+ UrlState.write('tf', { filterDimensions: { state: 'active' }, sort: 'grade', dir: 'desc', search: 'bob', page: 2 })
44
+ assert.equal(urls[0], '/items?tf_sort=grade&tf_dir=desc&tf_page=2&tf_search=bob&tf_filter_state=active')
45
+ })
46
+
47
+ test('write drops params equal to their defaults and preserves foreign params', () => {
48
+ const urls = withLocation('?keep=1&tf_filter_state=active')
49
+ UrlState.write('tf', { filterDimensions: {}, sort: 'name', dir: 'asc', search: '', page: 1 })
50
+ assert.equal(urls[0], '/items?keep=1')
51
+ })
52
+
53
+ test('write serializes dimension filters as separate params', () => {
54
+ const urls = withLocation('')
55
+ UrlState.write('tf', {
56
+ filterDimensions: { state: 'active', payment: 'paid' },
57
+ sort: 'name', dir: 'asc', search: '', page: 1
58
+ })
59
+ assert.equal(urls[0], '/items?tf_filter_state=active&tf_filter_payment=paid')
60
+ })
61
+
62
+ test('write drops dimension params that are empty or all', () => {
63
+ const urls = withLocation('?tf_filter_state=active&tf_filter_payment=paid')
64
+ UrlState.write('tf', {
65
+ filterDimensions: { state: 'all', payment: '' },
66
+ sort: 'name', dir: 'asc', search: '', page: 1
67
+ })
68
+ assert.equal(urls[0], '/items?')
69
+ })
@@ -0,0 +1,6 @@
1
+ const stub = new URL('./controller_stub.mjs', import.meta.url).href
2
+
3
+ export async function resolve(specifier, context, next) {
4
+ if (specifier === '@hotwired/stimulus') return { shortCircuit: true, url: stub }
5
+ return next(specifier, context)
6
+ }
metadata ADDED
@@ -0,0 +1,107 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: stimulus_table_filter
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - icoluccio
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: railties
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '7.0'
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '9'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: '7.0'
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '9'
32
+ description: Stimulus controller and view helpers that add instant search, filter
33
+ dimensions, multi-column sort, pagination and live footer stats to any Rails table.
34
+ Everything is wired through data attributes, with no JavaScript dependencies or
35
+ configuration required.
36
+ email:
37
+ - ignacio.coluccio@gmail.com
38
+ executables: []
39
+ extensions: []
40
+ extra_rdoc_files: []
41
+ files:
42
+ - CHANGELOG.md
43
+ - Gemfile
44
+ - Gemfile.lock
45
+ - README.md
46
+ - app/assets/javascripts/stimulus_table_filter/filter_list.js
47
+ - app/assets/javascripts/stimulus_table_filter/page_navigator.js
48
+ - app/assets/javascripts/stimulus_table_filter/paginator.js
49
+ - app/assets/javascripts/stimulus_table_filter/row_matcher.js
50
+ - app/assets/javascripts/stimulus_table_filter/row_sorter.js
51
+ - app/assets/javascripts/stimulus_table_filter/row_stats.js
52
+ - app/assets/javascripts/stimulus_table_filter/table_filter.js
53
+ - app/assets/javascripts/stimulus_table_filter/table_filter_controller.js
54
+ - app/assets/javascripts/stimulus_table_filter/table_filter_view.js
55
+ - app/assets/javascripts/stimulus_table_filter/url_state.js
56
+ - app/assets/stylesheets/stimulus_table_filter/table_filter.css
57
+ - config/importmap.rb
58
+ - eslint.config.js
59
+ - lib/spec/shared_examples/table_filter_footer.rb
60
+ - lib/spec/shared_examples/table_filter_rows.rb
61
+ - lib/spec/shared_examples/table_filter_view.rb
62
+ - lib/stimulus_table_filter.rb
63
+ - lib/stimulus_table_filter/engine.rb
64
+ - lib/stimulus_table_filter/error.rb
65
+ - lib/stimulus_table_filter/rspec.rb
66
+ - lib/stimulus_table_filter/rspec/helpers.rb
67
+ - lib/stimulus_table_filter/rspec/matchers.rb
68
+ - lib/stimulus_table_filter/version.rb
69
+ - lib/stimulus_table_filter/view_helper.rb
70
+ - lib/stimulus_table_filter/view_helper/container.rb
71
+ - lib/stimulus_table_filter/view_helper/controls.rb
72
+ - lib/stimulus_table_filter/view_helper/rows.rb
73
+ - lib/stimulus_table_filter/view_helper/sort.rb
74
+ - lib/stimulus_table_filter/view_helper/stats.rb
75
+ - package-lock.json
76
+ - package.json
77
+ - stimulus_table_filter.gemspec
78
+ - test/javascript/controller.test.mjs
79
+ - test/javascript/controller_stub.mjs
80
+ - test/javascript/paginator.test.mjs
81
+ - test/javascript/register.mjs
82
+ - test/javascript/row_matcher.test.mjs
83
+ - test/javascript/row_sorter.test.mjs
84
+ - test/javascript/row_stats_and_url_state.test.mjs
85
+ - test/javascript/stimulus_stub.mjs
86
+ homepage: https://github.com/icoluccio/stimulus-table-filter
87
+ licenses:
88
+ - MIT
89
+ metadata: {}
90
+ rdoc_options: []
91
+ require_paths:
92
+ - lib
93
+ required_ruby_version: !ruby/object:Gem::Requirement
94
+ requirements:
95
+ - - ">="
96
+ - !ruby/object:Gem::Version
97
+ version: '3.2'
98
+ required_rubygems_version: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - ">="
101
+ - !ruby/object:Gem::Version
102
+ version: '0'
103
+ requirements: []
104
+ rubygems_version: 3.6.9
105
+ specification_version: 4
106
+ summary: Client-side table filtering, sorting and search for Rails
107
+ test_files: []