@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,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @package @lovett/ui
|
|
3
|
+
* @file filter-core/faceting.ts
|
|
4
|
+
*
|
|
5
|
+
* Faceted counts, as PURE FUNCTIONS of the rows you pass in.
|
|
6
|
+
*
|
|
7
|
+
* THIS IS BUG FIX #3. The source computed facet counts inside a `useMemo`
|
|
8
|
+
* with an EMPTY dependency array (filter-value.tsx:481-489), so the numbers
|
|
9
|
+
* froze at popover-mount: adding a filter changed the board and left the
|
|
10
|
+
* facet counts showing the previous world. There is no memo, no lazy getter
|
|
11
|
+
* and no cache class here — counts are derived from the rows given, so they
|
|
12
|
+
* cannot go stale. Callers memoize with the row array as the dependency.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { filterRows } from './filter-fns'
|
|
16
|
+
import { isColumnOfType } from './columns'
|
|
17
|
+
import type { AnyColumnConfig, FiltersState } from './types'
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Count rows per option value for an `option` / `multiOption` column.
|
|
21
|
+
*
|
|
22
|
+
* Every value declared in `column.options` is present in the result, INCLUDING
|
|
23
|
+
* the ones no row has — a facet list that quietly omits its empty buckets
|
|
24
|
+
* reads as "this option does not exist" rather than "nothing matches". Values
|
|
25
|
+
* found in the data but not declared are appended after the declared ones.
|
|
26
|
+
*
|
|
27
|
+
* Throws for a column type that has no facets; asking a text column for its
|
|
28
|
+
* value counts is a programming error, not a runtime condition.
|
|
29
|
+
*/
|
|
30
|
+
export function facetedCounts<TData>(
|
|
31
|
+
rows: readonly TData[],
|
|
32
|
+
column: AnyColumnConfig<TData>,
|
|
33
|
+
): Map<string, number> {
|
|
34
|
+
const counts = new Map<string, number>()
|
|
35
|
+
|
|
36
|
+
if (isColumnOfType(column, 'option')) {
|
|
37
|
+
for (const option of column.options ?? []) counts.set(option.value, 0)
|
|
38
|
+
for (const row of rows) {
|
|
39
|
+
const value = column.accessor(row)
|
|
40
|
+
if (typeof value !== 'string') continue
|
|
41
|
+
counts.set(value, (counts.get(value) ?? 0) + 1)
|
|
42
|
+
}
|
|
43
|
+
return counts
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (isColumnOfType(column, 'multiOption')) {
|
|
47
|
+
for (const option of column.options ?? []) counts.set(option.value, 0)
|
|
48
|
+
for (const row of rows) {
|
|
49
|
+
const values = column.accessor(row)
|
|
50
|
+
if (!values) continue
|
|
51
|
+
// A row counts once per DISTINCT value it carries, so a duplicated
|
|
52
|
+
// label on one task cannot inflate the facet past the row count.
|
|
53
|
+
for (const value of new Set(values)) {
|
|
54
|
+
if (typeof value !== 'string') continue
|
|
55
|
+
counts.set(value, (counts.get(value) ?? 0) + 1)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return counts
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
throw new Error(
|
|
62
|
+
`[filter-core] facetedCounts is only defined for option and multiOption ` +
|
|
63
|
+
`columns; "${column.id}" is "${column.type}"`,
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The rows a facet list for `columnId` should count: everything surviving
|
|
69
|
+
* every OTHER column's filters.
|
|
70
|
+
*
|
|
71
|
+
* Counting against the fully filtered set would zero every unselected option
|
|
72
|
+
* the moment one is picked, which makes the list unusable for widening a
|
|
73
|
+
* selection. Excluding the column's own filter is the standard behaviour and
|
|
74
|
+
* it stays a pure function of `(rows, columns, filters)`.
|
|
75
|
+
*/
|
|
76
|
+
export function facetedRowsFor<TData>(
|
|
77
|
+
rows: readonly TData[],
|
|
78
|
+
columns: ReadonlyArray<AnyColumnConfig<TData>>,
|
|
79
|
+
filters: FiltersState,
|
|
80
|
+
columnId: string,
|
|
81
|
+
): TData[] {
|
|
82
|
+
return filterRows(
|
|
83
|
+
rows,
|
|
84
|
+
columns,
|
|
85
|
+
filters.filter((filter) => filter.columnId !== columnId),
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The observed `[min, max]` of a number column, or its declared bounds when
|
|
91
|
+
* both are configured. `null` when neither is available.
|
|
92
|
+
*/
|
|
93
|
+
export function facetedMinMax<TData>(
|
|
94
|
+
rows: readonly TData[],
|
|
95
|
+
column: AnyColumnConfig<TData>,
|
|
96
|
+
): readonly [number, number] | null {
|
|
97
|
+
if (!isColumnOfType(column, 'number')) {
|
|
98
|
+
throw new Error(
|
|
99
|
+
`[filter-core] facetedMinMax is only defined for number columns; ` +
|
|
100
|
+
`"${column.id}" is "${column.type}"`,
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const { min, max } = column
|
|
105
|
+
if (typeof min === 'number' && typeof max === 'number') return [min, max]
|
|
106
|
+
|
|
107
|
+
let low = Number.POSITIVE_INFINITY
|
|
108
|
+
let high = Number.NEGATIVE_INFINITY
|
|
109
|
+
let seen = false
|
|
110
|
+
|
|
111
|
+
for (const row of rows) {
|
|
112
|
+
const value = column.accessor(row)
|
|
113
|
+
if (typeof value !== 'number' || Number.isNaN(value)) continue
|
|
114
|
+
seen = true
|
|
115
|
+
if (value < low) low = value
|
|
116
|
+
if (value > high) high = value
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return seen ? [low, high] : null
|
|
120
|
+
}
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @package @lovett/ui
|
|
3
|
+
* @file filter-core/filter-fns.ts
|
|
4
|
+
*
|
|
5
|
+
* The predicates — one per column type — and the combinator over them.
|
|
6
|
+
*
|
|
7
|
+
* Semantics:
|
|
8
|
+
* - ACROSS columns: AND. Every filter in the state must pass.
|
|
9
|
+
* - WITHIN a column: the operator decides. `is any of` is an OR over the
|
|
10
|
+
* value list; `include all of` is an AND over it.
|
|
11
|
+
* - An empty value list is a no-op filter (passes everything), so a
|
|
12
|
+
* half-built filter chip never blanks the board.
|
|
13
|
+
*
|
|
14
|
+
* Missing values. Each predicate takes `Nullable<T>` because an accessor is
|
|
15
|
+
* allowed to return nothing, and the answer is not the same for every type:
|
|
16
|
+
* - text — a missing value behaves as the empty string, so `does not
|
|
17
|
+
* contain "x"` is TRUE for a row with no title. The source
|
|
18
|
+
* returned false for both halves of the pair, which made the two
|
|
19
|
+
* operators non-complementary.
|
|
20
|
+
* - option / multiOption — a missing value matches no positive operator and
|
|
21
|
+
* every negated one, for the same reason.
|
|
22
|
+
* - number / date — a missing value matches NOTHING, including the negated
|
|
23
|
+
* operators. "Due before Friday" over a task with no due date is
|
|
24
|
+
* not a question with a true answer; excluding it is the
|
|
25
|
+
* behaviour a board wants. Config-level sentinels (see the
|
|
26
|
+
* `assignee` example) are the way to make absence filterable.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { isColumnOfType } from './columns'
|
|
30
|
+
import { endOfDay, isSameDay, startOfDay } from './date-utils'
|
|
31
|
+
import { operatorDetails } from './operators'
|
|
32
|
+
import type {
|
|
33
|
+
AnyColumnConfig,
|
|
34
|
+
FilterModel,
|
|
35
|
+
FiltersState,
|
|
36
|
+
Nullable,
|
|
37
|
+
} from './types'
|
|
38
|
+
|
|
39
|
+
/* ------------------------------------------------------------------ */
|
|
40
|
+
/* Arity guards */
|
|
41
|
+
/* ------------------------------------------------------------------ */
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Assert a filter carries the number of values its operator requires.
|
|
45
|
+
*
|
|
46
|
+
* THIS IS BUG FIX #1. The source wrote the plural half as
|
|
47
|
+
*
|
|
48
|
+
* filterValue.operator in ['is between', 'is not between']
|
|
49
|
+
*
|
|
50
|
+
* (filter-fns.ts:80). `in` on an ARRAY tests index keys — '0', '1', 'length'
|
|
51
|
+
* — so the condition is false for every operator and the guard never fired.
|
|
52
|
+
* A one-value `is between` sailed past it into an interval with an undefined
|
|
53
|
+
* end. Here the requirement is read off the operator table's `arity`, which
|
|
54
|
+
* cannot be written that way.
|
|
55
|
+
*/
|
|
56
|
+
function assertArity(filter: FilterModel): void {
|
|
57
|
+
const details = operatorDetails(filter.type, filter.operator)
|
|
58
|
+
const count = filter.values.length
|
|
59
|
+
|
|
60
|
+
if (details.arity === 'one' && count > 1) {
|
|
61
|
+
throw new Error(
|
|
62
|
+
`[filter-core] operator "${filter.operator}" on column ` +
|
|
63
|
+
`"${filter.columnId}" takes at most one value, got ${count}`,
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
if (details.arity === 'two' && count !== 2) {
|
|
67
|
+
throw new Error(
|
|
68
|
+
`[filter-core] operator "${filter.operator}" on column ` +
|
|
69
|
+
`"${filter.columnId}" requires exactly two values, got ${count}`,
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/* ------------------------------------------------------------------ */
|
|
75
|
+
/* Predicates */
|
|
76
|
+
/* ------------------------------------------------------------------ */
|
|
77
|
+
|
|
78
|
+
export function textFilterFn(
|
|
79
|
+
input: Nullable<string>,
|
|
80
|
+
filter: FilterModel<'text'>,
|
|
81
|
+
): boolean {
|
|
82
|
+
assertArity(filter)
|
|
83
|
+
const needle = filter.values[0]
|
|
84
|
+
if (needle === undefined) return true
|
|
85
|
+
|
|
86
|
+
const trimmed = needle.toLowerCase().trim()
|
|
87
|
+
if (trimmed === '') return true
|
|
88
|
+
|
|
89
|
+
const found = (input ?? '').toLowerCase().trim().includes(trimmed)
|
|
90
|
+
|
|
91
|
+
switch (filter.operator) {
|
|
92
|
+
case 'contains':
|
|
93
|
+
return found
|
|
94
|
+
case 'does not contain':
|
|
95
|
+
return !found
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function numberFilterFn(
|
|
100
|
+
input: Nullable<number>,
|
|
101
|
+
filter: FilterModel<'number'>,
|
|
102
|
+
): boolean {
|
|
103
|
+
assertArity(filter)
|
|
104
|
+
const first = filter.values[0]
|
|
105
|
+
if (first === undefined) return true
|
|
106
|
+
if (typeof input !== 'number' || Number.isNaN(input)) return false
|
|
107
|
+
|
|
108
|
+
switch (filter.operator) {
|
|
109
|
+
case 'is':
|
|
110
|
+
return input === first
|
|
111
|
+
case 'is not':
|
|
112
|
+
return input !== first
|
|
113
|
+
case 'is greater than':
|
|
114
|
+
return input > first
|
|
115
|
+
case 'is greater than or equal to':
|
|
116
|
+
return input >= first
|
|
117
|
+
case 'is less than':
|
|
118
|
+
return input < first
|
|
119
|
+
case 'is less than or equal to':
|
|
120
|
+
return input <= first
|
|
121
|
+
case 'is between':
|
|
122
|
+
case 'is not between': {
|
|
123
|
+
const second = filter.values[1]
|
|
124
|
+
// assertArity has already guaranteed two values.
|
|
125
|
+
if (second === undefined) return true
|
|
126
|
+
const lower = Math.min(first, second)
|
|
127
|
+
const upper = Math.max(first, second)
|
|
128
|
+
const within = input >= lower && input <= upper
|
|
129
|
+
return filter.operator === 'is between' ? within : !within
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function dateFilterFn(
|
|
135
|
+
input: Nullable<Date>,
|
|
136
|
+
filter: FilterModel<'date'>,
|
|
137
|
+
): boolean {
|
|
138
|
+
assertArity(filter)
|
|
139
|
+
const first = filter.values[0]
|
|
140
|
+
if (first === undefined) return true
|
|
141
|
+
if (!(input instanceof Date) || Number.isNaN(input.getTime())) return false
|
|
142
|
+
|
|
143
|
+
switch (filter.operator) {
|
|
144
|
+
case 'is':
|
|
145
|
+
return isSameDay(input, first)
|
|
146
|
+
case 'is not':
|
|
147
|
+
return !isSameDay(input, first)
|
|
148
|
+
case 'is before':
|
|
149
|
+
return input.getTime() < startOfDay(first).getTime()
|
|
150
|
+
case 'is on or after':
|
|
151
|
+
return input.getTime() >= startOfDay(first).getTime()
|
|
152
|
+
case 'is after':
|
|
153
|
+
return input.getTime() > endOfDay(first).getTime()
|
|
154
|
+
case 'is on or before':
|
|
155
|
+
return input.getTime() <= endOfDay(first).getTime()
|
|
156
|
+
case 'is between':
|
|
157
|
+
case 'is not between': {
|
|
158
|
+
const second = filter.values[1]
|
|
159
|
+
// assertArity has already guaranteed two values.
|
|
160
|
+
if (second === undefined) return true
|
|
161
|
+
const [lower, upper] =
|
|
162
|
+
first.getTime() <= second.getTime() ? [first, second] : [second, first]
|
|
163
|
+
const within =
|
|
164
|
+
input.getTime() >= startOfDay(lower).getTime() &&
|
|
165
|
+
input.getTime() <= endOfDay(upper).getTime()
|
|
166
|
+
return filter.operator === 'is between' ? within : !within
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function optionFilterFn(
|
|
172
|
+
input: Nullable<string>,
|
|
173
|
+
filter: FilterModel<'option'>,
|
|
174
|
+
): boolean {
|
|
175
|
+
assertArity(filter)
|
|
176
|
+
if (filter.values.length === 0) return true
|
|
177
|
+
|
|
178
|
+
// Exact match, not case-folded. Option values are ids; lower-casing them
|
|
179
|
+
// (as the source did) silently collides two options that differ by case.
|
|
180
|
+
const found = input != null && filter.values.includes(input)
|
|
181
|
+
|
|
182
|
+
switch (filter.operator) {
|
|
183
|
+
case 'is':
|
|
184
|
+
case 'is any of':
|
|
185
|
+
return found
|
|
186
|
+
case 'is not':
|
|
187
|
+
case 'is none of':
|
|
188
|
+
return !found
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function multiOptionFilterFn(
|
|
193
|
+
input: Nullable<readonly string[]>,
|
|
194
|
+
filter: FilterModel<'multiOption'>,
|
|
195
|
+
): boolean {
|
|
196
|
+
assertArity(filter)
|
|
197
|
+
if (filter.values.length === 0) return true
|
|
198
|
+
|
|
199
|
+
const present = new Set(input ?? [])
|
|
200
|
+
const selected = filter.values
|
|
201
|
+
const anyPresent = selected.some((value) => present.has(value))
|
|
202
|
+
const allPresent = selected.every((value) => present.has(value))
|
|
203
|
+
|
|
204
|
+
switch (filter.operator) {
|
|
205
|
+
case 'include':
|
|
206
|
+
case 'include any of':
|
|
207
|
+
return anyPresent
|
|
208
|
+
case 'include all of':
|
|
209
|
+
return allPresent
|
|
210
|
+
case 'exclude':
|
|
211
|
+
case 'exclude if any of':
|
|
212
|
+
return !anyPresent
|
|
213
|
+
case 'exclude if all':
|
|
214
|
+
return !allPresent
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/* ------------------------------------------------------------------ */
|
|
219
|
+
/* Combinator */
|
|
220
|
+
/* ------------------------------------------------------------------ */
|
|
221
|
+
|
|
222
|
+
/** Unreachable-branch guard. A new ColumnDataType fails to compile here. */
|
|
223
|
+
function assertNever(value: never, message: string): never {
|
|
224
|
+
throw new Error(`${message}: ${JSON.stringify(value)}`)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Test one row against one filter, through one column.
|
|
229
|
+
*
|
|
230
|
+
* THIS IS BUG FIX #2. Every declared type has a case; there is no fallthrough
|
|
231
|
+
* `return true`. A filter whose type disagrees with its column's THROWS —
|
|
232
|
+
* a state that only arises from a programming error, since `pruneFilters`
|
|
233
|
+
* drops such a filter when it comes off the URL.
|
|
234
|
+
*/
|
|
235
|
+
export function rowMatchesFilter<TData>(
|
|
236
|
+
row: TData,
|
|
237
|
+
column: AnyColumnConfig<TData>,
|
|
238
|
+
filter: FilterModel,
|
|
239
|
+
): boolean {
|
|
240
|
+
const mismatch = (): never => {
|
|
241
|
+
throw new Error(
|
|
242
|
+
`[filter-core] filter on column "${filter.columnId}" is typed ` +
|
|
243
|
+
`"${filter.type}" but the column is "${column.type}"`,
|
|
244
|
+
)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
switch (filter.type) {
|
|
248
|
+
case 'text':
|
|
249
|
+
return isColumnOfType(column, 'text')
|
|
250
|
+
? textFilterFn(column.accessor(row), filter)
|
|
251
|
+
: mismatch()
|
|
252
|
+
case 'number':
|
|
253
|
+
return isColumnOfType(column, 'number')
|
|
254
|
+
? numberFilterFn(column.accessor(row), filter)
|
|
255
|
+
: mismatch()
|
|
256
|
+
case 'date':
|
|
257
|
+
return isColumnOfType(column, 'date')
|
|
258
|
+
? dateFilterFn(column.accessor(row), filter)
|
|
259
|
+
: mismatch()
|
|
260
|
+
case 'option':
|
|
261
|
+
return isColumnOfType(column, 'option')
|
|
262
|
+
? optionFilterFn(column.accessor(row), filter)
|
|
263
|
+
: mismatch()
|
|
264
|
+
case 'multiOption':
|
|
265
|
+
return isColumnOfType(column, 'multiOption')
|
|
266
|
+
? multiOptionFilterFn(column.accessor(row), filter)
|
|
267
|
+
: mismatch()
|
|
268
|
+
default:
|
|
269
|
+
return assertNever(filter, '[filter-core] unhandled filter type')
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Drop filters that cannot be applied to this column set: an unknown column
|
|
275
|
+
* id (a stale link after a column was renamed) or a type that disagrees with
|
|
276
|
+
* the column's declared type (a hand-edited URL). Also collapses duplicate
|
|
277
|
+
* entries for one column, keeping the first.
|
|
278
|
+
*
|
|
279
|
+
* Dropping is right here and throwing is right in `rowMatchesFilter`, because
|
|
280
|
+
* this is where UNTRUSTED input meets the config. CLAUDE.md: parse at the
|
|
281
|
+
* boundary, and let broken invariants throw further in.
|
|
282
|
+
*/
|
|
283
|
+
export function pruneFilters<TData>(
|
|
284
|
+
filters: FiltersState,
|
|
285
|
+
columns: ReadonlyArray<AnyColumnConfig<TData>>,
|
|
286
|
+
): FiltersState {
|
|
287
|
+
const byId = new Map(columns.map((column) => [column.id, column]))
|
|
288
|
+
const kept: FiltersState = []
|
|
289
|
+
const seen = new Set<string>()
|
|
290
|
+
|
|
291
|
+
for (const filter of filters) {
|
|
292
|
+
const column = byId.get(filter.columnId)
|
|
293
|
+
if (!column) continue
|
|
294
|
+
if (column.type !== filter.type) continue
|
|
295
|
+
if (seen.has(filter.columnId)) continue
|
|
296
|
+
seen.add(filter.columnId)
|
|
297
|
+
kept.push(filter)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return kept
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Whether a row survives every filter in the state. AND across columns. */
|
|
304
|
+
export function rowMatchesFilters<TData>(
|
|
305
|
+
row: TData,
|
|
306
|
+
columns: ReadonlyMap<string, AnyColumnConfig<TData>>,
|
|
307
|
+
filters: FiltersState,
|
|
308
|
+
): boolean {
|
|
309
|
+
for (const filter of filters) {
|
|
310
|
+
const column = columns.get(filter.columnId)
|
|
311
|
+
// An unknown column id is not a filter we can honour. Ignore it rather
|
|
312
|
+
// than blanking the board; pruneFilters is how a caller sees it happen.
|
|
313
|
+
if (!column) continue
|
|
314
|
+
if (!rowMatchesFilter(row, column, filter)) return false
|
|
315
|
+
}
|
|
316
|
+
return true
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Apply a filter state to a row set. Pure — same inputs, same output, no
|
|
321
|
+
* caching. The caller memoizes.
|
|
322
|
+
*/
|
|
323
|
+
export function filterRows<TData>(
|
|
324
|
+
rows: readonly TData[],
|
|
325
|
+
columns: ReadonlyArray<AnyColumnConfig<TData>>,
|
|
326
|
+
filters: FiltersState,
|
|
327
|
+
): TData[] {
|
|
328
|
+
if (filters.length === 0) return [...rows]
|
|
329
|
+
|
|
330
|
+
const byId = new Map(columns.map((column) => [column.id, column]))
|
|
331
|
+
const applicable = pruneFilters(filters, columns)
|
|
332
|
+
if (applicable.length === 0) return [...rows]
|
|
333
|
+
|
|
334
|
+
return rows.filter((row) => rowMatchesFilters(row, byId, applicable))
|
|
335
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @package @lovett/ui
|
|
3
|
+
* @file filter-core/index.ts
|
|
4
|
+
*
|
|
5
|
+
* `filter-core` — the pure-TypeScript filter model behind the board header.
|
|
6
|
+
*
|
|
7
|
+
* Zero React, zero CSS, zero domain knowledge, zero runtime dependencies.
|
|
8
|
+
* The boundary is `ColumnConfig[]` in, `FiltersState` out; the UI layer sits
|
|
9
|
+
* on top of it and the URL is its persistence.
|
|
10
|
+
*
|
|
11
|
+
* Ported from bazza/ui's `data-table-filter` (vendored in continuity-control)
|
|
12
|
+
* with three of its bugs fixed on the way in — see the `THIS IS BUG FIX`
|
|
13
|
+
* comments in filter-fns.ts (arity guard, exhaustive combinator) and
|
|
14
|
+
* faceting.ts (counts as a pure function).
|
|
15
|
+
*
|
|
16
|
+
* The example config lives at ./examples/task-filter-columns and is NOT
|
|
17
|
+
* exported from here: it is illustration, not API.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export {
|
|
21
|
+
COLUMN_DATA_TYPES,
|
|
22
|
+
} from './types'
|
|
23
|
+
|
|
24
|
+
export type {
|
|
25
|
+
AnyColumnConfig,
|
|
26
|
+
ColumnAccessor,
|
|
27
|
+
ColumnConfig,
|
|
28
|
+
ColumnDataNativeMap,
|
|
29
|
+
ColumnDataType,
|
|
30
|
+
ColumnOption,
|
|
31
|
+
ColumnValueMap,
|
|
32
|
+
DateFilterOperator,
|
|
33
|
+
ElementType,
|
|
34
|
+
FilterDetails,
|
|
35
|
+
FilterModel,
|
|
36
|
+
FilterOperatorArity,
|
|
37
|
+
FilterOperatorDetails,
|
|
38
|
+
FilterOperators,
|
|
39
|
+
FilterOperatorTarget,
|
|
40
|
+
FiltersState,
|
|
41
|
+
FilterTypeOperatorDetails,
|
|
42
|
+
FilterValues,
|
|
43
|
+
MultiOptionFilterOperator,
|
|
44
|
+
Nullable,
|
|
45
|
+
NumberFilterOperator,
|
|
46
|
+
OptionBasedColumnDataType,
|
|
47
|
+
OptionFilterOperator,
|
|
48
|
+
TextFilterOperator,
|
|
49
|
+
} from './types'
|
|
50
|
+
|
|
51
|
+
export {
|
|
52
|
+
DEFAULT_OPERATORS,
|
|
53
|
+
FILTER_OPERATOR_DETAILS,
|
|
54
|
+
dateFilterOperators,
|
|
55
|
+
determineNewOperator,
|
|
56
|
+
isOperatorOf,
|
|
57
|
+
multiOptionFilterOperators,
|
|
58
|
+
negateOperator,
|
|
59
|
+
numberFilterOperators,
|
|
60
|
+
operatorDetails,
|
|
61
|
+
operatorValueArity,
|
|
62
|
+
operatorsForType,
|
|
63
|
+
optionFilterOperators,
|
|
64
|
+
relatedOperators,
|
|
65
|
+
textFilterOperators,
|
|
66
|
+
} from './operators'
|
|
67
|
+
|
|
68
|
+
export type { FilterOperatorDetailsShape } from './operators'
|
|
69
|
+
|
|
70
|
+
export { defineColumns, indexColumns, isColumnOfType } from './columns'
|
|
71
|
+
|
|
72
|
+
export {
|
|
73
|
+
dateFilterFn,
|
|
74
|
+
filterRows,
|
|
75
|
+
multiOptionFilterFn,
|
|
76
|
+
numberFilterFn,
|
|
77
|
+
optionFilterFn,
|
|
78
|
+
pruneFilters,
|
|
79
|
+
rowMatchesFilter,
|
|
80
|
+
rowMatchesFilters,
|
|
81
|
+
textFilterFn,
|
|
82
|
+
} from './filter-fns'
|
|
83
|
+
|
|
84
|
+
export { facetedCounts, facetedMinMax, facetedRowsFor } from './faceting'
|
|
85
|
+
|
|
86
|
+
export {
|
|
87
|
+
clearFilters,
|
|
88
|
+
getFilter,
|
|
89
|
+
removeFilter,
|
|
90
|
+
setDateFilter,
|
|
91
|
+
setFilterOperator,
|
|
92
|
+
setMultiOptionFilter,
|
|
93
|
+
setNumberFilter,
|
|
94
|
+
setOptionFilter,
|
|
95
|
+
setTextFilter,
|
|
96
|
+
toggleMultiOptionValue,
|
|
97
|
+
toggleOptionValue,
|
|
98
|
+
upsertFilter,
|
|
99
|
+
} from './state'
|
|
100
|
+
|
|
101
|
+
export {
|
|
102
|
+
FILTERS_SEARCH_PARAM,
|
|
103
|
+
parseFilters,
|
|
104
|
+
serializeFilters,
|
|
105
|
+
} from './url'
|