@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 ADDED
@@ -0,0 +1,272 @@
1
+ # @hachej/boring-data-explorer
2
+
3
+ <div align="center">
4
+
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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,6 @@
1
+ import { ExplorerDataSource } from '../shared/index.js';
2
+
3
+ declare function createMockSeriesAdapter(): ExplorerDataSource;
4
+ declare function createMockTablesAdapter(): ExplorerDataSource;
5
+
6
+ export { createMockSeriesAdapter, createMockTablesAdapter };
@@ -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
+ }