@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,350 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* URL serialisation, and the defensive parse of untrusted input.
|
|
3
|
+
*
|
|
4
|
+
* A query string is hand-editable and shareable, so `parseFilters` must never
|
|
5
|
+
* throw and must never let a malformed entry through to the predicates.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, expect, it } from 'vitest'
|
|
8
|
+
import {
|
|
9
|
+
FILTERS_SEARCH_PARAM,
|
|
10
|
+
filterRows,
|
|
11
|
+
defineColumns,
|
|
12
|
+
parseFilters,
|
|
13
|
+
serializeFilters,
|
|
14
|
+
} from '../index'
|
|
15
|
+
import type { FiltersState } from '../index'
|
|
16
|
+
|
|
17
|
+
const roundTrip = (state: FiltersState): FiltersState =>
|
|
18
|
+
parseFilters(serializeFilters(state))
|
|
19
|
+
|
|
20
|
+
describe('serializeFilters', () => {
|
|
21
|
+
it('serialises an empty state to the empty string', () => {
|
|
22
|
+
// The caller's cue to DELETE the param rather than write ?filters=[].
|
|
23
|
+
expect(serializeFilters([])).toBe('')
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('names the conventional search param', () => {
|
|
27
|
+
expect(FILTERS_SEARCH_PARAM).toBe('filters')
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('survives a URLSearchParams round trip', () => {
|
|
31
|
+
const state: FiltersState = [
|
|
32
|
+
{ columnId: 'priority', type: 'option', operator: 'is any of', values: ['high', 'low'] },
|
|
33
|
+
]
|
|
34
|
+
const params = new URLSearchParams()
|
|
35
|
+
params.set(FILTERS_SEARCH_PARAM, serializeFilters(state))
|
|
36
|
+
const back = parseFilters(new URLSearchParams(params.toString()).get(FILTERS_SEARCH_PARAM))
|
|
37
|
+
expect(back).toEqual(state)
|
|
38
|
+
})
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
describe('parseFilters round trip', () => {
|
|
42
|
+
it('round-trips an option filter', () => {
|
|
43
|
+
const state: FiltersState = [
|
|
44
|
+
{ columnId: 'priority', type: 'option', operator: 'is any of', values: ['high', 'low'] },
|
|
45
|
+
]
|
|
46
|
+
expect(roundTrip(state)).toEqual(state)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('round-trips a text filter', () => {
|
|
50
|
+
const state: FiltersState = [
|
|
51
|
+
{ columnId: 'title', type: 'text', operator: 'does not contain', values: ['rail'] },
|
|
52
|
+
]
|
|
53
|
+
expect(roundTrip(state)).toEqual(state)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('round-trips a number between filter', () => {
|
|
57
|
+
const state: FiltersState = [
|
|
58
|
+
{ columnId: 'points', type: 'number', operator: 'is between', values: [1, 9] },
|
|
59
|
+
]
|
|
60
|
+
expect(roundTrip(state)).toEqual(state)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('round-trips a date filter as real Date instances', () => {
|
|
64
|
+
const state: FiltersState = [
|
|
65
|
+
{
|
|
66
|
+
columnId: 'due',
|
|
67
|
+
type: 'date',
|
|
68
|
+
operator: 'is between',
|
|
69
|
+
values: [new Date(2026, 0, 1), new Date(2026, 0, 31)],
|
|
70
|
+
},
|
|
71
|
+
]
|
|
72
|
+
const back = roundTrip(state)
|
|
73
|
+
const first = back[0]
|
|
74
|
+
expect(first?.type).toBe('date')
|
|
75
|
+
expect(back).toEqual(state)
|
|
76
|
+
if (first?.type === 'date') {
|
|
77
|
+
expect(first.values[0]).toBeInstanceOf(Date)
|
|
78
|
+
expect(first.values[0]?.getTime()).toBe(new Date(2026, 0, 1).getTime())
|
|
79
|
+
}
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('round-trips a multiOption filter', () => {
|
|
83
|
+
const state: FiltersState = [
|
|
84
|
+
{ columnId: 'labels', type: 'multiOption', operator: 'include all of', values: ['bug', 'ui'] },
|
|
85
|
+
]
|
|
86
|
+
expect(roundTrip(state)).toEqual(state)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('round-trips several columns at once, in order', () => {
|
|
90
|
+
const state: FiltersState = [
|
|
91
|
+
{ columnId: 'priority', type: 'option', operator: 'is', values: ['high'] },
|
|
92
|
+
{ columnId: 'title', type: 'text', operator: 'contains', values: ['ship'] },
|
|
93
|
+
{ columnId: 'points', type: 'number', operator: 'is greater than', values: [2] },
|
|
94
|
+
]
|
|
95
|
+
expect(roundTrip(state)).toEqual(state)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('produces a state that still filters after the trip', () => {
|
|
99
|
+
interface Row { readonly id: string; readonly priority: string }
|
|
100
|
+
const columns = defineColumns<Row>([
|
|
101
|
+
{
|
|
102
|
+
id: 'priority',
|
|
103
|
+
type: 'option',
|
|
104
|
+
displayName: 'Priority',
|
|
105
|
+
accessor: (r) => r.priority,
|
|
106
|
+
options: [
|
|
107
|
+
{ value: 'high', label: 'High' },
|
|
108
|
+
{ value: 'low', label: 'Low' },
|
|
109
|
+
],
|
|
110
|
+
},
|
|
111
|
+
])
|
|
112
|
+
const rows: Row[] = [
|
|
113
|
+
{ id: 'a', priority: 'high' },
|
|
114
|
+
{ id: 'b', priority: 'low' },
|
|
115
|
+
]
|
|
116
|
+
const state = parseFilters(
|
|
117
|
+
serializeFilters([
|
|
118
|
+
{ columnId: 'priority', type: 'option', operator: 'is', values: ['high'] },
|
|
119
|
+
]),
|
|
120
|
+
)
|
|
121
|
+
expect(filterRows(rows, columns, state).map((r) => r.id)).toEqual(['a'])
|
|
122
|
+
})
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
describe('parseFilters is defensive', () => {
|
|
126
|
+
it('never throws, whatever it is handed', () => {
|
|
127
|
+
const hostile: unknown[] = [
|
|
128
|
+
null,
|
|
129
|
+
undefined,
|
|
130
|
+
'',
|
|
131
|
+
' ',
|
|
132
|
+
'not json',
|
|
133
|
+
'{',
|
|
134
|
+
'{}',
|
|
135
|
+
'"a string"',
|
|
136
|
+
'42',
|
|
137
|
+
'null',
|
|
138
|
+
42,
|
|
139
|
+
true,
|
|
140
|
+
{},
|
|
141
|
+
[],
|
|
142
|
+
[null],
|
|
143
|
+
[[]],
|
|
144
|
+
new Date(),
|
|
145
|
+
Symbol('nope'),
|
|
146
|
+
() => undefined,
|
|
147
|
+
]
|
|
148
|
+
for (const input of hostile) {
|
|
149
|
+
expect(() => parseFilters(input)).not.toThrow()
|
|
150
|
+
expect(Array.isArray(parseFilters(input))).toBe(true)
|
|
151
|
+
}
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('returns an empty state for a non-array payload', () => {
|
|
155
|
+
expect(parseFilters('{"columnId":"priority"}')).toEqual([])
|
|
156
|
+
expect(parseFilters('42')).toEqual([])
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('drops an entry that is not an object', () => {
|
|
160
|
+
expect(parseFilters('[1, "x", null, []]')).toEqual([])
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('drops an entry with an unknown column data type', () => {
|
|
164
|
+
expect(
|
|
165
|
+
parseFilters('[{"columnId":"a","type":"boolean","operator":"is","values":["x"]}]'),
|
|
166
|
+
).toEqual([])
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('drops an entry with an operator that is not on that type', () => {
|
|
170
|
+
// "contains" is a text operator; it must not survive on an option column.
|
|
171
|
+
expect(
|
|
172
|
+
parseFilters('[{"columnId":"a","type":"option","operator":"contains","values":["x"]}]'),
|
|
173
|
+
).toEqual([])
|
|
174
|
+
expect(
|
|
175
|
+
parseFilters('[{"columnId":"a","type":"option","operator":"is sort of","values":["x"]}]'),
|
|
176
|
+
).toEqual([])
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it('drops an entry whose values are not an array', () => {
|
|
180
|
+
expect(
|
|
181
|
+
parseFilters('[{"columnId":"a","type":"option","operator":"is","values":"high"}]'),
|
|
182
|
+
).toEqual([])
|
|
183
|
+
expect(
|
|
184
|
+
parseFilters('[{"columnId":"a","type":"option","operator":"is","values":{"0":"high"}}]'),
|
|
185
|
+
).toEqual([])
|
|
186
|
+
expect(
|
|
187
|
+
parseFilters('[{"columnId":"a","type":"option","operator":"is"}]'),
|
|
188
|
+
).toEqual([])
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it('drops an entry whose values are the wrong primitive for its type', () => {
|
|
192
|
+
expect(
|
|
193
|
+
parseFilters('[{"columnId":"a","type":"number","operator":"is","values":["5"]}]'),
|
|
194
|
+
).toEqual([])
|
|
195
|
+
expect(
|
|
196
|
+
parseFilters('[{"columnId":"a","type":"option","operator":"is","values":[5]}]'),
|
|
197
|
+
).toEqual([])
|
|
198
|
+
expect(
|
|
199
|
+
parseFilters('[{"columnId":"a","type":"date","operator":"is","values":["not a date"]}]'),
|
|
200
|
+
).toEqual([])
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
it('drops a number filter carrying NaN or Infinity', () => {
|
|
204
|
+
// JSON has no NaN literal, so this arrives as an already-parsed value.
|
|
205
|
+
expect(
|
|
206
|
+
parseFilters([
|
|
207
|
+
{ columnId: 'a', type: 'number', operator: 'is', values: [Number.NaN] },
|
|
208
|
+
]),
|
|
209
|
+
).toEqual([])
|
|
210
|
+
expect(
|
|
211
|
+
parseFilters([
|
|
212
|
+
{
|
|
213
|
+
columnId: 'a',
|
|
214
|
+
type: 'number',
|
|
215
|
+
operator: 'is',
|
|
216
|
+
values: [Number.POSITIVE_INFINITY],
|
|
217
|
+
},
|
|
218
|
+
]),
|
|
219
|
+
).toEqual([])
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
it('drops an entry whose value count the operator cannot use', () => {
|
|
223
|
+
// This is what keeps a hand-edited URL from reaching the arity throw in
|
|
224
|
+
// the predicates: "is between" with one value never gets that far.
|
|
225
|
+
expect(
|
|
226
|
+
parseFilters('[{"columnId":"a","type":"date","operator":"is between","values":["2026-01-01T00:00:00.000Z"]}]'),
|
|
227
|
+
).toEqual([])
|
|
228
|
+
expect(
|
|
229
|
+
parseFilters('[{"columnId":"a","type":"option","operator":"is","values":["a","b"]}]'),
|
|
230
|
+
).toEqual([])
|
|
231
|
+
expect(
|
|
232
|
+
parseFilters('[{"columnId":"a","type":"option","operator":"is any of","values":[]}]'),
|
|
233
|
+
).toEqual([])
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
it('drops an entry with a blank or over-long column id', () => {
|
|
237
|
+
expect(
|
|
238
|
+
parseFilters('[{"columnId":"","type":"option","operator":"is","values":["x"]}]'),
|
|
239
|
+
).toEqual([])
|
|
240
|
+
const long = 'c'.repeat(200)
|
|
241
|
+
expect(
|
|
242
|
+
parseFilters(
|
|
243
|
+
`[{"columnId":"${long}","type":"option","operator":"is","values":["x"]}]`,
|
|
244
|
+
),
|
|
245
|
+
).toEqual([])
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
it('drops an over-long value string', () => {
|
|
249
|
+
const long = 'v'.repeat(300)
|
|
250
|
+
expect(
|
|
251
|
+
parseFilters(
|
|
252
|
+
`[{"columnId":"a","type":"option","operator":"is","values":["${long}"]}]`,
|
|
253
|
+
),
|
|
254
|
+
).toEqual([])
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
it('drops an over-long payload outright', () => {
|
|
258
|
+
const filler = 'x'.repeat(9000)
|
|
259
|
+
expect(parseFilters(`["${filler}"]`)).toEqual([])
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
it('caps the number of filters and the number of values', () => {
|
|
263
|
+
const many = Array.from({ length: 40 }, (_, index) => ({
|
|
264
|
+
columnId: `c${index}`,
|
|
265
|
+
type: 'option',
|
|
266
|
+
operator: 'is',
|
|
267
|
+
values: ['x'],
|
|
268
|
+
}))
|
|
269
|
+
expect(parseFilters(many)).toHaveLength(32)
|
|
270
|
+
|
|
271
|
+
const wide = [
|
|
272
|
+
{
|
|
273
|
+
columnId: 'a',
|
|
274
|
+
type: 'option',
|
|
275
|
+
operator: 'is any of',
|
|
276
|
+
values: Array.from({ length: 100 }, (_, index) => `v${index}`),
|
|
277
|
+
},
|
|
278
|
+
]
|
|
279
|
+
expect(parseFilters(wide)).toEqual([])
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
it('keeps the first entry when a column appears twice', () => {
|
|
283
|
+
const state = parseFilters([
|
|
284
|
+
{ columnId: 'a', type: 'option', operator: 'is', values: ['first'] },
|
|
285
|
+
{ columnId: 'a', type: 'option', operator: 'is', values: ['second'] },
|
|
286
|
+
])
|
|
287
|
+
expect(state).toHaveLength(1)
|
|
288
|
+
expect(state[0]?.values).toEqual(['first'])
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
it('keeps the good entries alongside the bad ones', () => {
|
|
292
|
+
const state = parseFilters([
|
|
293
|
+
{ columnId: 'a', type: 'nonsense', operator: 'is', values: ['x'] },
|
|
294
|
+
{ columnId: 'b', type: 'option', operator: 'is', values: ['high'] },
|
|
295
|
+
'garbage',
|
|
296
|
+
])
|
|
297
|
+
expect(state).toHaveLength(1)
|
|
298
|
+
expect(state[0]?.columnId).toBe('b')
|
|
299
|
+
})
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
describe('parseFilters resists prototype pollution', () => {
|
|
303
|
+
it('rejects a prototype-shaped column id', () => {
|
|
304
|
+
for (const id of ['__proto__', 'constructor', 'prototype']) {
|
|
305
|
+
expect(
|
|
306
|
+
parseFilters(
|
|
307
|
+
`[{"columnId":"${id}","type":"option","operator":"is","values":["x"]}]`,
|
|
308
|
+
),
|
|
309
|
+
).toEqual([])
|
|
310
|
+
}
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
it('does not pollute Object.prototype from a __proto__ payload', () => {
|
|
314
|
+
parseFilters('[{"__proto__":{"polluted":true},"columnId":"a","type":"option","operator":"is","values":["x"]}]')
|
|
315
|
+
parseFilters('{"__proto__":{"polluted":true}}')
|
|
316
|
+
const probe: Record<string, unknown> = {}
|
|
317
|
+
expect(probe['polluted']).toBeUndefined()
|
|
318
|
+
expect(Object.hasOwn(Object.prototype, 'polluted')).toBe(false)
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
it('does not read inherited properties as filter fields', () => {
|
|
322
|
+
// A parsed object whose fields live on its PROTOTYPE must not be
|
|
323
|
+
// accepted: only own properties count.
|
|
324
|
+
const inherited = Object.create({
|
|
325
|
+
columnId: 'a',
|
|
326
|
+
type: 'option',
|
|
327
|
+
operator: 'is',
|
|
328
|
+
values: ['x'],
|
|
329
|
+
}) as object
|
|
330
|
+
expect(parseFilters([inherited])).toEqual([])
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
it('never copies extra keys into the parsed model', () => {
|
|
334
|
+
const state = parseFilters([
|
|
335
|
+
{
|
|
336
|
+
columnId: 'a',
|
|
337
|
+
type: 'option',
|
|
338
|
+
operator: 'is',
|
|
339
|
+
values: ['x'],
|
|
340
|
+
extra: 'should not survive',
|
|
341
|
+
},
|
|
342
|
+
])
|
|
343
|
+
expect(state[0]).toEqual({
|
|
344
|
+
columnId: 'a',
|
|
345
|
+
type: 'option',
|
|
346
|
+
operator: 'is',
|
|
347
|
+
values: ['x'],
|
|
348
|
+
})
|
|
349
|
+
})
|
|
350
|
+
})
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @package @lovett/ui
|
|
3
|
+
* @file filter-core/columns.ts
|
|
4
|
+
*
|
|
5
|
+
* Column config validation. This is where "a type we cannot handle" becomes a
|
|
6
|
+
* THROWN ERROR AT CONFIG TIME.
|
|
7
|
+
*
|
|
8
|
+
* The source had no such gate: its combinator listed cases for `option` and
|
|
9
|
+
* `multiOption` and fell through to `return true` for everything else
|
|
10
|
+
* (issue-filter-columns.tsx:187-188). Declaring a date column there did not
|
|
11
|
+
* fail loudly — it silently disabled filtering on that column, which looks
|
|
12
|
+
* exactly like "the filter matched everything".
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { COLUMN_DATA_TYPES } from './types'
|
|
16
|
+
import type { AnyColumnConfig, ColumnDataType } from './types'
|
|
17
|
+
|
|
18
|
+
/** Column ids that would collide with object plumbing. Rejected outright. */
|
|
19
|
+
const RESERVED_IDS: ReadonlySet<string> = new Set([
|
|
20
|
+
'__proto__',
|
|
21
|
+
'constructor',
|
|
22
|
+
'prototype',
|
|
23
|
+
])
|
|
24
|
+
|
|
25
|
+
function isColumnDataType(value: unknown): value is ColumnDataType {
|
|
26
|
+
return (
|
|
27
|
+
typeof value === 'string' &&
|
|
28
|
+
(COLUMN_DATA_TYPES as readonly string[]).includes(value)
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Validate a column set and hand it back.
|
|
34
|
+
*
|
|
35
|
+
* Call this once, at module scope, next to where the columns are declared —
|
|
36
|
+
* a config error then throws at import rather than at first render.
|
|
37
|
+
*
|
|
38
|
+
* Throws on: an unknown `type`, a missing/blank/reserved/duplicate `id`, a
|
|
39
|
+
* missing accessor or display name, an option column whose `options` are
|
|
40
|
+
* malformed or contain duplicate values, or a number column with `min > max`.
|
|
41
|
+
*/
|
|
42
|
+
export function defineColumns<TData>(
|
|
43
|
+
columns: ReadonlyArray<AnyColumnConfig<TData>>,
|
|
44
|
+
): ReadonlyArray<AnyColumnConfig<TData>> {
|
|
45
|
+
const seenIds = new Set<string>()
|
|
46
|
+
|
|
47
|
+
for (const column of columns) {
|
|
48
|
+
const label = column.id ? `"${column.id}"` : '<missing id>'
|
|
49
|
+
|
|
50
|
+
if (typeof column.id !== 'string' || column.id.trim() === '') {
|
|
51
|
+
throw new Error('[filter-core] every column needs a non-empty string id')
|
|
52
|
+
}
|
|
53
|
+
if (RESERVED_IDS.has(column.id)) {
|
|
54
|
+
throw new Error(`[filter-core] column id ${label} is reserved`)
|
|
55
|
+
}
|
|
56
|
+
if (seenIds.has(column.id)) {
|
|
57
|
+
throw new Error(`[filter-core] duplicate column id ${label}`)
|
|
58
|
+
}
|
|
59
|
+
seenIds.add(column.id)
|
|
60
|
+
|
|
61
|
+
if (!isColumnDataType(column.type)) {
|
|
62
|
+
throw new Error(
|
|
63
|
+
`[filter-core] column ${label} declares unsupported type ` +
|
|
64
|
+
`"${String(column.type)}". Supported: ${COLUMN_DATA_TYPES.join(', ')}. ` +
|
|
65
|
+
'A type with no predicate must fail here, not filter to everything.',
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
if (typeof column.accessor !== 'function') {
|
|
69
|
+
throw new Error(`[filter-core] column ${label} needs an accessor function`)
|
|
70
|
+
}
|
|
71
|
+
if (typeof column.displayName !== 'string' || column.displayName === '') {
|
|
72
|
+
throw new Error(`[filter-core] column ${label} needs a displayName`)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (column.type === 'option' || column.type === 'multiOption') {
|
|
76
|
+
const options = column.options
|
|
77
|
+
if (options !== undefined) {
|
|
78
|
+
if (!Array.isArray(options)) {
|
|
79
|
+
throw new Error(`[filter-core] column ${label} options must be an array`)
|
|
80
|
+
}
|
|
81
|
+
const seenValues = new Set<string>()
|
|
82
|
+
for (const option of options) {
|
|
83
|
+
if (
|
|
84
|
+
typeof option?.value !== 'string' ||
|
|
85
|
+
option.value === '' ||
|
|
86
|
+
typeof option.label !== 'string'
|
|
87
|
+
) {
|
|
88
|
+
throw new Error(
|
|
89
|
+
`[filter-core] column ${label} has a malformed option; ` +
|
|
90
|
+
'each needs a non-empty string value and a string label',
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
if (seenValues.has(option.value)) {
|
|
94
|
+
throw new Error(
|
|
95
|
+
`[filter-core] column ${label} has duplicate option value "${option.value}"`,
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
seenValues.add(option.value)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (column.type === 'number') {
|
|
104
|
+
const { min, max } = column
|
|
105
|
+
if (typeof min === 'number' && typeof max === 'number' && min > max) {
|
|
106
|
+
throw new Error(`[filter-core] column ${label} has min greater than max`)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return columns
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Index a column set by id, for O(1) lookup while filtering. */
|
|
115
|
+
export function indexColumns<TData>(
|
|
116
|
+
columns: ReadonlyArray<AnyColumnConfig<TData>>,
|
|
117
|
+
): ReadonlyMap<string, AnyColumnConfig<TData>> {
|
|
118
|
+
const index = new Map<string, AnyColumnConfig<TData>>()
|
|
119
|
+
for (const column of columns) index.set(column.id, column)
|
|
120
|
+
return index
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Narrow a column of unknown type to one specific type.
|
|
125
|
+
*
|
|
126
|
+
* A real runtime check, not a cast: the discriminant is read before the
|
|
127
|
+
* predicate returns true.
|
|
128
|
+
*/
|
|
129
|
+
export function isColumnOfType<TData, TType extends ColumnDataType>(
|
|
130
|
+
column: AnyColumnConfig<TData>,
|
|
131
|
+
type: TType,
|
|
132
|
+
): column is Extract<AnyColumnConfig<TData>, { type: TType }> {
|
|
133
|
+
return column.type === type
|
|
134
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @package @lovett/ui
|
|
3
|
+
* @file filter-core/date-utils.ts
|
|
4
|
+
*
|
|
5
|
+
* Day-granular date helpers.
|
|
6
|
+
*
|
|
7
|
+
* The source used date-fns. `date-fns` is not a dependency of @lovett/ui and
|
|
8
|
+
* these four functions are the whole of what was used, so they are inlined
|
|
9
|
+
* rather than pulling a runtime dependency into the design system (CLAUDE.md
|
|
10
|
+
* §1 — a new runtime dep needs an ADR).
|
|
11
|
+
*
|
|
12
|
+
* All boundaries are LOCAL time. A board filter reads "due today" against the
|
|
13
|
+
* user's calendar day, not UTC.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** Midnight at the start of `date`'s local day. */
|
|
17
|
+
export function startOfDay(date: Date): Date {
|
|
18
|
+
const result = new Date(date.getTime())
|
|
19
|
+
result.setHours(0, 0, 0, 0)
|
|
20
|
+
return result
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The last millisecond of `date`'s local day. */
|
|
24
|
+
export function endOfDay(date: Date): Date {
|
|
25
|
+
const result = new Date(date.getTime())
|
|
26
|
+
result.setHours(23, 59, 59, 999)
|
|
27
|
+
return result
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Whether two instants fall on the same local calendar day. */
|
|
31
|
+
export function isSameDay(a: Date, b: Date): boolean {
|
|
32
|
+
return startOfDay(a).getTime() === startOfDay(b).getTime()
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A `Date` that is not Invalid Date. */
|
|
36
|
+
export function isValidDate(value: unknown): value is Date {
|
|
37
|
+
return value instanceof Date && !Number.isNaN(value.getTime())
|
|
38
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @package @lovett/ui
|
|
3
|
+
* @file filter-core/examples/task-filter-columns.ts
|
|
4
|
+
*
|
|
5
|
+
* EXAMPLE — NOT PART OF THE CORE, AND NOT EXPORTED FROM `filter-core/index`.
|
|
6
|
+
*
|
|
7
|
+
* This is what a Tasks-board column set looks like against the generic model.
|
|
8
|
+
* It is here so the intended usage is visible next to the code, and so the
|
|
9
|
+
* type gating is exercised by something shaped like the real thing. The real
|
|
10
|
+
* board declares its own columns in the lens, over the lens's own row type
|
|
11
|
+
* and its own lane/label data — do not import this into a lens.
|
|
12
|
+
*
|
|
13
|
+
* Two patterns worth copying:
|
|
14
|
+
*
|
|
15
|
+
* 1. THE SENTINEL. `number`/`date` filters exclude rows with no value (see
|
|
16
|
+
* the note at the top of filter-fns.ts), and "unassigned" is a state a
|
|
17
|
+
* user genuinely wants to filter FOR. So the accessor maps absence onto a
|
|
18
|
+
* real option value rather than returning null. Same for priority `none`.
|
|
19
|
+
*
|
|
20
|
+
* 2. LANES AS OPTIONS. The board's columns are data, so `options` here is
|
|
21
|
+
* built from the board rather than hardcoded — which is also what makes
|
|
22
|
+
* the facet list able to show a lane with a count of zero.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { defineColumns } from '../columns'
|
|
26
|
+
import type { AnyColumnConfig, ColumnOption } from '../types'
|
|
27
|
+
|
|
28
|
+
/** The row shape this example filters. The real lens has its own. */
|
|
29
|
+
export interface ExampleTaskRow {
|
|
30
|
+
readonly id: string
|
|
31
|
+
readonly title: string
|
|
32
|
+
readonly columnId: string
|
|
33
|
+
readonly priority: 'none' | 'low' | 'medium' | 'high'
|
|
34
|
+
readonly assigneeId: string | null
|
|
35
|
+
readonly dateLabel: Date | null
|
|
36
|
+
readonly endDate: Date | null
|
|
37
|
+
readonly labelIds: readonly string[]
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The sentinel option value standing in for "nobody is assigned". */
|
|
41
|
+
export const UNASSIGNED = 'unassigned'
|
|
42
|
+
|
|
43
|
+
export const PRIORITY_OPTIONS: readonly ColumnOption[] = [
|
|
44
|
+
{ value: 'none', label: 'No priority', icon: 'Minus' },
|
|
45
|
+
{ value: 'low', label: 'Low', icon: 'SignalLow' },
|
|
46
|
+
{ value: 'medium', label: 'Medium', icon: 'SignalMedium' },
|
|
47
|
+
{ value: 'high', label: 'High', icon: 'SignalHigh' },
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
export interface ExampleBoardMeta {
|
|
51
|
+
/** The board's lanes, in board order. */
|
|
52
|
+
readonly lanes: readonly ColumnOption[]
|
|
53
|
+
/** Assignable people. The `unassigned` sentinel is prepended for you. */
|
|
54
|
+
readonly assignees: readonly ColumnOption[]
|
|
55
|
+
/** Labels available on this board. */
|
|
56
|
+
readonly labels: readonly ColumnOption[]
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Build the Tasks board's filter columns.
|
|
61
|
+
*
|
|
62
|
+
* `defineColumns` validates at call time, so a typo in a type or a duplicate
|
|
63
|
+
* option value throws where the columns are declared rather than showing up
|
|
64
|
+
* as a filter that matches everything.
|
|
65
|
+
*/
|
|
66
|
+
export function createTaskFilterColumns(
|
|
67
|
+
meta: ExampleBoardMeta,
|
|
68
|
+
): ReadonlyArray<AnyColumnConfig<ExampleTaskRow>> {
|
|
69
|
+
return defineColumns<ExampleTaskRow>([
|
|
70
|
+
{
|
|
71
|
+
id: 'title',
|
|
72
|
+
type: 'text',
|
|
73
|
+
displayName: 'Title',
|
|
74
|
+
accessor: (task) => task.title,
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
id: 'column',
|
|
78
|
+
type: 'option',
|
|
79
|
+
displayName: 'Column',
|
|
80
|
+
accessor: (task) => task.columnId,
|
|
81
|
+
options: meta.lanes,
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
id: 'priority',
|
|
85
|
+
type: 'option',
|
|
86
|
+
displayName: 'Priority',
|
|
87
|
+
accessor: (task) => task.priority,
|
|
88
|
+
options: PRIORITY_OPTIONS,
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
id: 'assignee',
|
|
92
|
+
type: 'option',
|
|
93
|
+
displayName: 'Assignee',
|
|
94
|
+
// The sentinel: absence becomes a filterable value.
|
|
95
|
+
accessor: (task) => task.assigneeId ?? UNASSIGNED,
|
|
96
|
+
options: [
|
|
97
|
+
{ value: UNASSIGNED, label: 'Unassigned', icon: 'CircleDashed' },
|
|
98
|
+
...meta.assignees,
|
|
99
|
+
],
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
id: 'dateLabel',
|
|
103
|
+
type: 'date',
|
|
104
|
+
displayName: 'Date',
|
|
105
|
+
accessor: (task) => task.dateLabel,
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
id: 'endDate',
|
|
109
|
+
type: 'date',
|
|
110
|
+
displayName: 'Due',
|
|
111
|
+
accessor: (task) => task.endDate,
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
id: 'labels',
|
|
115
|
+
type: 'multiOption',
|
|
116
|
+
displayName: 'Labels',
|
|
117
|
+
accessor: (task) => task.labelIds,
|
|
118
|
+
options: meta.labels,
|
|
119
|
+
},
|
|
120
|
+
])
|
|
121
|
+
}
|