@prairielearn/ui 1.3.0 → 1.5.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 +28 -0
- package/README.md +4 -2
- package/dist/components/CategoricalColumnFilter.d.ts +7 -12
- package/dist/components/CategoricalColumnFilter.d.ts.map +1 -1
- package/dist/components/CategoricalColumnFilter.js +15 -11
- package/dist/components/CategoricalColumnFilter.js.map +1 -1
- package/dist/components/ColumnManager.d.ts +6 -3
- package/dist/components/ColumnManager.d.ts.map +1 -1
- package/dist/components/ColumnManager.js +98 -18
- package/dist/components/ColumnManager.js.map +1 -1
- package/dist/components/MultiSelectColumnFilter.d.ts +8 -12
- package/dist/components/MultiSelectColumnFilter.d.ts.map +1 -1
- package/dist/components/MultiSelectColumnFilter.js +21 -13
- package/dist/components/MultiSelectColumnFilter.js.map +1 -1
- package/dist/components/NumericInputColumnFilter.d.ts +13 -13
- package/dist/components/NumericInputColumnFilter.d.ts.map +1 -1
- package/dist/components/NumericInputColumnFilter.js +44 -15
- package/dist/components/NumericInputColumnFilter.js.map +1 -1
- package/dist/components/NumericInputColumnFilter.test.d.ts +2 -0
- package/dist/components/NumericInputColumnFilter.test.d.ts.map +1 -0
- package/dist/components/NumericInputColumnFilter.test.js +90 -0
- package/dist/components/NumericInputColumnFilter.test.js.map +1 -0
- package/dist/components/OverlayTrigger.d.ts +78 -0
- package/dist/components/OverlayTrigger.d.ts.map +1 -0
- package/dist/components/OverlayTrigger.js +89 -0
- package/dist/components/OverlayTrigger.js.map +1 -0
- package/dist/components/PresetFilterDropdown.d.ts +19 -0
- package/dist/components/PresetFilterDropdown.d.ts.map +1 -0
- package/dist/components/PresetFilterDropdown.js +93 -0
- package/dist/components/PresetFilterDropdown.js.map +1 -0
- package/dist/components/TanstackTable.d.ts +15 -4
- package/dist/components/TanstackTable.d.ts.map +1 -1
- package/dist/components/TanstackTable.js +148 -197
- package/dist/components/TanstackTable.js.map +1 -1
- package/dist/components/TanstackTableDownloadButton.d.ts +4 -2
- package/dist/components/TanstackTableDownloadButton.d.ts.map +1 -1
- package/dist/components/TanstackTableDownloadButton.js +4 -3
- package/dist/components/TanstackTableDownloadButton.js.map +1 -1
- package/dist/components/TanstackTableHeaderCell.d.ts +13 -0
- package/dist/components/TanstackTableHeaderCell.d.ts.map +1 -0
- package/dist/components/TanstackTableHeaderCell.js +98 -0
- package/dist/components/TanstackTableHeaderCell.js.map +1 -0
- package/dist/components/{TanstackTable.css → styles.css} +11 -6
- package/dist/components/useAutoSizeColumns.d.ts +17 -0
- package/dist/components/useAutoSizeColumns.d.ts.map +1 -0
- package/dist/components/useAutoSizeColumns.js +99 -0
- package/dist/components/useAutoSizeColumns.js.map +1 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/react-table.d.ts +13 -0
- package/dist/react-table.d.ts.map +1 -0
- package/dist/react-table.js +3 -0
- package/dist/react-table.js.map +1 -0
- package/package.json +2 -2
- package/src/components/CategoricalColumnFilter.tsx +28 -28
- package/src/components/ColumnManager.tsx +222 -46
- package/src/components/MultiSelectColumnFilter.tsx +45 -32
- package/src/components/NumericInputColumnFilter.test.ts +67 -19
- package/src/components/NumericInputColumnFilter.tsx +102 -42
- package/src/components/OverlayTrigger.tsx +168 -0
- package/src/components/PresetFilterDropdown.tsx +155 -0
- package/src/components/TanstackTable.tsx +315 -363
- package/src/components/TanstackTableDownloadButton.tsx +8 -5
- package/src/components/TanstackTableHeaderCell.tsx +207 -0
- package/src/components/{TanstackTable.css → styles.css} +11 -6
- package/src/components/useAutoSizeColumns.tsx +168 -0
- package/src/index.ts +7 -0
- package/src/react-table.ts +17 -0
- package/tsconfig.json +1 -2
|
@@ -1,31 +1,43 @@
|
|
|
1
|
+
import type { Column } from '@tanstack/table-core';
|
|
1
2
|
import clsx from 'clsx';
|
|
2
3
|
import Dropdown from 'react-bootstrap/Dropdown';
|
|
3
4
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
5
|
+
export type NumericColumnFilterValue =
|
|
6
|
+
| {
|
|
7
|
+
filterValue: string;
|
|
8
|
+
emptyOnly: false;
|
|
9
|
+
}
|
|
10
|
+
| {
|
|
11
|
+
filterValue: '';
|
|
12
|
+
emptyOnly: true;
|
|
13
|
+
};
|
|
10
14
|
|
|
11
15
|
/**
|
|
12
16
|
* A component that allows the user to filter a numeric column using comparison operators.
|
|
13
17
|
* Supports syntax like: <1, >0, <=5, >=10, =5, or just 5 (implicit equals)
|
|
14
18
|
*
|
|
15
19
|
* @param params
|
|
16
|
-
* @param params.
|
|
17
|
-
* @param params.columnLabel - The label of the column, e.g. "Manual Points"
|
|
18
|
-
* @param params.value - The current filter value (e.g., ">5" or "10")
|
|
19
|
-
* @param params.onChange - Callback when the filter value changes
|
|
20
|
+
* @param params.column - The TanStack Table column object
|
|
20
21
|
*/
|
|
21
|
-
export function NumericInputColumnFilter({
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
const
|
|
28
|
-
|
|
22
|
+
export function NumericInputColumnFilter<TData, TValue>({
|
|
23
|
+
column,
|
|
24
|
+
}: {
|
|
25
|
+
column: Column<TData, TValue>;
|
|
26
|
+
}) {
|
|
27
|
+
const columnId = column.id;
|
|
28
|
+
const value = (column.getFilterValue() as NumericColumnFilterValue | undefined) ?? {
|
|
29
|
+
filterValue: '',
|
|
30
|
+
emptyOnly: false,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const label =
|
|
34
|
+
column.columnDef.meta?.label ??
|
|
35
|
+
(typeof column.columnDef.header === 'string' ? column.columnDef.header : column.id);
|
|
36
|
+
|
|
37
|
+
const filterValue = value.filterValue;
|
|
38
|
+
const emptyOnly = value.emptyOnly;
|
|
39
|
+
const hasActiveFilter = filterValue.trim().length > 0 || emptyOnly;
|
|
40
|
+
const isInvalid = filterValue.trim().length > 0 && parseNumericFilter(filterValue) === null;
|
|
29
41
|
|
|
30
42
|
return (
|
|
31
43
|
<Dropdown align="end">
|
|
@@ -36,8 +48,8 @@ export function NumericInputColumnFilter({
|
|
|
36
48
|
hasActiveFilter && (isInvalid ? 'text-warning' : 'text-primary'),
|
|
37
49
|
)}
|
|
38
50
|
id={`filter-${columnId}`}
|
|
39
|
-
aria-label={`Filter ${
|
|
40
|
-
title={`Filter ${
|
|
51
|
+
aria-label={`Filter ${label.toLowerCase()}`}
|
|
52
|
+
title={`Filter ${label.toLowerCase()}`}
|
|
41
53
|
>
|
|
42
54
|
<i
|
|
43
55
|
class={clsx(
|
|
@@ -51,17 +63,42 @@ export function NumericInputColumnFilter({
|
|
|
51
63
|
aria-hidden="true"
|
|
52
64
|
/>
|
|
53
65
|
</Dropdown.Toggle>
|
|
54
|
-
<Dropdown.Menu
|
|
66
|
+
<Dropdown.Menu
|
|
67
|
+
// eslint-disable-next-line @eslint-react/no-forbidden-props
|
|
68
|
+
className="p-0"
|
|
69
|
+
>
|
|
55
70
|
<div class="p-3" style={{ minWidth: '240px' }}>
|
|
56
|
-
<
|
|
71
|
+
<div class="d-flex align-items-center justify-content-between mb-2">
|
|
72
|
+
<label class="form-label fw-semibold mb-0" id={`${columnId}-filter-label`}>
|
|
73
|
+
{label}
|
|
74
|
+
</label>
|
|
75
|
+
<button
|
|
76
|
+
type="button"
|
|
77
|
+
class={clsx(
|
|
78
|
+
'btn btn-link btn-sm text-decoration-none',
|
|
79
|
+
!hasActiveFilter && 'invisible',
|
|
80
|
+
)}
|
|
81
|
+
onClick={() => {
|
|
82
|
+
column.setFilterValue({ filterValue: '', emptyOnly: false });
|
|
83
|
+
}}
|
|
84
|
+
>
|
|
85
|
+
Clear
|
|
86
|
+
</button>
|
|
87
|
+
</div>
|
|
57
88
|
<input
|
|
58
89
|
type="text"
|
|
59
90
|
class={clsx('form-control form-control-sm', isInvalid && 'is-invalid')}
|
|
60
91
|
placeholder="e.g., >0, <5, =10"
|
|
61
|
-
|
|
92
|
+
aria-labelledby={`${columnId}-filter-label`}
|
|
93
|
+
value={filterValue}
|
|
94
|
+
disabled={emptyOnly}
|
|
95
|
+
aria-describedby={`${columnId}-filter-description`}
|
|
62
96
|
onInput={(e) => {
|
|
63
97
|
if (e.target instanceof HTMLInputElement) {
|
|
64
|
-
|
|
98
|
+
column.setFilterValue({
|
|
99
|
+
filterValue: e.target.value,
|
|
100
|
+
emptyOnly: false,
|
|
101
|
+
});
|
|
65
102
|
}
|
|
66
103
|
}}
|
|
67
104
|
onClick={(e) => e.stopPropagation()}
|
|
@@ -72,22 +109,31 @@ export function NumericInputColumnFilter({
|
|
|
72
109
|
</div>
|
|
73
110
|
)}
|
|
74
111
|
{!isInvalid && (
|
|
75
|
-
<
|
|
76
|
-
|
|
112
|
+
<small class="form-text text-nowrap" id={`${columnId}-filter-description`}>
|
|
113
|
+
Operators: <code><</code>, <code>></code>, <code><=</code>,{' '}
|
|
77
114
|
<code>>=</code>, <code>=</code>
|
|
78
|
-
|
|
79
|
-
Example: <code>>5</code> or <code><=10</code>
|
|
80
|
-
</div>
|
|
81
|
-
)}
|
|
82
|
-
{hasActiveFilter && (
|
|
83
|
-
<button
|
|
84
|
-
type="button"
|
|
85
|
-
class="btn btn-sm btn-link text-decoration-none mt-2 p-0"
|
|
86
|
-
onClick={() => onChange('')}
|
|
87
|
-
>
|
|
88
|
-
Clear filter
|
|
89
|
-
</button>
|
|
115
|
+
</small>
|
|
90
116
|
)}
|
|
117
|
+
<div class="form-check mt-2">
|
|
118
|
+
<input
|
|
119
|
+
class="form-check-input"
|
|
120
|
+
type="checkbox"
|
|
121
|
+
checked={emptyOnly}
|
|
122
|
+
id={`${columnId}-empty-filter`}
|
|
123
|
+
onChange={(e) => {
|
|
124
|
+
if (e.target instanceof HTMLInputElement) {
|
|
125
|
+
column.setFilterValue(
|
|
126
|
+
e.target.checked
|
|
127
|
+
? { filterValue: '', emptyOnly: true }
|
|
128
|
+
: { filterValue: '', emptyOnly: false },
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
}}
|
|
132
|
+
/>
|
|
133
|
+
<label class="form-check-label" for={`${columnId}-empty-filter`}>
|
|
134
|
+
Empty values
|
|
135
|
+
</label>
|
|
136
|
+
</div>
|
|
91
137
|
</div>
|
|
92
138
|
</Dropdown.Menu>
|
|
93
139
|
</Dropdown>
|
|
@@ -129,13 +175,27 @@ export function parseNumericFilter(filterValue: string): {
|
|
|
129
175
|
* filterFn: numericColumnFilterFn,
|
|
130
176
|
* }
|
|
131
177
|
*/
|
|
132
|
-
export function numericColumnFilterFn(
|
|
178
|
+
export function numericColumnFilterFn(
|
|
179
|
+
row: any,
|
|
180
|
+
columnId: string,
|
|
181
|
+
{ filterValue, emptyOnly }: NumericColumnFilterValue,
|
|
182
|
+
): boolean {
|
|
183
|
+
// Handle object-based filter value
|
|
184
|
+
const cellValue = row.getValue(columnId) as number | null;
|
|
185
|
+
const isEmpty = cellValue == null;
|
|
186
|
+
|
|
187
|
+
if (emptyOnly) {
|
|
188
|
+
return isEmpty;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// If there's no numeric filter, show all rows
|
|
133
192
|
const parsed = parseNumericFilter(filterValue);
|
|
134
|
-
if (!parsed) return true;
|
|
193
|
+
if (!parsed) return true;
|
|
135
194
|
|
|
136
|
-
|
|
137
|
-
if (
|
|
195
|
+
// If cell is empty and we're doing numeric filtering, don't show it
|
|
196
|
+
if (isEmpty) return false;
|
|
138
197
|
|
|
198
|
+
// Apply numeric filter
|
|
139
199
|
switch (parsed.operator) {
|
|
140
200
|
case '<':
|
|
141
201
|
return cellValue < parsed.value;
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { useEffect, useRef } from 'preact/compat';
|
|
2
|
+
import {
|
|
3
|
+
// eslint-disable-next-line no-restricted-imports
|
|
4
|
+
OverlayTrigger as BootstrapOverlayTrigger,
|
|
5
|
+
type OverlayTriggerProps as BootstrapOverlayTriggerProps,
|
|
6
|
+
Popover,
|
|
7
|
+
type PopoverProps,
|
|
8
|
+
Tooltip,
|
|
9
|
+
type TooltipProps,
|
|
10
|
+
} from 'react-bootstrap';
|
|
11
|
+
|
|
12
|
+
import { type FocusTrap, focusFirstFocusableChild, trapFocus } from '@prairielearn/browser-utils';
|
|
13
|
+
|
|
14
|
+
export interface OverlayTriggerProps extends Omit<BootstrapOverlayTriggerProps, 'overlay'> {
|
|
15
|
+
popover?: {
|
|
16
|
+
/**
|
|
17
|
+
* Additional props to pass to the Popover component.
|
|
18
|
+
*/
|
|
19
|
+
props?: Omit<PopoverProps, 'children'>;
|
|
20
|
+
/**
|
|
21
|
+
* The content to display in the popover body.
|
|
22
|
+
*/
|
|
23
|
+
body: React.ReactNode;
|
|
24
|
+
/**
|
|
25
|
+
* Optional header content for the popover.
|
|
26
|
+
*/
|
|
27
|
+
header?: React.ReactNode;
|
|
28
|
+
};
|
|
29
|
+
tooltip?: {
|
|
30
|
+
/**
|
|
31
|
+
* Additional props to pass to the Tooltip component. `id` is required for accessibility.
|
|
32
|
+
*/
|
|
33
|
+
props: Omit<TooltipProps, 'children' | 'id'> & { id: string };
|
|
34
|
+
/**
|
|
35
|
+
* The content to display in the tooltip body.
|
|
36
|
+
*/
|
|
37
|
+
body: React.ReactNode;
|
|
38
|
+
};
|
|
39
|
+
/**
|
|
40
|
+
* Whether to trap focus inside the overlay when it's shown.
|
|
41
|
+
* If true, focus will be trapped and moved to the first focusable element.
|
|
42
|
+
* @default true
|
|
43
|
+
*/
|
|
44
|
+
trapFocus?: boolean;
|
|
45
|
+
/**
|
|
46
|
+
* Whether to return focus to the trigger element when the overlay is hidden.
|
|
47
|
+
* @default true
|
|
48
|
+
*/
|
|
49
|
+
returnFocus?: boolean;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* A wrapper around react-bootstrap's OverlayTrigger that adds accessibility features:
|
|
54
|
+
* - Automatic focus trapping when the overlay is shown
|
|
55
|
+
* - Auto-focus on the first focusable element in the overlay
|
|
56
|
+
* - Returns focus to the trigger element when the overlay is hidden
|
|
57
|
+
* - Automatically constructs a Popover with proper ref management
|
|
58
|
+
*
|
|
59
|
+
* This component provides a simpler API than react-bootstrap's OverlayTrigger by
|
|
60
|
+
* handling the Popover construction and ref management internally.
|
|
61
|
+
*
|
|
62
|
+
* @example
|
|
63
|
+
* ```tsx
|
|
64
|
+
* <OverlayTrigger
|
|
65
|
+
* tooltip={{
|
|
66
|
+
* body: 'Tooltip content',
|
|
67
|
+
* props: { id: 'tooltip-id' },
|
|
68
|
+
* }}
|
|
69
|
+
* placement="right"
|
|
70
|
+
* >
|
|
71
|
+
* <button>Hover me</button>
|
|
72
|
+
* </OverlayTrigger>
|
|
73
|
+
* ```
|
|
74
|
+
*
|
|
75
|
+
* @example
|
|
76
|
+
* ```tsx
|
|
77
|
+
* <OverlayTrigger
|
|
78
|
+
* popover={{
|
|
79
|
+
* header: 'Popover title',
|
|
80
|
+
* body: 'Popover content',
|
|
81
|
+
* }}
|
|
82
|
+
* placement="right"
|
|
83
|
+
* >
|
|
84
|
+
* <button>Click me</button>
|
|
85
|
+
* </OverlayTrigger>
|
|
86
|
+
* ```
|
|
87
|
+
*/
|
|
88
|
+
export function OverlayTrigger({
|
|
89
|
+
children,
|
|
90
|
+
popover,
|
|
91
|
+
tooltip,
|
|
92
|
+
trapFocus: shouldTrapFocus = true,
|
|
93
|
+
returnFocus = true,
|
|
94
|
+
onEntered,
|
|
95
|
+
onExit,
|
|
96
|
+
...props
|
|
97
|
+
}: OverlayTriggerProps) {
|
|
98
|
+
const overlayBodyRef = useRef<HTMLDivElement>(null);
|
|
99
|
+
const focusTrapRef = useRef<FocusTrap | null>(null);
|
|
100
|
+
const triggerElementRef = useRef<HTMLElement | null>(null);
|
|
101
|
+
|
|
102
|
+
const handleEntered = (node: HTMLElement, isAppearing: boolean) => {
|
|
103
|
+
// Store the currently focused element (the trigger) before we move focus
|
|
104
|
+
if (returnFocus && document.activeElement instanceof HTMLElement) {
|
|
105
|
+
triggerElementRef.current = document.activeElement;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (shouldTrapFocus && overlayBodyRef.current && props.trigger === 'click') {
|
|
109
|
+
// Trap focus inside the overlay body
|
|
110
|
+
focusTrapRef.current = trapFocus(overlayBodyRef.current);
|
|
111
|
+
|
|
112
|
+
// Move focus to the first focusable element
|
|
113
|
+
focusFirstFocusableChild(overlayBodyRef.current);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Call the original onEntered callback if provided
|
|
117
|
+
onEntered?.(node, isAppearing);
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
// Deactivate the focus trap when the component unmounts
|
|
121
|
+
useEffect(() => {
|
|
122
|
+
return () => {
|
|
123
|
+
focusTrapRef.current?.deactivate();
|
|
124
|
+
};
|
|
125
|
+
}, []);
|
|
126
|
+
|
|
127
|
+
const handleExit = (node: HTMLElement) => {
|
|
128
|
+
// Deactivate the focus trap
|
|
129
|
+
if (focusTrapRef.current) {
|
|
130
|
+
focusTrapRef.current.deactivate();
|
|
131
|
+
focusTrapRef.current = null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Return focus to the trigger element
|
|
135
|
+
if (returnFocus && triggerElementRef.current) {
|
|
136
|
+
triggerElementRef.current.focus();
|
|
137
|
+
triggerElementRef.current = null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Call the original onExit callback if provided
|
|
141
|
+
onExit?.(node);
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
if (Boolean(popover) === Boolean(tooltip)) {
|
|
145
|
+
throw new Error('Only one of popover or tooltip must be provided');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Construct the popover with our managed ref
|
|
149
|
+
const popoverOverlay = popover ? (
|
|
150
|
+
<Popover {...popover.props}>
|
|
151
|
+
{popover.header && <Popover.Header>{popover.header}</Popover.Header>}
|
|
152
|
+
<Popover.Body ref={overlayBodyRef}>{popover.body}</Popover.Body>
|
|
153
|
+
</Popover>
|
|
154
|
+
) : null;
|
|
155
|
+
|
|
156
|
+
const tooltipOverlay = tooltip ? <Tooltip {...tooltip.props}>{tooltip.body}</Tooltip> : null;
|
|
157
|
+
|
|
158
|
+
return (
|
|
159
|
+
<BootstrapOverlayTrigger
|
|
160
|
+
{...props}
|
|
161
|
+
overlay={popoverOverlay ?? tooltipOverlay!}
|
|
162
|
+
onEntered={handleEntered}
|
|
163
|
+
onExit={handleExit}
|
|
164
|
+
>
|
|
165
|
+
{children}
|
|
166
|
+
</BootstrapOverlayTrigger>
|
|
167
|
+
);
|
|
168
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import type { ColumnFiltersState, Table } from '@tanstack/react-table';
|
|
2
|
+
import { useMemo } from 'preact/compat';
|
|
3
|
+
import { ButtonGroup, Dropdown } from 'react-bootstrap';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Compares two filter values for deep equality using JSON serialization.
|
|
7
|
+
*/
|
|
8
|
+
function filtersEqual(a: unknown, b: unknown): boolean {
|
|
9
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Extracts all unique column IDs referenced across all preset options.
|
|
14
|
+
*/
|
|
15
|
+
function getRelevantColumnIds(options: Record<string, ColumnFiltersState>): Set<string> {
|
|
16
|
+
const columnIds = new Set<string>();
|
|
17
|
+
for (const filters of Object.values(options)) {
|
|
18
|
+
for (const filter of filters) {
|
|
19
|
+
columnIds.add(filter.id);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return columnIds;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Gets the current filter values for the relevant columns from the table.
|
|
27
|
+
*/
|
|
28
|
+
function getRelevantFilters<TData>(
|
|
29
|
+
table: Table<TData>,
|
|
30
|
+
relevantColumnIds: Set<string>,
|
|
31
|
+
): ColumnFiltersState {
|
|
32
|
+
const allFilters = table.getState().columnFilters;
|
|
33
|
+
return allFilters.filter((f) => relevantColumnIds.has(f.id));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Checks if the current filters match a preset's filters.
|
|
38
|
+
* Both must have the same column IDs with equal values.
|
|
39
|
+
*/
|
|
40
|
+
function filtersMatchPreset(current: ColumnFiltersState, preset: ColumnFiltersState): boolean {
|
|
41
|
+
// If lengths differ, they don't match
|
|
42
|
+
if (current.length !== preset.length) return false;
|
|
43
|
+
|
|
44
|
+
// For empty presets, current must also be empty
|
|
45
|
+
if (preset.length === 0) return current.length === 0;
|
|
46
|
+
|
|
47
|
+
// Check that every preset filter exists in current with the same value
|
|
48
|
+
for (const presetFilter of preset) {
|
|
49
|
+
const currentFilter = current.find((f) => f.id === presetFilter.id);
|
|
50
|
+
if (!currentFilter || !filtersEqual(currentFilter.value, presetFilter.value)) {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* A dropdown component that allows users to select from preset filter configurations.
|
|
60
|
+
* The selected state is derived from the table's current column filters.
|
|
61
|
+
* If no preset matches, a "Custom" option is shown as selected.
|
|
62
|
+
*
|
|
63
|
+
* Currently, this component expects that the filters states are arrays.
|
|
64
|
+
*/
|
|
65
|
+
export function PresetFilterDropdown<OptionName extends string, TData>({
|
|
66
|
+
table,
|
|
67
|
+
options,
|
|
68
|
+
label = 'Filter',
|
|
69
|
+
onSelect,
|
|
70
|
+
}: {
|
|
71
|
+
/** The TanStack Table instance */
|
|
72
|
+
table: Table<TData>;
|
|
73
|
+
/** Mapping of option names to their filter configurations */
|
|
74
|
+
options: Record<OptionName, ColumnFiltersState>;
|
|
75
|
+
/** Label prefix for the dropdown button (e.g., "Filter") */
|
|
76
|
+
label?: string;
|
|
77
|
+
/** Callback when an option is selected, useful for side effects like column visibility */
|
|
78
|
+
onSelect?: (optionName: OptionName) => void;
|
|
79
|
+
}) {
|
|
80
|
+
const relevantColumnIds = getRelevantColumnIds(options);
|
|
81
|
+
|
|
82
|
+
const currentRelevantFilters = useMemo(
|
|
83
|
+
() => getRelevantFilters(table, relevantColumnIds),
|
|
84
|
+
[table, relevantColumnIds],
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
// Find which option matches the current filters
|
|
88
|
+
const selectedOption = useMemo<OptionName | null>(() => {
|
|
89
|
+
for (const [optionName, presetFilters] of Object.entries(options)) {
|
|
90
|
+
if (filtersMatchPreset(currentRelevantFilters, presetFilters as ColumnFiltersState)) {
|
|
91
|
+
return optionName as OptionName;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return null; // No preset matches - custom filter state
|
|
95
|
+
}, [options, currentRelevantFilters]);
|
|
96
|
+
|
|
97
|
+
const handleOptionClick = (optionName: OptionName) => {
|
|
98
|
+
const presetFilters = options[optionName];
|
|
99
|
+
|
|
100
|
+
// Get current filters, removing any that are in our relevant columns
|
|
101
|
+
const currentFilters = table.getState().columnFilters;
|
|
102
|
+
const preservedFilters = currentFilters.filter((f) => !relevantColumnIds.has(f.id));
|
|
103
|
+
|
|
104
|
+
// For columns not in the preset, explicitly set empty filter to clear them
|
|
105
|
+
// This ensures the table's onColumnFiltersChange handler can sync the cleared state
|
|
106
|
+
const clearedFilters = Array.from(relevantColumnIds)
|
|
107
|
+
.filter((colId) => !presetFilters.some((f) => f.id === colId))
|
|
108
|
+
.map((colId) => ({
|
|
109
|
+
id: colId,
|
|
110
|
+
// TODO: This expects that we are only clearing filters whose state is an array.
|
|
111
|
+
value: [],
|
|
112
|
+
}));
|
|
113
|
+
|
|
114
|
+
// Combine preserved filters with the new preset filters and cleared filters
|
|
115
|
+
const newFilters = [...preservedFilters, ...presetFilters, ...clearedFilters];
|
|
116
|
+
table.setColumnFilters(newFilters);
|
|
117
|
+
|
|
118
|
+
onSelect?.(optionName);
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const displayLabel = selectedOption ?? 'Custom';
|
|
122
|
+
|
|
123
|
+
return (
|
|
124
|
+
<Dropdown as={ButtonGroup}>
|
|
125
|
+
<Dropdown.Toggle variant="tanstack-table">
|
|
126
|
+
<i class="bi bi-funnel me-2" aria-hidden="true" />
|
|
127
|
+
{label}: {displayLabel}
|
|
128
|
+
</Dropdown.Toggle>
|
|
129
|
+
<Dropdown.Menu>
|
|
130
|
+
{Object.keys(options).map((optionName) => {
|
|
131
|
+
const isSelected = selectedOption === optionName;
|
|
132
|
+
return (
|
|
133
|
+
<Dropdown.Item
|
|
134
|
+
key={optionName}
|
|
135
|
+
as="button"
|
|
136
|
+
type="button"
|
|
137
|
+
active={isSelected}
|
|
138
|
+
onClick={() => handleOptionClick(optionName as OptionName)}
|
|
139
|
+
>
|
|
140
|
+
<i class={`bi ${isSelected ? 'bi-check-circle-fill' : 'bi-circle'} me-2`} />
|
|
141
|
+
{optionName}
|
|
142
|
+
</Dropdown.Item>
|
|
143
|
+
);
|
|
144
|
+
})}
|
|
145
|
+
{/* Show Custom option only when no preset matches */}
|
|
146
|
+
{selectedOption === null && (
|
|
147
|
+
<Dropdown.Item as="button" type="button" active disabled>
|
|
148
|
+
<i class="bi bi-check-circle-fill me-2" />
|
|
149
|
+
Custom
|
|
150
|
+
</Dropdown.Item>
|
|
151
|
+
)}
|
|
152
|
+
</Dropdown.Menu>
|
|
153
|
+
</Dropdown>
|
|
154
|
+
);
|
|
155
|
+
}
|