@hachej/boring-data-explorer 0.1.13
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 +272 -0
- package/dist/front/index.d.ts +79 -0
- package/dist/front/index.js +696 -0
- package/dist/shared/index.d.ts +73 -0
- package/dist/shared/index.js +0 -0
- package/dist/testing/index.d.ts +6 -0
- package/dist/testing/index.js +159 -0
- package/package.json +67 -0
package/README.md
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
# @hachej/boring-data-explorer
|
|
2
|
+
|
|
3
|
+
<div align="center">
|
|
4
|
+
|
|
5
|
+
[](https://opensource.org/licenses/MIT)
|
|
6
|
+
|
|
7
|
+
</div>
|
|
8
|
+
|
|
9
|
+
Searchable, faceted data tables for the workbench. The headless primitive that `data-catalog` and other explorer-style plugins build on top of.
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
git clone https://github.com/hachej/boring-ui.git && cd boring-ui && pnpm install
|
|
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
|
|
38
|
+
|
|
39
|
+
```tsx
|
|
40
|
+
import { DataExplorer, useExplorerState } from "@hachej/boring-data-explorer/front"
|
|
41
|
+
import type { ExplorerDataSource, SearchArgs, SearchResult } from "@hachej/boring-data-explorer/shared"
|
|
42
|
+
|
|
43
|
+
// 1. Implement the adapter against your backend
|
|
44
|
+
const customersSource: ExplorerDataSource = {
|
|
45
|
+
async search({ query, filters, limit, offset, signal }): Promise<SearchResult> {
|
|
46
|
+
const res = await fetch(`/api/customers?q=${query}&limit=${limit}&offset=${offset}`, { signal })
|
|
47
|
+
const { items, total } = await res.json()
|
|
48
|
+
return { items, total, hasMore: offset + items.length < total }
|
|
49
|
+
},
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// 2. Mount the explorer
|
|
53
|
+
export function CustomersPane() {
|
|
54
|
+
const state = useExplorerState({ source: customersSource, pageSize: 50 })
|
|
55
|
+
return <DataExplorer state={state} />
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
The result is a sortable, filterable table — users can search, filter by facets, select rows, and drag rows into other panels.
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
## Adapter Contract
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
// Explorer item shape rendered by the DataExplorer
|
|
67
|
+
export type ExplorerItem = {
|
|
68
|
+
id: string
|
|
69
|
+
title: string
|
|
70
|
+
subtitle?: string // muted second line (truncates with title)
|
|
71
|
+
group?: string // group key — matches a facet value for grouping
|
|
72
|
+
leading?: Badge // leading mono chip (e.g. type code)
|
|
73
|
+
trailing?: Badge[] // trailing chips for status flags
|
|
74
|
+
meta?: string // right-aligned plain text (e.g. "1.2M")
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export type Badge = {
|
|
78
|
+
code: string // 1-4 char mono code rendered as a chip
|
|
79
|
+
tooltip?: string
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Search arguments from the explorer to your adapter
|
|
83
|
+
export type SearchArgs = {
|
|
84
|
+
query: string
|
|
85
|
+
filters: Record<string, string[]> // active facet filters
|
|
86
|
+
group?: { key: string; value: string } // scope to single group (paging inside a group)
|
|
87
|
+
limit: number
|
|
88
|
+
offset: number
|
|
89
|
+
signal?: AbortSignal
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Search result your adapter returns
|
|
93
|
+
export type SearchResult = {
|
|
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 }
|
|
102
|
+
|
|
103
|
+
export type FacetsArgs = {
|
|
104
|
+
filters: Record<string, string[]>
|
|
105
|
+
signal?: AbortSignal
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// The adapter contract
|
|
109
|
+
export interface ExplorerDataSource {
|
|
110
|
+
search(args: SearchArgs): Promise<SearchResult>
|
|
111
|
+
fetchFacets?(args: FacetsArgs): Promise<Facets> // optional
|
|
112
|
+
}
|
|
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
|
+
|
|
119
|
+
```tsx
|
|
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
|
+
}
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
---
|
|
140
|
+
|
|
141
|
+
## Installation
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
# From source (workspace-only — not published to npm)
|
|
145
|
+
cd boring-ui/plugins/data-explorer && pnpm install && pnpm build
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
---
|
|
149
|
+
|
|
150
|
+
## Package Surfaces
|
|
151
|
+
|
|
152
|
+
| Import | Environment | What You Get |
|
|
153
|
+
|--------|-------------|--------------|
|
|
154
|
+
| `@hachej/boring-data-explorer` | Browser | `<DataExplorer>`, `useExplorerState`, all types re-exported |
|
|
155
|
+
| `@hachej/boring-data-explorer/front` | Browser | Same as top-level (explicit subpath) |
|
|
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
|
+
---
|
|
258
|
+
|
|
259
|
+
## Used by
|
|
260
|
+
|
|
261
|
+
- **[`@hachej/boring-data-catalog`](../data-catalog/README.md)** — configurable catalog tab listing multiple data sources
|
|
262
|
+
- Any plugin that needs a faceted, searchable table inside a workbench panel
|
|
263
|
+
|
|
264
|
+
---
|
|
265
|
+
|
|
266
|
+
*About Contributions:* Please don't take this the wrong way, but I do not accept outside contributions for any of my projects. I simply don't have the mental bandwidth to review anything, and it's my name on the thing, so I'm responsible for any problems it causes; thus, the risk-reward is highly asymmetric from my perspective. I'd also have to worry about other "stakeholders," which seems unwise for tools I mostly make for myself for free. Feel free to submit issues, and even PRs if you want to illustrate a proposed fix, but know I won't merge them directly. Instead, I'll have Claude or Codex review submissions via `gh` and independently decide whether and how to address them. Bug reports in particular are welcome. Sorry if this offends, but I want to avoid wasted time and hurt feelings. I understand this isn't in sync with the prevailing open-source ethos that seeks community contributions, but it's the only way I can move at this velocity and keep my sanity.
|
|
267
|
+
|
|
268
|
+
---
|
|
269
|
+
|
|
270
|
+
## License
|
|
271
|
+
|
|
272
|
+
MIT
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import * as react_jsx_runtime from 'react/jsx-runtime';
|
|
2
|
+
import { ReactNode } from 'react';
|
|
3
|
+
import { ExplorerDataSource, FacetConfig, ExplorerItem, DragPayload, Facets } from '../shared/index.js';
|
|
4
|
+
export { Badge, FacetValue, FacetsArgs, SearchArgs, SearchResult } from '../shared/index.js';
|
|
5
|
+
|
|
6
|
+
type DataExplorerProps = {
|
|
7
|
+
adapter: ExplorerDataSource;
|
|
8
|
+
/** Facets shown in the toolbar popover. Adapter must implement fetchFacets for this to work. */
|
|
9
|
+
facets?: FacetConfig[];
|
|
10
|
+
/** Single grouping axis (must match a facet key). When set, renders tree mode. */
|
|
11
|
+
groupBy?: string;
|
|
12
|
+
/** Activated when a row is clicked, double-clicked, or Enter-pressed. */
|
|
13
|
+
onActivate?: (row: ExplorerItem) => void;
|
|
14
|
+
/** Returning a payload makes rows draggable. */
|
|
15
|
+
getDragPayload?: (row: ExplorerItem) => DragPayload | null | undefined;
|
|
16
|
+
/** Empty state shown when the top-level result has no rows and no query/filters. */
|
|
17
|
+
emptyState?: ReactNode;
|
|
18
|
+
searchPlaceholder?: string;
|
|
19
|
+
/** Hide the search input. Default true. */
|
|
20
|
+
searchable?: boolean;
|
|
21
|
+
/**
|
|
22
|
+
* Controlled query. When set, the toolbar's search input is hidden and the
|
|
23
|
+
* caller is responsible for supplying (and debouncing) the query value.
|
|
24
|
+
* Useful when an outer chrome already owns a search box.
|
|
25
|
+
*/
|
|
26
|
+
query?: string;
|
|
27
|
+
/** Called when the toolbar search changes. Use with `query` for controlled per-tab search. */
|
|
28
|
+
onQueryChange?: (query: string) => void;
|
|
29
|
+
/** Page size and debounce — passed through to useExplorerState. */
|
|
30
|
+
pageSize?: number;
|
|
31
|
+
debounceMs?: number;
|
|
32
|
+
className?: string;
|
|
33
|
+
};
|
|
34
|
+
declare function DataExplorer({ adapter, facets: facetConfigs, groupBy, onActivate, getDragPayload, emptyState, searchPlaceholder, searchable, query, onQueryChange, pageSize, debounceMs, className, }: DataExplorerProps): react_jsx_runtime.JSX.Element;
|
|
35
|
+
|
|
36
|
+
type UseExplorerStateOptions = {
|
|
37
|
+
adapter: ExplorerDataSource;
|
|
38
|
+
/** Facets shown in the toolbar popover. Adapter must implement fetchFacets. */
|
|
39
|
+
facets?: FacetConfig[];
|
|
40
|
+
/** Facet key used as the single grouping axis (tree mode). */
|
|
41
|
+
groupBy?: string;
|
|
42
|
+
/** Page size for both top-level and per-group pagination. */
|
|
43
|
+
pageSize?: number;
|
|
44
|
+
/** Debounce window (ms) applied to setQuery. */
|
|
45
|
+
debounceMs?: number;
|
|
46
|
+
/**
|
|
47
|
+
* Controlled query. When set, the hook bypasses internal debounce and
|
|
48
|
+
* `setQuery` becomes a no-op — the caller owns the value (and any debounce
|
|
49
|
+
* the caller wants to apply). Searches re-run when this prop changes.
|
|
50
|
+
*/
|
|
51
|
+
query?: string;
|
|
52
|
+
};
|
|
53
|
+
type GroupState = {
|
|
54
|
+
items: ExplorerItem[];
|
|
55
|
+
total: number;
|
|
56
|
+
hasMore: boolean;
|
|
57
|
+
loading: boolean;
|
|
58
|
+
};
|
|
59
|
+
declare function useExplorerState(options: UseExplorerStateOptions): {
|
|
60
|
+
query: string;
|
|
61
|
+
filters: Record<string, string[]>;
|
|
62
|
+
facets: Facets | null;
|
|
63
|
+
topItems: ExplorerItem[];
|
|
64
|
+
topTotal: number;
|
|
65
|
+
topHasMore: boolean;
|
|
66
|
+
loading: boolean;
|
|
67
|
+
getGroup: (value: string) => GroupState;
|
|
68
|
+
isExpanded: (value: string) => boolean;
|
|
69
|
+
setQuery: (q: string) => void;
|
|
70
|
+
toggleFilter: (key: string, value: string) => void;
|
|
71
|
+
clearFilters: () => void;
|
|
72
|
+
expandGroup: (value: string) => void;
|
|
73
|
+
collapseGroup: (value: string) => void;
|
|
74
|
+
loadMoreTop: () => void;
|
|
75
|
+
loadMoreGroup: (value: string) => void;
|
|
76
|
+
};
|
|
77
|
+
type UseExplorerStateReturn = ReturnType<typeof useExplorerState>;
|
|
78
|
+
|
|
79
|
+
export { DataExplorer, type DataExplorerProps, DragPayload, ExplorerDataSource, ExplorerItem, FacetConfig, Facets, type UseExplorerStateOptions, type UseExplorerStateReturn, useExplorerState };
|
|
@@ -0,0 +1,696 @@
|
|
|
1
|
+
// src/front/DataExplorer.tsx
|
|
2
|
+
import { useMemo } from "react";
|
|
3
|
+
import { ChevronRightIcon, ChevronDownIcon, FilterIcon, SearchIcon, XIcon } from "lucide-react";
|
|
4
|
+
|
|
5
|
+
// src/front/utils.ts
|
|
6
|
+
import { clsx } from "clsx";
|
|
7
|
+
import { twMerge } from "tailwind-merge";
|
|
8
|
+
function cn(...inputs) {
|
|
9
|
+
return twMerge(clsx(inputs));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// src/front/DataExplorer.tsx
|
|
13
|
+
import { Button, Chip as UiChip, ChipButton, EmptyState, Input, Spinner, Toolbar as UiToolbar } from "@hachej/boring-ui-kit";
|
|
14
|
+
import { Popover, PopoverTrigger, PopoverContent } from "@hachej/boring-ui-kit";
|
|
15
|
+
|
|
16
|
+
// src/front/useExplorerState.ts
|
|
17
|
+
import { useCallback, useEffect, useReducer, useRef } from "react";
|
|
18
|
+
var EMPTY_GROUP = { items: [], total: 0, hasMore: false, loading: false };
|
|
19
|
+
function reducer(state, action) {
|
|
20
|
+
switch (action.type) {
|
|
21
|
+
case "setPendingQuery":
|
|
22
|
+
return { ...state, pendingQuery: action.query };
|
|
23
|
+
case "applyQuery":
|
|
24
|
+
return {
|
|
25
|
+
...state,
|
|
26
|
+
query: action.query,
|
|
27
|
+
pendingQuery: action.query,
|
|
28
|
+
topItems: [],
|
|
29
|
+
topTotal: 0,
|
|
30
|
+
topHasMore: false,
|
|
31
|
+
topOffset: 0,
|
|
32
|
+
expanded: {},
|
|
33
|
+
groups: {}
|
|
34
|
+
};
|
|
35
|
+
case "setFilters":
|
|
36
|
+
return {
|
|
37
|
+
...state,
|
|
38
|
+
filters: action.filters,
|
|
39
|
+
topItems: [],
|
|
40
|
+
topTotal: 0,
|
|
41
|
+
topHasMore: false,
|
|
42
|
+
topOffset: 0,
|
|
43
|
+
expanded: {},
|
|
44
|
+
groups: {}
|
|
45
|
+
};
|
|
46
|
+
case "expandGroup":
|
|
47
|
+
return { ...state, expanded: { ...state.expanded, [action.value]: true } };
|
|
48
|
+
case "collapseGroup":
|
|
49
|
+
return { ...state, expanded: { ...state.expanded, [action.value]: false } };
|
|
50
|
+
case "topResolved":
|
|
51
|
+
return {
|
|
52
|
+
...state,
|
|
53
|
+
topItems: action.append ? [...state.topItems, ...action.items] : action.items,
|
|
54
|
+
topTotal: action.total,
|
|
55
|
+
topHasMore: action.hasMore,
|
|
56
|
+
topOffset: action.offset + action.items.length
|
|
57
|
+
};
|
|
58
|
+
case "groupResolved": {
|
|
59
|
+
const prev = state.groups[action.value] ?? EMPTY_GROUP;
|
|
60
|
+
return {
|
|
61
|
+
...state,
|
|
62
|
+
groups: {
|
|
63
|
+
...state.groups,
|
|
64
|
+
[action.value]: {
|
|
65
|
+
items: action.append ? [...prev.items, ...action.items] : action.items,
|
|
66
|
+
total: action.total,
|
|
67
|
+
hasMore: action.hasMore,
|
|
68
|
+
loading: false
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
case "groupLoading": {
|
|
74
|
+
const prev = state.groups[action.value] ?? EMPTY_GROUP;
|
|
75
|
+
return {
|
|
76
|
+
...state,
|
|
77
|
+
groups: { ...state.groups, [action.value]: { ...prev, loading: action.loading } }
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
case "facetsResolved":
|
|
81
|
+
return { ...state, facets: action.facets };
|
|
82
|
+
case "loading":
|
|
83
|
+
return { ...state, loading: action.loading };
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
var INITIAL = {
|
|
87
|
+
query: "",
|
|
88
|
+
pendingQuery: "",
|
|
89
|
+
filters: {},
|
|
90
|
+
topItems: [],
|
|
91
|
+
topTotal: 0,
|
|
92
|
+
topHasMore: false,
|
|
93
|
+
topOffset: 0,
|
|
94
|
+
expanded: {},
|
|
95
|
+
groups: {},
|
|
96
|
+
facets: null,
|
|
97
|
+
loading: false
|
|
98
|
+
};
|
|
99
|
+
function isAbortError(err) {
|
|
100
|
+
return err instanceof DOMException && err.name === "AbortError";
|
|
101
|
+
}
|
|
102
|
+
function useExplorerState(options) {
|
|
103
|
+
const {
|
|
104
|
+
adapter,
|
|
105
|
+
facets: facetConfigs,
|
|
106
|
+
groupBy,
|
|
107
|
+
pageSize = 50,
|
|
108
|
+
debounceMs = 200,
|
|
109
|
+
query: controlledQuery
|
|
110
|
+
} = options;
|
|
111
|
+
const isControlled = controlledQuery !== void 0;
|
|
112
|
+
const [state, dispatch] = useReducer(reducer, INITIAL);
|
|
113
|
+
const stateRef = useRef(state);
|
|
114
|
+
stateRef.current = state;
|
|
115
|
+
const adapterRef = useRef(adapter);
|
|
116
|
+
adapterRef.current = adapter;
|
|
117
|
+
const optsRef = useRef({ pageSize, groupBy, hasFacets: !!facetConfigs?.length });
|
|
118
|
+
optsRef.current = { pageSize, groupBy, hasFacets: !!facetConfigs?.length };
|
|
119
|
+
const topController = useRef(null);
|
|
120
|
+
const groupControllers = useRef(/* @__PURE__ */ new Map());
|
|
121
|
+
const facetController = useRef(null);
|
|
122
|
+
const debounceTimer = useRef(null);
|
|
123
|
+
const mounted = useRef(true);
|
|
124
|
+
const runTopSearch = useCallback(
|
|
125
|
+
async (args) => {
|
|
126
|
+
topController.current?.abort();
|
|
127
|
+
const ctrl = new AbortController();
|
|
128
|
+
topController.current = ctrl;
|
|
129
|
+
dispatch({ type: "loading", loading: true });
|
|
130
|
+
const searchArgs = {
|
|
131
|
+
query: args.query,
|
|
132
|
+
filters: args.filters,
|
|
133
|
+
offset: args.offset,
|
|
134
|
+
limit: optsRef.current.pageSize,
|
|
135
|
+
signal: ctrl.signal
|
|
136
|
+
};
|
|
137
|
+
try {
|
|
138
|
+
const result = await adapterRef.current.search(searchArgs);
|
|
139
|
+
if (!mounted.current || ctrl.signal.aborted) return;
|
|
140
|
+
dispatch({
|
|
141
|
+
type: "topResolved",
|
|
142
|
+
items: result.items,
|
|
143
|
+
total: result.total,
|
|
144
|
+
hasMore: result.hasMore,
|
|
145
|
+
offset: args.offset,
|
|
146
|
+
append: args.append
|
|
147
|
+
});
|
|
148
|
+
} catch (err) {
|
|
149
|
+
if (isAbortError(err)) return;
|
|
150
|
+
console.error("DataExplorer: search failed", err);
|
|
151
|
+
} finally {
|
|
152
|
+
if (mounted.current && topController.current === ctrl) {
|
|
153
|
+
dispatch({ type: "loading", loading: false });
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
},
|
|
157
|
+
[]
|
|
158
|
+
);
|
|
159
|
+
const runGroupSearch = useCallback(
|
|
160
|
+
async (args) => {
|
|
161
|
+
const groupKey = optsRef.current.groupBy;
|
|
162
|
+
if (!groupKey) return;
|
|
163
|
+
groupControllers.current.get(args.groupValue)?.abort();
|
|
164
|
+
const ctrl = new AbortController();
|
|
165
|
+
groupControllers.current.set(args.groupValue, ctrl);
|
|
166
|
+
dispatch({ type: "groupLoading", value: args.groupValue, loading: true });
|
|
167
|
+
const searchArgs = {
|
|
168
|
+
query: args.query,
|
|
169
|
+
filters: args.filters,
|
|
170
|
+
group: { key: groupKey, value: args.groupValue },
|
|
171
|
+
offset: args.offset,
|
|
172
|
+
limit: optsRef.current.pageSize,
|
|
173
|
+
signal: ctrl.signal
|
|
174
|
+
};
|
|
175
|
+
try {
|
|
176
|
+
const result = await adapterRef.current.search(searchArgs);
|
|
177
|
+
if (!mounted.current || ctrl.signal.aborted) return;
|
|
178
|
+
dispatch({
|
|
179
|
+
type: "groupResolved",
|
|
180
|
+
value: args.groupValue,
|
|
181
|
+
items: result.items,
|
|
182
|
+
total: result.total,
|
|
183
|
+
hasMore: result.hasMore,
|
|
184
|
+
append: args.append
|
|
185
|
+
});
|
|
186
|
+
} catch (err) {
|
|
187
|
+
if (isAbortError(err)) return;
|
|
188
|
+
console.error("DataExplorer: group search failed", err);
|
|
189
|
+
if (mounted.current) {
|
|
190
|
+
dispatch({ type: "groupLoading", value: args.groupValue, loading: false });
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
},
|
|
194
|
+
[]
|
|
195
|
+
);
|
|
196
|
+
const runFetchFacets = useCallback(async (filters) => {
|
|
197
|
+
if (!optsRef.current.hasFacets) return;
|
|
198
|
+
const fetchFacets = adapterRef.current.fetchFacets;
|
|
199
|
+
if (!fetchFacets) return;
|
|
200
|
+
facetController.current?.abort();
|
|
201
|
+
const ctrl = new AbortController();
|
|
202
|
+
facetController.current = ctrl;
|
|
203
|
+
try {
|
|
204
|
+
const facets = await fetchFacets({ filters, signal: ctrl.signal });
|
|
205
|
+
if (!mounted.current || ctrl.signal.aborted) return;
|
|
206
|
+
dispatch({ type: "facetsResolved", facets });
|
|
207
|
+
} catch (err) {
|
|
208
|
+
if (isAbortError(err)) return;
|
|
209
|
+
console.error("DataExplorer: fetchFacets failed", err);
|
|
210
|
+
}
|
|
211
|
+
}, []);
|
|
212
|
+
const refreshScope = useCallback(
|
|
213
|
+
(query, filters) => {
|
|
214
|
+
void runTopSearch({ query, filters, offset: 0, append: false });
|
|
215
|
+
void runFetchFacets(filters);
|
|
216
|
+
},
|
|
217
|
+
[runTopSearch, runFetchFacets]
|
|
218
|
+
);
|
|
219
|
+
const isControlledRef = useRef(isControlled);
|
|
220
|
+
isControlledRef.current = isControlled;
|
|
221
|
+
const setQuery = useCallback(
|
|
222
|
+
(q) => {
|
|
223
|
+
if (isControlledRef.current) return;
|
|
224
|
+
dispatch({ type: "setPendingQuery", query: q });
|
|
225
|
+
if (debounceTimer.current) clearTimeout(debounceTimer.current);
|
|
226
|
+
debounceTimer.current = setTimeout(() => {
|
|
227
|
+
dispatch({ type: "applyQuery", query: q });
|
|
228
|
+
refreshScope(q, stateRef.current.filters);
|
|
229
|
+
}, debounceMs);
|
|
230
|
+
},
|
|
231
|
+
[debounceMs, refreshScope]
|
|
232
|
+
);
|
|
233
|
+
const toggleFilter = useCallback(
|
|
234
|
+
(key, value) => {
|
|
235
|
+
const current = stateRef.current.filters[key] ?? [];
|
|
236
|
+
const next = current.includes(value) ? current.filter((v) => v !== value) : [...current, value];
|
|
237
|
+
const nextFilters = { ...stateRef.current.filters };
|
|
238
|
+
if (next.length) nextFilters[key] = next;
|
|
239
|
+
else delete nextFilters[key];
|
|
240
|
+
dispatch({ type: "setFilters", filters: nextFilters });
|
|
241
|
+
refreshScope(stateRef.current.query, nextFilters);
|
|
242
|
+
},
|
|
243
|
+
[refreshScope]
|
|
244
|
+
);
|
|
245
|
+
const clearFilters = useCallback(() => {
|
|
246
|
+
dispatch({ type: "setFilters", filters: {} });
|
|
247
|
+
refreshScope(stateRef.current.query, {});
|
|
248
|
+
}, [refreshScope]);
|
|
249
|
+
const expandGroup = useCallback(
|
|
250
|
+
(value) => {
|
|
251
|
+
const isLoaded = (stateRef.current.groups[value]?.items.length ?? 0) > 0;
|
|
252
|
+
dispatch({ type: "expandGroup", value });
|
|
253
|
+
if (!isLoaded) {
|
|
254
|
+
void runGroupSearch({
|
|
255
|
+
groupValue: value,
|
|
256
|
+
query: stateRef.current.query,
|
|
257
|
+
filters: stateRef.current.filters,
|
|
258
|
+
offset: 0,
|
|
259
|
+
append: false
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
},
|
|
263
|
+
[runGroupSearch]
|
|
264
|
+
);
|
|
265
|
+
const collapseGroup = useCallback((value) => {
|
|
266
|
+
dispatch({ type: "collapseGroup", value });
|
|
267
|
+
}, []);
|
|
268
|
+
const loadMoreTop = useCallback(() => {
|
|
269
|
+
const s = stateRef.current;
|
|
270
|
+
if (!s.topHasMore) return;
|
|
271
|
+
void runTopSearch({
|
|
272
|
+
query: s.query,
|
|
273
|
+
filters: s.filters,
|
|
274
|
+
offset: s.topOffset,
|
|
275
|
+
append: true
|
|
276
|
+
});
|
|
277
|
+
}, [runTopSearch]);
|
|
278
|
+
const loadMoreGroup = useCallback(
|
|
279
|
+
(value) => {
|
|
280
|
+
const s = stateRef.current;
|
|
281
|
+
const g = s.groups[value];
|
|
282
|
+
if (!g?.hasMore) return;
|
|
283
|
+
void runGroupSearch({
|
|
284
|
+
groupValue: value,
|
|
285
|
+
query: s.query,
|
|
286
|
+
filters: s.filters,
|
|
287
|
+
offset: g.items.length,
|
|
288
|
+
append: true
|
|
289
|
+
});
|
|
290
|
+
},
|
|
291
|
+
[runGroupSearch]
|
|
292
|
+
);
|
|
293
|
+
useEffect(() => {
|
|
294
|
+
mounted.current = true;
|
|
295
|
+
const initialQuery = isControlledRef.current ? controlledQuery ?? "" : "";
|
|
296
|
+
if (initialQuery) {
|
|
297
|
+
dispatch({ type: "applyQuery", query: initialQuery });
|
|
298
|
+
}
|
|
299
|
+
refreshScope(initialQuery, {});
|
|
300
|
+
return () => {
|
|
301
|
+
mounted.current = false;
|
|
302
|
+
if (debounceTimer.current) clearTimeout(debounceTimer.current);
|
|
303
|
+
topController.current?.abort();
|
|
304
|
+
facetController.current?.abort();
|
|
305
|
+
for (const ctrl of groupControllers.current.values()) ctrl.abort();
|
|
306
|
+
groupControllers.current.clear();
|
|
307
|
+
};
|
|
308
|
+
}, []);
|
|
309
|
+
const lastControlledQuery = useRef(controlledQuery);
|
|
310
|
+
useEffect(() => {
|
|
311
|
+
if (!isControlled) return;
|
|
312
|
+
if (lastControlledQuery.current === controlledQuery) return;
|
|
313
|
+
lastControlledQuery.current = controlledQuery;
|
|
314
|
+
const next = controlledQuery ?? "";
|
|
315
|
+
dispatch({ type: "applyQuery", query: next });
|
|
316
|
+
refreshScope(next, stateRef.current.filters);
|
|
317
|
+
}, [isControlled, controlledQuery, refreshScope]);
|
|
318
|
+
const getGroup = useCallback(
|
|
319
|
+
(value) => state.groups[value] ?? EMPTY_GROUP,
|
|
320
|
+
[state.groups]
|
|
321
|
+
);
|
|
322
|
+
const isExpanded = useCallback(
|
|
323
|
+
(value) => !!state.expanded[value],
|
|
324
|
+
[state.expanded]
|
|
325
|
+
);
|
|
326
|
+
return {
|
|
327
|
+
query: state.pendingQuery,
|
|
328
|
+
filters: state.filters,
|
|
329
|
+
facets: state.facets,
|
|
330
|
+
topItems: state.topItems,
|
|
331
|
+
topTotal: state.topTotal,
|
|
332
|
+
topHasMore: state.topHasMore,
|
|
333
|
+
loading: state.loading,
|
|
334
|
+
getGroup,
|
|
335
|
+
isExpanded,
|
|
336
|
+
setQuery,
|
|
337
|
+
toggleFilter,
|
|
338
|
+
clearFilters,
|
|
339
|
+
expandGroup,
|
|
340
|
+
collapseGroup,
|
|
341
|
+
loadMoreTop,
|
|
342
|
+
loadMoreGroup
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// src/front/DataExplorer.tsx
|
|
347
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
348
|
+
function DataExplorer({
|
|
349
|
+
adapter,
|
|
350
|
+
facets: facetConfigs,
|
|
351
|
+
groupBy,
|
|
352
|
+
onActivate,
|
|
353
|
+
getDragPayload,
|
|
354
|
+
emptyState = "No results",
|
|
355
|
+
searchPlaceholder = "Search\u2026",
|
|
356
|
+
searchable = true,
|
|
357
|
+
query,
|
|
358
|
+
onQueryChange,
|
|
359
|
+
pageSize,
|
|
360
|
+
debounceMs,
|
|
361
|
+
className
|
|
362
|
+
}) {
|
|
363
|
+
const state = useExplorerState({
|
|
364
|
+
adapter,
|
|
365
|
+
facets: facetConfigs,
|
|
366
|
+
groupBy,
|
|
367
|
+
pageSize,
|
|
368
|
+
debounceMs,
|
|
369
|
+
query
|
|
370
|
+
});
|
|
371
|
+
const isControlled = query !== void 0;
|
|
372
|
+
const showSearch = searchable && (!isControlled || !!onQueryChange);
|
|
373
|
+
const setToolbarQuery = isControlled && onQueryChange ? onQueryChange : state.setQuery;
|
|
374
|
+
const hasQuery = (query ?? state.query ?? "").length > 0;
|
|
375
|
+
const hasFilters = Object.values(state.filters).some((v) => v.length > 0);
|
|
376
|
+
const treeMode = !!groupBy && !hasQuery && !hasFilters;
|
|
377
|
+
const filterCount = Object.values(state.filters).reduce((n, v) => n + v.length, 0);
|
|
378
|
+
const groupEntries = useMemo(() => {
|
|
379
|
+
if (!treeMode || !groupBy) return [];
|
|
380
|
+
const config = facetConfigs?.find((f) => f.key === groupBy);
|
|
381
|
+
const values = state.facets?.[groupBy] ?? [];
|
|
382
|
+
const ordered = [...values];
|
|
383
|
+
if (config?.order?.length) {
|
|
384
|
+
const orderIdx = (v) => {
|
|
385
|
+
const i = config.order.indexOf(v);
|
|
386
|
+
return i === -1 ? Number.MAX_SAFE_INTEGER : i;
|
|
387
|
+
};
|
|
388
|
+
ordered.sort((a, b) => orderIdx(a.value) - orderIdx(b.value));
|
|
389
|
+
}
|
|
390
|
+
return ordered.map((v) => ({
|
|
391
|
+
value: v.value,
|
|
392
|
+
count: v.count,
|
|
393
|
+
label: config?.formatValue ? config.formatValue(v.value) : v.value
|
|
394
|
+
}));
|
|
395
|
+
}, [treeMode, groupBy, facetConfigs, state.facets]);
|
|
396
|
+
const showEmpty = !state.loading && !treeMode && state.topItems.length === 0 && state.query.length === 0 && !hasFilters;
|
|
397
|
+
return /* @__PURE__ */ jsxs("div", { className: cn("flex h-full flex-col", className), "data-slot": "data-explorer", children: [
|
|
398
|
+
showSearch || facetConfigs?.length ? /* @__PURE__ */ jsx(
|
|
399
|
+
Toolbar,
|
|
400
|
+
{
|
|
401
|
+
searchable: showSearch,
|
|
402
|
+
searchPlaceholder,
|
|
403
|
+
query: state.query,
|
|
404
|
+
onQueryChange: setToolbarQuery,
|
|
405
|
+
facetConfigs,
|
|
406
|
+
facets: state.facets,
|
|
407
|
+
filters: state.filters,
|
|
408
|
+
filterCount,
|
|
409
|
+
onToggleFilter: state.toggleFilter,
|
|
410
|
+
onClearFilters: state.clearFilters,
|
|
411
|
+
total: treeMode ? null : state.topTotal
|
|
412
|
+
}
|
|
413
|
+
) : null,
|
|
414
|
+
/* @__PURE__ */ jsx("div", { className: "flex-1 overflow-y-auto", "data-slot": "data-explorer-list", children: showEmpty ? /* @__PURE__ */ jsx("div", { className: "flex h-full items-center justify-center px-4 py-8", children: /* @__PURE__ */ jsx(EmptyState, { className: "min-h-0 border-0", description: emptyState }) }) : treeMode ? /* @__PURE__ */ jsx(
|
|
415
|
+
TreeList,
|
|
416
|
+
{
|
|
417
|
+
entries: groupEntries,
|
|
418
|
+
isExpanded: state.isExpanded,
|
|
419
|
+
getGroup: state.getGroup,
|
|
420
|
+
onExpand: state.expandGroup,
|
|
421
|
+
onCollapse: state.collapseGroup,
|
|
422
|
+
onLoadMoreGroup: state.loadMoreGroup,
|
|
423
|
+
onActivate,
|
|
424
|
+
getDragPayload
|
|
425
|
+
}
|
|
426
|
+
) : /* @__PURE__ */ jsx(
|
|
427
|
+
FlatList,
|
|
428
|
+
{
|
|
429
|
+
items: state.topItems,
|
|
430
|
+
hasMore: state.topHasMore,
|
|
431
|
+
loading: state.loading,
|
|
432
|
+
onLoadMore: state.loadMoreTop,
|
|
433
|
+
onActivate,
|
|
434
|
+
getDragPayload
|
|
435
|
+
}
|
|
436
|
+
) })
|
|
437
|
+
] });
|
|
438
|
+
}
|
|
439
|
+
function Toolbar({
|
|
440
|
+
searchable,
|
|
441
|
+
searchPlaceholder,
|
|
442
|
+
query,
|
|
443
|
+
onQueryChange,
|
|
444
|
+
facetConfigs,
|
|
445
|
+
facets,
|
|
446
|
+
filters,
|
|
447
|
+
filterCount,
|
|
448
|
+
onToggleFilter,
|
|
449
|
+
onClearFilters,
|
|
450
|
+
total
|
|
451
|
+
}) {
|
|
452
|
+
return /* @__PURE__ */ jsxs(UiToolbar, { className: "border-b border-border/60 px-2 py-1.5", children: [
|
|
453
|
+
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" }),
|
|
455
|
+
searchable ? /* @__PURE__ */ jsxs(Popover, { children: [
|
|
456
|
+
/* @__PURE__ */ jsxs(
|
|
457
|
+
PopoverTrigger,
|
|
458
|
+
{
|
|
459
|
+
"aria-label": "Search",
|
|
460
|
+
className: cn(
|
|
461
|
+
"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",
|
|
462
|
+
query.length > 0 && "bg-muted text-foreground"
|
|
463
|
+
),
|
|
464
|
+
children: [
|
|
465
|
+
/* @__PURE__ */ jsx(SearchIcon, { size: 12 }),
|
|
466
|
+
query.length > 0 ? /* @__PURE__ */ jsx("span", { className: "max-w-20 truncate text-[11px]", children: query }) : null
|
|
467
|
+
]
|
|
468
|
+
}
|
|
469
|
+
),
|
|
470
|
+
/* @__PURE__ */ jsxs(PopoverContent, { side: "right", align: "start", sideOffset: 8, className: "w-64 p-3", children: [
|
|
471
|
+
/* @__PURE__ */ jsx(
|
|
472
|
+
Input,
|
|
473
|
+
{
|
|
474
|
+
"aria-label": "Search",
|
|
475
|
+
autoFocus: true,
|
|
476
|
+
placeholder: searchPlaceholder,
|
|
477
|
+
value: query,
|
|
478
|
+
onChange: (e) => onQueryChange(e.target.value),
|
|
479
|
+
className: "h-8 rounded-sm text-[12.5px]"
|
|
480
|
+
}
|
|
481
|
+
),
|
|
482
|
+
query.length > 0 ? /* @__PURE__ */ jsxs(Button, { type: "button", variant: "ghost", size: "xs", onClick: () => onQueryChange(""), className: "mt-2 gap-1 text-[11px] text-muted-foreground hover:text-foreground", children: [
|
|
483
|
+
/* @__PURE__ */ jsx(XIcon, { size: 11 }),
|
|
484
|
+
" Clear search"
|
|
485
|
+
] }) : null
|
|
486
|
+
] })
|
|
487
|
+
] }) : null,
|
|
488
|
+
facetConfigs?.length ? /* @__PURE__ */ jsxs(Popover, { children: [
|
|
489
|
+
/* @__PURE__ */ jsxs(
|
|
490
|
+
PopoverTrigger,
|
|
491
|
+
{
|
|
492
|
+
"aria-label": "Filters",
|
|
493
|
+
className: cn(
|
|
494
|
+
"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",
|
|
495
|
+
filterCount > 0 && "bg-muted text-foreground"
|
|
496
|
+
),
|
|
497
|
+
children: [
|
|
498
|
+
/* @__PURE__ */ jsx(FilterIcon, { size: 12 }),
|
|
499
|
+
filterCount > 0 ? /* @__PURE__ */ jsx("span", { className: "font-mono text-[10px]", children: filterCount }) : null
|
|
500
|
+
]
|
|
501
|
+
}
|
|
502
|
+
),
|
|
503
|
+
/* @__PURE__ */ jsxs(
|
|
504
|
+
PopoverContent,
|
|
505
|
+
{
|
|
506
|
+
side: "right",
|
|
507
|
+
align: "start",
|
|
508
|
+
sideOffset: 8,
|
|
509
|
+
className: "w-64 space-y-3 p-3 text-[12px]",
|
|
510
|
+
children: [
|
|
511
|
+
facetConfigs.map((config) => /* @__PURE__ */ jsx(
|
|
512
|
+
FacetSection,
|
|
513
|
+
{
|
|
514
|
+
config,
|
|
515
|
+
values: facets?.[config.key] ?? [],
|
|
516
|
+
selected: filters[config.key] ?? [],
|
|
517
|
+
onToggle: onToggleFilter
|
|
518
|
+
},
|
|
519
|
+
config.key
|
|
520
|
+
)),
|
|
521
|
+
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: [
|
|
522
|
+
/* @__PURE__ */ jsx(XIcon, { size: 11 }),
|
|
523
|
+
" Clear all"
|
|
524
|
+
] }) : null
|
|
525
|
+
]
|
|
526
|
+
}
|
|
527
|
+
)
|
|
528
|
+
] }) : null
|
|
529
|
+
] });
|
|
530
|
+
}
|
|
531
|
+
function FacetSection({
|
|
532
|
+
config,
|
|
533
|
+
values,
|
|
534
|
+
selected,
|
|
535
|
+
onToggle
|
|
536
|
+
}) {
|
|
537
|
+
if (!values.length) return null;
|
|
538
|
+
const ordered = config.order ? [...values].sort((a, b) => {
|
|
539
|
+
const ia = config.order.indexOf(a.value);
|
|
540
|
+
const ib = config.order.indexOf(b.value);
|
|
541
|
+
return (ia === -1 ? 999 : ia) - (ib === -1 ? 999 : ib);
|
|
542
|
+
}) : values;
|
|
543
|
+
return /* @__PURE__ */ jsxs("div", { className: "space-y-1.5", children: [
|
|
544
|
+
/* @__PURE__ */ jsx("div", { className: "font-mono text-[10px] uppercase tracking-[0.06em] text-muted-foreground", children: config.label }),
|
|
545
|
+
/* @__PURE__ */ jsx("div", { className: "flex flex-wrap gap-1", children: ordered.map((v) => {
|
|
546
|
+
const active = selected.includes(v.value);
|
|
547
|
+
const label = config.formatValue ? config.formatValue(v.value) : v.value;
|
|
548
|
+
return /* @__PURE__ */ jsxs(
|
|
549
|
+
ChipButton,
|
|
550
|
+
{
|
|
551
|
+
type: "button",
|
|
552
|
+
selected: active,
|
|
553
|
+
onClick: () => onToggle(config.key, v.value),
|
|
554
|
+
className: "gap-1 rounded-sm text-[11px]",
|
|
555
|
+
children: [
|
|
556
|
+
label,
|
|
557
|
+
/* @__PURE__ */ jsx("span", { className: "font-mono text-[10px] text-muted-foreground/80", children: v.count.toLocaleString() })
|
|
558
|
+
]
|
|
559
|
+
},
|
|
560
|
+
v.value
|
|
561
|
+
);
|
|
562
|
+
}) })
|
|
563
|
+
] });
|
|
564
|
+
}
|
|
565
|
+
function FlatList({
|
|
566
|
+
items,
|
|
567
|
+
hasMore,
|
|
568
|
+
loading,
|
|
569
|
+
onLoadMore,
|
|
570
|
+
onActivate,
|
|
571
|
+
getDragPayload
|
|
572
|
+
}) {
|
|
573
|
+
return /* @__PURE__ */ jsxs("ul", { className: "flex flex-col px-1 py-1", children: [
|
|
574
|
+
items.map((row) => /* @__PURE__ */ jsx(
|
|
575
|
+
Row,
|
|
576
|
+
{
|
|
577
|
+
row,
|
|
578
|
+
onActivate,
|
|
579
|
+
getDragPayload
|
|
580
|
+
},
|
|
581
|
+
row.id
|
|
582
|
+
)),
|
|
583
|
+
hasMore ? /* @__PURE__ */ jsx("li", { className: "px-3 py-2", children: /* @__PURE__ */ jsx(Button, { type: "button", variant: "ghost", size: "xs", onClick: onLoadMore, disabled: loading, className: "w-full justify-start text-[11px] text-muted-foreground hover:text-foreground", children: loading ? "Loading\u2026" : "Load more" }) }) : null
|
|
584
|
+
] });
|
|
585
|
+
}
|
|
586
|
+
function TreeList({
|
|
587
|
+
entries,
|
|
588
|
+
isExpanded,
|
|
589
|
+
getGroup,
|
|
590
|
+
onExpand,
|
|
591
|
+
onCollapse,
|
|
592
|
+
onLoadMoreGroup,
|
|
593
|
+
onActivate,
|
|
594
|
+
getDragPayload
|
|
595
|
+
}) {
|
|
596
|
+
return /* @__PURE__ */ jsx("ul", { className: "flex flex-col py-1", children: entries.map((entry) => {
|
|
597
|
+
const expanded = isExpanded(entry.value);
|
|
598
|
+
const group = getGroup(entry.value);
|
|
599
|
+
return /* @__PURE__ */ jsxs("li", { children: [
|
|
600
|
+
/* @__PURE__ */ jsxs(
|
|
601
|
+
Button,
|
|
602
|
+
{
|
|
603
|
+
type: "button",
|
|
604
|
+
variant: "ghost",
|
|
605
|
+
size: "sm",
|
|
606
|
+
"aria-expanded": expanded,
|
|
607
|
+
onClick: () => expanded ? onCollapse(entry.value) : onExpand(entry.value),
|
|
608
|
+
className: "group mx-1 h-auto w-[calc(100%-0.5rem)] justify-start gap-1.5 px-1.5 py-1 text-left hover:bg-muted/40",
|
|
609
|
+
children: [
|
|
610
|
+
expanded ? /* @__PURE__ */ jsx(ChevronDownIcon, { size: 11, className: "text-muted-foreground/80" }) : /* @__PURE__ */ jsx(ChevronRightIcon, { size: 11, className: "text-muted-foreground/80" }),
|
|
611
|
+
/* @__PURE__ */ jsx("span", { className: "text-[12.5px] font-medium text-foreground", children: entry.label }),
|
|
612
|
+
/* @__PURE__ */ jsx("span", { className: "ml-auto font-mono text-[10.5px] text-muted-foreground/80", children: entry.count.toLocaleString() })
|
|
613
|
+
]
|
|
614
|
+
}
|
|
615
|
+
),
|
|
616
|
+
expanded ? /* @__PURE__ */ jsxs("ul", { className: "flex flex-col", children: [
|
|
617
|
+
group.items.map((row) => /* @__PURE__ */ jsx(
|
|
618
|
+
Row,
|
|
619
|
+
{
|
|
620
|
+
row,
|
|
621
|
+
indent: true,
|
|
622
|
+
onActivate,
|
|
623
|
+
getDragPayload
|
|
624
|
+
},
|
|
625
|
+
row.id
|
|
626
|
+
)),
|
|
627
|
+
group.loading && group.items.length === 0 ? /* @__PURE__ */ jsxs("li", { className: "flex items-center gap-1.5 pl-7 pr-3 py-1.5 text-[11px] text-muted-foreground/80", children: [
|
|
628
|
+
/* @__PURE__ */ jsx(Spinner, { className: "size-3" }),
|
|
629
|
+
"Loading\u2026"
|
|
630
|
+
] }) : null,
|
|
631
|
+
group.hasMore ? /* @__PURE__ */ jsx("li", { className: "pl-7 pr-3 py-1", children: /* @__PURE__ */ jsx(Button, { type: "button", variant: "ghost", size: "xs", onClick: () => onLoadMoreGroup(entry.value), disabled: group.loading, className: "text-[11px] text-muted-foreground hover:text-foreground", children: group.loading ? "Loading\u2026" : "Load more" }) }) : null
|
|
632
|
+
] }) : null
|
|
633
|
+
] }, entry.value);
|
|
634
|
+
}) });
|
|
635
|
+
}
|
|
636
|
+
function Row({
|
|
637
|
+
row,
|
|
638
|
+
indent,
|
|
639
|
+
onActivate,
|
|
640
|
+
getDragPayload
|
|
641
|
+
}) {
|
|
642
|
+
const interactive = !!onActivate;
|
|
643
|
+
const payload = getDragPayload?.(row);
|
|
644
|
+
const draggable = !!payload;
|
|
645
|
+
const handleKeyDown = (e) => {
|
|
646
|
+
if (!interactive) return;
|
|
647
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
648
|
+
e.preventDefault();
|
|
649
|
+
onActivate?.(row);
|
|
650
|
+
}
|
|
651
|
+
};
|
|
652
|
+
const handleDragStart = (e) => {
|
|
653
|
+
if (!payload) return;
|
|
654
|
+
e.dataTransfer.setData(payload.mimeType, payload.value);
|
|
655
|
+
e.dataTransfer.setData("text/plain", payload.value);
|
|
656
|
+
e.dataTransfer.effectAllowed = "copy";
|
|
657
|
+
};
|
|
658
|
+
return /* @__PURE__ */ jsxs(
|
|
659
|
+
"li",
|
|
660
|
+
{
|
|
661
|
+
...interactive ? { role: "button", tabIndex: 0, onClick: () => onActivate?.(row), onKeyDown: handleKeyDown } : {},
|
|
662
|
+
...draggable ? { draggable: true, onDragStart: handleDragStart } : {},
|
|
663
|
+
className: cn(
|
|
664
|
+
"group mx-1 flex items-start gap-2 rounded-md px-1.5 py-1",
|
|
665
|
+
"transition-colors duration-120 ease-[cubic-bezier(0.22,1,0.36,1)]",
|
|
666
|
+
interactive && "cursor-pointer hover:bg-foreground/5",
|
|
667
|
+
indent && "pl-7"
|
|
668
|
+
),
|
|
669
|
+
title: row.title,
|
|
670
|
+
children: [
|
|
671
|
+
row.leading ? /* @__PURE__ */ jsx(BadgeChip, { badge: row.leading }) : null,
|
|
672
|
+
/* @__PURE__ */ jsxs("span", { className: "flex min-w-0 flex-1 flex-col", children: [
|
|
673
|
+
/* @__PURE__ */ jsx("span", { className: "truncate text-[12.5px] font-medium leading-tight text-foreground", children: row.title }),
|
|
674
|
+
row.subtitle ? /* @__PURE__ */ jsx("span", { className: "truncate text-[11.5px] leading-snug text-muted-foreground/85", children: row.subtitle }) : null
|
|
675
|
+
] }),
|
|
676
|
+
row.trailing?.length ? /* @__PURE__ */ jsx("span", { className: "flex shrink-0 items-center gap-1", children: row.trailing.map((b, i) => /* @__PURE__ */ jsx(BadgeChip, { badge: b }, i)) }) : null,
|
|
677
|
+
row.meta ? /* @__PURE__ */ jsx("span", { className: "shrink-0 self-center font-mono text-[10.5px] text-muted-foreground/80", children: row.meta }) : null
|
|
678
|
+
]
|
|
679
|
+
}
|
|
680
|
+
);
|
|
681
|
+
}
|
|
682
|
+
function BadgeChip({ badge }) {
|
|
683
|
+
return /* @__PURE__ */ jsx(
|
|
684
|
+
UiChip,
|
|
685
|
+
{
|
|
686
|
+
"aria-hidden": "true",
|
|
687
|
+
title: badge.tooltip,
|
|
688
|
+
className: "mt-[1px] h-[16px] min-w-[24px] rounded-[3px] border-0 bg-muted/60 px-1 font-mono text-[9.5px] uppercase tracking-[0.06em] text-muted-foreground group-hover:text-foreground",
|
|
689
|
+
children: badge.code
|
|
690
|
+
}
|
|
691
|
+
);
|
|
692
|
+
}
|
|
693
|
+
export {
|
|
694
|
+
DataExplorer,
|
|
695
|
+
useExplorerState
|
|
696
|
+
};
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DataExplorer shared types — no runtime deps.
|
|
3
|
+
*
|
|
4
|
+
* Importable from BOTH front and server bundles without dragging in
|
|
5
|
+
* platform-specific code.
|
|
6
|
+
*/
|
|
7
|
+
type Badge = {
|
|
8
|
+
/** 1–4 char mono code rendered as a chip. */
|
|
9
|
+
code: string;
|
|
10
|
+
tooltip?: string;
|
|
11
|
+
};
|
|
12
|
+
type ExplorerItem = {
|
|
13
|
+
id: string;
|
|
14
|
+
title: string;
|
|
15
|
+
/** Optional muted second line (truncates with title). */
|
|
16
|
+
subtitle?: string;
|
|
17
|
+
/** Group key — must match one of the facet values for `groupBy`. */
|
|
18
|
+
group?: string;
|
|
19
|
+
/** Leading mono chip (e.g. type code, frequency). */
|
|
20
|
+
leading?: Badge;
|
|
21
|
+
/** Trailing mono chips for status flags (e.g. [D] derived, [LIVE]). */
|
|
22
|
+
trailing?: Badge[];
|
|
23
|
+
/** Right-aligned plain text for numeric metadata (e.g. "1.2M", "2.4s"). */
|
|
24
|
+
meta?: string;
|
|
25
|
+
};
|
|
26
|
+
type FacetValue = {
|
|
27
|
+
value: string;
|
|
28
|
+
count: number;
|
|
29
|
+
};
|
|
30
|
+
type Facets = Record<string, FacetValue[]>;
|
|
31
|
+
type FacetConfig = {
|
|
32
|
+
/** Filter key sent to the adapter (e.g. "frequency"). */
|
|
33
|
+
key: string;
|
|
34
|
+
/** Display label (e.g. "Frequency"). */
|
|
35
|
+
label: string;
|
|
36
|
+
/** Explicit display order; unknown values go after in adapter order. */
|
|
37
|
+
order?: string[];
|
|
38
|
+
/** Display formatter for raw values (e.g. "M" → "Monthly"). */
|
|
39
|
+
formatValue?: (value: string) => string;
|
|
40
|
+
};
|
|
41
|
+
type SearchArgs = {
|
|
42
|
+
query: string;
|
|
43
|
+
filters: Record<string, string[]>;
|
|
44
|
+
/** Scope to a single group's value (only set when paginating inside a group). */
|
|
45
|
+
group?: {
|
|
46
|
+
key: string;
|
|
47
|
+
value: string;
|
|
48
|
+
};
|
|
49
|
+
limit: number;
|
|
50
|
+
offset: number;
|
|
51
|
+
signal?: AbortSignal;
|
|
52
|
+
};
|
|
53
|
+
type SearchResult = {
|
|
54
|
+
items: ExplorerItem[];
|
|
55
|
+
/** Total count for the current scope (query + filters + optional group). */
|
|
56
|
+
total: number;
|
|
57
|
+
hasMore: boolean;
|
|
58
|
+
};
|
|
59
|
+
type FacetsArgs = {
|
|
60
|
+
filters: Record<string, string[]>;
|
|
61
|
+
signal?: AbortSignal;
|
|
62
|
+
};
|
|
63
|
+
type ExplorerDataSource = {
|
|
64
|
+
search(args: SearchArgs): Promise<SearchResult>;
|
|
65
|
+
/** Optional. When omitted, the explorer renders flat (no facet popover). */
|
|
66
|
+
fetchFacets?(args: FacetsArgs): Promise<Facets>;
|
|
67
|
+
};
|
|
68
|
+
type DragPayload = {
|
|
69
|
+
mimeType: string;
|
|
70
|
+
value: string;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export type { Badge, DragPayload, ExplorerDataSource, ExplorerItem, FacetConfig, FacetValue, Facets, FacetsArgs, SearchArgs, SearchResult };
|
|
File without changes
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// src/front/storybookAdapters.ts
|
|
2
|
+
var wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
3
|
+
function makeAdapter(rows, extractFacets, opts = {}) {
|
|
4
|
+
const latency = opts.latencyMs ?? 80;
|
|
5
|
+
const matchFilters = (row, filters) => {
|
|
6
|
+
const facetValues = extractFacets(row);
|
|
7
|
+
for (const [key, vs] of Object.entries(filters)) {
|
|
8
|
+
if (!vs.length) continue;
|
|
9
|
+
if (!vs.includes(facetValues[key] ?? "")) return false;
|
|
10
|
+
}
|
|
11
|
+
return true;
|
|
12
|
+
};
|
|
13
|
+
return {
|
|
14
|
+
async search(args) {
|
|
15
|
+
await wait(latency);
|
|
16
|
+
let pool = rows;
|
|
17
|
+
if (args.group) {
|
|
18
|
+
pool = pool.filter((r) => extractFacets(r)[args.group.key] === args.group.value);
|
|
19
|
+
}
|
|
20
|
+
if (args.query) {
|
|
21
|
+
const q = args.query.toLowerCase();
|
|
22
|
+
pool = pool.filter(
|
|
23
|
+
(r) => r.id.toLowerCase().includes(q) || r.title.toLowerCase().includes(q) || (r.subtitle?.toLowerCase().includes(q) ?? false)
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
pool = pool.filter((r) => matchFilters(r, args.filters));
|
|
27
|
+
const slice = pool.slice(args.offset, args.offset + args.limit);
|
|
28
|
+
return {
|
|
29
|
+
items: slice,
|
|
30
|
+
total: pool.length,
|
|
31
|
+
hasMore: args.offset + slice.length < pool.length
|
|
32
|
+
};
|
|
33
|
+
},
|
|
34
|
+
async fetchFacets(args) {
|
|
35
|
+
await wait(latency / 2);
|
|
36
|
+
const pool = rows.filter((r) => matchFilters(r, args.filters));
|
|
37
|
+
const facets = {};
|
|
38
|
+
for (const row of pool) {
|
|
39
|
+
const values = extractFacets(row);
|
|
40
|
+
for (const [key, value] of Object.entries(values)) {
|
|
41
|
+
if (!facets[key]) facets[key] = [];
|
|
42
|
+
const entry = facets[key].find((e) => e.value === value);
|
|
43
|
+
if (entry) entry.count += 1;
|
|
44
|
+
else facets[key].push({ value, count: 1 });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return facets;
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
var FREQ_LABELS = {
|
|
52
|
+
D: "Daily",
|
|
53
|
+
W: "Weekly",
|
|
54
|
+
M: "Monthly",
|
|
55
|
+
Q: "Quarterly",
|
|
56
|
+
SA: "Semiannual",
|
|
57
|
+
A: "Annual"
|
|
58
|
+
};
|
|
59
|
+
var FREQ_DISTRIBUTION = [
|
|
60
|
+
{ code: "D", n: 120 },
|
|
61
|
+
{ code: "W", n: 60 },
|
|
62
|
+
{ code: "M", n: 240 },
|
|
63
|
+
{ code: "Q", n: 90 },
|
|
64
|
+
{ code: "SA", n: 30 },
|
|
65
|
+
{ code: "A", n: 60 }
|
|
66
|
+
];
|
|
67
|
+
var SERIES_NAMES = [
|
|
68
|
+
"Real GDP",
|
|
69
|
+
"Nominal GDP",
|
|
70
|
+
"Personal Consumption",
|
|
71
|
+
"Industrial Production",
|
|
72
|
+
"Capacity Utilization",
|
|
73
|
+
"Unemployment Rate",
|
|
74
|
+
"Initial Claims",
|
|
75
|
+
"Nonfarm Payrolls",
|
|
76
|
+
"CPI All Urban",
|
|
77
|
+
"Core CPI",
|
|
78
|
+
"PCE Price Index",
|
|
79
|
+
"Federal Funds Rate",
|
|
80
|
+
"10-Year Treasury",
|
|
81
|
+
"30-Year Mortgage",
|
|
82
|
+
"Crude Oil WTI",
|
|
83
|
+
"Brent Crude",
|
|
84
|
+
"S&P 500",
|
|
85
|
+
"Trade Weighted Dollar",
|
|
86
|
+
"Retail Sales",
|
|
87
|
+
"Housing Starts"
|
|
88
|
+
];
|
|
89
|
+
function generateSeriesRows() {
|
|
90
|
+
const rows = [];
|
|
91
|
+
let i = 0;
|
|
92
|
+
for (const { code, n } of FREQ_DISTRIBUTION) {
|
|
93
|
+
for (let k = 0; k < n; k++) {
|
|
94
|
+
const baseName = SERIES_NAMES[(i + k) % SERIES_NAMES.length];
|
|
95
|
+
const idx = i + k;
|
|
96
|
+
const id = `${code}${String(idx).padStart(4, "0")}`;
|
|
97
|
+
const derived = idx % 13 === 0;
|
|
98
|
+
rows.push({
|
|
99
|
+
id,
|
|
100
|
+
title: `${baseName}${k > SERIES_NAMES.length ? ` v${k}` : ""}`,
|
|
101
|
+
subtitle: `${FREQ_LABELS[code]} \xB7 seasonally adjusted`,
|
|
102
|
+
group: code,
|
|
103
|
+
leading: { code, tooltip: FREQ_LABELS[code] },
|
|
104
|
+
trailing: derived ? [{ code: "D", tooltip: "Derived series" }] : void 0
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
i += n;
|
|
108
|
+
}
|
|
109
|
+
return rows;
|
|
110
|
+
}
|
|
111
|
+
function createMockSeriesAdapter() {
|
|
112
|
+
const rows = generateSeriesRows();
|
|
113
|
+
return makeAdapter(
|
|
114
|
+
rows,
|
|
115
|
+
(row) => ({
|
|
116
|
+
frequency: row.group ?? "",
|
|
117
|
+
source: row.trailing?.some((b) => b.code === "D") ? "derived" : "fred"
|
|
118
|
+
}),
|
|
119
|
+
{ latencyMs: 60 }
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
var TABLE_DEFS = [
|
|
123
|
+
{ schema: "public", name: "users", kind: "TBL", rows: "1.2M" },
|
|
124
|
+
{ schema: "public", name: "events", kind: "TBL", rows: "84M" },
|
|
125
|
+
{ schema: "public", name: "sessions", kind: "TBL", rows: "240k" },
|
|
126
|
+
{ schema: "public", name: "active_users", kind: "VW", rows: "\u2014" },
|
|
127
|
+
{ schema: "public", name: "live_orders", kind: "STR", rows: "live" },
|
|
128
|
+
{ schema: "analytics", name: "daily_revenue", kind: "MAT", rows: "365" },
|
|
129
|
+
{ schema: "analytics", name: "weekly_cohort", kind: "MAT", rows: "52" },
|
|
130
|
+
{ schema: "analytics", name: "session_funnel", kind: "VW", rows: "\u2014" },
|
|
131
|
+
{ schema: "billing", name: "invoices", kind: "TBL", rows: "412k" },
|
|
132
|
+
{ schema: "billing", name: "subscriptions", kind: "TBL", rows: "38k" },
|
|
133
|
+
{ schema: "billing", name: "open_invoices", kind: "VW", rows: "\u2014" },
|
|
134
|
+
{ schema: "audit", name: "access_log", kind: "TBL", rows: "16M" },
|
|
135
|
+
{ schema: "audit", name: "auth_events", kind: "STR", rows: "live" }
|
|
136
|
+
];
|
|
137
|
+
function createMockTablesAdapter() {
|
|
138
|
+
const rows = TABLE_DEFS.map((t) => ({
|
|
139
|
+
id: `${t.schema}.${t.name}`,
|
|
140
|
+
title: t.name,
|
|
141
|
+
subtitle: t.schema,
|
|
142
|
+
group: t.schema,
|
|
143
|
+
leading: { code: t.kind },
|
|
144
|
+
trailing: t.kind === "STR" ? [{ code: "LIVE" }] : void 0,
|
|
145
|
+
meta: t.rows
|
|
146
|
+
}));
|
|
147
|
+
return makeAdapter(
|
|
148
|
+
rows,
|
|
149
|
+
(row) => ({
|
|
150
|
+
schema: row.group ?? "",
|
|
151
|
+
kind: row.leading?.code ?? ""
|
|
152
|
+
}),
|
|
153
|
+
{ latencyMs: 40 }
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
export {
|
|
157
|
+
createMockSeriesAdapter,
|
|
158
|
+
createMockTablesAdapter
|
|
159
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@hachej/boring-data-explorer",
|
|
3
|
+
"version": "0.1.13",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"private": false,
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"description": "Data explorer plugin primitive and shared explorer contracts for Boring workspace plugins.",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "https://github.com/hachej/boring-ui"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/hachej/boring-ui",
|
|
13
|
+
"files": [
|
|
14
|
+
"dist"
|
|
15
|
+
],
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"types": "./dist/front/index.d.ts",
|
|
19
|
+
"import": "./dist/front/index.js"
|
|
20
|
+
},
|
|
21
|
+
"./front": {
|
|
22
|
+
"types": "./dist/front/index.d.ts",
|
|
23
|
+
"import": "./dist/front/index.js"
|
|
24
|
+
},
|
|
25
|
+
"./shared": {
|
|
26
|
+
"types": "./dist/shared/index.d.ts",
|
|
27
|
+
"import": "./dist/shared/index.js"
|
|
28
|
+
},
|
|
29
|
+
"./testing": {
|
|
30
|
+
"types": "./dist/testing/index.d.ts",
|
|
31
|
+
"import": "./dist/testing/index.js"
|
|
32
|
+
},
|
|
33
|
+
"./package.json": "./package.json"
|
|
34
|
+
},
|
|
35
|
+
"sideEffects": false,
|
|
36
|
+
"scripts": {
|
|
37
|
+
"build": "tsup",
|
|
38
|
+
"typecheck": "pnpm --filter @hachej/boring-ui-kit build && tsc --noEmit",
|
|
39
|
+
"test": "pnpm --filter @hachej/boring-ui-kit build && vitest run --passWithNoTests",
|
|
40
|
+
"lint": "pnpm run typecheck",
|
|
41
|
+
"clean": "rm -rf dist .tsbuildinfo"
|
|
42
|
+
},
|
|
43
|
+
"peerDependencies": {
|
|
44
|
+
"react": "^18.0.0 || ^19.0.0",
|
|
45
|
+
"react-dom": "^18.0.0 || ^19.0.0"
|
|
46
|
+
},
|
|
47
|
+
"dependencies": {
|
|
48
|
+
"@hachej/boring-ui-kit": "workspace:*",
|
|
49
|
+
"clsx": "^2.1.1",
|
|
50
|
+
"lucide-react": "^1.8.0",
|
|
51
|
+
"tailwind-merge": "^3.5.0"
|
|
52
|
+
},
|
|
53
|
+
"devDependencies": {
|
|
54
|
+
"@testing-library/jest-dom": "^6.9.1",
|
|
55
|
+
"@testing-library/react": "^16.3.2",
|
|
56
|
+
"@testing-library/user-event": "^14.6.1",
|
|
57
|
+
"@types/react": "^19.0.0",
|
|
58
|
+
"@types/react-dom": "^19.0.0",
|
|
59
|
+
"@vitejs/plugin-react": "^4.0.0",
|
|
60
|
+
"jsdom": "^29.0.2",
|
|
61
|
+
"react": "^19.0.0",
|
|
62
|
+
"react-dom": "^19.0.0",
|
|
63
|
+
"tsup": "^8.0.0",
|
|
64
|
+
"typescript": "^5.4.0",
|
|
65
|
+
"vitest": "^2.0.0"
|
|
66
|
+
}
|
|
67
|
+
}
|