@lovett/ui 0.0.10 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/chunk-RBYWGBQ2.js +2752 -0
- package/dist/chunk-RBYWGBQ2.js.map +1 -0
- package/dist/index.d.ts +6064 -80
- package/dist/index.js +21561 -9696
- 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 +1586 -2
- package/dist/theme-v2.css +228 -0
- package/dist/tokens.css +121 -0
- package/package.json +8 -1
- package/src/__tests__/anchor.test.tsx +422 -0
- 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__/combobox.test.tsx +677 -0
- package/src/__tests__/display-popover.test.tsx +195 -0
- package/src/__tests__/display-store.test.tsx +208 -0
- package/src/__tests__/donut-chart.test.tsx +397 -0
- package/src/__tests__/dropdown-menu.test.tsx +572 -0
- package/src/__tests__/filter-menu.test.tsx +175 -0
- package/src/__tests__/gauge-ring.test.tsx +233 -0
- package/src/__tests__/helpers/geometry.ts +58 -0
- package/src/__tests__/layer-stack.test.tsx +228 -0
- package/src/__tests__/line-chart.test.tsx +612 -0
- package/src/__tests__/popover.test.tsx +460 -0
- package/src/__tests__/ranked-bars.test.tsx +343 -0
- package/src/__tests__/remark-underline.test.ts +194 -0
- package/src/__tests__/select.test.tsx +543 -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/__tests__/tooltip.test.tsx +355 -0
- package/src/avatar.tsx +352 -0
- package/src/bar-chart.tsx +1214 -0
- package/src/board.tsx +658 -0
- package/src/calculator-shell-v2.tsx +19 -39
- 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/combobox.tsx +796 -0
- package/src/delta-chip.tsx +263 -0
- package/src/detail/__tests__/activity-pane.test.tsx +186 -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 +507 -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 +242 -0
- package/src/donut-chart.tsx +988 -0
- package/src/dropdown-menu.tsx +412 -151
- 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 +402 -0
- package/src/kbd.tsx +27 -4
- package/src/lib/anchor.ts +427 -0
- package/src/lib/chart.ts +866 -0
- package/src/lib/focus.ts +74 -0
- package/src/lib/layer-stack.ts +215 -0
- package/src/lib/refs.ts +31 -0
- 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/metric-card.tsx +57 -22
- package/src/modal.tsx +73 -66
- package/src/popover.tsx +407 -0
- package/src/progress-ledger.tsx +304 -0
- package/src/ranked-bars.tsx +386 -0
- package/src/segmented-pill.tsx +32 -9
- package/src/select.tsx +646 -0
- package/src/sortable.tsx +315 -1
- package/src/sparkline.tsx +416 -0
- package/src/stat-card.tsx +376 -0
- package/src/stat-row.tsx +108 -70
- package/src/stat-strip.tsx +327 -0
- package/src/status.tsx +215 -0
- package/src/styles.css +1586 -2
- 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 +235 -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 +662 -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 +915 -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 +218 -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 +121 -0
- package/src/tooltip.tsx +297 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# filter-core — export wiring
|
|
2
|
+
|
|
3
|
+
`packages/ui/src/filter-core/**` is self-contained and currently reachable
|
|
4
|
+
only by its own path. Nothing outside this directory was edited.
|
|
5
|
+
|
|
6
|
+
## The one line to add
|
|
7
|
+
|
|
8
|
+
Append to `packages/ui/src/index.ts`:
|
|
9
|
+
|
|
10
|
+
```ts
|
|
11
|
+
// Board filter model — pure TypeScript, no React. See src/filter-core/index.ts.
|
|
12
|
+
export * from './filter-core'
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
`filter-core/index.ts` is an explicit barrel (no wildcard re-exports of its
|
|
16
|
+
own), so `export *` here surfaces exactly the list below and nothing else.
|
|
17
|
+
Every type is exported with `export type`, so `verbatimModuleSyntax` is
|
|
18
|
+
satisfied and none of it survives into the JS bundle.
|
|
19
|
+
|
|
20
|
+
### If you would rather namespace it
|
|
21
|
+
|
|
22
|
+
The alternative, if `@lovett/ui`'s top level should stay component-only:
|
|
23
|
+
|
|
24
|
+
```json
|
|
25
|
+
// packages/ui/package.json — "exports"
|
|
26
|
+
"./filter-core": "./src/filter-core/index.ts"
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
…plus the matching `publishConfig.exports` entry and a second `tsup` entry
|
|
30
|
+
(`entry: { index: 'src/index.ts', 'filter-core': 'src/filter-core/index.ts' }`).
|
|
31
|
+
That is three edits instead of one; the flat `export *` is the cheaper default
|
|
32
|
+
and there are no name collisions (checked against the current `src/index.ts`).
|
|
33
|
+
|
|
34
|
+
## What that line surfaces
|
|
35
|
+
|
|
36
|
+
**Values**
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
COLUMN_DATA_TYPES
|
|
40
|
+
DEFAULT_OPERATORS, FILTER_OPERATOR_DETAILS
|
|
41
|
+
textFilterOperators, numberFilterOperators, dateFilterOperators,
|
|
42
|
+
optionFilterOperators, multiOptionFilterOperators
|
|
43
|
+
operatorsForType, operatorDetails, operatorValueArity, relatedOperators,
|
|
44
|
+
isOperatorOf, negateOperator, determineNewOperator
|
|
45
|
+
defineColumns, indexColumns, isColumnOfType
|
|
46
|
+
textFilterFn, numberFilterFn, dateFilterFn, optionFilterFn, multiOptionFilterFn
|
|
47
|
+
rowMatchesFilter, rowMatchesFilters, pruneFilters, filterRows
|
|
48
|
+
facetedCounts, facetedRowsFor, facetedMinMax
|
|
49
|
+
getFilter, upsertFilter, removeFilter, clearFilters, setFilterOperator
|
|
50
|
+
setTextFilter, setNumberFilter, setDateFilter, setOptionFilter,
|
|
51
|
+
setMultiOptionFilter, toggleOptionValue, toggleMultiOptionValue
|
|
52
|
+
FILTERS_SEARCH_PARAM, serializeFilters, parseFilters
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
**Types**
|
|
56
|
+
|
|
57
|
+
```
|
|
58
|
+
Nullable, ElementType
|
|
59
|
+
ColumnDataType, OptionBasedColumnDataType, ColumnDataNativeMap, ColumnValueMap
|
|
60
|
+
ColumnOption, ColumnAccessor, ColumnConfig, AnyColumnConfig
|
|
61
|
+
FilterValues, FilterModel, FiltersState
|
|
62
|
+
FilterOperators, FilterOperatorTarget, FilterOperatorArity
|
|
63
|
+
FilterOperatorDetails, FilterOperatorDetailsShape, FilterDetails,
|
|
64
|
+
FilterTypeOperatorDetails
|
|
65
|
+
TextFilterOperator, NumberFilterOperator, DateFilterOperator,
|
|
66
|
+
OptionFilterOperator, MultiOptionFilterOperator
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## What it deliberately does NOT surface
|
|
70
|
+
|
|
71
|
+
`./examples/task-filter-columns` — the Tasks-board column set is illustration,
|
|
72
|
+
not API. The lens declares its own columns over its own row type. Import it
|
|
73
|
+
only from a test or while reading.
|
|
74
|
+
|
|
75
|
+
## Notes for whoever wires this
|
|
76
|
+
|
|
77
|
+
- **No new dependency.** Nothing was added to `package.json` and nothing was
|
|
78
|
+
installed. `date-fns` (four helpers) is inlined in `date-utils.ts`.
|
|
79
|
+
- **`zod` was asked for and is NOT used**, because zod is not a dependency of
|
|
80
|
+
`@lovett/ui` and adding one to the design system is an ADR-level call plus a
|
|
81
|
+
`package.json` edit — outside this change's scope. `url.ts` hand-validates to
|
|
82
|
+
the same standard (every field, every bound, fresh object literals) and is
|
|
83
|
+
funnelled through a single `parseEntry` so a zod schema can replace it in one
|
|
84
|
+
place if you want it later.
|
|
85
|
+
- **CSS: none.** Nothing to add to `styles.css`.
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Config-time validation.
|
|
3
|
+
*
|
|
4
|
+
* BUG 2, the other half: a column type with no predicate must FAIL LOUDLY
|
|
5
|
+
* where the columns are declared. In the source, declaring a date column was
|
|
6
|
+
* accepted and then silently filtered to everything.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, expect, it } from 'vitest'
|
|
9
|
+
import { defineColumns, indexColumns, isColumnOfType } from '../index'
|
|
10
|
+
import type { AnyColumnConfig } from '../index'
|
|
11
|
+
|
|
12
|
+
interface Row {
|
|
13
|
+
readonly id: string
|
|
14
|
+
readonly name: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Build a config the type system would reject, to exercise the RUNTIME gate.
|
|
19
|
+
* The double assertion is confined to this helper and is the only way to
|
|
20
|
+
* reach a validator whose whole job is catching what types cannot.
|
|
21
|
+
*/
|
|
22
|
+
function illegal(config: unknown): AnyColumnConfig<Row> {
|
|
23
|
+
return config as AnyColumnConfig<Row>
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const ok = { id: 'name', displayName: 'Name', accessor: (r: Row) => r.name }
|
|
27
|
+
|
|
28
|
+
describe('defineColumns', () => {
|
|
29
|
+
it('returns the columns it was given', () => {
|
|
30
|
+
const columns = defineColumns<Row>([{ ...ok, type: 'text' }])
|
|
31
|
+
expect(columns).toHaveLength(1)
|
|
32
|
+
expect(columns[0]?.id).toBe('name')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('throws on an unsupported column type', () => {
|
|
36
|
+
expect(() =>
|
|
37
|
+
defineColumns<Row>([illegal({ ...ok, type: 'boolean' })]),
|
|
38
|
+
).toThrow(/unsupported type "boolean"/)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('throws on a missing type', () => {
|
|
42
|
+
expect(() => defineColumns<Row>([illegal({ ...ok })])).toThrow(
|
|
43
|
+
/unsupported type/,
|
|
44
|
+
)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('throws on a blank id', () => {
|
|
48
|
+
expect(() =>
|
|
49
|
+
defineColumns<Row>([illegal({ ...ok, id: ' ', type: 'text' })]),
|
|
50
|
+
).toThrow(/non-empty string id/)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('throws on a reserved id', () => {
|
|
54
|
+
expect(() =>
|
|
55
|
+
defineColumns<Row>([illegal({ ...ok, id: '__proto__', type: 'text' })]),
|
|
56
|
+
).toThrow(/reserved/)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('throws on duplicate ids', () => {
|
|
60
|
+
expect(() =>
|
|
61
|
+
defineColumns<Row>([
|
|
62
|
+
{ ...ok, type: 'text' },
|
|
63
|
+
{ ...ok, type: 'text' },
|
|
64
|
+
]),
|
|
65
|
+
).toThrow(/duplicate column id/)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('throws on a missing accessor or displayName', () => {
|
|
69
|
+
expect(() =>
|
|
70
|
+
defineColumns<Row>([illegal({ id: 'x', displayName: 'X', type: 'text' })]),
|
|
71
|
+
).toThrow(/accessor function/)
|
|
72
|
+
expect(() =>
|
|
73
|
+
defineColumns<Row>([illegal({ ...ok, displayName: '', type: 'text' })]),
|
|
74
|
+
).toThrow(/displayName/)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('throws on a malformed option', () => {
|
|
78
|
+
expect(() =>
|
|
79
|
+
defineColumns<Row>([
|
|
80
|
+
illegal({ ...ok, type: 'option', options: [{ label: 'No value' }] }),
|
|
81
|
+
]),
|
|
82
|
+
).toThrow(/malformed option/)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('throws on duplicate option values', () => {
|
|
86
|
+
expect(() =>
|
|
87
|
+
defineColumns<Row>([
|
|
88
|
+
{
|
|
89
|
+
...ok,
|
|
90
|
+
type: 'option',
|
|
91
|
+
options: [
|
|
92
|
+
{ value: 'a', label: 'A' },
|
|
93
|
+
{ value: 'a', label: 'Also A' },
|
|
94
|
+
],
|
|
95
|
+
},
|
|
96
|
+
]),
|
|
97
|
+
).toThrow(/duplicate option value/)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('throws when a number column has min above max', () => {
|
|
101
|
+
expect(() =>
|
|
102
|
+
defineColumns<Row>([
|
|
103
|
+
illegal({ ...ok, type: 'number', min: 10, max: 1 }),
|
|
104
|
+
]),
|
|
105
|
+
).toThrow(/min greater than max/)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('accepts every supported type', () => {
|
|
109
|
+
const columns = defineColumns<Row>([
|
|
110
|
+
{ id: 'a', displayName: 'A', type: 'text', accessor: (r) => r.name },
|
|
111
|
+
{ id: 'b', displayName: 'B', type: 'number', accessor: () => 1, min: 0, max: 5 },
|
|
112
|
+
{ id: 'c', displayName: 'C', type: 'date', accessor: () => new Date() },
|
|
113
|
+
{
|
|
114
|
+
id: 'd',
|
|
115
|
+
displayName: 'D',
|
|
116
|
+
type: 'option',
|
|
117
|
+
accessor: (r) => r.name,
|
|
118
|
+
options: [{ value: 'x', label: 'X' }],
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
id: 'e',
|
|
122
|
+
displayName: 'E',
|
|
123
|
+
type: 'multiOption',
|
|
124
|
+
accessor: () => ['x'],
|
|
125
|
+
options: [{ value: 'x', label: 'X' }],
|
|
126
|
+
},
|
|
127
|
+
])
|
|
128
|
+
expect(columns).toHaveLength(5)
|
|
129
|
+
})
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
describe('indexColumns / isColumnOfType', () => {
|
|
133
|
+
const columns = defineColumns<Row>([
|
|
134
|
+
{ id: 'a', displayName: 'A', type: 'text', accessor: (r) => r.name },
|
|
135
|
+
{
|
|
136
|
+
id: 'b',
|
|
137
|
+
displayName: 'B',
|
|
138
|
+
type: 'option',
|
|
139
|
+
accessor: (r) => r.name,
|
|
140
|
+
options: [{ value: 'x', label: 'X' }],
|
|
141
|
+
},
|
|
142
|
+
])
|
|
143
|
+
|
|
144
|
+
it('indexes by id', () => {
|
|
145
|
+
const index = indexColumns(columns)
|
|
146
|
+
expect(index.get('a')?.type).toBe('text')
|
|
147
|
+
expect(index.get('missing')).toBeUndefined()
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('narrows on the discriminant', () => {
|
|
151
|
+
const column = columns[1]
|
|
152
|
+
if (!column) throw new Error('fixture missing')
|
|
153
|
+
expect(isColumnOfType(column, 'option')).toBe(true)
|
|
154
|
+
expect(isColumnOfType(column, 'text')).toBe(false)
|
|
155
|
+
if (isColumnOfType(column, 'option')) {
|
|
156
|
+
expect(column.options?.[0]?.value).toBe('x')
|
|
157
|
+
}
|
|
158
|
+
})
|
|
159
|
+
})
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Faceted counts.
|
|
3
|
+
*
|
|
4
|
+
* BUG 3 — the source computed counts inside a `useMemo` with an EMPTY
|
|
5
|
+
* dependency array (filter-value.tsx:481-489), so they froze at popover
|
|
6
|
+
* mount and disagreed with the board from the first filter onwards. These
|
|
7
|
+
* are pure functions of the rows passed in, so the same call with different
|
|
8
|
+
* rows must give different answers.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, expect, it } from 'vitest'
|
|
11
|
+
import {
|
|
12
|
+
defineColumns,
|
|
13
|
+
facetedCounts,
|
|
14
|
+
facetedMinMax,
|
|
15
|
+
facetedRowsFor,
|
|
16
|
+
filterRows,
|
|
17
|
+
} from '../index'
|
|
18
|
+
import type { AnyColumnConfig, FiltersState } from '../index'
|
|
19
|
+
|
|
20
|
+
interface Task {
|
|
21
|
+
readonly id: string
|
|
22
|
+
readonly priority: string | null
|
|
23
|
+
readonly labels: readonly string[]
|
|
24
|
+
readonly points: number | null
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const columns = defineColumns<Task>([
|
|
28
|
+
{
|
|
29
|
+
id: 'priority',
|
|
30
|
+
type: 'option',
|
|
31
|
+
displayName: 'Priority',
|
|
32
|
+
accessor: (t) => t.priority,
|
|
33
|
+
options: [
|
|
34
|
+
{ value: 'low', label: 'Low' },
|
|
35
|
+
{ value: 'medium', label: 'Medium' },
|
|
36
|
+
{ value: 'high', label: 'High' },
|
|
37
|
+
],
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
id: 'labels',
|
|
41
|
+
type: 'multiOption',
|
|
42
|
+
displayName: 'Labels',
|
|
43
|
+
accessor: (t) => t.labels,
|
|
44
|
+
options: [
|
|
45
|
+
{ value: 'bug', label: 'Bug' },
|
|
46
|
+
{ value: 'ui', label: 'UI' },
|
|
47
|
+
{ value: 'perf', label: 'Perf' },
|
|
48
|
+
],
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
id: 'points',
|
|
52
|
+
type: 'number',
|
|
53
|
+
displayName: 'Points',
|
|
54
|
+
accessor: (t) => t.points,
|
|
55
|
+
},
|
|
56
|
+
])
|
|
57
|
+
|
|
58
|
+
function columnById(id: string): AnyColumnConfig<Task> {
|
|
59
|
+
const column = columns.find((candidate) => candidate.id === id)
|
|
60
|
+
if (!column) throw new Error(`no column ${id}`)
|
|
61
|
+
return column
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const rows: Task[] = [
|
|
65
|
+
{ id: 'a', priority: 'high', labels: ['bug'], points: 3 },
|
|
66
|
+
{ id: 'b', priority: 'high', labels: ['ui', 'bug'], points: 8 },
|
|
67
|
+
{ id: 'c', priority: 'low', labels: [], points: null },
|
|
68
|
+
{ id: 'd', priority: null, labels: ['ui'], points: 5 },
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
describe('facetedCounts', () => {
|
|
72
|
+
it('counts option values', () => {
|
|
73
|
+
const counts = facetedCounts(rows, columnById('priority'))
|
|
74
|
+
expect(counts.get('high')).toBe(2)
|
|
75
|
+
expect(counts.get('low')).toBe(1)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('keeps declared options with no rows as zero buckets', () => {
|
|
79
|
+
const counts = facetedCounts(rows, columnById('priority'))
|
|
80
|
+
expect(counts.has('medium')).toBe(true)
|
|
81
|
+
expect(counts.get('medium')).toBe(0)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('lists declared options first, in declaration order', () => {
|
|
85
|
+
const counts = facetedCounts(rows, columnById('priority'))
|
|
86
|
+
expect([...counts.keys()]).toEqual(['low', 'medium', 'high'])
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('ignores rows with no value rather than inventing a bucket', () => {
|
|
90
|
+
const counts = facetedCounts(rows, columnById('priority'))
|
|
91
|
+
const total = [...counts.values()].reduce((sum, n) => sum + n, 0)
|
|
92
|
+
expect(total).toBe(3)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('counts each member of a multiOption column', () => {
|
|
96
|
+
const counts = facetedCounts(rows, columnById('labels'))
|
|
97
|
+
expect(counts.get('bug')).toBe(2)
|
|
98
|
+
expect(counts.get('ui')).toBe(2)
|
|
99
|
+
expect(counts.get('perf')).toBe(0)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('counts a row once per distinct value, not once per occurrence', () => {
|
|
103
|
+
const duplicated: Task[] = [
|
|
104
|
+
{ id: 'x', priority: null, labels: ['bug', 'bug'], points: null },
|
|
105
|
+
]
|
|
106
|
+
expect(facetedCounts(duplicated, columnById('labels')).get('bug')).toBe(1)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('appends an undeclared value found in the data', () => {
|
|
110
|
+
const extra: Task[] = [
|
|
111
|
+
{ id: 'x', priority: 'urgent', labels: [], points: null },
|
|
112
|
+
]
|
|
113
|
+
const counts = facetedCounts(extra, columnById('priority'))
|
|
114
|
+
expect([...counts.keys()]).toEqual(['low', 'medium', 'high', 'urgent'])
|
|
115
|
+
expect(counts.get('urgent')).toBe(1)
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('throws for a column type with no facets', () => {
|
|
119
|
+
expect(() => facetedCounts(rows, columnById('points'))).toThrow(
|
|
120
|
+
/only defined for option and multiOption/,
|
|
121
|
+
)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
// BUG 3
|
|
125
|
+
it('recomputes from the rows it is given, so counts cannot go stale', () => {
|
|
126
|
+
const priority = columnById('priority')
|
|
127
|
+
const before = facetedCounts(rows, priority)
|
|
128
|
+
expect(before.get('high')).toBe(2)
|
|
129
|
+
|
|
130
|
+
const narrowed = filterRows(rows, columns, [
|
|
131
|
+
{ columnId: 'labels', type: 'multiOption', operator: 'include', values: ['ui'] },
|
|
132
|
+
])
|
|
133
|
+
const after = facetedCounts(narrowed, priority)
|
|
134
|
+
|
|
135
|
+
expect(after.get('high')).toBe(1)
|
|
136
|
+
// And the first result was not mutated by the second call.
|
|
137
|
+
expect(before.get('high')).toBe(2)
|
|
138
|
+
})
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
describe('facetedRowsFor', () => {
|
|
142
|
+
const state: FiltersState = [
|
|
143
|
+
{ columnId: 'priority', type: 'option', operator: 'is', values: ['high'] },
|
|
144
|
+
{ columnId: 'labels', type: 'multiOption', operator: 'include', values: ['ui'] },
|
|
145
|
+
]
|
|
146
|
+
|
|
147
|
+
it('excludes the column its own facet list is for', () => {
|
|
148
|
+
// Without this, picking "high" would zero every other priority bucket and
|
|
149
|
+
// the list could never be widened.
|
|
150
|
+
const forPriority = facetedRowsFor(rows, columns, state, 'priority')
|
|
151
|
+
expect(forPriority.map((t) => t.id)).toEqual(['b', 'd'])
|
|
152
|
+
const counts = facetedCounts(forPriority, columnById('priority'))
|
|
153
|
+
expect(counts.get('high')).toBe(1)
|
|
154
|
+
expect(counts.get('low')).toBe(0)
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('still applies every other column filter', () => {
|
|
158
|
+
const forLabels = facetedRowsFor(rows, columns, state, 'labels')
|
|
159
|
+
expect(forLabels.map((t) => t.id)).toEqual(['a', 'b'])
|
|
160
|
+
})
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
describe('facetedMinMax', () => {
|
|
164
|
+
it('reports the observed range', () => {
|
|
165
|
+
expect(facetedMinMax(rows, columnById('points'))).toEqual([3, 8])
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('returns null when no row has a value', () => {
|
|
169
|
+
expect(facetedMinMax([], columnById('points'))).toBeNull()
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it('prefers declared bounds over observed ones', () => {
|
|
173
|
+
const bounded = defineColumns<Task>([
|
|
174
|
+
{
|
|
175
|
+
id: 'points',
|
|
176
|
+
type: 'number',
|
|
177
|
+
displayName: 'Points',
|
|
178
|
+
accessor: (t) => t.points,
|
|
179
|
+
min: 0,
|
|
180
|
+
max: 100,
|
|
181
|
+
},
|
|
182
|
+
])
|
|
183
|
+
const column = bounded[0]
|
|
184
|
+
if (!column) throw new Error('fixture missing')
|
|
185
|
+
expect(facetedMinMax(rows, column)).toEqual([0, 100])
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it('throws for a non-number column', () => {
|
|
189
|
+
expect(() => facetedMinMax(rows, columnById('priority'))).toThrow(
|
|
190
|
+
/only defined for number columns/,
|
|
191
|
+
)
|
|
192
|
+
})
|
|
193
|
+
})
|