torch-glare 2.1.1 → 2.1.2
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/apps/lib/components/BadgeField.tsx +2 -2
- package/apps/lib/components/DataViews/ARCHITECTURE.md +439 -0
- package/apps/lib/components/DataViews/DataViewsConfigPanel.tsx +416 -0
- package/apps/lib/components/DataViews/DataViewsHeader.tsx +126 -0
- package/apps/lib/components/DataViews/DataViewsLayout.tsx +300 -0
- package/apps/lib/components/DataViews/FilterPanel.tsx +324 -0
- package/apps/lib/components/DataViews/InboxView.tsx +514 -0
- package/apps/lib/components/DataViews/KanbanView.tsx +242 -0
- package/apps/lib/components/DataViews/PanelControls.tsx +80 -0
- package/apps/lib/components/DataViews/SettingsPanel.tsx +285 -0
- package/apps/lib/components/DataViews/TableView.tsx +232 -0
- package/apps/lib/components/DataViews/TreeView.tsx +363 -0
- package/apps/lib/components/DataViews/badgeAdapter.ts +45 -0
- package/apps/lib/components/DataViews/fieldRenderers.tsx +334 -0
- package/apps/lib/components/DataViews/filters/DateRangePopover.tsx +113 -0
- package/apps/lib/components/DataViews/filters/PresetChips.tsx +45 -0
- package/apps/lib/components/DataViews/filters/RangeSliderWithInputs.tsx +154 -0
- package/apps/lib/components/DataViews/index.ts +30 -0
- package/apps/lib/components/DataViews/tree/TreeDrawer.tsx +54 -0
- package/apps/lib/components/DataViews/tree/TreeSidebar.tsx +77 -0
- package/apps/lib/components/DataViews/types.ts +177 -0
- package/apps/lib/components/TreeFolder/TreeFolder.tsx +387 -0
- package/apps/lib/components/TreeFolder/TreeFolderBreadcrumb.tsx +80 -0
- package/apps/lib/components/TreeFolder/TreeFolderRow.tsx +235 -0
- package/apps/lib/components/TreeFolder/TreeFolderStyles.tsx +60 -0
- package/apps/lib/components/TreeFolder/icons.tsx +63 -0
- package/apps/lib/components/TreeFolder/index.ts +17 -0
- package/apps/lib/components/TreeFolder/treeFolderUtils.ts +114 -0
- package/apps/lib/components/TreeFolder/types.ts +68 -0
- package/apps/lib/components/TreeFolder/useTreeFolderDnD.ts +261 -0
- package/apps/lib/hooks/useDataViewsState.ts +169 -0
- package/apps/lib/hooks/useIsMobile.ts +21 -0
- package/apps/lib/utils/dataViews/columnUtils.ts +130 -0
- package/apps/lib/utils/dataViews/fieldUtils.ts +198 -0
- package/apps/lib/utils/dataViews/nestedDataUtils.tsx +364 -0
- package/apps/lib/utils/dataViews/pathUtils.ts +132 -0
- package/apps/lib/utils/dataViews/rangeUtils.ts +225 -0
- package/apps/lib/utils/dataViews/treeUtils.ts +403 -0
- package/dist/bin/index.js +3 -3
- package/dist/bin/index.js.map +1 -1
- package/dist/src/commands/add.d.ts.map +1 -1
- package/dist/src/commands/add.js +29 -6
- package/dist/src/commands/add.js.map +1 -1
- package/dist/src/commands/utils.d.ts.map +1 -1
- package/dist/src/commands/utils.js +22 -2
- package/dist/src/commands/utils.js.map +1 -1
- package/dist/src/shared/copyComponentsRecursively.d.ts.map +1 -1
- package/dist/src/shared/copyComponentsRecursively.js +8 -1
- package/dist/src/shared/copyComponentsRecursively.js.map +1 -1
- package/dist/src/shared/getDependenciesAndInstallNestedComponents.d.ts +18 -4
- package/dist/src/shared/getDependenciesAndInstallNestedComponents.d.ts.map +1 -1
- package/dist/src/shared/getDependenciesAndInstallNestedComponents.js +110 -40
- package/dist/src/shared/getDependenciesAndInstallNestedComponents.js.map +1 -1
- package/docs/components/form-stepper.md +244 -0
- package/docs/components/stepper.md +215 -0
- package/docs/components/timeline.md +248 -0
- package/package.json +6 -6
- package/apps/lib/components/Charts-dev.tsx +0 -365
- package/apps/lib/components/Command-dev.tsx +0 -151
- package/apps/lib/components/IosDatePicker-dev.tsx +0 -341
- /package/docs/components/{labeled-checkbox.md → labeled-check-box.md} +0 -0
- /package/docs/components/{tree-dropdown.md → tree-drop-down.md} +0 -0
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
forwardRef,
|
|
5
|
+
useCallback,
|
|
6
|
+
useEffect,
|
|
7
|
+
useMemo,
|
|
8
|
+
useRef,
|
|
9
|
+
useState,
|
|
10
|
+
} from "react";
|
|
11
|
+
import { List, LayoutGrid, Inbox as InboxIcon, Network } from "lucide-react";
|
|
12
|
+
import type {
|
|
13
|
+
DynamicRecord,
|
|
14
|
+
DynamicColumnConfig,
|
|
15
|
+
DynamicFilterConfig,
|
|
16
|
+
FilterState,
|
|
17
|
+
FieldConfig,
|
|
18
|
+
InboxConfig,
|
|
19
|
+
TreeConfig,
|
|
20
|
+
ViewConfig,
|
|
21
|
+
ViewType,
|
|
22
|
+
ViewVisibility,
|
|
23
|
+
} from "./types";
|
|
24
|
+
import { TableView } from "./TableView";
|
|
25
|
+
import { KanbanView } from "./KanbanView";
|
|
26
|
+
import { InboxView } from "./InboxView";
|
|
27
|
+
import { TreeView } from "./TreeView";
|
|
28
|
+
import { DataViewsHeader, type DataViewsHeaderView } from "./DataViewsHeader";
|
|
29
|
+
import { DataViewsConfigPanel } from "./DataViewsConfigPanel";
|
|
30
|
+
import { useDataViewsState } from "../../hooks/useDataViewsState";
|
|
31
|
+
import { cn } from "../../utils/cn";
|
|
32
|
+
import type { Themes } from "../../utils/types";
|
|
33
|
+
|
|
34
|
+
export type DataViewsLayoutProps = {
|
|
35
|
+
data?: DynamicRecord[];
|
|
36
|
+
config?: Partial<ViewConfig>;
|
|
37
|
+
title?: string;
|
|
38
|
+
description?: string;
|
|
39
|
+
|
|
40
|
+
fields?: FieldConfig[];
|
|
41
|
+
inboxConfig?: InboxConfig;
|
|
42
|
+
treeConfig?: TreeConfig;
|
|
43
|
+
kanbanGroupBy?: string;
|
|
44
|
+
|
|
45
|
+
views?: ViewVisibility;
|
|
46
|
+
|
|
47
|
+
columns?: Partial<DynamicColumnConfig>[];
|
|
48
|
+
filters?: DynamicFilterConfig[];
|
|
49
|
+
|
|
50
|
+
filterState?: FilterState;
|
|
51
|
+
onFilterChange?: (filters: FilterState) => void;
|
|
52
|
+
|
|
53
|
+
showFilters?: boolean;
|
|
54
|
+
showSettings?: boolean;
|
|
55
|
+
showTitle?: boolean;
|
|
56
|
+
|
|
57
|
+
onAddNew?: () => void;
|
|
58
|
+
addNewLabel?: string;
|
|
59
|
+
|
|
60
|
+
className?: string;
|
|
61
|
+
theme?: Themes;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const VIEW_META: Record<
|
|
65
|
+
ViewType,
|
|
66
|
+
{ label: string; icon: DataViewsHeaderView["icon"] }
|
|
67
|
+
> = {
|
|
68
|
+
table: { label: "List", icon: <List /> },
|
|
69
|
+
kanban: { label: "Board", icon: <LayoutGrid /> },
|
|
70
|
+
inbox: { label: "Inbox", icon: <InboxIcon /> },
|
|
71
|
+
tree: { label: "Tree", icon: <Network /> },
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const VIEW_ORDER: ViewType[] = ["table", "kanban", "inbox", "tree"];
|
|
75
|
+
|
|
76
|
+
export const DataViewsLayout = forwardRef<HTMLDivElement, DataViewsLayoutProps>(
|
|
77
|
+
function DataViewsLayout(props, ref) {
|
|
78
|
+
const {
|
|
79
|
+
title = "Data Views",
|
|
80
|
+
data,
|
|
81
|
+
config: initialConfig,
|
|
82
|
+
fields,
|
|
83
|
+
inboxConfig,
|
|
84
|
+
treeConfig,
|
|
85
|
+
kanbanGroupBy,
|
|
86
|
+
views,
|
|
87
|
+
columns,
|
|
88
|
+
filters,
|
|
89
|
+
filterState: externalFilterState,
|
|
90
|
+
onFilterChange,
|
|
91
|
+
// `showFilters` is retained on the public API but filters now live in the
|
|
92
|
+
// right-side config rail rather than an inline per-view panel.
|
|
93
|
+
showSettings = true,
|
|
94
|
+
showTitle = true,
|
|
95
|
+
onAddNew,
|
|
96
|
+
addNewLabel,
|
|
97
|
+
className,
|
|
98
|
+
theme,
|
|
99
|
+
} = props;
|
|
100
|
+
|
|
101
|
+
const {
|
|
102
|
+
items,
|
|
103
|
+
flatItems,
|
|
104
|
+
resolvedFields,
|
|
105
|
+
detectedColumns,
|
|
106
|
+
config,
|
|
107
|
+
setConfig,
|
|
108
|
+
currentView,
|
|
109
|
+
setCurrentView,
|
|
110
|
+
filterState,
|
|
111
|
+
setFilterState,
|
|
112
|
+
onDataUpdate,
|
|
113
|
+
enabledViews,
|
|
114
|
+
} = useDataViewsState({
|
|
115
|
+
data,
|
|
116
|
+
fields,
|
|
117
|
+
columns,
|
|
118
|
+
config: initialConfig,
|
|
119
|
+
treeConfig,
|
|
120
|
+
views,
|
|
121
|
+
filterState: externalFilterState,
|
|
122
|
+
onFilterChange,
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// `panelOpen` drives intent; `panelMounted` keeps the panel in the tree
|
|
126
|
+
// through the close animation before unmounting.
|
|
127
|
+
const [panelOpen, setPanelOpen] = useState(false);
|
|
128
|
+
const [panelMounted, setPanelMounted] = useState(false);
|
|
129
|
+
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
130
|
+
|
|
131
|
+
const openPanel = useCallback(() => {
|
|
132
|
+
if (closeTimer.current) {
|
|
133
|
+
clearTimeout(closeTimer.current);
|
|
134
|
+
closeTimer.current = null;
|
|
135
|
+
}
|
|
136
|
+
// Mount at width 0 first, then flip to open on the next frame so the
|
|
137
|
+
// width transition animates from 0 → 260px instead of snapping.
|
|
138
|
+
setPanelMounted(true);
|
|
139
|
+
requestAnimationFrame(() =>
|
|
140
|
+
requestAnimationFrame(() => setPanelOpen(true)),
|
|
141
|
+
);
|
|
142
|
+
}, []);
|
|
143
|
+
|
|
144
|
+
const closePanel = useCallback(() => {
|
|
145
|
+
setPanelOpen(false);
|
|
146
|
+
if (closeTimer.current) clearTimeout(closeTimer.current);
|
|
147
|
+
// Keep mounted through the width/fade animation (300ms) before unmounting.
|
|
148
|
+
closeTimer.current = setTimeout(() => setPanelMounted(false), 300);
|
|
149
|
+
}, []);
|
|
150
|
+
|
|
151
|
+
const togglePanel = useCallback(() => {
|
|
152
|
+
if (panelOpen) closePanel();
|
|
153
|
+
else openPanel();
|
|
154
|
+
}, [panelOpen, openPanel, closePanel]);
|
|
155
|
+
|
|
156
|
+
useEffect(() => {
|
|
157
|
+
return () => {
|
|
158
|
+
if (closeTimer.current) clearTimeout(closeTimer.current);
|
|
159
|
+
};
|
|
160
|
+
}, []);
|
|
161
|
+
|
|
162
|
+
const effectiveKanbanGroupBy = kanbanGroupBy ?? config.kanbanGroupBy;
|
|
163
|
+
// Filters now live in the right-side rail, not as an inline per-view panel.
|
|
164
|
+
const effectiveConfig: ViewConfig = { ...config, showFilters: false };
|
|
165
|
+
|
|
166
|
+
const headerViews = useMemo<DataViewsHeaderView[]>(
|
|
167
|
+
() =>
|
|
168
|
+
VIEW_ORDER.filter((v) => enabledViews[v]).map((v) => ({
|
|
169
|
+
id: v,
|
|
170
|
+
label: VIEW_META[v].label,
|
|
171
|
+
icon: VIEW_META[v].icon,
|
|
172
|
+
})),
|
|
173
|
+
[enabledViews],
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
return (
|
|
177
|
+
<div
|
|
178
|
+
ref={ref}
|
|
179
|
+
data-theme={theme}
|
|
180
|
+
className={cn(
|
|
181
|
+
// Shell is always black (matches Figma): the dark header and config
|
|
182
|
+
// rail sit on it; the Master Container is the white surface inside.
|
|
183
|
+
"flex h-screen gap-2 bg-black p-2 text-content-presentation-global-primary",
|
|
184
|
+
className,
|
|
185
|
+
)}
|
|
186
|
+
>
|
|
187
|
+
{/* Left column: header + content. Shrinks as the panel expands. */}
|
|
188
|
+
<div className="flex min-w-0 flex-1 flex-col gap-2">
|
|
189
|
+
{showTitle && (
|
|
190
|
+
<DataViewsHeader
|
|
191
|
+
title={title}
|
|
192
|
+
views={headerViews}
|
|
193
|
+
currentView={currentView}
|
|
194
|
+
onViewChange={setCurrentView}
|
|
195
|
+
showSettings={showSettings}
|
|
196
|
+
settingsOpen={panelOpen}
|
|
197
|
+
onToggleSettings={togglePanel}
|
|
198
|
+
onAddNew={onAddNew}
|
|
199
|
+
addNewLabel={addNewLabel}
|
|
200
|
+
/>
|
|
201
|
+
)}
|
|
202
|
+
|
|
203
|
+
<main className="flex min-h-0 flex-1 overflow-hidden ">
|
|
204
|
+
{/* Master Container — white card, 16px radius, #D4D4D4 hairline
|
|
205
|
+
border. Fixed surface (matches header chrome). */}
|
|
206
|
+
<div className="flex flex-1 overflow-hidden rounded-[16px] border border-border-presentation-global-primary ">
|
|
207
|
+
{/* Clip the scrollable surface to the parent radius MINUS the 1px
|
|
208
|
+
border (16 − 1 = 15px). Using the full 16px here let the
|
|
209
|
+
opaque view background sit flush with the parent's outer edge
|
|
210
|
+
and bleed past the border as a ~1px light line on the
|
|
211
|
+
left/right straight sides. */}
|
|
212
|
+
<div className="flex-1 overflow-auto rounded-[15px]">
|
|
213
|
+
{currentView === "table" && enabledViews.table && (
|
|
214
|
+
<TableView
|
|
215
|
+
data={flatItems}
|
|
216
|
+
columns={detectedColumns}
|
|
217
|
+
fields={resolvedFields}
|
|
218
|
+
config={effectiveConfig}
|
|
219
|
+
onDataUpdate={onDataUpdate}
|
|
220
|
+
onSortChange={(sortBy, sortOrder) =>
|
|
221
|
+
setConfig({ sortBy, sortOrder })
|
|
222
|
+
}
|
|
223
|
+
filters={filters}
|
|
224
|
+
filterState={filterState}
|
|
225
|
+
onFilterChange={setFilterState}
|
|
226
|
+
showFilters={false}
|
|
227
|
+
/>
|
|
228
|
+
)}
|
|
229
|
+
{currentView === "kanban" && enabledViews.kanban && (
|
|
230
|
+
<KanbanView
|
|
231
|
+
data={flatItems}
|
|
232
|
+
columns={detectedColumns}
|
|
233
|
+
fields={resolvedFields}
|
|
234
|
+
config={effectiveConfig}
|
|
235
|
+
onDataUpdate={onDataUpdate}
|
|
236
|
+
groupByField={effectiveKanbanGroupBy}
|
|
237
|
+
/>
|
|
238
|
+
)}
|
|
239
|
+
{currentView === "inbox" && enabledViews.inbox && (
|
|
240
|
+
<InboxView
|
|
241
|
+
data={flatItems}
|
|
242
|
+
columns={detectedColumns}
|
|
243
|
+
fields={resolvedFields}
|
|
244
|
+
inboxConfig={inboxConfig}
|
|
245
|
+
config={effectiveConfig}
|
|
246
|
+
onDataUpdate={onDataUpdate}
|
|
247
|
+
filters={filters}
|
|
248
|
+
filterState={filterState}
|
|
249
|
+
onFilterChange={setFilterState}
|
|
250
|
+
showFilters={false}
|
|
251
|
+
/>
|
|
252
|
+
)}
|
|
253
|
+
{currentView === "tree" && enabledViews.tree && (
|
|
254
|
+
<TreeView
|
|
255
|
+
data={items}
|
|
256
|
+
columns={detectedColumns}
|
|
257
|
+
fields={resolvedFields}
|
|
258
|
+
treeConfig={treeConfig}
|
|
259
|
+
config={effectiveConfig}
|
|
260
|
+
onDataUpdate={onDataUpdate}
|
|
261
|
+
filters={filters}
|
|
262
|
+
filterState={filterState}
|
|
263
|
+
onFilterChange={setFilterState}
|
|
264
|
+
showFilters={false}
|
|
265
|
+
/>
|
|
266
|
+
)}
|
|
267
|
+
</div>
|
|
268
|
+
</div>
|
|
269
|
+
</main>
|
|
270
|
+
</div>
|
|
271
|
+
|
|
272
|
+
{/* Right rail: full-height sibling of the [header + content] column, so
|
|
273
|
+
opening it pushes the header as well as the content. The wrapper
|
|
274
|
+
animates its width so the left column reflows in sync with the
|
|
275
|
+
panel's slide-in. */}
|
|
276
|
+
{showSettings && panelMounted && (
|
|
277
|
+
<div
|
|
278
|
+
className={cn(
|
|
279
|
+
"shrink-0 overflow-hidden transition-[width] duration-300 ease-in-out",
|
|
280
|
+
panelOpen ? "w-[260px]" : "w-0",
|
|
281
|
+
)}
|
|
282
|
+
>
|
|
283
|
+
<DataViewsConfigPanel
|
|
284
|
+
state={panelOpen ? "open" : "closed"}
|
|
285
|
+
config={config}
|
|
286
|
+
onConfigChange={setConfig}
|
|
287
|
+
onClose={closePanel}
|
|
288
|
+
currentView={currentView}
|
|
289
|
+
fields={resolvedFields}
|
|
290
|
+
data={flatItems}
|
|
291
|
+
filterState={filterState}
|
|
292
|
+
onFilterChange={setFilterState}
|
|
293
|
+
filterConfig={filters}
|
|
294
|
+
/>
|
|
295
|
+
</div>
|
|
296
|
+
)}
|
|
297
|
+
</div>
|
|
298
|
+
);
|
|
299
|
+
},
|
|
300
|
+
);
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { Button } from "../Button"
|
|
4
|
+
import { Badge } from "../Badge"
|
|
5
|
+
import { X } from "lucide-react"
|
|
6
|
+
import { Checkbox } from "../Checkbox"
|
|
7
|
+
import { Divider } from "../Divider"
|
|
8
|
+
import { Label } from "../Label"
|
|
9
|
+
import type {
|
|
10
|
+
DynamicRecord,
|
|
11
|
+
DynamicFilterConfig,
|
|
12
|
+
FieldConfig,
|
|
13
|
+
FieldType,
|
|
14
|
+
FilterState,
|
|
15
|
+
FilterValue,
|
|
16
|
+
NumericRangeFilter,
|
|
17
|
+
DateRangeFilter,
|
|
18
|
+
} from "./types"
|
|
19
|
+
import { getByPath, formatPathLabel } from "../../utils/dataViews/pathUtils"
|
|
20
|
+
import {
|
|
21
|
+
computeNumericExtremes,
|
|
22
|
+
countActiveFilters,
|
|
23
|
+
inferStep,
|
|
24
|
+
isDateRange,
|
|
25
|
+
isNumericRange,
|
|
26
|
+
resolvePresets,
|
|
27
|
+
} from "../../utils/dataViews/rangeUtils"
|
|
28
|
+
import { RangeSliderWithInputs } from "./filters/RangeSliderWithInputs"
|
|
29
|
+
import { DateRangePopover } from "./filters/DateRangePopover"
|
|
30
|
+
import { PresetChips } from "./filters/PresetChips"
|
|
31
|
+
import { resolveBadgeVariant } from "./badgeAdapter"
|
|
32
|
+
|
|
33
|
+
type FilterPanelProps = {
|
|
34
|
+
data: DynamicRecord[]
|
|
35
|
+
fields: FieldConfig[]
|
|
36
|
+
filters: FilterState
|
|
37
|
+
onFilterChange: (path: string, value: FilterValue) => void
|
|
38
|
+
onClearAll: () => void
|
|
39
|
+
filterConfig?: DynamicFilterConfig[]
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const NUMERIC_TYPES: FieldType[] = [
|
|
43
|
+
"number",
|
|
44
|
+
"number-format",
|
|
45
|
+
"currency",
|
|
46
|
+
"progress-bar",
|
|
47
|
+
"star-rating",
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
const DATE_TYPES: FieldType[] = ["date", "date-format"]
|
|
51
|
+
|
|
52
|
+
type FilterKind = "categorical" | "numeric-range" | "date-range"
|
|
53
|
+
|
|
54
|
+
type Entry = {
|
|
55
|
+
path: string
|
|
56
|
+
label: string
|
|
57
|
+
kind: FilterKind
|
|
58
|
+
field?: FieldConfig
|
|
59
|
+
legacy?: DynamicFilterConfig
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function buildFilterableEntries(
|
|
63
|
+
data: DynamicRecord[],
|
|
64
|
+
fields: FieldConfig[],
|
|
65
|
+
legacy?: DynamicFilterConfig[],
|
|
66
|
+
): Entry[] {
|
|
67
|
+
const legacyByPath = new Map(
|
|
68
|
+
(legacy ?? []).filter((f) => f.enabled !== false).map((f) => [f.id, f]),
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
const entries: Entry[] = []
|
|
72
|
+
const seen = new Set<string>()
|
|
73
|
+
|
|
74
|
+
for (const f of fields) {
|
|
75
|
+
if (f.type === "hidden") continue
|
|
76
|
+
if (f.filterable === false) continue
|
|
77
|
+
|
|
78
|
+
const isExplicit = f.filterable === true
|
|
79
|
+
const isCategoricalAuto =
|
|
80
|
+
f.type === "enum-badge" ||
|
|
81
|
+
f.type === "boolean" ||
|
|
82
|
+
f.type === "badge-array" ||
|
|
83
|
+
f.type === "icon-text"
|
|
84
|
+
const isNumeric = f.type != null && NUMERIC_TYPES.includes(f.type)
|
|
85
|
+
const isDate = f.type != null && DATE_TYPES.includes(f.type)
|
|
86
|
+
|
|
87
|
+
let include = isExplicit || isCategoricalAuto
|
|
88
|
+
|
|
89
|
+
if (!include) {
|
|
90
|
+
if (f.type !== "text" && f.type !== undefined) continue
|
|
91
|
+
const unique = new Set<string>()
|
|
92
|
+
for (const item of data) {
|
|
93
|
+
const v = getByPath(item, f.path)
|
|
94
|
+
if (v == null) continue
|
|
95
|
+
unique.add(String(v))
|
|
96
|
+
if (unique.size > 10) break
|
|
97
|
+
}
|
|
98
|
+
include = unique.size > 0 && unique.size <= 10
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (!include) continue
|
|
102
|
+
|
|
103
|
+
const kind: FilterKind = isNumeric ? "numeric-range" : isDate ? "date-range" : "categorical"
|
|
104
|
+
|
|
105
|
+
entries.push({
|
|
106
|
+
path: f.path,
|
|
107
|
+
label: f.filterLabel ?? f.label ?? formatPathLabel(f.path),
|
|
108
|
+
kind,
|
|
109
|
+
field: f,
|
|
110
|
+
legacy: legacyByPath.get(f.path),
|
|
111
|
+
})
|
|
112
|
+
seen.add(f.path)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
for (const lf of legacyByPath.values()) {
|
|
116
|
+
if (seen.has(lf.id)) continue
|
|
117
|
+
entries.push({
|
|
118
|
+
path: lf.id,
|
|
119
|
+
label: lf.label ?? formatPathLabel(lf.id),
|
|
120
|
+
kind: "categorical",
|
|
121
|
+
legacy: lf,
|
|
122
|
+
})
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return entries
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function getCategoricalOptions(
|
|
129
|
+
data: DynamicRecord[],
|
|
130
|
+
path: string,
|
|
131
|
+
field?: FieldConfig,
|
|
132
|
+
legacy?: DynamicFilterConfig,
|
|
133
|
+
): string[] {
|
|
134
|
+
if (field?.filterOptions && field.filterOptions.length > 0) {
|
|
135
|
+
return normalizeOptions(field.filterOptions)
|
|
136
|
+
}
|
|
137
|
+
if (legacy?.options && legacy.options.length > 0) {
|
|
138
|
+
return normalizeOptions(legacy.options)
|
|
139
|
+
}
|
|
140
|
+
if (field?.variants) {
|
|
141
|
+
const fromMap = Object.keys(field.variants)
|
|
142
|
+
const fromData = collectUnique(data, path)
|
|
143
|
+
return Array.from(new Set([...fromMap, ...fromData]))
|
|
144
|
+
}
|
|
145
|
+
return collectUnique(data, path)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function normalizeOptions(opts: NonNullable<FieldConfig["filterOptions"]>): string[] {
|
|
149
|
+
if (opts.length === 0) return []
|
|
150
|
+
if (typeof opts[0] === "string") return opts as string[]
|
|
151
|
+
return (opts as { label: string; value: string }[]).map((o) => o.value)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function collectUnique(data: DynamicRecord[], path: string): string[] {
|
|
155
|
+
const set = new Set<string>()
|
|
156
|
+
for (const item of data) {
|
|
157
|
+
const v = getByPath(item, path)
|
|
158
|
+
if (v == null) continue
|
|
159
|
+
if (Array.isArray(v)) {
|
|
160
|
+
for (const x of v) set.add(String(x))
|
|
161
|
+
} else {
|
|
162
|
+
set.add(String(v))
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return Array.from(set).sort()
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function FilterPanel({
|
|
169
|
+
data,
|
|
170
|
+
fields,
|
|
171
|
+
filters,
|
|
172
|
+
onFilterChange,
|
|
173
|
+
onClearAll,
|
|
174
|
+
filterConfig,
|
|
175
|
+
}: FilterPanelProps) {
|
|
176
|
+
const entries = buildFilterableEntries(data, fields, filterConfig)
|
|
177
|
+
|
|
178
|
+
const setFilter = (path: string, value: FilterValue) => {
|
|
179
|
+
onFilterChange(path, value)
|
|
180
|
+
const field = fields.find((f) => f.path === path)
|
|
181
|
+
field?.onFilterChange?.(value)
|
|
182
|
+
if (Array.isArray(value)) {
|
|
183
|
+
const legacy = filterConfig?.find((f) => f.id === path)
|
|
184
|
+
legacy?.onChange?.(value)
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const toggleCategorical = (path: string, option: string) => {
|
|
189
|
+
const current = filters[path]
|
|
190
|
+
const arr = Array.isArray(current) ? current : []
|
|
191
|
+
const next = arr.includes(option) ? arr.filter((v) => v !== option) : [...arr, option]
|
|
192
|
+
setFilter(path, next)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const totalFilters = countActiveFilters(filters)
|
|
196
|
+
|
|
197
|
+
if (entries.length === 0) return null
|
|
198
|
+
|
|
199
|
+
const countBadge = resolveBadgeVariant("gray")
|
|
200
|
+
|
|
201
|
+
return (
|
|
202
|
+
<div className="w-64 border-r border-border-presentation-global-primary bg-background-presentation-body-overlay-primary p-4 overflow-y-auto">
|
|
203
|
+
<div className="flex items-center justify-between mb-4">
|
|
204
|
+
<div className="flex items-center gap-2">
|
|
205
|
+
<h3 className="font-semibold text-content-presentation-global-primary">Filters</h3>
|
|
206
|
+
{totalFilters > 0 && (
|
|
207
|
+
<Badge
|
|
208
|
+
{...countBadge}
|
|
209
|
+
label={String(totalFilters)}
|
|
210
|
+
className="h-5 min-w-[20px] rounded-full p-0 text-xs"
|
|
211
|
+
size="XS"
|
|
212
|
+
|
|
213
|
+
/>
|
|
214
|
+
)}
|
|
215
|
+
</div>
|
|
216
|
+
{totalFilters > 0 && (
|
|
217
|
+
<Button variant="PrimeStyle" size="M" onClick={onClearAll} className="h-7 gap-1 text-xs">
|
|
218
|
+
<X className="h-3 w-3" />
|
|
219
|
+
Clear
|
|
220
|
+
</Button>
|
|
221
|
+
)}
|
|
222
|
+
</div>
|
|
223
|
+
|
|
224
|
+
<div className="space-y-4">
|
|
225
|
+
{entries.map((entry, index) => (
|
|
226
|
+
<div key={entry.path}>
|
|
227
|
+
{index > 0 && <Divider className="mb-4" />}
|
|
228
|
+
<div className="space-y-2">
|
|
229
|
+
<Label className="text-content-presentation-global-primary">{entry.label}</Label>
|
|
230
|
+
<FilterBody
|
|
231
|
+
entry={entry}
|
|
232
|
+
data={data}
|
|
233
|
+
value={filters[entry.path]}
|
|
234
|
+
onCategoricalToggle={(opt) => toggleCategorical(entry.path, opt)}
|
|
235
|
+
onSetFilter={(v) => setFilter(entry.path, v)}
|
|
236
|
+
/>
|
|
237
|
+
</div>
|
|
238
|
+
</div>
|
|
239
|
+
))}
|
|
240
|
+
</div>
|
|
241
|
+
</div>
|
|
242
|
+
)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function FilterBody({
|
|
246
|
+
entry,
|
|
247
|
+
data,
|
|
248
|
+
value,
|
|
249
|
+
onCategoricalToggle,
|
|
250
|
+
onSetFilter,
|
|
251
|
+
}: {
|
|
252
|
+
entry: Entry
|
|
253
|
+
data: DynamicRecord[]
|
|
254
|
+
value: FilterValue | undefined
|
|
255
|
+
onCategoricalToggle: (option: string) => void
|
|
256
|
+
onSetFilter: (next: FilterValue) => void
|
|
257
|
+
}) {
|
|
258
|
+
if (entry.kind === "numeric-range" && entry.field) {
|
|
259
|
+
const extremes = computeNumericExtremes(data, entry.path)
|
|
260
|
+
if (!extremes || extremes.min === extremes.max) {
|
|
261
|
+
return <div className="text-xs text-content-presentation-global-tertiary">No range to filter.</div>
|
|
262
|
+
}
|
|
263
|
+
const step = inferStep(entry.field, extremes)
|
|
264
|
+
const presets = resolvePresets(entry.field)
|
|
265
|
+
const numericValue: NumericRangeFilter | undefined = isNumericRange(value) ? value : undefined
|
|
266
|
+
return (
|
|
267
|
+
<div className="space-y-3">
|
|
268
|
+
{presets.length > 0 && (
|
|
269
|
+
<PresetChips presets={presets} current={value} onSelect={onSetFilter} />
|
|
270
|
+
)}
|
|
271
|
+
<RangeSliderWithInputs
|
|
272
|
+
field={entry.field}
|
|
273
|
+
extremes={extremes}
|
|
274
|
+
step={step}
|
|
275
|
+
value={numericValue}
|
|
276
|
+
onChange={onSetFilter}
|
|
277
|
+
/>
|
|
278
|
+
</div>
|
|
279
|
+
)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (entry.kind === "date-range" && entry.field) {
|
|
283
|
+
const presets = resolvePresets(entry.field)
|
|
284
|
+
const dateValue: DateRangeFilter | undefined = isDateRange(value) ? value : undefined
|
|
285
|
+
return (
|
|
286
|
+
<DateRangePopover
|
|
287
|
+
value={dateValue}
|
|
288
|
+
onChange={onSetFilter}
|
|
289
|
+
presets={presets.length > 0 ? presets : undefined}
|
|
290
|
+
/>
|
|
291
|
+
)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const opts = getCategoricalOptions(data, entry.path, entry.field, entry.legacy)
|
|
295
|
+
const selected = Array.isArray(value) ? value : []
|
|
296
|
+
return (
|
|
297
|
+
<div className="space-y-2">
|
|
298
|
+
{opts.map((opt) => {
|
|
299
|
+
const isSelected = selected.includes(opt)
|
|
300
|
+
const variant = entry.field?.variants?.[opt]
|
|
301
|
+
const badgeProps = variant ? resolveBadgeVariant(variant) : null
|
|
302
|
+
return (
|
|
303
|
+
<div key={opt} className="flex items-center space-x-2">
|
|
304
|
+
<Checkbox
|
|
305
|
+
id={`${entry.path}-${opt}`}
|
|
306
|
+
checked={isSelected}
|
|
307
|
+
onCheckedChange={() => onCategoricalToggle(opt)}
|
|
308
|
+
/>
|
|
309
|
+
<label
|
|
310
|
+
htmlFor={`${entry.path}-${opt}`}
|
|
311
|
+
className="text-sm text-content-presentation-global-primary cursor-pointer leading-none flex-1"
|
|
312
|
+
>
|
|
313
|
+
{entry.legacy?.render
|
|
314
|
+
? entry.legacy.render(opt, isSelected)
|
|
315
|
+
: badgeProps
|
|
316
|
+
? <Badge {...badgeProps} label={opt} size="XS" />
|
|
317
|
+
: opt}
|
|
318
|
+
</label>
|
|
319
|
+
</div>
|
|
320
|
+
)
|
|
321
|
+
})}
|
|
322
|
+
</div>
|
|
323
|
+
)
|
|
324
|
+
}
|