@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,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Operator tables, defaults, and the singular <-> plural promotion rule.
|
|
3
|
+
*/
|
|
4
|
+
import { describe, expect, it } from 'vitest'
|
|
5
|
+
import {
|
|
6
|
+
COLUMN_DATA_TYPES,
|
|
7
|
+
DEFAULT_OPERATORS,
|
|
8
|
+
FILTER_OPERATOR_DETAILS,
|
|
9
|
+
determineNewOperator,
|
|
10
|
+
isOperatorOf,
|
|
11
|
+
negateOperator,
|
|
12
|
+
operatorDetails,
|
|
13
|
+
operatorValueArity,
|
|
14
|
+
operatorsForType,
|
|
15
|
+
relatedOperators,
|
|
16
|
+
} from '../index'
|
|
17
|
+
import type { ColumnDataType } from '../index'
|
|
18
|
+
|
|
19
|
+
describe('operator tables', () => {
|
|
20
|
+
it('declares a table for every column data type', () => {
|
|
21
|
+
for (const type of COLUMN_DATA_TYPES) {
|
|
22
|
+
expect(Object.keys(FILTER_OPERATOR_DETAILS[type]).length).toBeGreaterThan(0)
|
|
23
|
+
}
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('keys every entry by its own value', () => {
|
|
27
|
+
for (const type of COLUMN_DATA_TYPES) {
|
|
28
|
+
for (const operator of operatorsForType(type)) {
|
|
29
|
+
expect(operatorDetails(type, operator).value).toBe(operator)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('points singularOf at a multiple-target operator and pluralOf at a single one', () => {
|
|
35
|
+
// Promotion is many-to-one on purpose: five number operators all promote
|
|
36
|
+
// to "is between", which can only demote back to one of them. So the
|
|
37
|
+
// invariant is about DIRECTION, not a round trip.
|
|
38
|
+
for (const type of COLUMN_DATA_TYPES) {
|
|
39
|
+
for (const operator of operatorsForType(type)) {
|
|
40
|
+
const details = operatorDetails(type, operator)
|
|
41
|
+
if (details.singularOf) {
|
|
42
|
+
const target = operatorDetails(type, details.singularOf)
|
|
43
|
+
expect(details.target).toBe('single')
|
|
44
|
+
expect(target.target).toBe('multiple')
|
|
45
|
+
}
|
|
46
|
+
if (details.pluralOf) {
|
|
47
|
+
const target = operatorDetails(type, details.pluralOf)
|
|
48
|
+
expect(details.target).toBe('multiple')
|
|
49
|
+
expect(target.target).toBe('single')
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('round-trips the is / is any of family exactly', () => {
|
|
56
|
+
const pairs = [
|
|
57
|
+
['option', 'is', 'is any of'],
|
|
58
|
+
['option', 'is not', 'is none of'],
|
|
59
|
+
['number', 'is', 'is between'],
|
|
60
|
+
['date', 'is', 'is between'],
|
|
61
|
+
['multiOption', 'include', 'include any of'],
|
|
62
|
+
] as const
|
|
63
|
+
for (const [type, singular, plural] of pairs) {
|
|
64
|
+
expect(operatorDetails(type, singular).singularOf).toBe(plural)
|
|
65
|
+
expect(operatorDetails(type, plural).pluralOf).toBe(singular)
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('documents the lossy demotions, so they are a decision and not a surprise', () => {
|
|
70
|
+
// "include all of" and "exclude if all" have no one-value form of their
|
|
71
|
+
// own, so they demote to "include" / "exclude" and a later second value
|
|
72
|
+
// promotes to the ANY form. Inherited from the source; pinned here so a
|
|
73
|
+
// change to it is visible.
|
|
74
|
+
expect(operatorDetails('multiOption', 'include all of').pluralOf).toBe('include')
|
|
75
|
+
expect(operatorDetails('multiOption', 'include').singularOf).toBe('include any of')
|
|
76
|
+
expect(operatorDetails('number', 'is greater than').singularOf).toBe('is between')
|
|
77
|
+
expect(operatorDetails('number', 'is between').pluralOf).toBe('is')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('pairs negations symmetrically', () => {
|
|
81
|
+
for (const type of COLUMN_DATA_TYPES) {
|
|
82
|
+
for (const operator of operatorsForType(type)) {
|
|
83
|
+
const details = operatorDetails(type, operator)
|
|
84
|
+
const twin = details.negation ?? details.negationOf
|
|
85
|
+
expect(twin).toBeDefined()
|
|
86
|
+
if (!twin) continue
|
|
87
|
+
expect(negateOperator(type, twin)).toBe(operator)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('gives every default operator a real table entry', () => {
|
|
93
|
+
for (const type of COLUMN_DATA_TYPES) {
|
|
94
|
+
const defaults = DEFAULT_OPERATORS[type]
|
|
95
|
+
expect(isOperatorOf(type, defaults.single)).toBe(true)
|
|
96
|
+
expect(isOperatorOf(type, defaults.multiple)).toBe(true)
|
|
97
|
+
}
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('marks only "not"-phrased operators as negated', () => {
|
|
101
|
+
// isNegated is about PHRASING, not logic. "is less than" is the
|
|
102
|
+
// complement of "is greater than or equal to", but it is not a negation
|
|
103
|
+
// and a UI must not render it with a "not" chip.
|
|
104
|
+
expect(operatorDetails('number', 'is less than').isNegated).toBe(false)
|
|
105
|
+
expect(operatorDetails('date', 'is on or after').isNegated).toBe(false)
|
|
106
|
+
expect(operatorDetails('number', 'is not').isNegated).toBe(true)
|
|
107
|
+
expect(operatorDetails('option', 'is none of').isNegated).toBe(true)
|
|
108
|
+
expect(operatorDetails('multiOption', 'exclude if all').isNegated).toBe(true)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('rejects an unknown operator at lookup', () => {
|
|
112
|
+
expect(isOperatorOf('option', 'is sort of')).toBe(false)
|
|
113
|
+
expect(isOperatorOf('option', '__proto__')).toBe(false)
|
|
114
|
+
expect(isOperatorOf('option', 'toString')).toBe(false)
|
|
115
|
+
expect(isOperatorOf('text', 42)).toBe(false)
|
|
116
|
+
})
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
describe('operatorValueArity', () => {
|
|
120
|
+
it('requires exactly two values for the between operators', () => {
|
|
121
|
+
expect(operatorValueArity('date', 'is between')).toEqual([2, 2])
|
|
122
|
+
expect(operatorValueArity('date', 'is not between')).toEqual([2, 2])
|
|
123
|
+
expect(operatorValueArity('number', 'is between')).toEqual([2, 2])
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('requires exactly one value for the single-target operators', () => {
|
|
127
|
+
expect(operatorValueArity('text', 'contains')).toEqual([1, 1])
|
|
128
|
+
expect(operatorValueArity('option', 'is')).toEqual([1, 1])
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('allows an open-ended list for the many-target operators', () => {
|
|
132
|
+
const [min, max] = operatorValueArity('option', 'is any of')
|
|
133
|
+
expect(min).toBe(1)
|
|
134
|
+
expect(max).toBe(Number.POSITIVE_INFINITY)
|
|
135
|
+
})
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
describe('relatedOperators', () => {
|
|
139
|
+
it('offers only operators with the same target', () => {
|
|
140
|
+
expect(relatedOperators('option', 'is')).toEqual(['is not'])
|
|
141
|
+
expect(relatedOperators('option', 'is any of')).toEqual(['is none of'])
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('never offers the operator itself', () => {
|
|
145
|
+
for (const type of COLUMN_DATA_TYPES) {
|
|
146
|
+
for (const operator of operatorsForType(type)) {
|
|
147
|
+
expect(relatedOperators(type, operator)).not.toContain(operator)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
})
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
describe('determineNewOperator', () => {
|
|
154
|
+
it('promotes is -> is any of when a second value is picked', () => {
|
|
155
|
+
expect(determineNewOperator('option', ['a'], ['a', 'b'], 'is')).toBe(
|
|
156
|
+
'is any of',
|
|
157
|
+
)
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('promotes is not -> is none of', () => {
|
|
161
|
+
expect(determineNewOperator('option', ['a'], ['a', 'b'], 'is not')).toBe(
|
|
162
|
+
'is none of',
|
|
163
|
+
)
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('demotes is any of -> is when the list falls back to one', () => {
|
|
167
|
+
expect(
|
|
168
|
+
determineNewOperator('option', ['a', 'b'], ['a'], 'is any of'),
|
|
169
|
+
).toBe('is')
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it('demotes is none of -> is not', () => {
|
|
173
|
+
expect(
|
|
174
|
+
determineNewOperator('option', ['a', 'b'], ['b'], 'is none of'),
|
|
175
|
+
).toBe('is not')
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it('demotes to the singular form when the list empties', () => {
|
|
179
|
+
expect(determineNewOperator('option', ['a', 'b'], [], 'is any of')).toBe('is')
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it('leaves the operator alone inside the single band', () => {
|
|
183
|
+
expect(determineNewOperator('option', [], ['a'], 'is')).toBe('is')
|
|
184
|
+
expect(determineNewOperator('option', ['a'], ['b'], 'is not')).toBe('is not')
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it('leaves the operator alone inside the multiple band', () => {
|
|
188
|
+
expect(
|
|
189
|
+
determineNewOperator('option', ['a', 'b'], ['a', 'b', 'c'], 'is any of'),
|
|
190
|
+
).toBe('is any of')
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('promotes number and date to their between forms', () => {
|
|
194
|
+
expect(determineNewOperator('number', [1], [1, 9], 'is')).toBe('is between')
|
|
195
|
+
expect(determineNewOperator('number', [1], [1, 9], 'is not')).toBe(
|
|
196
|
+
'is not between',
|
|
197
|
+
)
|
|
198
|
+
expect(
|
|
199
|
+
determineNewOperator(
|
|
200
|
+
'date',
|
|
201
|
+
[new Date(2026, 0, 1)],
|
|
202
|
+
[new Date(2026, 0, 1), new Date(2026, 0, 9)],
|
|
203
|
+
'is',
|
|
204
|
+
),
|
|
205
|
+
).toBe('is between')
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
it('promotes multiOption include -> include any of and back', () => {
|
|
209
|
+
expect(determineNewOperator('multiOption', ['x'], ['x', 'y'], 'include')).toBe(
|
|
210
|
+
'include any of',
|
|
211
|
+
)
|
|
212
|
+
expect(
|
|
213
|
+
determineNewOperator('multiOption', ['x', 'y'], ['x'], 'include all of'),
|
|
214
|
+
).toBe('include')
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
it('leaves an operator with no plural twin alone', () => {
|
|
218
|
+
// text has one target band, so nothing to promote to.
|
|
219
|
+
expect(determineNewOperator('text', ['a'], ['a', 'b'], 'contains')).toBe(
|
|
220
|
+
'contains',
|
|
221
|
+
)
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
it('never returns an operator outside the type table', () => {
|
|
225
|
+
const types: ColumnDataType[] = [...COLUMN_DATA_TYPES]
|
|
226
|
+
for (const type of types) {
|
|
227
|
+
for (const operator of operatorsForType(type)) {
|
|
228
|
+
const grown = determineNewOperator(type, ['a'], ['a', 'b'], operator)
|
|
229
|
+
const shrunk = determineNewOperator(type, ['a', 'b'], ['a'], operator)
|
|
230
|
+
expect(isOperatorOf(type, grown)).toBe(true)
|
|
231
|
+
expect(isOperatorOf(type, shrunk)).toBe(true)
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
})
|
|
235
|
+
})
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Immutable filter-state transitions, and the one place operator promotion
|
|
3
|
+
* is applied.
|
|
4
|
+
*/
|
|
5
|
+
import { describe, expect, it } from 'vitest'
|
|
6
|
+
import {
|
|
7
|
+
clearFilters,
|
|
8
|
+
defineColumns,
|
|
9
|
+
getFilter,
|
|
10
|
+
removeFilter,
|
|
11
|
+
setDateFilter,
|
|
12
|
+
setFilterOperator,
|
|
13
|
+
setMultiOptionFilter,
|
|
14
|
+
setNumberFilter,
|
|
15
|
+
setOptionFilter,
|
|
16
|
+
setTextFilter,
|
|
17
|
+
toggleMultiOptionValue,
|
|
18
|
+
toggleOptionValue,
|
|
19
|
+
upsertFilter,
|
|
20
|
+
} from '../index'
|
|
21
|
+
import type { ColumnConfig, FiltersState } from '../index'
|
|
22
|
+
|
|
23
|
+
interface Task {
|
|
24
|
+
readonly title: string
|
|
25
|
+
readonly priority: string
|
|
26
|
+
readonly points: number
|
|
27
|
+
readonly due: Date
|
|
28
|
+
readonly labels: readonly string[]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const title: ColumnConfig<Task, 'text'> = {
|
|
32
|
+
id: 'title',
|
|
33
|
+
type: 'text',
|
|
34
|
+
displayName: 'Title',
|
|
35
|
+
accessor: (t) => t.title,
|
|
36
|
+
}
|
|
37
|
+
const priority: ColumnConfig<Task, 'option'> = {
|
|
38
|
+
id: 'priority',
|
|
39
|
+
type: 'option',
|
|
40
|
+
displayName: 'Priority',
|
|
41
|
+
accessor: (t) => t.priority,
|
|
42
|
+
options: [
|
|
43
|
+
{ value: 'low', label: 'Low' },
|
|
44
|
+
{ value: 'high', label: 'High' },
|
|
45
|
+
],
|
|
46
|
+
}
|
|
47
|
+
const points: ColumnConfig<Task, 'number'> = {
|
|
48
|
+
id: 'points',
|
|
49
|
+
type: 'number',
|
|
50
|
+
displayName: 'Points',
|
|
51
|
+
accessor: (t) => t.points,
|
|
52
|
+
}
|
|
53
|
+
const due: ColumnConfig<Task, 'date'> = {
|
|
54
|
+
id: 'due',
|
|
55
|
+
type: 'date',
|
|
56
|
+
displayName: 'Due',
|
|
57
|
+
accessor: (t) => t.due,
|
|
58
|
+
}
|
|
59
|
+
const labels: ColumnConfig<Task, 'multiOption'> = {
|
|
60
|
+
id: 'labels',
|
|
61
|
+
type: 'multiOption',
|
|
62
|
+
displayName: 'Labels',
|
|
63
|
+
accessor: (t) => t.labels,
|
|
64
|
+
options: [
|
|
65
|
+
{ value: 'bug', label: 'Bug' },
|
|
66
|
+
{ value: 'ui', label: 'UI' },
|
|
67
|
+
],
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// The columns are also a valid config set.
|
|
71
|
+
defineColumns<Task>([title, priority, points, due, labels])
|
|
72
|
+
|
|
73
|
+
describe('immutability', () => {
|
|
74
|
+
it('never mutates the state it is given', () => {
|
|
75
|
+
const state: FiltersState = []
|
|
76
|
+
const next = setOptionFilter(state, priority, ['high'])
|
|
77
|
+
expect(state).toEqual([])
|
|
78
|
+
expect(next).not.toBe(state)
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
describe('setOptionFilter', () => {
|
|
83
|
+
it('creates a filter on the singular default operator', () => {
|
|
84
|
+
const state = setOptionFilter([], priority, ['high'])
|
|
85
|
+
expect(state[0]).toEqual({
|
|
86
|
+
columnId: 'priority',
|
|
87
|
+
type: 'option',
|
|
88
|
+
operator: 'is',
|
|
89
|
+
values: ['high'],
|
|
90
|
+
})
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('creates a filter on the plural default when handed several values', () => {
|
|
94
|
+
const state = setOptionFilter([], priority, ['high', 'low'])
|
|
95
|
+
expect(state[0]?.operator).toBe('is any of')
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('promotes on growth and demotes on shrink', () => {
|
|
99
|
+
let state = setOptionFilter([], priority, ['high'])
|
|
100
|
+
state = setOptionFilter(state, priority, ['high', 'low'])
|
|
101
|
+
expect(state[0]?.operator).toBe('is any of')
|
|
102
|
+
state = setOptionFilter(state, priority, ['low'])
|
|
103
|
+
expect(state[0]?.operator).toBe('is')
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('preserves a chosen negated operator across a promotion', () => {
|
|
107
|
+
let state = setOptionFilter([], priority, ['high'])
|
|
108
|
+
state = setFilterOperator(state, 'priority', 'is not')
|
|
109
|
+
state = setOptionFilter(state, priority, ['high', 'low'])
|
|
110
|
+
expect(state[0]?.operator).toBe('is none of')
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('removes the filter when the value list empties', () => {
|
|
114
|
+
const state = setOptionFilter(setOptionFilter([], priority, ['high']), priority, [])
|
|
115
|
+
expect(state).toEqual([])
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('keeps position when updating an existing filter', () => {
|
|
119
|
+
let state = setTextFilter([], title, 'ship')
|
|
120
|
+
state = setOptionFilter(state, priority, ['high'])
|
|
121
|
+
state = setTextFilter(state, title, 'rail')
|
|
122
|
+
expect(state.map((f) => f.columnId)).toEqual(['title', 'priority'])
|
|
123
|
+
})
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
describe('toggleOptionValue', () => {
|
|
127
|
+
it('adds, promotes, then removes and demotes', () => {
|
|
128
|
+
let state = toggleOptionValue([], priority, 'high')
|
|
129
|
+
expect(state[0]?.values).toEqual(['high'])
|
|
130
|
+
expect(state[0]?.operator).toBe('is')
|
|
131
|
+
|
|
132
|
+
state = toggleOptionValue(state, priority, 'low')
|
|
133
|
+
expect(state[0]?.values).toEqual(['high', 'low'])
|
|
134
|
+
expect(state[0]?.operator).toBe('is any of')
|
|
135
|
+
|
|
136
|
+
state = toggleOptionValue(state, priority, 'high')
|
|
137
|
+
expect(state[0]?.values).toEqual(['low'])
|
|
138
|
+
expect(state[0]?.operator).toBe('is')
|
|
139
|
+
|
|
140
|
+
state = toggleOptionValue(state, priority, 'low')
|
|
141
|
+
expect(state).toEqual([])
|
|
142
|
+
})
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
describe('toggleMultiOptionValue', () => {
|
|
146
|
+
it('promotes include -> include any of', () => {
|
|
147
|
+
let state = toggleMultiOptionValue([], labels, 'bug')
|
|
148
|
+
expect(state[0]?.operator).toBe('include')
|
|
149
|
+
state = toggleMultiOptionValue(state, labels, 'ui')
|
|
150
|
+
expect(state[0]?.operator).toBe('include any of')
|
|
151
|
+
expect(state[0]?.values).toEqual(['bug', 'ui'])
|
|
152
|
+
})
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
describe('setMultiOptionFilter', () => {
|
|
156
|
+
it('creates on the plural default, and removes when emptied', () => {
|
|
157
|
+
const state = setMultiOptionFilter([], labels, ['bug', 'ui'])
|
|
158
|
+
expect(state[0]).toEqual({
|
|
159
|
+
columnId: 'labels',
|
|
160
|
+
type: 'multiOption',
|
|
161
|
+
operator: 'include any of',
|
|
162
|
+
values: ['bug', 'ui'],
|
|
163
|
+
})
|
|
164
|
+
expect(setMultiOptionFilter(state, labels, [])).toEqual([])
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it('demotes include any of back to include', () => {
|
|
168
|
+
const state = setMultiOptionFilter(
|
|
169
|
+
setMultiOptionFilter([], labels, ['bug', 'ui']),
|
|
170
|
+
labels,
|
|
171
|
+
['bug'],
|
|
172
|
+
)
|
|
173
|
+
expect(state[0]?.operator).toBe('include')
|
|
174
|
+
})
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
describe('setTextFilter', () => {
|
|
178
|
+
it('stores a single value and removes on blank', () => {
|
|
179
|
+
const state = setTextFilter([], title, 'ship')
|
|
180
|
+
expect(state[0]).toEqual({
|
|
181
|
+
columnId: 'title',
|
|
182
|
+
type: 'text',
|
|
183
|
+
operator: 'contains',
|
|
184
|
+
values: ['ship'],
|
|
185
|
+
})
|
|
186
|
+
expect(setTextFilter(state, title, ' ')).toEqual([])
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
it('keeps a chosen negated operator', () => {
|
|
190
|
+
let state = setTextFilter([], title, 'ship')
|
|
191
|
+
state = setFilterOperator(state, 'title', 'does not contain')
|
|
192
|
+
state = setTextFilter(state, title, 'rail')
|
|
193
|
+
expect(state[0]?.operator).toBe('does not contain')
|
|
194
|
+
})
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
describe('setNumberFilter / setDateFilter', () => {
|
|
198
|
+
it('promotes number is -> is between', () => {
|
|
199
|
+
let state = setNumberFilter([], points, [3])
|
|
200
|
+
expect(state[0]?.operator).toBe('is')
|
|
201
|
+
state = setNumberFilter(state, points, [3, 8])
|
|
202
|
+
expect(state[0]?.operator).toBe('is between')
|
|
203
|
+
state = setNumberFilter(state, points, [3])
|
|
204
|
+
expect(state[0]?.operator).toBe('is')
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
it('promotes date is -> is between', () => {
|
|
208
|
+
let state = setDateFilter([], due, [new Date(2026, 0, 1)])
|
|
209
|
+
expect(state[0]?.operator).toBe('is')
|
|
210
|
+
state = setDateFilter(state, due, [new Date(2026, 0, 1), new Date(2026, 0, 9)])
|
|
211
|
+
expect(state[0]?.operator).toBe('is between')
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
it('copies the value array rather than aliasing the caller s', () => {
|
|
215
|
+
const values = [3, 8]
|
|
216
|
+
const state = setNumberFilter([], points, values)
|
|
217
|
+
values.push(99)
|
|
218
|
+
expect(state[0]?.values).toEqual([3, 8])
|
|
219
|
+
})
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
describe('setFilterOperator', () => {
|
|
223
|
+
it('leaves the state alone for an unknown column', () => {
|
|
224
|
+
const state = setOptionFilter([], priority, ['high'])
|
|
225
|
+
expect(setFilterOperator(state, 'nope', 'is not')).toBe(state)
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
it('refuses an operator from another column type', () => {
|
|
229
|
+
const state = setOptionFilter([], priority, ['high'])
|
|
230
|
+
expect(setFilterOperator(state, 'priority', 'contains')).toBe(state)
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it('swaps to a legal operator of the same type', () => {
|
|
234
|
+
const state = setFilterOperator(
|
|
235
|
+
setOptionFilter([], priority, ['high']),
|
|
236
|
+
'priority',
|
|
237
|
+
'is not',
|
|
238
|
+
)
|
|
239
|
+
expect(state[0]?.operator).toBe('is not')
|
|
240
|
+
})
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
describe('getFilter / removeFilter / clearFilters / upsertFilter', () => {
|
|
244
|
+
it('reads, removes and clears', () => {
|
|
245
|
+
const state = setOptionFilter(setTextFilter([], title, 'ship'), priority, ['high'])
|
|
246
|
+
expect(getFilter(state, 'priority')?.values).toEqual(['high'])
|
|
247
|
+
expect(getFilter(state, 'missing')).toBeUndefined()
|
|
248
|
+
expect(removeFilter(state, 'priority').map((f) => f.columnId)).toEqual(['title'])
|
|
249
|
+
expect(clearFilters()).toEqual([])
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
it('appends an unseen column and replaces a seen one', () => {
|
|
253
|
+
const first = upsertFilter([], {
|
|
254
|
+
columnId: 'priority',
|
|
255
|
+
type: 'option',
|
|
256
|
+
operator: 'is',
|
|
257
|
+
values: ['high'],
|
|
258
|
+
})
|
|
259
|
+
const second = upsertFilter(first, {
|
|
260
|
+
columnId: 'priority',
|
|
261
|
+
type: 'option',
|
|
262
|
+
operator: 'is',
|
|
263
|
+
values: ['low'],
|
|
264
|
+
})
|
|
265
|
+
expect(second).toHaveLength(1)
|
|
266
|
+
expect(second[0]?.values).toEqual(['low'])
|
|
267
|
+
})
|
|
268
|
+
})
|