@hachej/boring-data-explorer 0.1.40 → 0.1.42
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/README.md +73 -217
- package/dist/front/index.d.ts +10 -2
- package/dist/front/index.js +96 -43
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,271 +1,127 @@
|
|
|
1
1
|
# @hachej/boring-data-explorer
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Searchable, faceted data tables for the workbench. This is the headless
|
|
4
|
+
primitive that `@hachej/boring-data-catalog` and other explorer-style plugins
|
|
5
|
+
build on. It is **not** a workspace plugin itself — it ships a React component,
|
|
6
|
+
a hook, and an adapter contract.
|
|
4
7
|
|
|
5
|
-
|
|
8
|
+
## What it does
|
|
6
9
|
|
|
7
|
-
|
|
10
|
+
- Renders a `<DataExplorer>` table: search box, faceted filter popover, optional
|
|
11
|
+
group/tree mode, row activation, and drag-out payloads.
|
|
12
|
+
- Manages query / facet / paging / selection state via `useExplorerState`.
|
|
13
|
+
- Defines the `ExplorerDataSource` adapter contract: you implement
|
|
14
|
+
`search(args)` (and optionally `fetchFacets(args)`) against any backend.
|
|
8
15
|
|
|
9
|
-
|
|
16
|
+
## Usage
|
|
10
17
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
```
|
|
14
|
-
|
|
15
|
-
> **Note:** This plugin is workspace-private (`"private": true`) — install from source within the monorepo.
|
|
16
|
-
|
|
17
|
-
---
|
|
18
|
-
|
|
19
|
-
## TL;DR
|
|
20
|
-
|
|
21
|
-
**The Problem**: You have data — customers, invoices, logs, metrics — and you want users to search, filter, and explore it inside an agent app. But building faceted tables with virtualization, paging, and selection from scratch is tedious.
|
|
22
|
-
|
|
23
|
-
**The Solution**: `@hachej/boring-data-explorer` provides a controlled `<DataExplorer>` component + `useExplorerState` hook + an `ExplorerDataSource` adapter contract. You implement `search(args)` (and optionally `fetchFacets(args)`) against any backend. The component handles the rest.
|
|
24
|
-
|
|
25
|
-
### Why Use @hachej/boring-data-explorer?
|
|
26
|
-
|
|
27
|
-
| Feature | What It Does |
|
|
28
|
-
|---------|--------------|
|
|
29
|
-
| **`<DataExplorer>` component** | Search box, faceted filters, virtualized rows, row selection, drag-and-drop payload |
|
|
30
|
-
| **`useExplorerState` hook** | Manages query, facets, paging, and selection state |
|
|
31
|
-
| **Adapter contract** | `search(args)` + optional `fetchFacets(args)` — implement once, plug any backend (SQL, REST, in-memory, gRPC) |
|
|
32
|
-
| **Agent-driven** | Rows expose a `DragPayload`; the agent can open any row via surface resolver |
|
|
33
|
-
| **Headless + styled** | Use the hook alone for custom UI, or mount the full component out of the box |
|
|
34
|
-
|
|
35
|
-
---
|
|
36
|
-
|
|
37
|
-
## Quick Example
|
|
18
|
+
`<DataExplorer>` is self-contained — it calls `useExplorerState` internally, so
|
|
19
|
+
you pass the **adapter** (not a state object):
|
|
38
20
|
|
|
39
21
|
```tsx
|
|
40
|
-
import { DataExplorer
|
|
41
|
-
import type { ExplorerDataSource
|
|
22
|
+
import { DataExplorer } from "@hachej/boring-data-explorer/front"
|
|
23
|
+
import type { ExplorerDataSource } from "@hachej/boring-data-explorer/shared"
|
|
42
24
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
async search({ query, filters, limit, offset, signal }): Promise<SearchResult> {
|
|
25
|
+
const customers: ExplorerDataSource = {
|
|
26
|
+
async search({ query, filters, limit, offset, signal }) {
|
|
46
27
|
const res = await fetch(`/api/customers?q=${query}&limit=${limit}&offset=${offset}`, { signal })
|
|
47
28
|
const { items, total } = await res.json()
|
|
48
29
|
return { items, total, hasMore: offset + items.length < total }
|
|
49
30
|
},
|
|
50
31
|
}
|
|
51
32
|
|
|
52
|
-
// 2. Mount the explorer
|
|
53
33
|
export function CustomersPane() {
|
|
54
|
-
|
|
55
|
-
return <DataExplorer state={state} />
|
|
34
|
+
return <DataExplorer adapter={customers} pageSize={50} />
|
|
56
35
|
}
|
|
57
36
|
```
|
|
58
37
|
|
|
59
|
-
|
|
38
|
+
Use `useExplorerState({ adapter, facets, groupBy, pageSize })` directly only
|
|
39
|
+
when you need to drive a fully custom UI.
|
|
40
|
+
|
|
41
|
+
### `<DataExplorer>` props
|
|
60
42
|
|
|
61
|
-
|
|
43
|
+
`adapter` (required), `facets`, `groupBy`, `onActivate(row)`,
|
|
44
|
+
`getDragPayload(row)`, `emptyState`, `searchPlaceholder`, `toolbarTitle`,
|
|
45
|
+
`toolbarIcon`, `searchable`, `query` + `onQueryChange` (controlled search),
|
|
46
|
+
`pageSize`, `debounceMs`, `className`. Facets render only when the adapter
|
|
47
|
+
implements `fetchFacets`. `groupBy` enables tree mode (the key must match a
|
|
48
|
+
facet key); an active query/filter forces flat mode.
|
|
62
49
|
|
|
63
|
-
## Adapter
|
|
50
|
+
## Adapter contract
|
|
64
51
|
|
|
65
52
|
```ts
|
|
66
|
-
|
|
67
|
-
export type ExplorerItem = {
|
|
53
|
+
type ExplorerItem = {
|
|
68
54
|
id: string
|
|
69
55
|
title: string
|
|
70
|
-
subtitle?: string
|
|
71
|
-
group?: string
|
|
72
|
-
leading?: Badge
|
|
73
|
-
trailing?: Badge[]
|
|
74
|
-
meta?: string
|
|
56
|
+
subtitle?: string // muted second line
|
|
57
|
+
group?: string // group key — must match a facet value
|
|
58
|
+
leading?: Badge // leading mono chip
|
|
59
|
+
trailing?: Badge[] // trailing status chips
|
|
60
|
+
meta?: string // right-aligned plain text (e.g. "1.2M")
|
|
75
61
|
}
|
|
62
|
+
type Badge = { code: string; tooltip?: string } // code = 1–4 char chip
|
|
76
63
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
64
|
+
type FacetConfig = {
|
|
65
|
+
key: string
|
|
66
|
+
label: string
|
|
67
|
+
order?: string[] // explicit display order
|
|
68
|
+
formatValue?: (value: string) => string
|
|
80
69
|
}
|
|
81
70
|
|
|
82
|
-
|
|
83
|
-
export type SearchArgs = {
|
|
71
|
+
type SearchArgs = {
|
|
84
72
|
query: string
|
|
85
|
-
filters: Record<string, string[]>
|
|
86
|
-
group?: { key: string; value: string }
|
|
73
|
+
filters: Record<string, string[]>
|
|
74
|
+
group?: { key: string; value: string } // set when paging inside a group
|
|
87
75
|
limit: number
|
|
88
76
|
offset: number
|
|
89
77
|
signal?: AbortSignal
|
|
90
78
|
}
|
|
79
|
+
type SearchResult = { items: ExplorerItem[]; total: number; hasMore: boolean }
|
|
91
80
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
items: ExplorerItem[]
|
|
95
|
-
total: number // total count for the current scope (query + filters + optional group)
|
|
96
|
-
hasMore: boolean // whether there are more pages
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
// Facets — optional. When omitted, the explorer renders flat (no facet popover)
|
|
100
|
-
export type Facets = Record<string, FacetValue[]>
|
|
101
|
-
export type FacetValue = { value: string; count: number }
|
|
81
|
+
type FacetsArgs = { filters: Record<string, string[]>; signal?: AbortSignal }
|
|
82
|
+
type Facets = Record<string, { value: string; count: number }[]>
|
|
102
83
|
|
|
103
|
-
|
|
104
|
-
filters: Record<string, string[]>
|
|
105
|
-
signal?: AbortSignal
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
// The adapter contract
|
|
109
|
-
export interface ExplorerDataSource {
|
|
84
|
+
interface ExplorerDataSource {
|
|
110
85
|
search(args: SearchArgs): Promise<SearchResult>
|
|
111
86
|
fetchFacets?(args: FacetsArgs): Promise<Facets> // optional
|
|
112
87
|
}
|
|
113
|
-
```
|
|
114
|
-
|
|
115
|
-
Implement against **any backend**. The component doesn't care whether data comes from SQL, REST, Elasticsearch, or a JSON file.
|
|
116
|
-
|
|
117
|
-
### Static Data Adapter
|
|
118
88
|
|
|
119
|
-
|
|
120
|
-
import type { ExplorerDataSource, ExplorerItem } from "@hachej/boring-data-explorer/shared"
|
|
121
|
-
|
|
122
|
-
const entries: ExplorerItem[] = [
|
|
123
|
-
{ id: "customers", title: "Customers", subtitle: "Customer records", leading: { code: "tbl" } },
|
|
124
|
-
{ id: "invoices", title: "Invoices", subtitle: "Invoice records", leading: { code: "tbl" }, group: "finance" },
|
|
125
|
-
]
|
|
126
|
-
|
|
127
|
-
const adapter: ExplorerDataSource = {
|
|
128
|
-
async search({ query, limit, offset }) {
|
|
129
|
-
const normalized = query.trim().toLowerCase()
|
|
130
|
-
const matched = normalized
|
|
131
|
-
? entries.filter((entry) => `${entry.title} ${entry.subtitle ?? ""}`.toLowerCase().includes(normalized))
|
|
132
|
-
: entries
|
|
133
|
-
const items = matched.slice(offset, offset + limit)
|
|
134
|
-
return { items, total: matched.length, hasMore: offset + items.length < matched.length }
|
|
135
|
-
},
|
|
136
|
-
}
|
|
89
|
+
type DragPayload = { mimeType: string; value: string }
|
|
137
90
|
```
|
|
138
91
|
|
|
139
|
-
|
|
92
|
+
The adapter is backend-agnostic — SQL, REST, in-memory, anything that can return
|
|
93
|
+
`ExplorerItem[]`. A static in-memory adapter just filters and slices an array
|
|
94
|
+
inside `search()`.
|
|
140
95
|
|
|
141
|
-
##
|
|
96
|
+
## Package surfaces
|
|
142
97
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
98
|
+
| Import | Exports |
|
|
99
|
+
|--------|---------|
|
|
100
|
+
| `@hachej/boring-data-explorer` / `/front` | `DataExplorer`, `useExplorerState`, all types |
|
|
101
|
+
| `@hachej/boring-data-explorer/shared` | contract types only (no React) |
|
|
102
|
+
| `@hachej/boring-data-explorer/testing` | `createMockSeriesAdapter`, `createMockTablesAdapter` |
|
|
147
103
|
|
|
148
|
-
|
|
104
|
+
## Notes
|
|
149
105
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
| `@hachej/boring-data-explorer/shared` | Any | `ExplorerDataSource`, `ExplorerItem`, `Facets`, `SearchArgs`, `DragPayload`, `Badge` — no React deps |
|
|
157
|
-
| `@hachej/boring-data-explorer/testing` | Browser | Test utilities and mock data sources |
|
|
158
|
-
|
|
159
|
-
---
|
|
160
|
-
|
|
161
|
-
## Architecture
|
|
162
|
-
|
|
163
|
-
```
|
|
164
|
-
┌─────────────────────────────┐
|
|
165
|
-
│ <DataExplorer> │
|
|
166
|
-
│ ┌───┬─────┬─────────────┐ │
|
|
167
|
-
│ │ 🔍 │ Facets │ Results │ │
|
|
168
|
-
│ └───┴─────┴─────────────┘ │
|
|
169
|
-
│ ┌───┐ ┌───────────────┐ │
|
|
170
|
-
│ │ ← │ │ 1..25 of 430 │ │
|
|
171
|
-
│ └───┘ └───────────────┘ │
|
|
172
|
-
└──────────────┬─────────────┘
|
|
173
|
-
│ source.search(args)
|
|
174
|
-
│ source.fetchFacets?(args)
|
|
175
|
-
┌──────────────▼─────────────┐
|
|
176
|
-
│ ExplorerDataSource │
|
|
177
|
-
│ (your adapter impl) │
|
|
178
|
-
│ │
|
|
179
|
-
│ search({ query, filters, │
|
|
180
|
-
│ limit, offset }) │
|
|
181
|
-
│ │
|
|
182
|
-
│ fetchFacets?({ filters }) │
|
|
183
|
-
└──────────────┬─────────────┘
|
|
184
|
-
│
|
|
185
|
-
┌──────────────▼─────────────┐
|
|
186
|
-
│ Any Backend │
|
|
187
|
-
│ PostgreSQL · REST API │
|
|
188
|
-
│ Elasticsearch · In-memory│
|
|
189
|
-
│ gRPC · whatever │
|
|
190
|
-
└────────────────────────────┘
|
|
191
|
-
```
|
|
192
|
-
|
|
193
|
-
---
|
|
194
|
-
|
|
195
|
-
## How @hachej/boring-data-explorer Compares
|
|
196
|
-
|
|
197
|
-
| Feature | @hachej/boring-data-explorer | AG Grid | TanStack Table | Build your own |
|
|
198
|
-
|---------|------------------------------|---------|----------------|----------------|
|
|
199
|
-
| Built-in search + facets | ✅ One hook | ⚠️ Filter only | ❌ DIY | ❌ |
|
|
200
|
-
| Backend adapter contract | ✅ `search` + `fetchFacets` | ❌ In-memory | ⚠️ Sorting/filtering | ❌ |
|
|
201
|
-
| Drag-and-drop payload | ✅ `DragPayload` type | ⚠️ Add-on | ❌ | ❌ |
|
|
202
|
-
| Agent integration | ✅ Surface resolver | ❌ | ❌ | ❌ |
|
|
203
|
-
| Virtualized rows | ✅ | ✅ | ⚠️ Manual | ❌ |
|
|
204
|
-
| Complexity | ✅ ~10 lines to mount | ❌ Heavy API | ⚠️ Complex API | ❌ Weeks |
|
|
205
|
-
|
|
206
|
-
**When to use @hachej/boring-data-explorer:**
|
|
207
|
-
- You want a fast, searchable table with facet filters in a boring-ui workbench
|
|
208
|
-
- You're building a data catalog plugin for an agent app
|
|
209
|
-
- You need drag-and-drop from table rows to other panels
|
|
210
|
-
|
|
211
|
-
**When it might not fit:**
|
|
212
|
-
- You need enterprise-grade data grids (use AG Grid or TanStack Table)
|
|
213
|
-
- You need editable cells, pivot tables, or group-by aggregations
|
|
214
|
-
- You're building outside of a boring-ui workspace (you'd need the layout shell)
|
|
215
|
-
|
|
216
|
-
---
|
|
217
|
-
|
|
218
|
-
## Troubleshooting
|
|
219
|
-
|
|
220
|
-
| Error | Cause | Fix |
|
|
221
|
-
|-------|-------|-----|
|
|
222
|
-
| No rows showing | `search()` returned empty items | Check your backend query — log the response |
|
|
223
|
-
| Facets empty | `fetchFacets()` not implemented | Add the optional `fetchFacets` method to your adapter |
|
|
224
|
-
| Infinite loading loop | `useExplorerState` deps changing every render | Memoize your `source` object or move it outside the component |
|
|
225
|
-
| Drag not working | Row has no `DragPayload` | Pass `getDragPayload` prop to `<DataExplorer>` |
|
|
226
|
-
| Wrong source selected | Adapter returns the wrong item ids or payload | Check your adapter's `search()` mapping |
|
|
227
|
-
|
|
228
|
-
---
|
|
229
|
-
|
|
230
|
-
## Limitations
|
|
231
|
-
|
|
232
|
-
- **Workspace-private** — `"private": true` in package.json. Not published to npm. Install from source within the monorepo.
|
|
233
|
-
- **Not an enterprise data grid** — No cell editing, pivot tables, or group-by. It's a search-and-filter primitive, not AG Grid.
|
|
234
|
-
- **No built-in data fetching** — You implement `search()` and optionally `fetchFacets()`. The adapter doesn't cache or debounce.
|
|
235
|
-
- **Requires boring-ui workspace chrome** — The component assumes it's mounted inside a workspace panel layout. Standalone use is possible but you lose the drag-to-panel behavior.
|
|
236
|
-
- **No column customization UI** — Column shape is derived from `ExplorerItem` (title / subtitle / leading / trailing / meta) — no arbitrary column projection.
|
|
237
|
-
|
|
238
|
-
---
|
|
239
|
-
|
|
240
|
-
## FAQ
|
|
241
|
-
|
|
242
|
-
**Q: What's the difference between `data-explorer` and `data-catalog`?**
|
|
243
|
-
A: `data-explorer` is the primitive — a single table you mount wherever. `data-catalog` is a higher-level plugin: it puts a sidebar tab listing data sources, and clicking a source opens the explorer table.
|
|
244
|
-
|
|
245
|
-
**Q: Can I use this without the boring-ui workspace?**
|
|
246
|
-
A: Yes. `<DataExplorer>` and `useExplorerState` are standalone React exports. You'll just lose the drag-to-panel integration since there's no workbench to receive the payload.
|
|
247
|
-
|
|
248
|
-
**Q: How do I customize the table columns?**
|
|
249
|
-
A: Columns are derived from `ExplorerItem` fields: `title` (primary), `subtitle` (secondary), `leading` (mono chip), `trailing` (status badges), `meta` (right-aligned text). There is no per-column projection API.
|
|
250
|
-
|
|
251
|
-
**Q: Does it support server-side pagination?**
|
|
252
|
-
A: Yes — `search()` receives `limit` and `offset`. Your backend can return a slice. `total` and `hasMore` drive the pager.
|
|
253
|
-
|
|
254
|
-
**Q: Can I use this for non-tabular data?**
|
|
255
|
-
A: Yes — map any data shape (GraphQL results, CSV rows, API responses) to `ExplorerItem[]`. The adapter is backend-agnostic.
|
|
256
|
-
|
|
257
|
-
---
|
|
106
|
+
- Not an enterprise grid: no cell editing, pivots, group-by aggregation, or
|
|
107
|
+
per-column projection. Columns are derived from `ExplorerItem` fields.
|
|
108
|
+
- No built-in caching/debounce of fetches beyond the explorer's own search
|
|
109
|
+
debounce — cache at the adapter layer if needed.
|
|
110
|
+
- Drag-to-panel only works inside a boring-ui workspace (which receives the
|
|
111
|
+
payload); standalone use still renders the table.
|
|
258
112
|
|
|
259
113
|
## Used by
|
|
260
114
|
|
|
261
|
-
-
|
|
262
|
-
|
|
115
|
+
- `@hachej/boring-data-catalog` — wraps this into a configurable catalog tab +
|
|
116
|
+
visualization panel + agent tool.
|
|
263
117
|
|
|
264
|
-
|
|
118
|
+
## Validation
|
|
265
119
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
120
|
+
```bash
|
|
121
|
+
pnpm --filter @hachej/boring-data-explorer typecheck
|
|
122
|
+
pnpm --filter @hachej/boring-data-explorer test
|
|
123
|
+
pnpm --filter @hachej/boring-data-explorer build
|
|
124
|
+
```
|
|
269
125
|
|
|
270
126
|
## License
|
|
271
127
|
|
package/dist/front/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as react_jsx_runtime from 'react/jsx-runtime';
|
|
2
|
-
import { ReactNode } from 'react';
|
|
2
|
+
import { ReactNode, ComponentType } from 'react';
|
|
3
3
|
import { ExplorerDataSource, FacetConfig, ExplorerItem, DragPayload, Facets } from '../shared/index.js';
|
|
4
4
|
export { Badge, FacetValue, FacetsArgs, SearchArgs, SearchResult } from '../shared/index.js';
|
|
5
5
|
|
|
@@ -16,6 +16,12 @@ type DataExplorerProps = {
|
|
|
16
16
|
/** Empty state shown when the top-level result has no rows and no query/filters. */
|
|
17
17
|
emptyState?: ReactNode;
|
|
18
18
|
searchPlaceholder?: string;
|
|
19
|
+
/** Optional title rendered inside the explorer toolbar (for chromeless left tabs). */
|
|
20
|
+
toolbarTitle?: ReactNode;
|
|
21
|
+
/** Optional icon rendered before toolbarTitle. */
|
|
22
|
+
toolbarIcon?: ComponentType<{
|
|
23
|
+
className?: string;
|
|
24
|
+
}>;
|
|
19
25
|
/** Hide the search input. Default true. */
|
|
20
26
|
searchable?: boolean;
|
|
21
27
|
/**
|
|
@@ -26,12 +32,14 @@ type DataExplorerProps = {
|
|
|
26
32
|
query?: string;
|
|
27
33
|
/** Called when the toolbar search changes. Use with `query` for controlled per-tab search. */
|
|
28
34
|
onQueryChange?: (query: string) => void;
|
|
35
|
+
/** Optional external chrome target for toolbar-only actions such as filters. */
|
|
36
|
+
toolbarPortalElement?: Element | null;
|
|
29
37
|
/** Page size and debounce — passed through to useExplorerState. */
|
|
30
38
|
pageSize?: number;
|
|
31
39
|
debounceMs?: number;
|
|
32
40
|
className?: string;
|
|
33
41
|
};
|
|
34
|
-
declare function DataExplorer({ adapter, facets: facetConfigs, groupBy, onActivate, getDragPayload, emptyState, searchPlaceholder, searchable, query, onQueryChange, pageSize, debounceMs, className, }: DataExplorerProps): react_jsx_runtime.JSX.Element;
|
|
42
|
+
declare function DataExplorer({ adapter, facets: facetConfigs, groupBy, onActivate, getDragPayload, emptyState, searchPlaceholder, toolbarTitle, toolbarIcon, searchable, query, onQueryChange, toolbarPortalElement, pageSize, debounceMs, className, }: DataExplorerProps): react_jsx_runtime.JSX.Element;
|
|
35
43
|
|
|
36
44
|
type UseExplorerStateOptions = {
|
|
37
45
|
adapter: ExplorerDataSource;
|
package/dist/front/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// src/front/DataExplorer.tsx
|
|
2
2
|
import { useMemo } from "react";
|
|
3
|
+
import { createPortal } from "react-dom";
|
|
3
4
|
import { ChevronRightIcon, ChevronDownIcon, FilterIcon, SearchIcon, XIcon } from "lucide-react";
|
|
4
5
|
|
|
5
6
|
// src/front/utils.ts
|
|
@@ -353,9 +354,12 @@ function DataExplorer({
|
|
|
353
354
|
getDragPayload,
|
|
354
355
|
emptyState = "No results",
|
|
355
356
|
searchPlaceholder = "Search\u2026",
|
|
357
|
+
toolbarTitle,
|
|
358
|
+
toolbarIcon,
|
|
356
359
|
searchable = true,
|
|
357
360
|
query,
|
|
358
361
|
onQueryChange,
|
|
362
|
+
toolbarPortalElement,
|
|
359
363
|
pageSize,
|
|
360
364
|
debounceMs,
|
|
361
365
|
className
|
|
@@ -375,6 +379,9 @@ function DataExplorer({
|
|
|
375
379
|
const hasFilters = Object.values(state.filters).some((v) => v.length > 0);
|
|
376
380
|
const treeMode = !!groupBy && !hasQuery && !hasFilters;
|
|
377
381
|
const filterCount = Object.values(state.filters).reduce((n, v) => n + v.length, 0);
|
|
382
|
+
const useExternalToolbarActions = Boolean(
|
|
383
|
+
toolbarPortalElement && !showSearch && facetConfigs?.length && !toolbarTitle
|
|
384
|
+
);
|
|
378
385
|
const groupEntries = useMemo(() => {
|
|
379
386
|
if (!treeMode || !groupBy) return [];
|
|
380
387
|
const config = facetConfigs?.find((f) => f.key === groupBy);
|
|
@@ -395,11 +402,27 @@ function DataExplorer({
|
|
|
395
402
|
}, [treeMode, groupBy, facetConfigs, state.facets]);
|
|
396
403
|
const showEmpty = !state.loading && !treeMode && state.topItems.length === 0 && state.query.length === 0 && !hasFilters;
|
|
397
404
|
return /* @__PURE__ */ jsxs("div", { className: cn("flex h-full flex-col", className), "data-slot": "data-explorer", children: [
|
|
398
|
-
|
|
405
|
+
useExternalToolbarActions && toolbarPortalElement ? createPortal(
|
|
406
|
+
/* @__PURE__ */ jsx(
|
|
407
|
+
ExternalToolbarActions,
|
|
408
|
+
{
|
|
409
|
+
facetConfigs,
|
|
410
|
+
facets: state.facets,
|
|
411
|
+
filters: state.filters,
|
|
412
|
+
filterCount,
|
|
413
|
+
onToggleFilter: state.toggleFilter,
|
|
414
|
+
onClearFilters: state.clearFilters
|
|
415
|
+
}
|
|
416
|
+
),
|
|
417
|
+
toolbarPortalElement
|
|
418
|
+
) : null,
|
|
419
|
+
(showSearch || facetConfigs?.length) && !useExternalToolbarActions ? /* @__PURE__ */ jsx(
|
|
399
420
|
Toolbar,
|
|
400
421
|
{
|
|
401
422
|
searchable: showSearch,
|
|
402
423
|
searchPlaceholder,
|
|
424
|
+
title: toolbarTitle,
|
|
425
|
+
icon: toolbarIcon,
|
|
403
426
|
query: state.query,
|
|
404
427
|
onQueryChange: setToolbarQuery,
|
|
405
428
|
facetConfigs,
|
|
@@ -439,6 +462,8 @@ function DataExplorer({
|
|
|
439
462
|
function Toolbar({
|
|
440
463
|
searchable,
|
|
441
464
|
searchPlaceholder,
|
|
465
|
+
title,
|
|
466
|
+
icon: Icon,
|
|
442
467
|
query,
|
|
443
468
|
onQueryChange,
|
|
444
469
|
facetConfigs,
|
|
@@ -450,8 +475,12 @@ function Toolbar({
|
|
|
450
475
|
total
|
|
451
476
|
}) {
|
|
452
477
|
return /* @__PURE__ */ jsxs(UiToolbar, { className: "border-b border-border/60 px-2 py-1.5", children: [
|
|
478
|
+
title ? /* @__PURE__ */ jsxs("div", { className: "flex min-w-0 flex-1 items-center gap-1.5 px-1", children: [
|
|
479
|
+
Icon ? /* @__PURE__ */ jsx(Icon, { className: "h-4 w-4 shrink-0 text-foreground/80" }) : null,
|
|
480
|
+
/* @__PURE__ */ jsx("span", { className: "truncate text-[14px] font-medium tracking-tight text-foreground", children: title })
|
|
481
|
+
] }) : null,
|
|
453
482
|
total != null ? /* @__PURE__ */ jsx("span", { className: "px-1 font-mono text-[10.5px] uppercase tracking-[0.05em] text-muted-foreground/80", children: total.toLocaleString() }) : null,
|
|
454
|
-
/* @__PURE__ */ jsx("div", { className: "flex-1" }),
|
|
483
|
+
/* @__PURE__ */ jsx("div", { className: title ? "w-1 shrink-0" : "flex-1" }),
|
|
455
484
|
searchable ? /* @__PURE__ */ jsxs(Popover, { children: [
|
|
456
485
|
/* @__PURE__ */ jsxs(
|
|
457
486
|
PopoverTrigger,
|
|
@@ -485,47 +514,71 @@ function Toolbar({
|
|
|
485
514
|
] }) : null
|
|
486
515
|
] })
|
|
487
516
|
] }) : null,
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
517
|
+
/* @__PURE__ */ jsx(
|
|
518
|
+
FilterControl,
|
|
519
|
+
{
|
|
520
|
+
facetConfigs,
|
|
521
|
+
facets,
|
|
522
|
+
filters,
|
|
523
|
+
filterCount,
|
|
524
|
+
onToggleFilter,
|
|
525
|
+
onClearFilters
|
|
526
|
+
}
|
|
527
|
+
)
|
|
528
|
+
] });
|
|
529
|
+
}
|
|
530
|
+
function ExternalToolbarActions(props) {
|
|
531
|
+
return /* @__PURE__ */ jsx(FilterControl, { ...props });
|
|
532
|
+
}
|
|
533
|
+
function FilterControl({
|
|
534
|
+
facetConfigs,
|
|
535
|
+
facets,
|
|
536
|
+
filters,
|
|
537
|
+
filterCount,
|
|
538
|
+
onToggleFilter,
|
|
539
|
+
onClearFilters
|
|
540
|
+
}) {
|
|
541
|
+
if (!facetConfigs?.length) return null;
|
|
542
|
+
return /* @__PURE__ */ jsxs(Popover, { children: [
|
|
543
|
+
/* @__PURE__ */ jsxs(
|
|
544
|
+
PopoverTrigger,
|
|
545
|
+
{
|
|
546
|
+
"aria-label": "Filters",
|
|
547
|
+
className: cn(
|
|
548
|
+
"inline-flex h-7 items-center gap-1 rounded-sm px-1.5 text-[11px] text-muted-foreground transition-colors hover:bg-muted/60 hover:text-foreground",
|
|
549
|
+
filterCount > 0 && "bg-muted text-foreground"
|
|
550
|
+
),
|
|
551
|
+
children: [
|
|
552
|
+
/* @__PURE__ */ jsx(FilterIcon, { size: 12 }),
|
|
553
|
+
filterCount > 0 ? /* @__PURE__ */ jsx("span", { className: "font-mono text-[10px]", children: filterCount }) : null
|
|
554
|
+
]
|
|
555
|
+
}
|
|
556
|
+
),
|
|
557
|
+
/* @__PURE__ */ jsxs(
|
|
558
|
+
PopoverContent,
|
|
559
|
+
{
|
|
560
|
+
side: "right",
|
|
561
|
+
align: "start",
|
|
562
|
+
sideOffset: 8,
|
|
563
|
+
className: "w-64 space-y-3 p-3 text-[12px]",
|
|
564
|
+
children: [
|
|
565
|
+
facetConfigs.map((config) => /* @__PURE__ */ jsx(
|
|
566
|
+
FacetSection,
|
|
567
|
+
{
|
|
568
|
+
config,
|
|
569
|
+
values: facets?.[config.key] ?? [],
|
|
570
|
+
selected: filters[config.key] ?? [],
|
|
571
|
+
onToggle: onToggleFilter
|
|
572
|
+
},
|
|
573
|
+
config.key
|
|
574
|
+
)),
|
|
575
|
+
filterCount > 0 ? /* @__PURE__ */ jsxs(Button, { type: "button", variant: "ghost", size: "xs", onClick: onClearFilters, className: "gap-1 text-[11px] text-muted-foreground hover:text-foreground", children: [
|
|
576
|
+
/* @__PURE__ */ jsx(XIcon, { size: 11 }),
|
|
577
|
+
" Clear all"
|
|
578
|
+
] }) : null
|
|
579
|
+
]
|
|
580
|
+
}
|
|
581
|
+
)
|
|
529
582
|
] });
|
|
530
583
|
}
|
|
531
584
|
function FacetSection({
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hachej/boring-data-explorer",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.42",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"private": false,
|
|
6
6
|
"license": "MIT",
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"clsx": "^2.1.1",
|
|
42
42
|
"lucide-react": "^1.8.0",
|
|
43
43
|
"tailwind-merge": "^3.5.0",
|
|
44
|
-
"@hachej/boring-ui-kit": "0.1.
|
|
44
|
+
"@hachej/boring-ui-kit": "0.1.42"
|
|
45
45
|
},
|
|
46
46
|
"devDependencies": {
|
|
47
47
|
"@testing-library/jest-dom": "^6.9.1",
|