@lovett/ui 0.0.11 → 0.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.
- package/dist/chunk-RBYWGBQ2.js +2752 -0
- package/dist/chunk-RBYWGBQ2.js.map +1 -0
- package/dist/index.d.ts +5574 -57
- package/dist/index.js +21650 -11206
- package/dist/index.js.map +1 -1
- package/dist/rich-composer-impl-5NO443A6.js +1859 -0
- package/dist/rich-composer-impl-5NO443A6.js.map +1 -0
- package/dist/styles.css +1570 -0
- package/dist/tokens.css +112 -0
- package/package.json +8 -1
- package/src/__tests__/avatar.test.tsx +272 -0
- package/src/__tests__/bar-chart.test.tsx +809 -0
- package/src/__tests__/board.test.tsx +420 -0
- package/src/__tests__/chart-math.test.ts +922 -0
- package/src/__tests__/chart-series.test.ts +339 -0
- package/src/__tests__/code-block.test.tsx +134 -0
- package/src/__tests__/display-popover.test.tsx +195 -0
- package/src/__tests__/display-store.test.tsx +307 -0
- package/src/__tests__/donut-chart.test.tsx +397 -0
- package/src/__tests__/dropdown-menu.test.tsx +156 -2
- package/src/__tests__/filter-menu.test.tsx +175 -0
- package/src/__tests__/gauge-ring.test.tsx +233 -0
- package/src/__tests__/line-chart.test.tsx +612 -0
- package/src/__tests__/ranked-bars.test.tsx +343 -0
- package/src/__tests__/remark-underline.test.ts +194 -0
- package/src/__tests__/sortable.test.tsx +394 -0
- package/src/__tests__/sparkline.test.tsx +368 -0
- package/src/__tests__/stat-layer.test.tsx +271 -0
- package/src/__tests__/stat-strip.test.tsx +175 -0
- package/src/__tests__/status.test.tsx +293 -0
- package/src/__tests__/tabs.test.tsx +303 -0
- package/src/__tests__/token-shape.test.ts +132 -2
- package/src/avatar.tsx +352 -0
- package/src/bar-chart.tsx +1214 -0
- package/src/board.tsx +658 -0
- package/src/chart-frame.tsx +960 -0
- package/src/chart-legend.tsx +304 -0
- package/src/chart-tooltip.tsx +267 -0
- package/src/code-block.tsx +62 -8
- package/src/delta-chip.tsx +263 -0
- package/src/detail/__tests__/activity-pane.test.tsx +369 -0
- package/src/detail/__tests__/detail-chrome.test.tsx +134 -0
- package/src/detail/__tests__/detail-surface.test.tsx +529 -0
- package/src/detail/__tests__/field-row.test.tsx +357 -0
- package/src/detail/activity-pane.tsx +611 -0
- package/src/detail/calendar.tsx +355 -0
- package/src/detail/detail-divider.tsx +261 -0
- package/src/detail/detail-header.tsx +287 -0
- package/src/detail/detail-menu.tsx +254 -0
- package/src/detail/detail-surface.tsx +1110 -0
- package/src/detail/field-list.tsx +196 -0
- package/src/detail/field-row.tsx +1131 -0
- package/src/detail/index.ts +58 -0
- package/src/detail/segmented-choice.tsx +94 -0
- package/src/detail/types.ts +129 -0
- package/src/display-popover.tsx +487 -0
- package/src/display-store.tsx +301 -0
- package/src/donut-chart.tsx +988 -0
- package/src/dropdown-menu.tsx +290 -19
- package/src/filter-core/EXPORTS.md +85 -0
- package/src/filter-core/__tests__/columns.test.ts +159 -0
- package/src/filter-core/__tests__/faceting.test.ts +193 -0
- package/src/filter-core/__tests__/filter-fns.test.ts +519 -0
- package/src/filter-core/__tests__/operators.test.ts +235 -0
- package/src/filter-core/__tests__/state.test.ts +268 -0
- package/src/filter-core/__tests__/url.test.ts +350 -0
- package/src/filter-core/columns.ts +134 -0
- package/src/filter-core/date-utils.ts +38 -0
- package/src/filter-core/examples/task-filter-columns.ts +121 -0
- package/src/filter-core/faceting.ts +120 -0
- package/src/filter-core/filter-fns.ts +335 -0
- package/src/filter-core/index.ts +105 -0
- package/src/filter-core/operators.ts +433 -0
- package/src/filter-core/state.ts +280 -0
- package/src/filter-core/types.ts +247 -0
- package/src/filter-core/url.ts +261 -0
- package/src/filter-dropdown.tsx +12 -0
- package/src/filter-menu.tsx +649 -0
- package/src/floating-drawer.tsx +19 -1
- package/src/gauge-ring.tsx +435 -0
- package/src/hue.ts +52 -0
- package/src/index.ts +303 -0
- package/src/kbd.tsx +27 -4
- package/src/lib/chart.ts +866 -0
- package/src/lib/focus.ts +43 -1
- package/src/lib/layer-stack.ts +30 -3
- package/src/lib/remark-underline.ts +443 -0
- package/src/lib/series.ts +169 -0
- package/src/line-chart.tsx +1176 -0
- package/src/markdown.tsx +26 -7
- package/src/modal.tsx +42 -18
- package/src/progress-ledger.tsx +304 -0
- package/src/ranked-bars.tsx +386 -0
- package/src/segmented-pill.tsx +32 -9
- package/src/sortable.tsx +520 -1
- package/src/sparkline.tsx +416 -0
- package/src/stat-card.tsx +376 -0
- package/src/stat-strip.tsx +327 -0
- package/src/status.tsx +215 -0
- package/src/styles.css +1570 -0
- package/src/tabs.tsx +206 -25
- package/src/task-card.tsx +610 -0
- package/src/thread/__tests__/comment-body-hostile.test.tsx +331 -0
- package/src/thread/__tests__/comment-tree.test.ts +151 -0
- package/src/thread/__tests__/emoji.test.ts +187 -0
- package/src/thread/__tests__/fixtures/thread-fixture.ts +252 -0
- package/src/thread/__tests__/link-preview-source.test.ts +120 -0
- package/src/thread/__tests__/link-preview.test.tsx +600 -0
- package/src/thread/__tests__/markdown-format.test.ts +82 -0
- package/src/thread/__tests__/markdown-spec.test.ts +469 -0
- package/src/thread/__tests__/relative-time.test.ts +71 -0
- package/src/thread/__tests__/rich-composer.test.tsx +799 -0
- package/src/thread/__tests__/scroll-caret.test.ts +58 -0
- package/src/thread/__tests__/suggestion-list.test.tsx +648 -0
- package/src/thread/__tests__/thread-scroll-ownership.test.tsx +88 -0
- package/src/thread/__tests__/thread.test.tsx +742 -0
- package/src/thread/__tests__/use-attachments.test.tsx +679 -0
- package/src/thread/actions.tsx +196 -0
- package/src/thread/attachments.tsx +1071 -0
- package/src/thread/comment-body.tsx +148 -0
- package/src/thread/comment-tree.ts +182 -0
- package/src/thread/comment.tsx +967 -0
- package/src/thread/composer-footer.tsx +125 -0
- package/src/thread/composer.tsx +319 -0
- package/src/thread/emoji.ts +283 -0
- package/src/thread/index.ts +153 -0
- package/src/thread/link-preview.tsx +341 -0
- package/src/thread/markdown-format.ts +155 -0
- package/src/thread/markdown-spec.ts +754 -0
- package/src/thread/rail.tsx +372 -0
- package/src/thread/reactions.tsx +415 -0
- package/src/thread/relative-time.tsx +94 -0
- package/src/thread/rich-composer-impl.tsx +1601 -0
- package/src/thread/rich-composer.tsx +195 -0
- package/src/thread/scroll-caret.ts +37 -0
- package/src/thread/suggestion-list.tsx +182 -0
- package/src/thread/thread.tsx +718 -0
- package/src/thread/types.ts +232 -0
- package/src/thread/use-attachments.ts +598 -0
- package/src/thread/use-now.ts +73 -0
- package/src/thread/use-thread.ts +316 -0
- package/src/tokens.css +112 -0
|
@@ -0,0 +1,519 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Predicates and the combinator.
|
|
3
|
+
*
|
|
4
|
+
* Includes the regression tests for two of the three bugs carried by the
|
|
5
|
+
* source implementation:
|
|
6
|
+
*
|
|
7
|
+
* BUG 1 — `filterValue.operator in ['is between', 'is not between']`
|
|
8
|
+
* (filter-fns.ts:80). `in` on an ARRAY tests index keys, so the
|
|
9
|
+
* guard never fired. The tests marked "BUG 1" fail against that
|
|
10
|
+
* form, because it does not throw.
|
|
11
|
+
*
|
|
12
|
+
* BUG 2 — the combinator listed only option/multiOption and fell through
|
|
13
|
+
* to `return true` for text/number/date (issue-filter-columns.tsx:
|
|
14
|
+
* 187-188), silently disabling filtering on a date column. The
|
|
15
|
+
* tests marked "BUG 2" fail against that form, because a date
|
|
16
|
+
* filter there matches every row.
|
|
17
|
+
*/
|
|
18
|
+
import { describe, expect, it } from 'vitest'
|
|
19
|
+
import {
|
|
20
|
+
dateFilterFn,
|
|
21
|
+
defineColumns,
|
|
22
|
+
filterRows,
|
|
23
|
+
multiOptionFilterFn,
|
|
24
|
+
numberFilterFn,
|
|
25
|
+
optionFilterFn,
|
|
26
|
+
pruneFilters,
|
|
27
|
+
rowMatchesFilter,
|
|
28
|
+
textFilterFn,
|
|
29
|
+
} from '../index'
|
|
30
|
+
import type { AnyColumnConfig, FilterModel, FiltersState } from '../index'
|
|
31
|
+
|
|
32
|
+
/* ------------------------------------------------------------------ */
|
|
33
|
+
/* Fixtures */
|
|
34
|
+
/* ------------------------------------------------------------------ */
|
|
35
|
+
|
|
36
|
+
interface Task {
|
|
37
|
+
readonly id: string
|
|
38
|
+
readonly title: string | null
|
|
39
|
+
readonly points: number | null
|
|
40
|
+
readonly due: Date | null
|
|
41
|
+
readonly priority: string | null
|
|
42
|
+
readonly labels: readonly string[]
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const d = (day: number): Date => new Date(2026, 0, day)
|
|
46
|
+
|
|
47
|
+
function task(overrides: Partial<Task> & { id: string }): Task {
|
|
48
|
+
return {
|
|
49
|
+
title: null,
|
|
50
|
+
points: null,
|
|
51
|
+
due: null,
|
|
52
|
+
priority: null,
|
|
53
|
+
labels: [],
|
|
54
|
+
...overrides,
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const columns = defineColumns<Task>([
|
|
59
|
+
{ id: 'title', type: 'text', displayName: 'Title', accessor: (t) => t.title },
|
|
60
|
+
{
|
|
61
|
+
id: 'points',
|
|
62
|
+
type: 'number',
|
|
63
|
+
displayName: 'Points',
|
|
64
|
+
accessor: (t) => t.points,
|
|
65
|
+
},
|
|
66
|
+
{ id: 'due', type: 'date', displayName: 'Due', accessor: (t) => t.due },
|
|
67
|
+
{
|
|
68
|
+
id: 'priority',
|
|
69
|
+
type: 'option',
|
|
70
|
+
displayName: 'Priority',
|
|
71
|
+
accessor: (t) => t.priority,
|
|
72
|
+
options: [
|
|
73
|
+
{ value: 'low', label: 'Low' },
|
|
74
|
+
{ value: 'medium', label: 'Medium' },
|
|
75
|
+
{ value: 'high', label: 'High' },
|
|
76
|
+
],
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
id: 'labels',
|
|
80
|
+
type: 'multiOption',
|
|
81
|
+
displayName: 'Labels',
|
|
82
|
+
accessor: (t) => t.labels,
|
|
83
|
+
options: [
|
|
84
|
+
{ value: 'bug', label: 'Bug' },
|
|
85
|
+
{ value: 'ui', label: 'UI' },
|
|
86
|
+
{ value: 'perf', label: 'Perf' },
|
|
87
|
+
],
|
|
88
|
+
},
|
|
89
|
+
])
|
|
90
|
+
|
|
91
|
+
const text = (
|
|
92
|
+
operator: FilterModel<'text'>['operator'],
|
|
93
|
+
values: string[],
|
|
94
|
+
): FilterModel<'text'> => ({ columnId: 'title', type: 'text', operator, values })
|
|
95
|
+
|
|
96
|
+
const num = (
|
|
97
|
+
operator: FilterModel<'number'>['operator'],
|
|
98
|
+
values: number[],
|
|
99
|
+
): FilterModel<'number'> => ({
|
|
100
|
+
columnId: 'points',
|
|
101
|
+
type: 'number',
|
|
102
|
+
operator,
|
|
103
|
+
values,
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
const date = (
|
|
107
|
+
operator: FilterModel<'date'>['operator'],
|
|
108
|
+
values: Date[],
|
|
109
|
+
): FilterModel<'date'> => ({ columnId: 'due', type: 'date', operator, values })
|
|
110
|
+
|
|
111
|
+
const option = (
|
|
112
|
+
operator: FilterModel<'option'>['operator'],
|
|
113
|
+
values: string[],
|
|
114
|
+
): FilterModel<'option'> => ({
|
|
115
|
+
columnId: 'priority',
|
|
116
|
+
type: 'option',
|
|
117
|
+
operator,
|
|
118
|
+
values,
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
const multi = (
|
|
122
|
+
operator: FilterModel<'multiOption'>['operator'],
|
|
123
|
+
values: string[],
|
|
124
|
+
): FilterModel<'multiOption'> => ({
|
|
125
|
+
columnId: 'labels',
|
|
126
|
+
type: 'multiOption',
|
|
127
|
+
operator,
|
|
128
|
+
values,
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
/* ------------------------------------------------------------------ */
|
|
132
|
+
/* text */
|
|
133
|
+
/* ------------------------------------------------------------------ */
|
|
134
|
+
|
|
135
|
+
describe('textFilterFn', () => {
|
|
136
|
+
it('handles contains and does not contain', () => {
|
|
137
|
+
expect(textFilterFn('Ship the board', text('contains', ['board']))).toBe(true)
|
|
138
|
+
expect(textFilterFn('Ship the board', text('contains', ['rail']))).toBe(false)
|
|
139
|
+
expect(
|
|
140
|
+
textFilterFn('Ship the board', text('does not contain', ['rail'])),
|
|
141
|
+
).toBe(true)
|
|
142
|
+
expect(
|
|
143
|
+
textFilterFn('Ship the board', text('does not contain', ['board'])),
|
|
144
|
+
).toBe(false)
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('is case- and whitespace-insensitive', () => {
|
|
148
|
+
expect(textFilterFn('Ship The BOARD', text('contains', [' board ']))).toBe(
|
|
149
|
+
true,
|
|
150
|
+
)
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('passes everything on an empty or blank needle', () => {
|
|
154
|
+
expect(textFilterFn('anything', text('contains', []))).toBe(true)
|
|
155
|
+
expect(textFilterFn('anything', text('contains', [' ']))).toBe(true)
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('treats a missing value as the empty string, so the pair stays complementary', () => {
|
|
159
|
+
// The source returned false for BOTH halves on a null input, which made
|
|
160
|
+
// "contains x" and "does not contain x" simultaneously false.
|
|
161
|
+
expect(textFilterFn(null, text('contains', ['x']))).toBe(false)
|
|
162
|
+
expect(textFilterFn(null, text('does not contain', ['x']))).toBe(true)
|
|
163
|
+
})
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
/* ------------------------------------------------------------------ */
|
|
167
|
+
/* number */
|
|
168
|
+
/* ------------------------------------------------------------------ */
|
|
169
|
+
|
|
170
|
+
describe('numberFilterFn', () => {
|
|
171
|
+
it('handles every comparison operator', () => {
|
|
172
|
+
expect(numberFilterFn(5, num('is', [5]))).toBe(true)
|
|
173
|
+
expect(numberFilterFn(5, num('is', [6]))).toBe(false)
|
|
174
|
+
expect(numberFilterFn(5, num('is not', [6]))).toBe(true)
|
|
175
|
+
expect(numberFilterFn(5, num('is greater than', [4]))).toBe(true)
|
|
176
|
+
expect(numberFilterFn(5, num('is greater than', [5]))).toBe(false)
|
|
177
|
+
expect(numberFilterFn(5, num('is greater than or equal to', [5]))).toBe(true)
|
|
178
|
+
expect(numberFilterFn(5, num('is less than', [6]))).toBe(true)
|
|
179
|
+
expect(numberFilterFn(5, num('is less than', [5]))).toBe(false)
|
|
180
|
+
expect(numberFilterFn(5, num('is less than or equal to', [5]))).toBe(true)
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it('treats between as inclusive and order-insensitive', () => {
|
|
184
|
+
expect(numberFilterFn(5, num('is between', [1, 9]))).toBe(true)
|
|
185
|
+
expect(numberFilterFn(5, num('is between', [9, 1]))).toBe(true)
|
|
186
|
+
expect(numberFilterFn(1, num('is between', [1, 9]))).toBe(true)
|
|
187
|
+
expect(numberFilterFn(9, num('is between', [1, 9]))).toBe(true)
|
|
188
|
+
expect(numberFilterFn(10, num('is between', [1, 9]))).toBe(false)
|
|
189
|
+
expect(numberFilterFn(10, num('is not between', [1, 9]))).toBe(true)
|
|
190
|
+
expect(numberFilterFn(5, num('is not between', [1, 9]))).toBe(false)
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('excludes rows with no value', () => {
|
|
194
|
+
expect(numberFilterFn(null, num('is', [5]))).toBe(false)
|
|
195
|
+
expect(numberFilterFn(undefined, num('is not', [5]))).toBe(false)
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it('passes everything on an empty value list', () => {
|
|
199
|
+
expect(numberFilterFn(5, num('is', []))).toBe(true)
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
// BUG 1
|
|
203
|
+
it('throws when a between operator does not get exactly two values', () => {
|
|
204
|
+
expect(() => numberFilterFn(5, num('is between', [1]))).toThrow(
|
|
205
|
+
/requires exactly two values/,
|
|
206
|
+
)
|
|
207
|
+
expect(() => numberFilterFn(5, num('is not between', [1, 2, 3]))).toThrow(
|
|
208
|
+
/requires exactly two values/,
|
|
209
|
+
)
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
it('throws when a single operator gets more than one value', () => {
|
|
213
|
+
expect(() => numberFilterFn(5, num('is', [1, 2]))).toThrow(
|
|
214
|
+
/takes at most one value/,
|
|
215
|
+
)
|
|
216
|
+
})
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
/* ------------------------------------------------------------------ */
|
|
220
|
+
/* date */
|
|
221
|
+
/* ------------------------------------------------------------------ */
|
|
222
|
+
|
|
223
|
+
describe('dateFilterFn', () => {
|
|
224
|
+
it('compares at day granularity', () => {
|
|
225
|
+
const noon = new Date(2026, 0, 10, 12, 30)
|
|
226
|
+
expect(dateFilterFn(noon, date('is', [d(10)]))).toBe(true)
|
|
227
|
+
expect(dateFilterFn(noon, date('is', [d(11)]))).toBe(false)
|
|
228
|
+
expect(dateFilterFn(noon, date('is not', [d(11)]))).toBe(true)
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
it('handles before / on or after', () => {
|
|
232
|
+
const noon = new Date(2026, 0, 10, 12, 30)
|
|
233
|
+
expect(dateFilterFn(noon, date('is before', [d(11)]))).toBe(true)
|
|
234
|
+
expect(dateFilterFn(noon, date('is before', [d(10)]))).toBe(false)
|
|
235
|
+
expect(dateFilterFn(noon, date('is on or after', [d(10)]))).toBe(true)
|
|
236
|
+
expect(dateFilterFn(noon, date('is on or after', [d(11)]))).toBe(false)
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
it('handles after / on or before, and keeps them complementary', () => {
|
|
240
|
+
// The source used isAfter(value, startOfDay(d1)) for "is after", so a
|
|
241
|
+
// later time on the SAME day satisfied both "is after" and "is on or
|
|
242
|
+
// before". Day-granular comparison makes them exact complements.
|
|
243
|
+
const noon = new Date(2026, 0, 10, 12, 30)
|
|
244
|
+
expect(dateFilterFn(noon, date('is after', [d(10)]))).toBe(false)
|
|
245
|
+
expect(dateFilterFn(noon, date('is on or before', [d(10)]))).toBe(true)
|
|
246
|
+
expect(dateFilterFn(noon, date('is after', [d(9)]))).toBe(true)
|
|
247
|
+
expect(dateFilterFn(noon, date('is on or before', [d(9)]))).toBe(false)
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
it('treats between as inclusive of both whole days, order-insensitive', () => {
|
|
251
|
+
expect(dateFilterFn(d(5), date('is between', [d(1), d(9)]))).toBe(true)
|
|
252
|
+
expect(dateFilterFn(d(5), date('is between', [d(9), d(1)]))).toBe(true)
|
|
253
|
+
expect(
|
|
254
|
+
dateFilterFn(new Date(2026, 0, 9, 23, 59), date('is between', [d(1), d(9)])),
|
|
255
|
+
).toBe(true)
|
|
256
|
+
expect(dateFilterFn(d(10), date('is between', [d(1), d(9)]))).toBe(false)
|
|
257
|
+
expect(dateFilterFn(d(10), date('is not between', [d(1), d(9)]))).toBe(true)
|
|
258
|
+
expect(dateFilterFn(d(5), date('is not between', [d(1), d(9)]))).toBe(false)
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
it('excludes rows with no date, and rejects Invalid Date', () => {
|
|
262
|
+
expect(dateFilterFn(null, date('is', [d(1)]))).toBe(false)
|
|
263
|
+
expect(dateFilterFn(new Date(Number.NaN), date('is', [d(1)]))).toBe(false)
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
it('passes everything on an empty value list', () => {
|
|
267
|
+
expect(dateFilterFn(d(1), date('is', []))).toBe(true)
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
// BUG 1 — this is the exact guard the source wrote as
|
|
271
|
+
// `filterValue.operator in ['is between', 'is not between']`
|
|
272
|
+
// `in` on an array tests '0' / '1' / 'length', so it was never true and a
|
|
273
|
+
// one-value "is between" ran on into an interval with an undefined end.
|
|
274
|
+
it('throws when "is between" gets one value (the guard the source never fired)', () => {
|
|
275
|
+
expect(() => dateFilterFn(d(5), date('is between', [d(1)]))).toThrow(
|
|
276
|
+
/requires exactly two values/,
|
|
277
|
+
)
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
it('throws when "is not between" gets one value', () => {
|
|
281
|
+
expect(() => dateFilterFn(d(5), date('is not between', [d(1)]))).toThrow(
|
|
282
|
+
/requires exactly two values/,
|
|
283
|
+
)
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
it('throws when a between operator gets three values', () => {
|
|
287
|
+
expect(() =>
|
|
288
|
+
dateFilterFn(d(5), date('is between', [d(1), d(2), d(3)])),
|
|
289
|
+
).toThrow(/requires exactly two values/)
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
it('throws when a singular operator gets more than one value', () => {
|
|
293
|
+
expect(() => dateFilterFn(d(5), date('is', [d(1), d(2)]))).toThrow(
|
|
294
|
+
/takes at most one value/,
|
|
295
|
+
)
|
|
296
|
+
})
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
/* ------------------------------------------------------------------ */
|
|
300
|
+
/* option */
|
|
301
|
+
/* ------------------------------------------------------------------ */
|
|
302
|
+
|
|
303
|
+
describe('optionFilterFn', () => {
|
|
304
|
+
it('handles is / is not', () => {
|
|
305
|
+
expect(optionFilterFn('high', option('is', ['high']))).toBe(true)
|
|
306
|
+
expect(optionFilterFn('low', option('is', ['high']))).toBe(false)
|
|
307
|
+
expect(optionFilterFn('low', option('is not', ['high']))).toBe(true)
|
|
308
|
+
expect(optionFilterFn('high', option('is not', ['high']))).toBe(false)
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
it('ORs within the column for is any of', () => {
|
|
312
|
+
expect(optionFilterFn('medium', option('is any of', ['high', 'medium']))).toBe(
|
|
313
|
+
true,
|
|
314
|
+
)
|
|
315
|
+
expect(optionFilterFn('low', option('is any of', ['high', 'medium']))).toBe(
|
|
316
|
+
false,
|
|
317
|
+
)
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
it('handles is none of', () => {
|
|
321
|
+
expect(optionFilterFn('low', option('is none of', ['high', 'medium']))).toBe(
|
|
322
|
+
true,
|
|
323
|
+
)
|
|
324
|
+
expect(optionFilterFn('high', option('is none of', ['high', 'medium']))).toBe(
|
|
325
|
+
false,
|
|
326
|
+
)
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
it('matches option values exactly rather than case-folded', () => {
|
|
330
|
+
// The source lower-cased both sides, silently merging two option values
|
|
331
|
+
// that differ only by case.
|
|
332
|
+
expect(optionFilterFn('High', option('is', ['high']))).toBe(false)
|
|
333
|
+
})
|
|
334
|
+
|
|
335
|
+
it('treats a missing value as matching no positive operator and every negated one', () => {
|
|
336
|
+
expect(optionFilterFn(null, option('is', ['high']))).toBe(false)
|
|
337
|
+
expect(optionFilterFn(null, option('is any of', ['high']))).toBe(false)
|
|
338
|
+
expect(optionFilterFn(null, option('is not', ['high']))).toBe(true)
|
|
339
|
+
expect(optionFilterFn(null, option('is none of', ['high']))).toBe(true)
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
it('passes everything on an empty value list', () => {
|
|
343
|
+
expect(optionFilterFn('high', option('is any of', []))).toBe(true)
|
|
344
|
+
})
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
/* ------------------------------------------------------------------ */
|
|
348
|
+
/* multiOption */
|
|
349
|
+
/* ------------------------------------------------------------------ */
|
|
350
|
+
|
|
351
|
+
describe('multiOptionFilterFn', () => {
|
|
352
|
+
const labels = ['bug', 'ui']
|
|
353
|
+
|
|
354
|
+
it('handles include / include any of as an OR', () => {
|
|
355
|
+
expect(multiOptionFilterFn(labels, multi('include', ['bug']))).toBe(true)
|
|
356
|
+
expect(multiOptionFilterFn(labels, multi('include', ['perf']))).toBe(false)
|
|
357
|
+
expect(
|
|
358
|
+
multiOptionFilterFn(labels, multi('include any of', ['perf', 'ui'])),
|
|
359
|
+
).toBe(true)
|
|
360
|
+
})
|
|
361
|
+
|
|
362
|
+
it('handles include all of as an AND', () => {
|
|
363
|
+
expect(
|
|
364
|
+
multiOptionFilterFn(labels, multi('include all of', ['bug', 'ui'])),
|
|
365
|
+
).toBe(true)
|
|
366
|
+
expect(
|
|
367
|
+
multiOptionFilterFn(labels, multi('include all of', ['bug', 'perf'])),
|
|
368
|
+
).toBe(false)
|
|
369
|
+
})
|
|
370
|
+
|
|
371
|
+
it('handles exclude and exclude if any of', () => {
|
|
372
|
+
expect(multiOptionFilterFn(labels, multi('exclude', ['perf']))).toBe(true)
|
|
373
|
+
expect(multiOptionFilterFn(labels, multi('exclude', ['bug']))).toBe(false)
|
|
374
|
+
expect(
|
|
375
|
+
multiOptionFilterFn(labels, multi('exclude if any of', ['perf', 'bug'])),
|
|
376
|
+
).toBe(false)
|
|
377
|
+
expect(
|
|
378
|
+
multiOptionFilterFn(labels, multi('exclude if any of', ['perf'])),
|
|
379
|
+
).toBe(true)
|
|
380
|
+
})
|
|
381
|
+
|
|
382
|
+
it('handles exclude if all', () => {
|
|
383
|
+
expect(
|
|
384
|
+
multiOptionFilterFn(labels, multi('exclude if all', ['bug', 'ui'])),
|
|
385
|
+
).toBe(false)
|
|
386
|
+
expect(
|
|
387
|
+
multiOptionFilterFn(labels, multi('exclude if all', ['bug', 'perf'])),
|
|
388
|
+
).toBe(true)
|
|
389
|
+
})
|
|
390
|
+
|
|
391
|
+
it('treats a missing list as empty', () => {
|
|
392
|
+
expect(multiOptionFilterFn(null, multi('include', ['bug']))).toBe(false)
|
|
393
|
+
expect(multiOptionFilterFn(null, multi('exclude', ['bug']))).toBe(true)
|
|
394
|
+
})
|
|
395
|
+
|
|
396
|
+
it('passes everything on an empty value list', () => {
|
|
397
|
+
expect(multiOptionFilterFn(labels, multi('include any of', []))).toBe(true)
|
|
398
|
+
})
|
|
399
|
+
})
|
|
400
|
+
|
|
401
|
+
/* ------------------------------------------------------------------ */
|
|
402
|
+
/* combinator */
|
|
403
|
+
/* ------------------------------------------------------------------ */
|
|
404
|
+
|
|
405
|
+
const rows: Task[] = [
|
|
406
|
+
task({ id: 'a', title: 'Ship board', points: 3, due: d(5), priority: 'high', labels: ['bug'] }),
|
|
407
|
+
task({ id: 'b', title: 'Rail stub', points: 8, due: d(12), priority: 'low', labels: ['ui'] }),
|
|
408
|
+
task({ id: 'c', title: 'Facet panel', points: 5, due: d(20), priority: 'high', labels: ['ui', 'perf'] }),
|
|
409
|
+
task({ id: 'd', title: 'Untriaged' }),
|
|
410
|
+
]
|
|
411
|
+
|
|
412
|
+
const ids = (result: readonly Task[]): string[] => result.map((t) => t.id)
|
|
413
|
+
|
|
414
|
+
describe('filterRows', () => {
|
|
415
|
+
it('returns every row when there are no filters', () => {
|
|
416
|
+
expect(ids(filterRows(rows, columns, []))).toEqual(['a', 'b', 'c', 'd'])
|
|
417
|
+
})
|
|
418
|
+
|
|
419
|
+
it('does not hand back the caller its own array', () => {
|
|
420
|
+
const out = filterRows(rows, columns, [])
|
|
421
|
+
expect(out).not.toBe(rows)
|
|
422
|
+
expect(out).toEqual(rows)
|
|
423
|
+
})
|
|
424
|
+
|
|
425
|
+
it('ANDs across columns', () => {
|
|
426
|
+
const state: FiltersState = [option('is', ['high']), num('is greater than', [4])]
|
|
427
|
+
expect(ids(filterRows(rows, columns, state))).toEqual(['c'])
|
|
428
|
+
})
|
|
429
|
+
|
|
430
|
+
it('ORs within a column via is any of', () => {
|
|
431
|
+
expect(
|
|
432
|
+
ids(filterRows(rows, columns, [option('is any of', ['high', 'low'])])),
|
|
433
|
+
).toEqual(['a', 'b', 'c'])
|
|
434
|
+
})
|
|
435
|
+
|
|
436
|
+
it('ANDs within a column via include all of', () => {
|
|
437
|
+
expect(
|
|
438
|
+
ids(filterRows(rows, columns, [multi('include all of', ['ui', 'perf'])])),
|
|
439
|
+
).toEqual(['c'])
|
|
440
|
+
expect(
|
|
441
|
+
ids(filterRows(rows, columns, [multi('include any of', ['ui', 'perf'])])),
|
|
442
|
+
).toEqual(['b', 'c'])
|
|
443
|
+
})
|
|
444
|
+
|
|
445
|
+
// BUG 2 — the source's combinator had no case for text/number/date and fell
|
|
446
|
+
// through to `return true`, so each of these three would return every row.
|
|
447
|
+
it('actually filters a text column', () => {
|
|
448
|
+
expect(ids(filterRows(rows, columns, [text('contains', ['rail'])]))).toEqual([
|
|
449
|
+
'b',
|
|
450
|
+
])
|
|
451
|
+
})
|
|
452
|
+
|
|
453
|
+
it('actually filters a number column', () => {
|
|
454
|
+
expect(
|
|
455
|
+
ids(filterRows(rows, columns, [num('is less than', [6])])),
|
|
456
|
+
).toEqual(['a', 'c'])
|
|
457
|
+
})
|
|
458
|
+
|
|
459
|
+
it('actually filters a date column', () => {
|
|
460
|
+
expect(
|
|
461
|
+
ids(filterRows(rows, columns, [date('is before', [d(15)])])),
|
|
462
|
+
).toEqual(['a', 'b'])
|
|
463
|
+
expect(
|
|
464
|
+
ids(filterRows(rows, columns, [date('is between', [d(1), d(15)])])),
|
|
465
|
+
).toEqual(['a', 'b'])
|
|
466
|
+
})
|
|
467
|
+
|
|
468
|
+
it('ignores a filter whose column is not in the set', () => {
|
|
469
|
+
const stale: FiltersState = [
|
|
470
|
+
{ columnId: 'gone', type: 'option', operator: 'is', values: ['x'] },
|
|
471
|
+
]
|
|
472
|
+
expect(ids(filterRows(rows, columns, stale))).toEqual(['a', 'b', 'c', 'd'])
|
|
473
|
+
})
|
|
474
|
+
|
|
475
|
+
it('ignores a filter whose type disagrees with its column', () => {
|
|
476
|
+
const tampered: FiltersState = [
|
|
477
|
+
{ columnId: 'priority', type: 'text', operator: 'contains', values: ['q'] },
|
|
478
|
+
]
|
|
479
|
+
expect(ids(filterRows(rows, columns, tampered))).toEqual(['a', 'b', 'c', 'd'])
|
|
480
|
+
})
|
|
481
|
+
|
|
482
|
+
it('applies only the first filter when a column is listed twice', () => {
|
|
483
|
+
const duplicated: FiltersState = [
|
|
484
|
+
option('is', ['high']),
|
|
485
|
+
option('is', ['low']),
|
|
486
|
+
]
|
|
487
|
+
expect(ids(filterRows(rows, columns, duplicated))).toEqual(['a', 'c'])
|
|
488
|
+
})
|
|
489
|
+
})
|
|
490
|
+
|
|
491
|
+
describe('pruneFilters', () => {
|
|
492
|
+
it('drops unknown columns, type mismatches and duplicates', () => {
|
|
493
|
+
const state: FiltersState = [
|
|
494
|
+
option('is', ['high']),
|
|
495
|
+
{ columnId: 'gone', type: 'option', operator: 'is', values: ['x'] },
|
|
496
|
+
{ columnId: 'priority', type: 'number', operator: 'is', values: [1] },
|
|
497
|
+
option('is', ['low']),
|
|
498
|
+
]
|
|
499
|
+
const pruned = pruneFilters(state, columns)
|
|
500
|
+
expect(pruned).toHaveLength(1)
|
|
501
|
+
expect(pruned[0]?.values).toEqual(['high'])
|
|
502
|
+
})
|
|
503
|
+
})
|
|
504
|
+
|
|
505
|
+
describe('rowMatchesFilter', () => {
|
|
506
|
+
function columnById(id: string): AnyColumnConfig<Task> {
|
|
507
|
+
const column = columns.find((candidate) => candidate.id === id)
|
|
508
|
+
if (!column) throw new Error(`no column ${id}`)
|
|
509
|
+
return column
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
it('throws when a filter type disagrees with its column type', () => {
|
|
513
|
+
const row = rows[0]
|
|
514
|
+
if (!row) throw new Error('fixture missing')
|
|
515
|
+
expect(() =>
|
|
516
|
+
rowMatchesFilter(row, columnById('priority'), text('contains', ['x'])),
|
|
517
|
+
).toThrow(/is typed "text" but the column is "option"/)
|
|
518
|
+
})
|
|
519
|
+
})
|