@prairielearn/ui 1.1.2 → 1.3.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/CHANGELOG.md +20 -0
- package/dist/components/CategoricalColumnFilter.d.ts.map +1 -1
- package/dist/components/CategoricalColumnFilter.js +13 -5
- package/dist/components/CategoricalColumnFilter.js.map +1 -1
- package/dist/components/ColumnManager.d.ts +2 -1
- package/dist/components/ColumnManager.d.ts.map +1 -1
- package/dist/components/ColumnManager.js +13 -28
- package/dist/components/ColumnManager.js.map +1 -1
- package/dist/components/MultiSelectColumnFilter.d.ts +25 -0
- package/dist/components/MultiSelectColumnFilter.d.ts.map +1 -0
- package/dist/components/MultiSelectColumnFilter.js +41 -0
- package/dist/components/MultiSelectColumnFilter.js.map +1 -0
- package/dist/components/NumericInputColumnFilter.d.ts +42 -0
- package/dist/components/NumericInputColumnFilter.d.ts.map +1 -0
- package/dist/components/NumericInputColumnFilter.js +79 -0
- package/dist/components/NumericInputColumnFilter.js.map +1 -0
- package/dist/components/TanstackTable.css +49 -0
- package/dist/components/TanstackTable.d.ts +8 -1
- package/dist/components/TanstackTable.d.ts.map +1 -1
- package/dist/components/TanstackTable.js +78 -46
- package/dist/components/TanstackTable.js.map +1 -1
- package/dist/components/TanstackTableDownloadButton.d.ts.map +1 -1
- package/dist/components/TanstackTableDownloadButton.js +3 -1
- package/dist/components/TanstackTableDownloadButton.js.map +1 -1
- package/dist/components/useShiftClickCheckbox.d.ts +26 -0
- package/dist/components/useShiftClickCheckbox.d.ts.map +1 -0
- package/dist/components/useShiftClickCheckbox.js +59 -0
- package/dist/components/useShiftClickCheckbox.js.map +1 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/package.json +6 -4
- package/src/components/CategoricalColumnFilter.tsx +57 -27
- package/src/components/ColumnManager.tsx +32 -57
- package/src/components/MultiSelectColumnFilter.tsx +103 -0
- package/src/components/NumericInputColumnFilter.test.ts +102 -0
- package/src/components/NumericInputColumnFilter.tsx +153 -0
- package/src/components/TanstackTable.css +49 -0
- package/src/components/TanstackTable.tsx +193 -116
- package/src/components/TanstackTableDownloadButton.tsx +27 -1
- package/src/components/useShiftClickCheckbox.tsx +67 -0
- package/src/index.ts +12 -1
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
export { TanstackTable, TanstackTableCard } from './components/TanstackTable.js';
|
|
1
|
+
export { TanstackTable, TanstackTableCard, TanstackTableEmptyState, } from './components/TanstackTable.js';
|
|
2
2
|
export { ColumnManager } from './components/ColumnManager.js';
|
|
3
3
|
export { TanstackTableDownloadButton } from './components/TanstackTableDownloadButton.js';
|
|
4
4
|
export { CategoricalColumnFilter } from './components/CategoricalColumnFilter.js';
|
|
5
|
+
export { MultiSelectColumnFilter } from './components/MultiSelectColumnFilter.js';
|
|
6
|
+
export { NumericInputColumnFilter, parseNumericFilter, numericColumnFilterFn, } from './components/NumericInputColumnFilter.js';
|
|
7
|
+
export { useShiftClickCheckbox } from './components/useShiftClickCheckbox.js';
|
|
5
8
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,aAAa,EACb,iBAAiB,EACjB,uBAAuB,GACxB,MAAM,+BAA+B,CAAC;AACvC,OAAO,EAAE,aAAa,EAAE,MAAM,+BAA+B,CAAC;AAC9D,OAAO,EAAE,2BAA2B,EAAE,MAAM,6CAA6C,CAAC;AAC1F,OAAO,EAAE,uBAAuB,EAAE,MAAM,yCAAyC,CAAC;AAClF,OAAO,EAAE,uBAAuB,EAAE,MAAM,yCAAyC,CAAC;AAClF,OAAO,EACL,wBAAwB,EACxB,kBAAkB,EAClB,qBAAqB,GACtB,MAAM,0CAA0C,CAAC;AAClD,OAAO,EAAE,qBAAqB,EAAE,MAAM,uCAAuC,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
export { TanstackTable, TanstackTableCard } from './components/TanstackTable.js';
|
|
1
|
+
export { TanstackTable, TanstackTableCard, TanstackTableEmptyState, } from './components/TanstackTable.js';
|
|
2
2
|
export { ColumnManager } from './components/ColumnManager.js';
|
|
3
3
|
export { TanstackTableDownloadButton } from './components/TanstackTableDownloadButton.js';
|
|
4
4
|
export { CategoricalColumnFilter } from './components/CategoricalColumnFilter.js';
|
|
5
|
+
export { MultiSelectColumnFilter } from './components/MultiSelectColumnFilter.js';
|
|
6
|
+
export { NumericInputColumnFilter, parseNumericFilter, numericColumnFilterFn, } from './components/NumericInputColumnFilter.js';
|
|
7
|
+
export { useShiftClickCheckbox } from './components/useShiftClickCheckbox.js';
|
|
5
8
|
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,aAAa,EACb,iBAAiB,EACjB,uBAAuB,GACxB,MAAM,+BAA+B,CAAC;AACvC,OAAO,EAAE,aAAa,EAAE,MAAM,+BAA+B,CAAC;AAC9D,OAAO,EAAE,2BAA2B,EAAE,MAAM,6CAA6C,CAAC;AAC1F,OAAO,EAAE,uBAAuB,EAAE,MAAM,yCAAyC,CAAC;AAClF,OAAO,EAAE,uBAAuB,EAAE,MAAM,yCAAyC,CAAC;AAClF,OAAO,EACL,wBAAwB,EACxB,kBAAkB,EAClB,qBAAqB,GACtB,MAAM,0CAA0C,CAAC;AAClD,OAAO,EAAE,qBAAqB,EAAE,MAAM,uCAAuC,CAAC","sourcesContent":["export {\n TanstackTable,\n TanstackTableCard,\n TanstackTableEmptyState,\n} from './components/TanstackTable.js';\nexport { ColumnManager } from './components/ColumnManager.js';\nexport { TanstackTableDownloadButton } from './components/TanstackTableDownloadButton.js';\nexport { CategoricalColumnFilter } from './components/CategoricalColumnFilter.js';\nexport { MultiSelectColumnFilter } from './components/MultiSelectColumnFilter.js';\nexport {\n NumericInputColumnFilter,\n parseNumericFilter,\n numericColumnFilterFn,\n} from './components/NumericInputColumnFilter.js';\nexport { useShiftClickCheckbox } from './components/useShiftClickCheckbox.js';\n"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@prairielearn/ui",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
},
|
|
14
14
|
"scripts": {
|
|
15
15
|
"build": "tsc && tscp",
|
|
16
|
-
"dev": "tsc --watch --preserveWatchOutput & tscp --watch"
|
|
16
|
+
"dev": "tsc --watch --preserveWatchOutput & tscp --watch",
|
|
17
|
+
"test": "vitest run --coverage"
|
|
17
18
|
},
|
|
18
19
|
"dependencies": {
|
|
19
20
|
"@prairielearn/browser-utils": "^2.5.1",
|
|
@@ -26,8 +27,9 @@
|
|
|
26
27
|
},
|
|
27
28
|
"devDependencies": {
|
|
28
29
|
"@prairielearn/tsconfig": "^0.0.0",
|
|
29
|
-
"@types/node": "^22.
|
|
30
|
+
"@types/node": "^22.19.0",
|
|
30
31
|
"typescript": "^5.9.3",
|
|
31
|
-
"typescript-cp": "^0.1.9"
|
|
32
|
+
"typescript-cp": "^0.1.9",
|
|
33
|
+
"vitest": "^4.0.7"
|
|
32
34
|
}
|
|
33
35
|
}
|
|
@@ -70,7 +70,7 @@ export function CategoricalColumnFilter<T extends readonly any[]>({
|
|
|
70
70
|
<Dropdown align="end">
|
|
71
71
|
<Dropdown.Toggle
|
|
72
72
|
variant="link"
|
|
73
|
-
class="text-muted p-0
|
|
73
|
+
class="text-muted p-0"
|
|
74
74
|
id={`filter-${columnId}`}
|
|
75
75
|
aria-label={`Filter ${columnLabel.toLowerCase()}`}
|
|
76
76
|
title={`Filter ${columnLabel.toLowerCase()}`}
|
|
@@ -81,40 +81,70 @@ export function CategoricalColumnFilter<T extends readonly any[]>({
|
|
|
81
81
|
/>
|
|
82
82
|
</Dropdown.Toggle>
|
|
83
83
|
<Dropdown.Menu class="p-0">
|
|
84
|
-
<div class="p-3">
|
|
84
|
+
<div class="p-3 pb-0">
|
|
85
85
|
<div class="d-flex align-items-center justify-content-between mb-2">
|
|
86
86
|
<div class="fw-semibold">{columnLabel}</div>
|
|
87
87
|
<button
|
|
88
88
|
type="button"
|
|
89
|
-
class=
|
|
90
|
-
|
|
89
|
+
class={clsx('btn btn-link btn-sm text-decoration-none', {
|
|
90
|
+
// Hide the clear button if no filters are applied.
|
|
91
|
+
// Use `visibility` instead of conditional rendering to avoid layout shift.
|
|
92
|
+
invisible: selected.size === 0 && mode === 'include',
|
|
93
|
+
})}
|
|
94
|
+
onClick={() => apply('include', new Set())}
|
|
91
95
|
>
|
|
92
96
|
Clear
|
|
93
97
|
</button>
|
|
94
98
|
</div>
|
|
95
99
|
|
|
96
|
-
<div class="btn-group w-100 mb-2"
|
|
97
|
-
<
|
|
98
|
-
type="
|
|
99
|
-
class=
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
100
|
+
<div class="btn-group btn-group-sm w-100 mb-2">
|
|
101
|
+
<input
|
|
102
|
+
type="radio"
|
|
103
|
+
class="btn-check"
|
|
104
|
+
name={`filter-${columnId}-options`}
|
|
105
|
+
id={`filter-${columnId}-include`}
|
|
106
|
+
autocomplete="off"
|
|
107
|
+
checked={mode === 'include'}
|
|
108
|
+
onChange={() => apply('include', selected)}
|
|
109
|
+
/>
|
|
110
|
+
<label class="btn btn-outline-primary" for={`filter-${columnId}-include`}>
|
|
111
|
+
<span class="text-nowrap">
|
|
112
|
+
{mode === 'include' && <i class="bi bi-check-lg me-1" aria-hidden="true" />}
|
|
113
|
+
Include
|
|
114
|
+
</span>
|
|
115
|
+
</label>
|
|
116
|
+
|
|
117
|
+
<input
|
|
118
|
+
type="radio"
|
|
119
|
+
class="btn-check"
|
|
120
|
+
name={`filter-${columnId}-options`}
|
|
121
|
+
id={`filter-${columnId}-exclude`}
|
|
122
|
+
autocomplete="off"
|
|
123
|
+
checked={mode === 'exclude'}
|
|
124
|
+
onChange={() => apply('exclude', selected)}
|
|
125
|
+
/>
|
|
126
|
+
<label class="btn btn-outline-primary" for={`filter-${columnId}-exclude`}>
|
|
127
|
+
<span class="text-nowrap">
|
|
128
|
+
{mode === 'exclude' && <i class="bi bi-check-lg me-1" aria-hidden="true" />}
|
|
129
|
+
Exclude
|
|
130
|
+
</span>
|
|
131
|
+
</label>
|
|
111
132
|
</div>
|
|
133
|
+
</div>
|
|
112
134
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
135
|
+
<div
|
|
136
|
+
class="list-group list-group-flush"
|
|
137
|
+
style={{
|
|
138
|
+
// This is needed to prevent the last item's background from covering
|
|
139
|
+
// the dropdown's border radius.
|
|
140
|
+
'--bs-list-group-bg': 'transparent',
|
|
141
|
+
}}
|
|
142
|
+
>
|
|
143
|
+
{allColumnValues.map((value) => {
|
|
144
|
+
const isSelected = selected.has(value);
|
|
145
|
+
return (
|
|
146
|
+
<div key={value} class="list-group-item d-flex align-items-center gap-3">
|
|
147
|
+
<div class="form-check">
|
|
118
148
|
<input
|
|
119
149
|
class="form-check-input"
|
|
120
150
|
type="checkbox"
|
|
@@ -129,9 +159,9 @@ export function CategoricalColumnFilter<T extends readonly any[]>({
|
|
|
129
159
|
})}
|
|
130
160
|
</label>
|
|
131
161
|
</div>
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
162
|
+
</div>
|
|
163
|
+
);
|
|
164
|
+
})}
|
|
135
165
|
</div>
|
|
136
166
|
</Dropdown.Menu>
|
|
137
167
|
</Dropdown>
|
|
@@ -2,59 +2,43 @@ import { type Column, type Table } from '@tanstack/react-table';
|
|
|
2
2
|
import { useEffect, useRef, useState } from 'preact/compat';
|
|
3
3
|
import Button from 'react-bootstrap/Button';
|
|
4
4
|
import Dropdown from 'react-bootstrap/Dropdown';
|
|
5
|
-
import OverlayTrigger from 'react-bootstrap/OverlayTrigger';
|
|
6
|
-
import Tooltip from 'react-bootstrap/Tooltip';
|
|
7
5
|
|
|
8
6
|
interface ColumnMenuItemProps<RowDataModel> {
|
|
9
7
|
column: Column<RowDataModel>;
|
|
10
8
|
hidePinButton: boolean;
|
|
11
9
|
onTogglePin: (columnId: string) => void;
|
|
12
|
-
onClearElementFocus: () => void;
|
|
13
10
|
}
|
|
14
11
|
|
|
15
12
|
function ColumnMenuItem<RowDataModel>({
|
|
16
13
|
column,
|
|
17
14
|
hidePinButton = false,
|
|
18
15
|
onTogglePin,
|
|
19
|
-
onClearElementFocus,
|
|
20
16
|
}: ColumnMenuItemProps<RowDataModel>) {
|
|
21
|
-
const pinButtonRef = useRef<HTMLButtonElement>(null);
|
|
22
|
-
|
|
23
17
|
if (!column.getCanHide() && !column.getCanPin()) return null;
|
|
24
18
|
|
|
25
|
-
|
|
19
|
+
// Use meta.label if available, otherwise fall back to header or column.id
|
|
20
|
+
const header =
|
|
21
|
+
(column.columnDef.meta as any)?.label ??
|
|
22
|
+
(typeof column.columnDef.header === 'string' ? column.columnDef.header : column.id);
|
|
26
23
|
|
|
27
24
|
return (
|
|
28
|
-
<
|
|
29
|
-
key={column.id}
|
|
30
|
-
as="div"
|
|
31
|
-
class="px-2 py-1 d-flex align-items-center justify-content-between"
|
|
32
|
-
onKeyDown={onClearElementFocus}
|
|
33
|
-
>
|
|
25
|
+
<div key={column.id} class="px-2 py-1 d-flex align-items-center justify-content-between">
|
|
34
26
|
<label class="form-check me-auto text-nowrap d-flex align-items-stretch">
|
|
35
|
-
<
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
aria-label={
|
|
45
|
-
column.getIsVisible() ? `Hide '${header}' column` : `Show '${header}' column`
|
|
46
|
-
}
|
|
47
|
-
aria-describedby={`${column.id}-label`}
|
|
48
|
-
onChange={column.getToggleVisibilityHandler()}
|
|
49
|
-
/>
|
|
50
|
-
</OverlayTrigger>
|
|
27
|
+
<input
|
|
28
|
+
type="checkbox"
|
|
29
|
+
class="form-check-input"
|
|
30
|
+
checked={column.getIsVisible()}
|
|
31
|
+
disabled={!column.getCanHide()}
|
|
32
|
+
aria-label={column.getIsVisible() ? `Hide '${header}' column` : `Show '${header}' column`}
|
|
33
|
+
aria-describedby={`${column.id}-label`}
|
|
34
|
+
onChange={column.getToggleVisibilityHandler()}
|
|
35
|
+
/>
|
|
51
36
|
<span class="form-check-label ms-2" id={`${column.id}-label`}>
|
|
52
37
|
{header}
|
|
53
38
|
</span>
|
|
54
39
|
</label>
|
|
55
40
|
{column.getCanPin() && !hidePinButton && (
|
|
56
41
|
<button
|
|
57
|
-
ref={pinButtonRef}
|
|
58
42
|
type="button"
|
|
59
43
|
// Since the HTML changes, but we want to refocus the pin button, we track
|
|
60
44
|
// the active pin button and refocuses it when the column manager is rerendered.
|
|
@@ -65,33 +49,22 @@ function ColumnMenuItem<RowDataModel>({
|
|
|
65
49
|
}
|
|
66
50
|
title={column.getIsPinned() ? 'Unfreeze column' : 'Freeze column'}
|
|
67
51
|
data-bs-toggle="tooltip"
|
|
68
|
-
|
|
69
|
-
onKeyDown={(e) => {
|
|
70
|
-
if (!pinButtonRef.current) {
|
|
71
|
-
throw new Error('pinButtonRef.current is null');
|
|
72
|
-
}
|
|
73
|
-
if (e.key === 'Enter' || e.key === ' ') {
|
|
74
|
-
e.preventDefault();
|
|
75
|
-
onTogglePin(column.id);
|
|
76
|
-
return;
|
|
77
|
-
}
|
|
78
|
-
}}
|
|
79
|
-
// Instead, use the arrow keys to move between interactive elements in each menu item.
|
|
80
|
-
onClick={() => {
|
|
81
|
-
if (!pinButtonRef.current) {
|
|
82
|
-
throw new Error('pinButtonRef.current is null');
|
|
83
|
-
}
|
|
84
|
-
onTogglePin(column.id);
|
|
85
|
-
}}
|
|
52
|
+
onClick={() => onTogglePin(column.id)}
|
|
86
53
|
>
|
|
87
54
|
<i class={`bi ${column.getIsPinned() ? 'bi-x' : 'bi-snow'}`} aria-hidden="true" />
|
|
88
55
|
</button>
|
|
89
56
|
)}
|
|
90
|
-
</
|
|
57
|
+
</div>
|
|
91
58
|
);
|
|
92
59
|
}
|
|
93
60
|
|
|
94
|
-
export function ColumnManager<RowDataModel>({
|
|
61
|
+
export function ColumnManager<RowDataModel>({
|
|
62
|
+
table,
|
|
63
|
+
id,
|
|
64
|
+
}: {
|
|
65
|
+
table: Table<RowDataModel>;
|
|
66
|
+
id: string;
|
|
67
|
+
}) {
|
|
95
68
|
const [activeElementId, setActiveElementId] = useState<string | null>(null);
|
|
96
69
|
const [dropdownOpen, setDropdownOpen] = useState(false);
|
|
97
70
|
const menuRef = useRef<HTMLDivElement>(null);
|
|
@@ -149,11 +122,15 @@ export function ColumnManager<RowDataModel>({ table }: { table: Table<RowDataMod
|
|
|
149
122
|
}
|
|
150
123
|
}}
|
|
151
124
|
>
|
|
152
|
-
<Dropdown.Toggle
|
|
153
|
-
|
|
154
|
-
|
|
125
|
+
<Dropdown.Toggle
|
|
126
|
+
variant="tanstack-table"
|
|
127
|
+
id={id}
|
|
128
|
+
// eslint-disable-next-line @eslint-react/no-forbidden-props
|
|
129
|
+
className="tanstack-table-focusable-shadow"
|
|
130
|
+
>
|
|
131
|
+
<i class="bi bi-view-list me-2" aria-hidden="true" /> View{' '}
|
|
155
132
|
</Dropdown.Toggle>
|
|
156
|
-
<Dropdown.Menu>
|
|
133
|
+
<Dropdown.Menu style={{ maxHeight: '60vh', overflowY: 'auto' }}>
|
|
157
134
|
{pinnedColumns.length > 0 && (
|
|
158
135
|
<>
|
|
159
136
|
<div class="px-2 py-1 text-muted small" role="presentation">
|
|
@@ -167,7 +144,6 @@ export function ColumnManager<RowDataModel>({ table }: { table: Table<RowDataMod
|
|
|
167
144
|
column={column}
|
|
168
145
|
hidePinButton={index !== pinnedColumns.length - 1}
|
|
169
146
|
onTogglePin={handleTogglePin}
|
|
170
|
-
onClearElementFocus={() => setActiveElementId(null)}
|
|
171
147
|
/>
|
|
172
148
|
);
|
|
173
149
|
})}
|
|
@@ -185,7 +161,6 @@ export function ColumnManager<RowDataModel>({ table }: { table: Table<RowDataMod
|
|
|
185
161
|
column={column}
|
|
186
162
|
hidePinButton={index !== 0}
|
|
187
163
|
onTogglePin={handleTogglePin}
|
|
188
|
-
onClearElementFocus={() => setActiveElementId(null)}
|
|
189
164
|
/>
|
|
190
165
|
);
|
|
191
166
|
})}
|
|
@@ -203,7 +178,7 @@ export function ColumnManager<RowDataModel>({ table }: { table: Table<RowDataMod
|
|
|
203
178
|
onClick={() => {
|
|
204
179
|
table.resetColumnVisibility();
|
|
205
180
|
table.resetColumnPinning();
|
|
206
|
-
setActiveElementId(
|
|
181
|
+
setActiveElementId(id);
|
|
207
182
|
}}
|
|
208
183
|
>
|
|
209
184
|
<i class="bi bi-arrow-counterclockwise me-2" aria-hidden="true" />
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import clsx from 'clsx';
|
|
2
|
+
import { type JSX, useMemo } from 'preact/compat';
|
|
3
|
+
import Dropdown from 'react-bootstrap/Dropdown';
|
|
4
|
+
|
|
5
|
+
function defaultRenderValueLabel<T>({ value }: { value: T }) {
|
|
6
|
+
return <span>{String(value)}</span>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A component that allows the user to filter a column containing arrays of values.
|
|
11
|
+
* Uses AND logic: rows must contain ALL selected values to match.
|
|
12
|
+
*
|
|
13
|
+
* @param params
|
|
14
|
+
* @param params.columnId - The ID of the column
|
|
15
|
+
* @param params.columnLabel - The label of the column, e.g. "Rubric Items"
|
|
16
|
+
* @param params.allColumnValues - All possible values that can appear in the column
|
|
17
|
+
* @param params.renderValueLabel - A function that renders the label for a value
|
|
18
|
+
* @param params.columnValuesFilter - The current state of the column filter
|
|
19
|
+
* @param params.setColumnValuesFilter - A function that sets the state of the column filter
|
|
20
|
+
*/
|
|
21
|
+
export function MultiSelectColumnFilter<T extends readonly any[]>({
|
|
22
|
+
columnId,
|
|
23
|
+
columnLabel,
|
|
24
|
+
allColumnValues,
|
|
25
|
+
renderValueLabel = defaultRenderValueLabel,
|
|
26
|
+
columnValuesFilter,
|
|
27
|
+
setColumnValuesFilter,
|
|
28
|
+
}: {
|
|
29
|
+
columnId: string;
|
|
30
|
+
columnLabel: string;
|
|
31
|
+
allColumnValues: T;
|
|
32
|
+
renderValueLabel?: (props: { value: T[number]; isSelected: boolean }) => JSX.Element;
|
|
33
|
+
columnValuesFilter: T[number][];
|
|
34
|
+
setColumnValuesFilter: (value: T[number][]) => void;
|
|
35
|
+
}) {
|
|
36
|
+
const selected = useMemo(() => new Set(columnValuesFilter), [columnValuesFilter]);
|
|
37
|
+
|
|
38
|
+
const toggleSelected = (value: T[number]) => {
|
|
39
|
+
const set = new Set(selected);
|
|
40
|
+
if (set.has(value)) {
|
|
41
|
+
set.delete(value);
|
|
42
|
+
} else {
|
|
43
|
+
set.add(value);
|
|
44
|
+
}
|
|
45
|
+
setColumnValuesFilter(Array.from(set));
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const hasActiveFilter = selected.size > 0;
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<Dropdown align="end">
|
|
52
|
+
<Dropdown.Toggle
|
|
53
|
+
variant="link"
|
|
54
|
+
class="text-muted p-0"
|
|
55
|
+
id={`filter-${columnId}`}
|
|
56
|
+
aria-label={`Filter ${columnLabel.toLowerCase()}`}
|
|
57
|
+
title={`Filter ${columnLabel.toLowerCase()}`}
|
|
58
|
+
>
|
|
59
|
+
<i
|
|
60
|
+
class={clsx('bi', hasActiveFilter ? ['bi-funnel-fill', 'text-primary'] : 'bi-funnel')}
|
|
61
|
+
aria-hidden="true"
|
|
62
|
+
/>
|
|
63
|
+
</Dropdown.Toggle>
|
|
64
|
+
<Dropdown.Menu class="p-0">
|
|
65
|
+
<div class="p-3" style={{ minWidth: '250px' }}>
|
|
66
|
+
<div class="d-flex align-items-center justify-content-between mb-2">
|
|
67
|
+
<div class="fw-semibold">{columnLabel}</div>
|
|
68
|
+
<button
|
|
69
|
+
type="button"
|
|
70
|
+
class="btn btn-link btn-sm text-decoration-none p-0"
|
|
71
|
+
onClick={() => setColumnValuesFilter([])}
|
|
72
|
+
>
|
|
73
|
+
Clear
|
|
74
|
+
</button>
|
|
75
|
+
</div>
|
|
76
|
+
|
|
77
|
+
<div class="list-group list-group-flush">
|
|
78
|
+
{allColumnValues.map((value) => {
|
|
79
|
+
const isSelected = selected.has(value);
|
|
80
|
+
return (
|
|
81
|
+
<div key={value} class="list-group-item d-flex align-items-center gap-3 px-0">
|
|
82
|
+
<input
|
|
83
|
+
class="form-check-input"
|
|
84
|
+
type="checkbox"
|
|
85
|
+
checked={isSelected}
|
|
86
|
+
id={`${columnId}-${value}`}
|
|
87
|
+
onChange={() => toggleSelected(value)}
|
|
88
|
+
/>
|
|
89
|
+
<label class="form-check-label fw-normal" for={`${columnId}-${value}`}>
|
|
90
|
+
{renderValueLabel({
|
|
91
|
+
value,
|
|
92
|
+
isSelected,
|
|
93
|
+
})}
|
|
94
|
+
</label>
|
|
95
|
+
</div>
|
|
96
|
+
);
|
|
97
|
+
})}
|
|
98
|
+
</div>
|
|
99
|
+
</div>
|
|
100
|
+
</Dropdown.Menu>
|
|
101
|
+
</Dropdown>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { numericColumnFilterFn, parseNumericFilter } from './NumericInputColumnFilter.js';
|
|
4
|
+
|
|
5
|
+
describe('parseNumericFilter', () => {
|
|
6
|
+
it('should parse equals operator', () => {
|
|
7
|
+
expect(parseNumericFilter('5')).toEqual({ operator: '=', value: 5 });
|
|
8
|
+
expect(parseNumericFilter('=5')).toEqual({ operator: '=', value: 5 });
|
|
9
|
+
expect(parseNumericFilter('= 5')).toEqual({ operator: '=', value: 5 });
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('should parse less than operator', () => {
|
|
13
|
+
expect(parseNumericFilter('<5')).toEqual({ operator: '<', value: 5 });
|
|
14
|
+
expect(parseNumericFilter('< 5')).toEqual({ operator: '<', value: 5 });
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('should parse greater than operator', () => {
|
|
18
|
+
expect(parseNumericFilter('>5')).toEqual({ operator: '>', value: 5 });
|
|
19
|
+
expect(parseNumericFilter('> 5')).toEqual({ operator: '>', value: 5 });
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('should parse less than or equal operator', () => {
|
|
23
|
+
expect(parseNumericFilter('<=5')).toEqual({ operator: '<=', value: 5 });
|
|
24
|
+
expect(parseNumericFilter('<= 5')).toEqual({ operator: '<=', value: 5 });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('should parse greater than or equal operator', () => {
|
|
28
|
+
expect(parseNumericFilter('>=5')).toEqual({ operator: '>=', value: 5 });
|
|
29
|
+
expect(parseNumericFilter('>= 5')).toEqual({ operator: '>=', value: 5 });
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('should handle decimals', () => {
|
|
33
|
+
expect(parseNumericFilter('5.5')).toEqual({ operator: '=', value: 5.5 });
|
|
34
|
+
expect(parseNumericFilter('>3.14')).toEqual({ operator: '>', value: 3.14 });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('should handle negative numbers', () => {
|
|
38
|
+
expect(parseNumericFilter('-5')).toEqual({ operator: '=', value: -5 });
|
|
39
|
+
expect(parseNumericFilter('<-3')).toEqual({ operator: '<', value: -3 });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('should return null for invalid input', () => {
|
|
43
|
+
expect(parseNumericFilter('')).toBeNull();
|
|
44
|
+
expect(parseNumericFilter(' ')).toBeNull();
|
|
45
|
+
expect(parseNumericFilter('abc')).toBeNull();
|
|
46
|
+
expect(parseNumericFilter('>>')).toBeNull();
|
|
47
|
+
expect(parseNumericFilter('5.5.5')).toBeNull();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('should handle whitespace', () => {
|
|
51
|
+
expect(parseNumericFilter(' > 5 ')).toEqual({ operator: '>', value: 5 });
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('numericColumnFilterFn', () => {
|
|
56
|
+
const createMockRow = (value: number | null) => ({
|
|
57
|
+
getValue: () => value,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('should filter with equals operator', () => {
|
|
61
|
+
expect(numericColumnFilterFn(createMockRow(5), 'col', '5')).toBe(true);
|
|
62
|
+
expect(numericColumnFilterFn(createMockRow(5), 'col', '=5')).toBe(true);
|
|
63
|
+
expect(numericColumnFilterFn(createMockRow(5), 'col', '4')).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('should filter with less than operator', () => {
|
|
67
|
+
expect(numericColumnFilterFn(createMockRow(3), 'col', '<5')).toBe(true);
|
|
68
|
+
expect(numericColumnFilterFn(createMockRow(5), 'col', '<5')).toBe(false);
|
|
69
|
+
expect(numericColumnFilterFn(createMockRow(7), 'col', '<5')).toBe(false);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('should filter with greater than operator', () => {
|
|
73
|
+
expect(numericColumnFilterFn(createMockRow(7), 'col', '>5')).toBe(true);
|
|
74
|
+
expect(numericColumnFilterFn(createMockRow(5), 'col', '>5')).toBe(false);
|
|
75
|
+
expect(numericColumnFilterFn(createMockRow(3), 'col', '>5')).toBe(false);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('should filter with less than or equal operator', () => {
|
|
79
|
+
expect(numericColumnFilterFn(createMockRow(3), 'col', '<=5')).toBe(true);
|
|
80
|
+
expect(numericColumnFilterFn(createMockRow(5), 'col', '<=5')).toBe(true);
|
|
81
|
+
expect(numericColumnFilterFn(createMockRow(7), 'col', '<=5')).toBe(false);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('should filter with greater than or equal operator', () => {
|
|
85
|
+
expect(numericColumnFilterFn(createMockRow(7), 'col', '>=5')).toBe(true);
|
|
86
|
+
expect(numericColumnFilterFn(createMockRow(5), 'col', '>=5')).toBe(true);
|
|
87
|
+
expect(numericColumnFilterFn(createMockRow(3), 'col', '>=5')).toBe(false);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('should return true for invalid or empty filter', () => {
|
|
91
|
+
expect(numericColumnFilterFn(createMockRow(5), 'col', '')).toBe(true);
|
|
92
|
+
expect(numericColumnFilterFn(createMockRow(5), 'col', 'invalid')).toBe(true);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('should return false for null values when filter is active', () => {
|
|
96
|
+
expect(numericColumnFilterFn(createMockRow(null), 'col', '>5')).toBe(false);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('should return true for null values when filter is empty', () => {
|
|
100
|
+
expect(numericColumnFilterFn(createMockRow(null), 'col', '')).toBe(true);
|
|
101
|
+
});
|
|
102
|
+
});
|