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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +16 -0
- data/Gemfile +17 -0
- data/Gemfile.lock +299 -0
- data/README.md +526 -0
- data/app/assets/javascripts/stimulus_table_filter/filter_list.js +17 -0
- data/app/assets/javascripts/stimulus_table_filter/page_navigator.js +25 -0
- data/app/assets/javascripts/stimulus_table_filter/paginator.js +29 -0
- data/app/assets/javascripts/stimulus_table_filter/row_matcher.js +36 -0
- data/app/assets/javascripts/stimulus_table_filter/row_sorter.js +33 -0
- data/app/assets/javascripts/stimulus_table_filter/row_stats.js +9 -0
- data/app/assets/javascripts/stimulus_table_filter/table_filter.js +75 -0
- data/app/assets/javascripts/stimulus_table_filter/table_filter_controller.js +234 -0
- data/app/assets/javascripts/stimulus_table_filter/table_filter_view.js +149 -0
- data/app/assets/javascripts/stimulus_table_filter/url_state.js +51 -0
- data/app/assets/stylesheets/stimulus_table_filter/table_filter.css +8 -0
- data/config/importmap.rb +2 -0
- data/eslint.config.js +18 -0
- data/lib/spec/shared_examples/table_filter_footer.rb +6 -0
- data/lib/spec/shared_examples/table_filter_rows.rb +13 -0
- data/lib/spec/shared_examples/table_filter_view.rb +87 -0
- data/lib/stimulus_table_filter/engine.rb +23 -0
- data/lib/stimulus_table_filter/error.rb +5 -0
- data/lib/stimulus_table_filter/rspec/helpers.rb +18 -0
- data/lib/stimulus_table_filter/rspec/matchers.rb +43 -0
- data/lib/stimulus_table_filter/rspec.rb +19 -0
- data/lib/stimulus_table_filter/version.rb +5 -0
- data/lib/stimulus_table_filter/view_helper/container.rb +28 -0
- data/lib/stimulus_table_filter/view_helper/controls.rb +42 -0
- data/lib/stimulus_table_filter/view_helper/rows.rb +32 -0
- data/lib/stimulus_table_filter/view_helper/sort.rb +31 -0
- data/lib/stimulus_table_filter/view_helper/stats.rb +47 -0
- data/lib/stimulus_table_filter/view_helper.rb +16 -0
- data/lib/stimulus_table_filter.rb +6 -0
- data/package-lock.json +1112 -0
- data/package.json +14 -0
- data/stimulus_table_filter.gemspec +29 -0
- data/test/javascript/controller.test.mjs +10 -0
- data/test/javascript/controller_stub.mjs +1 -0
- data/test/javascript/paginator.test.mjs +35 -0
- data/test/javascript/register.mjs +20 -0
- data/test/javascript/row_matcher.test.mjs +71 -0
- data/test/javascript/row_sorter.test.mjs +43 -0
- data/test/javascript/row_stats_and_url_state.test.mjs +69 -0
- data/test/javascript/stimulus_stub.mjs +6 -0
- metadata +107 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// Full data-attribute contract: README.md, "Data-attribute contract".
|
|
2
|
+
|
|
3
|
+
export const ALL_FILTER = "all"
|
|
4
|
+
export const HIDDEN_CLASS = "hidden"
|
|
5
|
+
export const HIDDEN_STYLE = "none"
|
|
6
|
+
export const ACTIVE_BUTTON_CLASS = "btn-active"
|
|
7
|
+
export const ARIA_SORT_NONE = "none"
|
|
8
|
+
export const NEUTRAL_ICON = " ↕"
|
|
9
|
+
|
|
10
|
+
export const SELECTORS = {
|
|
11
|
+
filterButton: "[data-filter-btn]",
|
|
12
|
+
sortButton: "[data-sort-btn]",
|
|
13
|
+
sortIcon: "[data-sort-icon]",
|
|
14
|
+
prevPageButton: "[data-prev-page]",
|
|
15
|
+
nextPageButton: "[data-next-page]",
|
|
16
|
+
count: "[data-count-dimension]"
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const PAGE_STEPS = [[SELECTORS.prevPageButton, -1], [SELECTORS.nextPageButton, 1]]
|
|
20
|
+
|
|
21
|
+
export const TARGETS = {
|
|
22
|
+
search: "search",
|
|
23
|
+
row: "row",
|
|
24
|
+
matchCount: "matchCount",
|
|
25
|
+
totalCount: "totalCount",
|
|
26
|
+
matchPct: "matchPct",
|
|
27
|
+
emptyRow: "emptyRow",
|
|
28
|
+
prevPage: "prevPage",
|
|
29
|
+
nextPage: "nextPage",
|
|
30
|
+
pageInfo: "pageInfo",
|
|
31
|
+
groupHeader: "groupHeader",
|
|
32
|
+
filterDisplay: "filterDisplay",
|
|
33
|
+
filterSelect: "filterSelect"
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const DIRECTIONS = {
|
|
37
|
+
asc: { opposite: "desc", multiplier: 1, aria: "ascending", icon: " ↑" },
|
|
38
|
+
desc: { opposite: "asc", multiplier: -1, aria: "descending", icon: " ↓" }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const pascalCase = (value) => value
|
|
42
|
+
.split("-")
|
|
43
|
+
.map(part => part.charAt(0).toUpperCase() + part.slice(1))
|
|
44
|
+
.join("")
|
|
45
|
+
|
|
46
|
+
// Dataset key carrying a row's value for a filter dimension:
|
|
47
|
+
// data-filter-payment-status → dataset.filterPaymentStatus
|
|
48
|
+
export const dimensionDatasetKey = (dimension) => `filter${pascalCase(dimension)}`
|
|
49
|
+
|
|
50
|
+
export const dimensionValue = (row, dimension) => row.dataset[dimensionDatasetKey(dimension)]
|
|
51
|
+
|
|
52
|
+
const parseSlashDate = (value, dayFirst) => {
|
|
53
|
+
const [first, second, year] = value.split("/")
|
|
54
|
+
const day = dayFirst ? first : second
|
|
55
|
+
const month = dayFirst ? second : first
|
|
56
|
+
return new Date(+year, month - 1, +day).getTime()
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// data-sort-type → parser: turns a cell string into a comparable number; strings stay locale-aware.
|
|
60
|
+
export const SORT_TYPES = {
|
|
61
|
+
string: { locale: true },
|
|
62
|
+
numeric: { parse: value => (value === "" ? null : parseFloat(value)) },
|
|
63
|
+
date: { parse: value => new Date(value).getTime() },
|
|
64
|
+
"date-dmy": { parse: value => parseSlashDate(value, true) },
|
|
65
|
+
"date-mdy": { parse: value => parseSlashDate(value, false) }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Defaults are dropped from the URL when equal; parse normalizes values read back.
|
|
69
|
+
// Filter dimensions serialize separately as tf_filter_{dimension} params.
|
|
70
|
+
export const URL_FIELDS = [
|
|
71
|
+
{ key: "sort", default: "name" },
|
|
72
|
+
{ key: "dir", default: "asc", parse: value => (DIRECTIONS[value] ? value : undefined) },
|
|
73
|
+
{ key: "page", default: 1, parse: value => parseInt(value) || 1 },
|
|
74
|
+
{ key: "search", default: "" }
|
|
75
|
+
]
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
import {
|
|
3
|
+
ALL_FILTER,
|
|
4
|
+
DIRECTIONS,
|
|
5
|
+
HIDDEN_CLASS,
|
|
6
|
+
PAGE_STEPS,
|
|
7
|
+
SELECTORS,
|
|
8
|
+
TARGETS
|
|
9
|
+
} from "stimulus_table_filter/table_filter"
|
|
10
|
+
import { PageNavigator } from "stimulus_table_filter/page_navigator"
|
|
11
|
+
import { RowMatcher } from "stimulus_table_filter/row_matcher"
|
|
12
|
+
import { RowSorter } from "stimulus_table_filter/row_sorter"
|
|
13
|
+
import { RowStats } from "stimulus_table_filter/row_stats"
|
|
14
|
+
import { TableView } from "stimulus_table_filter/table_filter_view"
|
|
15
|
+
import { UrlState } from "stimulus_table_filter/url_state"
|
|
16
|
+
|
|
17
|
+
export default class extends Controller {
|
|
18
|
+
static targets = Object.values(TARGETS)
|
|
19
|
+
static values = {
|
|
20
|
+
filterDimensions: { type: Object, default: {} },
|
|
21
|
+
sort: { type: String, default: "name" },
|
|
22
|
+
dir: { type: String, default: "asc" },
|
|
23
|
+
page: { type: Number, default: 1 },
|
|
24
|
+
search: { type: String, default: "" },
|
|
25
|
+
pageSize: { type: Number, default: 0 },
|
|
26
|
+
urlKey: { type: String, default: "tf" },
|
|
27
|
+
debounceMs: { type: Number, default: 0 }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
#debounceTimer = null
|
|
31
|
+
#interacted = false // URL state is only written after the first user interaction
|
|
32
|
+
#view = new TableView(this)
|
|
33
|
+
|
|
34
|
+
// Bound handlers stored so disconnect() can remove the same references
|
|
35
|
+
#onClick = (e) => this.#handleClick(e)
|
|
36
|
+
#onInput = (e) => this.#handleInput(e)
|
|
37
|
+
#onChange = (e) => this.#handleChange(e)
|
|
38
|
+
|
|
39
|
+
connect() {
|
|
40
|
+
this.#readUrlState()
|
|
41
|
+
this.element.addEventListener("click", this.#onClick)
|
|
42
|
+
this.element.addEventListener("input", this.#onInput)
|
|
43
|
+
this.element.addEventListener("change", this.#onChange)
|
|
44
|
+
this.refresh()
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
disconnect() {
|
|
48
|
+
this.element.removeEventListener("click", this.#onClick)
|
|
49
|
+
this.element.removeEventListener("input", this.#onInput)
|
|
50
|
+
this.element.removeEventListener("change", this.#onChange)
|
|
51
|
+
clearTimeout(this.#debounceTimer)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ── Public API (also callable from external elements as data-action handlers) ────
|
|
55
|
+
|
|
56
|
+
setFilter(event) { this.#applyFilter(event.currentTarget.dataset.filterBtn, event.currentTarget.dataset.filterDimension) }
|
|
57
|
+
sortBy(event) { this.#applySort(event.currentTarget.dataset.sortBtn) }
|
|
58
|
+
search() { this.pageValue = 1; this.refresh() }
|
|
59
|
+
|
|
60
|
+
refresh() {
|
|
61
|
+
this.#filterRows()
|
|
62
|
+
this.#reorder()
|
|
63
|
+
this.#renderStats() // counts the full filtered set; must run before #applyPage
|
|
64
|
+
this.#applyPage()
|
|
65
|
+
this.#view.renderGroupHeaders(this.rowTargets) // only header groups with rows on this page stay visible
|
|
66
|
+
this.#view.renderFilterButtons(this.filterDimensionsValue)
|
|
67
|
+
this.#view.renderSortTriggers(this.sortValue, this.dirValue)
|
|
68
|
+
this.#view.renderFilterDisplay(this.filterDimensionsValue)
|
|
69
|
+
this.#view.renderFilterSelect(this.filterDimensionsValue)
|
|
70
|
+
this.#view.renderCounts(this.#matchedRows())
|
|
71
|
+
this.#view.renderPagination(this.pageValue, this.#matchedRows().length, this.#navigator())
|
|
72
|
+
if (this.#interacted) this.#writeUrlState()
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
#handleClick(event) {
|
|
76
|
+
if (this.#clickFilter(event)) return
|
|
77
|
+
if (this.#clickSort(event)) return
|
|
78
|
+
this.#clickPage(event)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
#clickFilter(event) {
|
|
82
|
+
const btn = event.target.closest(SELECTORS.filterButton)
|
|
83
|
+
if (!btn) return false
|
|
84
|
+
this.#applyFilter(btn.dataset.filterBtn, btn.dataset.filterDimension)
|
|
85
|
+
return true
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
#clickSort(event) {
|
|
89
|
+
const btn = event.target.closest(SELECTORS.sortButton)
|
|
90
|
+
if (!btn) return false
|
|
91
|
+
this.#applySort(btn.dataset.sortBtn)
|
|
92
|
+
return true
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
#clickPage(event) {
|
|
96
|
+
for (const [selector, step] of PAGE_STEPS) {
|
|
97
|
+
if (!event.target.closest(selector)) continue
|
|
98
|
+
this.#goToPage(step)
|
|
99
|
+
return true
|
|
100
|
+
}
|
|
101
|
+
return false
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Every user-driven change resets pagination, marks the session as
|
|
105
|
+
// interactive and re-renders the table.
|
|
106
|
+
#rerun(update) {
|
|
107
|
+
update()
|
|
108
|
+
this.pageValue = 1
|
|
109
|
+
this.#interacted = true
|
|
110
|
+
this.refresh()
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
#applyFilter(value, dimension) {
|
|
114
|
+
if (!dimension) return
|
|
115
|
+
this.#rerun(() => this.#toggleDimensionFilter(dimension, value))
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
#applySort(col) {
|
|
119
|
+
this.#rerun(() => {
|
|
120
|
+
this.dirValue = this.sortValue === col ? DIRECTIONS[this.dirValue].opposite : "asc"
|
|
121
|
+
this.sortValue = col
|
|
122
|
+
})
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
#handleInput(event) {
|
|
126
|
+
if (!this.#hasTargetToken(event.target, "search")) return
|
|
127
|
+
this.searchValue = event.target.value
|
|
128
|
+
this.#interacted = true
|
|
129
|
+
this.pageValue = 1
|
|
130
|
+
this.debounceMsValue ? this.#deferRefresh() : this.refresh()
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
#deferRefresh() {
|
|
134
|
+
clearTimeout(this.#debounceTimer)
|
|
135
|
+
this.#debounceTimer = setTimeout(() => this.refresh(), this.debounceMsValue)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Select always replaces (single-choice); it cannot represent multi-value state.
|
|
139
|
+
#handleChange(event) {
|
|
140
|
+
const select = event.target
|
|
141
|
+
if (!this.#hasTargetToken(select, "filterSelect")) return
|
|
142
|
+
this.#rerun(() => this.#setDimensionFilter(select.dataset.filterDimension, select.value))
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// An element may carry several space-separated target names; match tokens, not the whole string.
|
|
146
|
+
#hasTargetToken(el, token) {
|
|
147
|
+
return (el.dataset?.tableFilterTarget || "").split(" ").includes(token)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Toggles a value within a dimension's list; "all" resets, and an emptied
|
|
151
|
+
// list removes the dimension.
|
|
152
|
+
#toggleDimensionFilter(dimension, value) {
|
|
153
|
+
const current = this.filterDimensionsValue[dimension] ?? ALL_FILTER
|
|
154
|
+
this.#setDimensionFilter(dimension, RowMatcher.toggledFilter(current, value))
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// A missing/empty value removes the dimension from the filter state.
|
|
158
|
+
#setDimensionFilter(dimension, value) {
|
|
159
|
+
if (!dimension) return
|
|
160
|
+
const { [dimension]: _omitted, ...rest } = this.filterDimensionsValue
|
|
161
|
+
const inactive = value === undefined || value === ALL_FILTER
|
|
162
|
+
this.filterDimensionsValue = inactive ? rest : { ...rest, [dimension]: value }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
#navigator() {
|
|
166
|
+
return new PageNavigator(this.pageSizeValue)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
#matchedRows() {
|
|
170
|
+
return this.rowTargets.filter(r => !r.classList.contains(HIDDEN_CLASS))
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
#filterRows() {
|
|
174
|
+
const matcher = new RowMatcher(this.searchValue, this.filterDimensionsValue)
|
|
175
|
+
this.#view.renderRowVisibility(this.rowTargets, matcher)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
#renderStats() {
|
|
179
|
+
const stats = RowStats.compute(this.rowTargets, this.#matchedRows())
|
|
180
|
+
this.#view.renderStats(stats)
|
|
181
|
+
this.#view.renderEmptyRow(stats.matched)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
#applyPage() {
|
|
185
|
+
const navigator = this.#navigator()
|
|
186
|
+
this.#view.clearPageWindow(this.rowTargets)
|
|
187
|
+
if (!navigator.enabled) return
|
|
188
|
+
|
|
189
|
+
const matched = this.#matchedRows()
|
|
190
|
+
this.pageValue = navigator.clamp(this.pageValue, matched.length) // stale ?tf_page= from the URL
|
|
191
|
+
this.#view.showPageWindow(matched, navigator.window(this.pageValue))
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
#goToPage(step) {
|
|
195
|
+
const navigator = this.#navigator()
|
|
196
|
+
const target = navigator.step(this.pageValue, step, this.#matchedRows().length)
|
|
197
|
+
if (target === this.pageValue) return
|
|
198
|
+
this.pageValue = target
|
|
199
|
+
this.#interacted = true
|
|
200
|
+
this.#applyPage()
|
|
201
|
+
this.#view.renderPagination(this.pageValue, this.#matchedRows().length, navigator)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
#reorder() {
|
|
205
|
+
this.#view.reorderRows(this.#sorter().sort(this.rowTargets))
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
#sorter() {
|
|
209
|
+
// Match by value instead of an interpolated attribute selector, since the sort
|
|
210
|
+
// column can come from the URL and selector injection must stay impossible.
|
|
211
|
+
const trigger = [...this.element.querySelectorAll(SELECTORS.sortButton)]
|
|
212
|
+
.find(btn => btn.dataset.sortBtn === this.sortValue)
|
|
213
|
+
return new RowSorter(this.sortValue, trigger?.dataset.sortType, this.dirValue)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
#readUrlState() {
|
|
217
|
+
this.#applyUrlState(UrlState.read(this.urlKeyValue))
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// URL_FIELDS keys are Stimulus values, so state applies by name.
|
|
221
|
+
#applyUrlState(state) {
|
|
222
|
+
for (const [key, value] of Object.entries(state)) this[`${key}Value`] = value
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
#writeUrlState() {
|
|
226
|
+
UrlState.write(this.urlKeyValue, {
|
|
227
|
+
filterDimensions: this.filterDimensionsValue,
|
|
228
|
+
sort: this.sortValue,
|
|
229
|
+
dir: this.dirValue,
|
|
230
|
+
search: this.searchValue.trim(),
|
|
231
|
+
page: this.pageValue
|
|
232
|
+
})
|
|
233
|
+
}
|
|
234
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ACTIVE_BUTTON_CLASS,
|
|
3
|
+
ALL_FILTER,
|
|
4
|
+
ARIA_SORT_NONE,
|
|
5
|
+
DIRECTIONS,
|
|
6
|
+
HIDDEN_CLASS,
|
|
7
|
+
HIDDEN_STYLE,
|
|
8
|
+
NEUTRAL_ICON,
|
|
9
|
+
SELECTORS,
|
|
10
|
+
TARGETS,
|
|
11
|
+
dimensionValue,
|
|
12
|
+
pascalCase
|
|
13
|
+
} from "stimulus_table_filter/table_filter"
|
|
14
|
+
import { FilterList } from "stimulus_table_filter/filter_list"
|
|
15
|
+
import { RowStats } from "stimulus_table_filter/row_stats"
|
|
16
|
+
|
|
17
|
+
export class TableView {
|
|
18
|
+
constructor(controller) {
|
|
19
|
+
this.controller = controller
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
renderRowVisibility(rows, matcher) {
|
|
23
|
+
rows.forEach(row => row.classList.toggle(HIDDEN_CLASS, !matcher.matches(row)))
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
reorderRows(rows) {
|
|
27
|
+
Map.groupBy(rows, row => row.parentElement)
|
|
28
|
+
.forEach((groupRows, parent) => parent.append(...groupRows))
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
renderGroupHeaders(rows) {
|
|
32
|
+
this.controller.groupHeaderTargets.forEach(header => {
|
|
33
|
+
const hasVisibleRows = rows.some(r => r.dataset.group === header.dataset.group && this.isVisible(r))
|
|
34
|
+
header.classList.toggle(HIDDEN_CLASS, !hasVisibleRows)
|
|
35
|
+
})
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
clearPageWindow(rows) {
|
|
39
|
+
rows.forEach(row => row.style.removeProperty("display"))
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
showPageWindow(rows, { start, end }) {
|
|
43
|
+
rows.forEach((row, index) => {
|
|
44
|
+
if (index < start || index >= end) row.style.display = HIDDEN_STYLE
|
|
45
|
+
})
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
renderStats({ matched, total }) {
|
|
49
|
+
const c = this.controller
|
|
50
|
+
window.__targetDebug = Object.fromEntries(
|
|
51
|
+
['matchCount', 'totalCount', 'matchPct'].map((name) => {
|
|
52
|
+
const key = pascalCase(name)
|
|
53
|
+
const dom = c.element.querySelector(`[data-table-filter-target~="${name}"]`)
|
|
54
|
+
return [name, {
|
|
55
|
+
has: c[`has${key}Target`],
|
|
56
|
+
get: c[`${key}Target`] ? 'el' : String(c[`${key}Target`]),
|
|
57
|
+
dom: dom ? 'el' : 'missing'
|
|
58
|
+
}]
|
|
59
|
+
})
|
|
60
|
+
)
|
|
61
|
+
this.#target(TARGETS.matchCount).textContent = matched
|
|
62
|
+
this.#target(TARGETS.totalCount).textContent = total
|
|
63
|
+
this.#target(TARGETS.matchPct).textContent = `${RowStats.percentage(matched, total)}%`
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
renderCounts(matchedRows) {
|
|
67
|
+
this.controller.element.querySelectorAll(SELECTORS.count).forEach(el => {
|
|
68
|
+
el.textContent = matchedRows
|
|
69
|
+
.filter(row => dimensionValue(row, el.dataset.countDimension) === el.dataset.countValue).length
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
renderEmptyRow(matched) {
|
|
74
|
+
this.#target(TARGETS.emptyRow).classList.toggle(HIDDEN_CLASS, matched > 0)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
renderFilterButtons(dimensions) {
|
|
78
|
+
this.controller.element.querySelectorAll(SELECTORS.filterButton).forEach(btn => {
|
|
79
|
+
const list = this.listFor(dimensions, this.dimensionOf(btn))
|
|
80
|
+
const isActive = btn.dataset.filterBtn === ALL_FILTER ? list.isEmpty : list.has(btn.dataset.filterBtn)
|
|
81
|
+
this.setActiveState(btn, isActive)
|
|
82
|
+
})
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
renderSortTriggers(sortValue, dirValue) {
|
|
86
|
+
const direction = DIRECTIONS[dirValue]
|
|
87
|
+
this.controller.element.querySelectorAll(SELECTORS.sortButton).forEach(btn => {
|
|
88
|
+
const active = btn.dataset.sortBtn === sortValue
|
|
89
|
+
this.renderSortTriggerState(btn, active, direction)
|
|
90
|
+
this.#sortIcon(btn).textContent = active ? direction.icon : NEUTRAL_ICON
|
|
91
|
+
})
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
renderSortTriggerState(btn, active, direction) {
|
|
95
|
+
if (btn.tagName === "TH") {
|
|
96
|
+
btn.setAttribute("aria-sort", active ? direction.aria : ARIA_SORT_NONE)
|
|
97
|
+
return
|
|
98
|
+
}
|
|
99
|
+
this.setActiveState(btn, active)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
renderFilterDisplay(dimensions) {
|
|
103
|
+
const display = this.#target(TARGETS.filterDisplay)
|
|
104
|
+
const list = this.listFor(dimensions, this.dimensionOf(display))
|
|
105
|
+
display.hidden = list.isEmpty
|
|
106
|
+
display.textContent = list.toString().replace(/,/g, ", ")
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
renderFilterSelect(dimensions) {
|
|
110
|
+
const select = this.#target(TARGETS.filterSelect)
|
|
111
|
+
select.value = this.listFor(dimensions, this.dimensionOf(select)).toString()
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
renderPagination(page, matchedTotal, navigator) {
|
|
115
|
+
if (!navigator.enabled) return
|
|
116
|
+
this.#target(TARGETS.prevPage).disabled = page <= 1
|
|
117
|
+
this.#target(TARGETS.nextPage).disabled = page >= navigator.pageCount(matchedTotal)
|
|
118
|
+
this.#target(TARGETS.pageInfo).textContent = navigator.info(page, matchedTotal)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
isVisible(row) {
|
|
122
|
+
return !row.classList.contains(HIDDEN_CLASS) && row.style.display !== HIDDEN_STYLE
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
setActiveState(btn, isActive) {
|
|
126
|
+
btn.classList.toggle(ACTIVE_BUTTON_CLASS, isActive)
|
|
127
|
+
btn.setAttribute("aria-pressed", isActive ? "true" : "false")
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// A trigger's data-filter-dimension names the dimension it acts on.
|
|
131
|
+
dimensionOf(el) {
|
|
132
|
+
return el.dataset.filterDimension
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
listFor(dimensions, dimension) {
|
|
136
|
+
return new FilterList(dimensions[dimension] ?? ALL_FILTER)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Writes to a missing target land on a detached sink element and are discarded.
|
|
140
|
+
// has is PascalCase (hasMatchCountTarget); the value getter is camelCase (matchCountTarget).
|
|
141
|
+
#target(name) {
|
|
142
|
+
const controller = this.controller
|
|
143
|
+
return controller[`has${pascalCase(name)}Target`] ? controller[`${name}Target`] : document.createElement("template")
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
#sortIcon(btn) {
|
|
147
|
+
return btn.querySelector(SELECTORS.sortIcon) ?? document.createElement("template")
|
|
148
|
+
}
|
|
149
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { URL_FIELDS } from "stimulus_table_filter/table_filter"
|
|
2
|
+
|
|
3
|
+
export class UrlState {
|
|
4
|
+
static read(prefix) {
|
|
5
|
+
const state = {}
|
|
6
|
+
const params = this.withParams(prefix, (params, field, param) => {
|
|
7
|
+
if (!params.has(param)) return
|
|
8
|
+
const value = field.parse ? field.parse(params.get(param)) : params.get(param)
|
|
9
|
+
if (value !== undefined) state[field.key] = value
|
|
10
|
+
})
|
|
11
|
+
const dimensions = this.readDimensions(params, prefix)
|
|
12
|
+
if (dimensions.size) state.filterDimensions = Object.fromEntries(dimensions)
|
|
13
|
+
return state
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
static write(prefix, state) {
|
|
17
|
+
const params = this.withParams(prefix, (params, field, param) => {
|
|
18
|
+
const value = state[field.key]
|
|
19
|
+
if (value === undefined || value === field.default) params.delete(param)
|
|
20
|
+
else params.set(param, String(value))
|
|
21
|
+
})
|
|
22
|
+
const dimensions = state.filterDimensions ?? {}
|
|
23
|
+
const marker = `${prefix}_filter_`
|
|
24
|
+
for (const key of [...params.keys()]) {
|
|
25
|
+
if (key.startsWith(marker) && !Object.hasOwn(dimensions, key.slice(marker.length))) params.delete(key)
|
|
26
|
+
}
|
|
27
|
+
for (const [dimension, value] of Object.entries(dimensions)) {
|
|
28
|
+
const param = `${prefix}_filter_${dimension}`
|
|
29
|
+
if (!value || value === "all") params.delete(param)
|
|
30
|
+
else params.set(param, value)
|
|
31
|
+
}
|
|
32
|
+
history.replaceState(null, "", `${location.pathname}?${params}`)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
static withParams(prefix, apply) {
|
|
36
|
+
const params = new URLSearchParams(location.search)
|
|
37
|
+
for (const field of URL_FIELDS) apply(params, field, `${prefix}_${field.key}`)
|
|
38
|
+
return params
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
static readDimensions(params, prefix) {
|
|
42
|
+
const dimensions = new Map()
|
|
43
|
+
const marker = `${prefix}_filter_`
|
|
44
|
+
for (const [key, value] of params) {
|
|
45
|
+
if (!key.startsWith(marker)) continue
|
|
46
|
+
const dimension = key.slice(marker.length)
|
|
47
|
+
if (dimension) dimensions.set(dimension, value)
|
|
48
|
+
}
|
|
49
|
+
return dimensions
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/* Required by the JS controller for row/group visibility toggling.
|
|
2
|
+
Scoped to the controller so the widget doesn't define a global utility class. */
|
|
3
|
+
[data-controller="table-filter"] .hidden { display: none; }
|
|
4
|
+
|
|
5
|
+
[data-sort-btn] {
|
|
6
|
+
cursor: pointer;
|
|
7
|
+
user-select: none;
|
|
8
|
+
}
|
data/config/importmap.rb
ADDED
data/eslint.config.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import js from "@eslint/js"
|
|
2
|
+
import globals from "globals"
|
|
3
|
+
|
|
4
|
+
export default [
|
|
5
|
+
{ ignores: ["vendor/**", "node_modules/**", "coverage/**", "gemfiles/**", "tmp/**"] },
|
|
6
|
+
{
|
|
7
|
+
files: ["app/assets/javascripts/**/*.js", "test/javascript/**/*.mjs", "eslint.config.js"],
|
|
8
|
+
languageOptions: {
|
|
9
|
+
ecmaVersion: 2024,
|
|
10
|
+
sourceType: "module",
|
|
11
|
+
globals: { ...globals.browser, ...globals.node }
|
|
12
|
+
},
|
|
13
|
+
rules: {
|
|
14
|
+
...js.configs.recommended.rules,
|
|
15
|
+
"no-unused-vars": ["error", { varsIgnorePattern: "^_", argsIgnorePattern: "^_" }]
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
]
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RSpec.shared_examples 'table filter row named' do |name|
|
|
4
|
+
it("has a filterable row for '#{name}'") { expect(html).to have_data('name', name) }
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
RSpec.shared_examples 'table filter rows with status' do |status|
|
|
8
|
+
it("has at least one row with status '#{status}'") { expect(html).to have_data('filter-status', status) }
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
RSpec.shared_examples 'table filter rows include names' do |*names|
|
|
12
|
+
names.each { |name| it_behaves_like 'table filter row named', name }
|
|
13
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RSpec.shared_examples 'a table filter view' do
|
|
4
|
+
it('has a table-filter controller') { expect(html).to have_data('controller', 'table-filter') }
|
|
5
|
+
it('has a search input target') { expect(html).to have_data_target('search') }
|
|
6
|
+
it('has at least one filterable row') { expect(html).to have_data_target('row') }
|
|
7
|
+
it('has filter status buttons') { expect(html).to have_data('filter-btn') }
|
|
8
|
+
it('has sort buttons') { expect(html).to have_data('sort-btn') }
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
RSpec.shared_examples 'a table filter with sortable th headers' do
|
|
12
|
+
it('uses th elements as sort triggers') { expect(html).to have_data('sort-btn').on('th') }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
RSpec.shared_examples 'a table filter with sort column' do |col|
|
|
16
|
+
it("has a sort trigger for column '#{col}'") { expect(html).to have_data('sort-btn', col) }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
RSpec.shared_examples 'a table filter with empty row' do
|
|
20
|
+
it('has an empty-state row target') { expect(html).to have_data_target('emptyRow') }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
RSpec.shared_examples 'a table filter with pagination controls' do
|
|
24
|
+
it('has a prev page button') { expect(html).to have_data('prev-page') }
|
|
25
|
+
it('has a next page button') { expect(html).to have_data('next-page') }
|
|
26
|
+
it('has a page info target') { expect(html).to have_data_target('pageInfo') }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
RSpec.shared_examples 'a table filter with group headers' do
|
|
30
|
+
it('has a groupHeader target') { expect(html).to have_data_target('groupHeader') }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
RSpec.shared_examples 'a table filter with group' do |group|
|
|
34
|
+
it("has rows in group '#{group}'") { expect(html).to have_data('group', group) }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
RSpec.shared_examples 'a table filter with filter select' do
|
|
38
|
+
it('has a filterSelect target') { expect(html).to have_data_target('filterSelect') }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
RSpec.shared_examples 'a table filter with filter display' do
|
|
42
|
+
it('has a filterDisplay target') { expect(html).to have_data_target('filterDisplay') }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
RSpec.shared_examples 'a table filter with initial sort' do |col, dir: 'asc'|
|
|
46
|
+
it("has sort-value '#{col}'") { expect(html).to have_data('table-filter-sort-value', col) }
|
|
47
|
+
it("has dir-value '#{dir}'") { expect(html).to have_data('table-filter-dir-value', dir) }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
RSpec.shared_examples 'a table filter with page size' do |n|
|
|
51
|
+
it("has page-size-value '#{n}'") { expect(html).to have_data('table-filter-page-size-value', n) }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
RSpec.shared_examples 'a table filter with url key' do |key|
|
|
55
|
+
it("has url-key-value '#{key}'") { expect(html).to have_data('table-filter-url-key-value', key) }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
RSpec.shared_examples 'a table filter with debounce' do |ms|
|
|
59
|
+
it("has debounce-ms-value '#{ms}'") { expect(html).to have_data('table-filter-debounce-ms-value', ms) }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
RSpec.shared_examples 'a table filter with count' do |dimension, value|
|
|
63
|
+
it("has a count for #{dimension} '#{value}'") {
|
|
64
|
+
expect(html).to have_data('count-dimension', dimension).and have_data('count-value', value)
|
|
65
|
+
}
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
RSpec.shared_examples 'a table filter with filter btn' do |value|
|
|
69
|
+
it("has a filter button for '#{value}'") { expect(html).to have_data('filter-btn', value) }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
RSpec.shared_examples 'a table filter with sort type' do |col, type|
|
|
73
|
+
it("sort trigger '#{col}' has type '#{type}'") {
|
|
74
|
+
expect(html).to have_data('sort-btn', col).and have_data('sort-type', type)
|
|
75
|
+
}
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
RSpec.shared_examples 'a table filter row with sort column' do |col|
|
|
79
|
+
it("rows have data-sort-#{col} attribute") { expect(html).to have_data("sort-#{col}") }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
RSpec.shared_examples 'a table filter with accessible sort headers' do
|
|
83
|
+
let(:sortable_ths) { Nokogiri::HTML.fragment(html).css('th[data-sort-btn]') }
|
|
84
|
+
|
|
85
|
+
it('has th elements as sort triggers') { expect(sortable_ths).not_to be_empty }
|
|
86
|
+
it('sort th triggers have scope="col"') { expect(sortable_ths.map { |th| th['scope'] }).to all(eq('col')) }
|
|
87
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module StimulusTableFilter
|
|
4
|
+
class Engine < ::Rails::Engine
|
|
5
|
+
initializer 'stimulus_table_filter.helpers' do
|
|
6
|
+
ActiveSupport.on_load(:action_view) { include StimulusTableFilter::ViewHelper }
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
initializer 'stimulus_table_filter.assets' do |app|
|
|
10
|
+
if app.config.respond_to?(:assets)
|
|
11
|
+
app.config.assets.paths << root.join('app/assets/javascripts')
|
|
12
|
+
app.config.assets.paths << root.join('app/assets/stylesheets')
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
initializer 'stimulus_table_filter.importmap', before: 'importmap' do |app|
|
|
17
|
+
if app.config.respond_to?(:importmap)
|
|
18
|
+
app.config.importmap.paths << root.join('config/importmap.rb')
|
|
19
|
+
app.config.importmap.cache_sweepers << root.join('app/assets/javascripts')
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|