@mdxui/terminal 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +571 -0
- package/dist/ansi-css-Sk5mWtdK.d.ts +119 -0
- package/dist/ansi-css-V6JIHGsM.d.ts +119 -0
- package/dist/ansi-css-_3eSEU9d.d.ts +119 -0
- package/dist/chunk-3EFDH7PK.js +5235 -0
- package/dist/chunk-3RG5ZIWI.js +10 -0
- package/dist/chunk-3X5IR6WE.js +884 -0
- package/dist/chunk-4FV5ZDCE.js +5236 -0
- package/dist/chunk-4OVMSF2J.js +243 -0
- package/dist/chunk-63FEETIS.js +4048 -0
- package/dist/chunk-B43KP7XJ.js +884 -0
- package/dist/chunk-BMTJXWUV.js +655 -0
- package/dist/chunk-C3SVH4N7.js +882 -0
- package/dist/chunk-EVWR7Y47.js +874 -0
- package/dist/chunk-F6A5VWUC.js +1285 -0
- package/dist/chunk-FD7KW7GE.js +882 -0
- package/dist/chunk-GBQ6UD6I.js +655 -0
- package/dist/chunk-GMDD3M6U.js +5227 -0
- package/dist/chunk-JBHRXOXM.js +1058 -0
- package/dist/chunk-JFOO3EYO.js +1182 -0
- package/dist/chunk-JQ5H3WXL.js +1291 -0
- package/dist/chunk-JQD5NASE.js +234 -0
- package/dist/chunk-KRHJP5R7.js +592 -0
- package/dist/chunk-KWF6WVJE.js +962 -0
- package/dist/chunk-LHYQVN3H.js +1038 -0
- package/dist/chunk-M3TLQLGC.js +1032 -0
- package/dist/chunk-MVW4Q5OP.js +240 -0
- package/dist/chunk-NXCZSWLU.js +1294 -0
- package/dist/chunk-O25TNRO6.js +607 -0
- package/dist/chunk-PNECDA2I.js +884 -0
- package/dist/chunk-QIHWRLJR.js +962 -0
- package/dist/chunk-QW5YMQ7K.js +882 -0
- package/dist/chunk-R5U7XKVJ.js +16 -0
- package/dist/chunk-RP2MVQLR.js +962 -0
- package/dist/chunk-TP6RXGXA.js +1087 -0
- package/dist/chunk-TQQSTITZ.js +655 -0
- package/dist/chunk-X24GWXQV.js +1281 -0
- package/dist/components/index.d.ts +802 -0
- package/dist/components/index.js +149 -0
- package/dist/data/index.d.ts +2554 -0
- package/dist/data/index.js +51 -0
- package/dist/forms/index.d.ts +1596 -0
- package/dist/forms/index.js +464 -0
- package/dist/index-CQRFZntR.d.ts +867 -0
- package/dist/index.d.ts +579 -0
- package/dist/index.js +786 -0
- package/dist/interactive-D0JkWosD.d.ts +217 -0
- package/dist/keyboard/index.d.ts +2 -0
- package/dist/keyboard/index.js +43 -0
- package/dist/renderers/index.d.ts +546 -0
- package/dist/renderers/index.js +2157 -0
- package/dist/storybook/index.d.ts +396 -0
- package/dist/storybook/index.js +641 -0
- package/dist/theme/index.d.ts +1339 -0
- package/dist/theme/index.js +123 -0
- package/dist/types-Bxu5PAgA.d.ts +710 -0
- package/dist/types-CIlop5Ji.d.ts +701 -0
- package/dist/types-Ca8p_p5X.d.ts +710 -0
- package/package.json +90 -0
- package/src/__tests__/components/data/card.test.ts +458 -0
- package/src/__tests__/components/data/list.test.ts +473 -0
- package/src/__tests__/components/data/metrics.test.ts +541 -0
- package/src/__tests__/components/data/table.test.ts +448 -0
- package/src/__tests__/components/input/field.test.ts +555 -0
- package/src/__tests__/components/input/form.test.ts +870 -0
- package/src/__tests__/components/input/search.test.ts +1238 -0
- package/src/__tests__/components/input/select.test.ts +658 -0
- package/src/__tests__/components/navigation/breadcrumb.test.ts +923 -0
- package/src/__tests__/components/navigation/command-palette.test.ts +1095 -0
- package/src/__tests__/components/navigation/sidebar.test.ts +1018 -0
- package/src/__tests__/components/navigation/tabs.test.ts +995 -0
- package/src/__tests__/components.test.tsx +1197 -0
- package/src/__tests__/core/compiler.test.ts +986 -0
- package/src/__tests__/core/parser.test.ts +785 -0
- package/src/__tests__/core/tier-switcher.test.ts +1103 -0
- package/src/__tests__/core/types.test.ts +1398 -0
- package/src/__tests__/data/collections.test.ts +1337 -0
- package/src/__tests__/data/db.test.ts +1265 -0
- package/src/__tests__/data/reactive.test.ts +1010 -0
- package/src/__tests__/data/sync.test.ts +1614 -0
- package/src/__tests__/errors.test.ts +660 -0
- package/src/__tests__/forms/integration.test.ts +444 -0
- package/src/__tests__/integration.test.ts +905 -0
- package/src/__tests__/keyboard.test.ts +1791 -0
- package/src/__tests__/renderer.test.ts +489 -0
- package/src/__tests__/renderers/ansi-css.test.ts +948 -0
- package/src/__tests__/renderers/ansi.test.ts +1366 -0
- package/src/__tests__/renderers/ascii.test.ts +1360 -0
- package/src/__tests__/renderers/interactive.test.ts +2353 -0
- package/src/__tests__/renderers/markdown.test.ts +1483 -0
- package/src/__tests__/renderers/text.test.ts +1369 -0
- package/src/__tests__/renderers/unicode.test.ts +1307 -0
- package/src/__tests__/theme.test.ts +639 -0
- package/src/__tests__/utils/assertions.ts +685 -0
- package/src/__tests__/utils/index.ts +115 -0
- package/src/__tests__/utils/test-renderer.ts +381 -0
- package/src/__tests__/utils/utils.test.ts +560 -0
- package/src/components/containers/card.ts +56 -0
- package/src/components/containers/dialog.ts +53 -0
- package/src/components/containers/index.ts +9 -0
- package/src/components/containers/panel.ts +59 -0
- package/src/components/feedback/badge.ts +40 -0
- package/src/components/feedback/index.ts +8 -0
- package/src/components/feedback/spinner.ts +23 -0
- package/src/components/helpers.ts +81 -0
- package/src/components/index.ts +153 -0
- package/src/components/layout/breadcrumb.ts +31 -0
- package/src/components/layout/index.ts +10 -0
- package/src/components/layout/list.ts +29 -0
- package/src/components/layout/sidebar.ts +79 -0
- package/src/components/layout/table.ts +62 -0
- package/src/components/primitives/box.ts +95 -0
- package/src/components/primitives/button.ts +54 -0
- package/src/components/primitives/index.ts +11 -0
- package/src/components/primitives/input.ts +88 -0
- package/src/components/primitives/select.ts +97 -0
- package/src/components/primitives/text.ts +60 -0
- package/src/components/render.ts +155 -0
- package/src/components/templates/app.ts +43 -0
- package/src/components/templates/index.ts +8 -0
- package/src/components/templates/site.ts +54 -0
- package/src/components/types.ts +777 -0
- package/src/core/compiler.ts +718 -0
- package/src/core/parser.ts +127 -0
- package/src/core/tier-switcher.ts +607 -0
- package/src/core/types.ts +672 -0
- package/src/data/collection.ts +316 -0
- package/src/data/collections.ts +50 -0
- package/src/data/context.tsx +174 -0
- package/src/data/db.ts +127 -0
- package/src/data/hooks.ts +532 -0
- package/src/data/index.ts +138 -0
- package/src/data/reactive.ts +1225 -0
- package/src/data/saas-collections.ts +375 -0
- package/src/data/sync.ts +1213 -0
- package/src/data/types.ts +660 -0
- package/src/forms/converters.ts +512 -0
- package/src/forms/index.ts +133 -0
- package/src/forms/schemas.ts +403 -0
- package/src/forms/types.ts +476 -0
- package/src/index.ts +542 -0
- package/src/keyboard/focus.ts +748 -0
- package/src/keyboard/index.ts +96 -0
- package/src/keyboard/integration.ts +371 -0
- package/src/keyboard/manager.ts +377 -0
- package/src/keyboard/presets.ts +90 -0
- package/src/renderers/ansi-css.ts +576 -0
- package/src/renderers/ansi.ts +802 -0
- package/src/renderers/ascii.ts +680 -0
- package/src/renderers/breadcrumb.ts +480 -0
- package/src/renderers/command-palette.ts +802 -0
- package/src/renderers/components/field.ts +210 -0
- package/src/renderers/components/form.ts +327 -0
- package/src/renderers/components/index.ts +21 -0
- package/src/renderers/components/search.ts +449 -0
- package/src/renderers/components/select.ts +222 -0
- package/src/renderers/index.ts +101 -0
- package/src/renderers/interactive/component-handlers.ts +622 -0
- package/src/renderers/interactive/cursor-manager.ts +147 -0
- package/src/renderers/interactive/focus-manager.ts +279 -0
- package/src/renderers/interactive/index.ts +661 -0
- package/src/renderers/interactive/input-handler.ts +164 -0
- package/src/renderers/interactive/keyboard-handler.ts +212 -0
- package/src/renderers/interactive/mouse-handler.ts +167 -0
- package/src/renderers/interactive/state-manager.ts +109 -0
- package/src/renderers/interactive/types.ts +338 -0
- package/src/renderers/interactive-string.ts +299 -0
- package/src/renderers/interactive.ts +59 -0
- package/src/renderers/markdown.ts +950 -0
- package/src/renderers/sidebar.ts +549 -0
- package/src/renderers/tabs.ts +682 -0
- package/src/renderers/text.ts +791 -0
- package/src/renderers/unicode.ts +917 -0
- package/src/renderers/utils.ts +942 -0
- package/src/router/adapters.ts +383 -0
- package/src/router/types.ts +140 -0
- package/src/router/utils.ts +452 -0
- package/src/schemas.ts +205 -0
- package/src/storybook/index.ts +91 -0
- package/src/storybook/interactive-decorator.tsx +659 -0
- package/src/storybook/keyboard-simulator.ts +501 -0
- package/src/theme/ansi-codes.ts +80 -0
- package/src/theme/box-drawing.ts +132 -0
- package/src/theme/color-convert.ts +254 -0
- package/src/theme/color-support.ts +321 -0
- package/src/theme/index.ts +134 -0
- package/src/theme/strip-ansi.ts +50 -0
- package/src/theme/tailwind-map.ts +469 -0
- package/src/theme/text-styles.ts +206 -0
- package/src/theme/theme-system.ts +568 -0
- package/src/types.ts +103 -0
|
@@ -0,0 +1,1225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @mdxui/terminal Reactive Data Hooks for Data Components
|
|
3
|
+
*
|
|
4
|
+
* This module provides React hooks for connecting data components (Table, List,
|
|
5
|
+
* Card, Metrics) to TanStack DB collections with reactive updates and optimistic
|
|
6
|
+
* mutations.
|
|
7
|
+
*
|
|
8
|
+
* Key features:
|
|
9
|
+
* - Reactive data binding - UI updates automatically when collection data changes
|
|
10
|
+
* - Optimistic updates - UI updates immediately, rolls back on error
|
|
11
|
+
* - Type-safe queries - Full TypeScript support with Zod validation
|
|
12
|
+
* - Live subscriptions - Real-time updates from collection changes
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* ```tsx
|
|
16
|
+
* import { useReactiveTable, useReactiveList } from '@mdxui/terminal'
|
|
17
|
+
*
|
|
18
|
+
* function UsersTable() {
|
|
19
|
+
* const { data, isLoading, mutate, sort, setSort } = useReactiveTable({
|
|
20
|
+
* collection: 'users',
|
|
21
|
+
* where: { status: 'active' },
|
|
22
|
+
* orderBy: { name: 'asc' },
|
|
23
|
+
* })
|
|
24
|
+
*
|
|
25
|
+
* // Data updates reactively when collection changes
|
|
26
|
+
* return (
|
|
27
|
+
* <Table
|
|
28
|
+
* columns={[
|
|
29
|
+
* { key: 'name', header: 'Name', sortable: true },
|
|
30
|
+
* { key: 'email', header: 'Email' },
|
|
31
|
+
* ]}
|
|
32
|
+
* data={data}
|
|
33
|
+
* sortBy={sort.field}
|
|
34
|
+
* sortDirection={sort.direction}
|
|
35
|
+
* onSort={(field) => setSort(field)}
|
|
36
|
+
* onRowAction={(action, row) => {
|
|
37
|
+
* if (action === 'delete') {
|
|
38
|
+
* mutate.delete({ id: row.id })
|
|
39
|
+
* }
|
|
40
|
+
* }}
|
|
41
|
+
* />
|
|
42
|
+
* )
|
|
43
|
+
* }
|
|
44
|
+
* ```
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
import { useState, useEffect, useCallback, useRef } from 'react'
|
|
48
|
+
import { useDBContext } from './context'
|
|
49
|
+
import type {
|
|
50
|
+
QueryOptions,
|
|
51
|
+
MutationResult,
|
|
52
|
+
WhereClause,
|
|
53
|
+
OrderByClause,
|
|
54
|
+
OrderDirection,
|
|
55
|
+
Collection,
|
|
56
|
+
} from './types'
|
|
57
|
+
|
|
58
|
+
// ============================================================================
|
|
59
|
+
// Types
|
|
60
|
+
// ============================================================================
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Sort state for reactive data components
|
|
64
|
+
*
|
|
65
|
+
* @example
|
|
66
|
+
* ```typescript
|
|
67
|
+
* const sort: SortState = {
|
|
68
|
+
* field: 'createdAt',
|
|
69
|
+
* direction: 'desc',
|
|
70
|
+
* }
|
|
71
|
+
* ```
|
|
72
|
+
*/
|
|
73
|
+
export interface SortState {
|
|
74
|
+
/** Field to sort by */
|
|
75
|
+
field: string | null
|
|
76
|
+
/** Sort direction: 'asc' or 'desc' */
|
|
77
|
+
direction: OrderDirection
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Selection state for reactive data components
|
|
82
|
+
*
|
|
83
|
+
* @example
|
|
84
|
+
* ```typescript
|
|
85
|
+
* const selection: SelectionState = {
|
|
86
|
+
* mode: 'multi',
|
|
87
|
+
* selected: new Set(['user-1', 'user-3']),
|
|
88
|
+
* }
|
|
89
|
+
* ```
|
|
90
|
+
*/
|
|
91
|
+
export interface SelectionState {
|
|
92
|
+
/** Selection mode: 'single', 'multi', or 'none' */
|
|
93
|
+
mode: 'single' | 'multi' | 'none'
|
|
94
|
+
/** Set of selected item IDs */
|
|
95
|
+
selected: Set<string>
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Pagination state for reactive data components
|
|
100
|
+
*
|
|
101
|
+
* @example
|
|
102
|
+
* ```typescript
|
|
103
|
+
* const pagination: PaginationState = {
|
|
104
|
+
* page: 1,
|
|
105
|
+
* pageSize: 20,
|
|
106
|
+
* total: 150,
|
|
107
|
+
* }
|
|
108
|
+
* ```
|
|
109
|
+
*/
|
|
110
|
+
export interface PaginationState {
|
|
111
|
+
/** Current page (1-indexed) */
|
|
112
|
+
page: number
|
|
113
|
+
/** Items per page */
|
|
114
|
+
pageSize: number
|
|
115
|
+
/** Total item count (from query) */
|
|
116
|
+
total: number
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Options for useReactiveData hook
|
|
121
|
+
*
|
|
122
|
+
* @template T - The document type
|
|
123
|
+
*
|
|
124
|
+
* @remarks
|
|
125
|
+
* Extends QueryOptions with reactive-specific options like selection, sorting,
|
|
126
|
+
* and pagination control.
|
|
127
|
+
*
|
|
128
|
+
* @example
|
|
129
|
+
* ```typescript
|
|
130
|
+
* const options: ReactiveDataOptions<User> = {
|
|
131
|
+
* collection: 'users',
|
|
132
|
+
* where: { role: 'admin' },
|
|
133
|
+
* orderBy: { createdAt: 'desc' },
|
|
134
|
+
* limit: 20,
|
|
135
|
+
* selectable: 'multi',
|
|
136
|
+
* optimistic: true,
|
|
137
|
+
* }
|
|
138
|
+
* ```
|
|
139
|
+
*/
|
|
140
|
+
export interface ReactiveDataOptions<T> {
|
|
141
|
+
/** Collection name to query from */
|
|
142
|
+
collection: string
|
|
143
|
+
/** Filter conditions */
|
|
144
|
+
where?: WhereClause<T>
|
|
145
|
+
/** Sort order */
|
|
146
|
+
orderBy?: OrderByClause<T> | OrderByClause<T>[]
|
|
147
|
+
/** Maximum items to return */
|
|
148
|
+
limit?: number
|
|
149
|
+
/** Items to skip (for pagination) */
|
|
150
|
+
offset?: number
|
|
151
|
+
/** Enable selection: 'single', 'multi', or false */
|
|
152
|
+
selectable?: 'single' | 'multi' | boolean
|
|
153
|
+
/** Enable optimistic updates (default: true) */
|
|
154
|
+
optimistic?: boolean
|
|
155
|
+
/** Primary key field (default: 'id') */
|
|
156
|
+
primaryKey?: keyof T
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Result of useReactiveData hook
|
|
161
|
+
*
|
|
162
|
+
* @template T - The document type
|
|
163
|
+
*
|
|
164
|
+
* @remarks
|
|
165
|
+
* Provides reactive data, loading/error states, mutation functions, and
|
|
166
|
+
* controls for sorting, selection, and pagination.
|
|
167
|
+
*
|
|
168
|
+
* @example
|
|
169
|
+
* ```typescript
|
|
170
|
+
* const {
|
|
171
|
+
* data,
|
|
172
|
+
* isLoading,
|
|
173
|
+
* error,
|
|
174
|
+
* mutate,
|
|
175
|
+
* sort,
|
|
176
|
+
* setSort,
|
|
177
|
+
* selection,
|
|
178
|
+
* toggleSelect,
|
|
179
|
+
* pagination,
|
|
180
|
+
* setPage,
|
|
181
|
+
* } = useReactiveData<User>({ collection: 'users' })
|
|
182
|
+
* ```
|
|
183
|
+
*/
|
|
184
|
+
export interface ReactiveDataResult<T> {
|
|
185
|
+
/** Reactive data array - updates when collection changes */
|
|
186
|
+
data: T[]
|
|
187
|
+
/** True while initial query is executing */
|
|
188
|
+
isLoading: boolean
|
|
189
|
+
/** Error if query or mutation failed */
|
|
190
|
+
error: Error | undefined
|
|
191
|
+
/** Manual refetch function */
|
|
192
|
+
refetch: () => void
|
|
193
|
+
|
|
194
|
+
// Mutation functions
|
|
195
|
+
/** Mutation functions for insert, update, delete */
|
|
196
|
+
mutate: {
|
|
197
|
+
/** Insert a new document */
|
|
198
|
+
insert: (data: T) => Promise<void>
|
|
199
|
+
/** Update documents matching filter */
|
|
200
|
+
update: (filter: Partial<T>, updates: Partial<T>) => Promise<void>
|
|
201
|
+
/** Delete a document by ID */
|
|
202
|
+
delete: (filter: { id: string } | Partial<T>) => Promise<void>
|
|
203
|
+
}
|
|
204
|
+
/** True while any mutation is pending */
|
|
205
|
+
isMutating: boolean
|
|
206
|
+
|
|
207
|
+
// Sort state
|
|
208
|
+
/** Current sort state */
|
|
209
|
+
sort: SortState
|
|
210
|
+
/** Set sort field (toggles direction if same field) */
|
|
211
|
+
setSort: (field: string) => void
|
|
212
|
+
|
|
213
|
+
// Selection state
|
|
214
|
+
/** Current selection state */
|
|
215
|
+
selection: SelectionState
|
|
216
|
+
/** Toggle selection for an item */
|
|
217
|
+
toggleSelect: (id: string) => void
|
|
218
|
+
/** Select all items */
|
|
219
|
+
selectAll: () => void
|
|
220
|
+
/** Clear all selections */
|
|
221
|
+
clearSelection: () => void
|
|
222
|
+
|
|
223
|
+
// Pagination state
|
|
224
|
+
/** Current pagination state */
|
|
225
|
+
pagination: PaginationState
|
|
226
|
+
/** Go to a specific page */
|
|
227
|
+
setPage: (page: number) => void
|
|
228
|
+
/** Change page size */
|
|
229
|
+
setPageSize: (size: number) => void
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Options for useReactiveTable hook
|
|
234
|
+
*
|
|
235
|
+
* @template T - The document type
|
|
236
|
+
*
|
|
237
|
+
* @remarks
|
|
238
|
+
* Table-specific options extending ReactiveDataOptions with column definitions.
|
|
239
|
+
*/
|
|
240
|
+
export interface ReactiveTableOptions<T> extends ReactiveDataOptions<T> {
|
|
241
|
+
/** Column definitions for the table */
|
|
242
|
+
columns?: Array<{
|
|
243
|
+
key: keyof T
|
|
244
|
+
header: string
|
|
245
|
+
sortable?: boolean
|
|
246
|
+
width?: number
|
|
247
|
+
align?: 'left' | 'center' | 'right'
|
|
248
|
+
}>
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Options for useReactiveList hook
|
|
253
|
+
*
|
|
254
|
+
* @template T - The document type
|
|
255
|
+
*
|
|
256
|
+
* @remarks
|
|
257
|
+
* List-specific options extending ReactiveDataOptions.
|
|
258
|
+
*/
|
|
259
|
+
export interface ReactiveListOptions<T> extends ReactiveDataOptions<T> {
|
|
260
|
+
/** Field to use as display text */
|
|
261
|
+
labelField?: keyof T
|
|
262
|
+
/** Field to use as icon */
|
|
263
|
+
iconField?: keyof T
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Options for useReactiveMetrics hook
|
|
268
|
+
*
|
|
269
|
+
* @template T - The document type
|
|
270
|
+
*
|
|
271
|
+
* @remarks
|
|
272
|
+
* Metrics-specific options for aggregated data display.
|
|
273
|
+
*/
|
|
274
|
+
export interface ReactiveMetricsOptions<T> extends Omit<ReactiveDataOptions<T>, 'limit' | 'offset'> {
|
|
275
|
+
/** Metrics to compute from collection data */
|
|
276
|
+
metrics: Array<{
|
|
277
|
+
/** Unique key for the metric */
|
|
278
|
+
key: string
|
|
279
|
+
/** Display label */
|
|
280
|
+
label: string
|
|
281
|
+
/** Field to aggregate */
|
|
282
|
+
field: keyof T
|
|
283
|
+
/** Aggregation function */
|
|
284
|
+
aggregate: 'count' | 'sum' | 'avg' | 'min' | 'max' | 'latest'
|
|
285
|
+
/** Format for display */
|
|
286
|
+
format?: 'number' | 'currency' | 'percentage'
|
|
287
|
+
/** Unit suffix */
|
|
288
|
+
unit?: string
|
|
289
|
+
}>
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Single metric result
|
|
294
|
+
*/
|
|
295
|
+
export interface MetricValue {
|
|
296
|
+
/** Metric key */
|
|
297
|
+
key: string
|
|
298
|
+
/** Display label */
|
|
299
|
+
label: string
|
|
300
|
+
/** Computed value */
|
|
301
|
+
value: number | string
|
|
302
|
+
/** Trend direction (computed from previous values if available) */
|
|
303
|
+
trend?: 'up' | 'down' | 'neutral'
|
|
304
|
+
/** Trend percentage change */
|
|
305
|
+
trendValue?: number
|
|
306
|
+
/** Optional sparkline data */
|
|
307
|
+
sparkline?: number[]
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Result of useReactiveMetrics hook
|
|
312
|
+
*/
|
|
313
|
+
export interface ReactiveMetricsResult {
|
|
314
|
+
/** Computed metric values */
|
|
315
|
+
metrics: MetricValue[]
|
|
316
|
+
/** True while initial query is executing */
|
|
317
|
+
isLoading: boolean
|
|
318
|
+
/** Error if query failed */
|
|
319
|
+
error: Error | undefined
|
|
320
|
+
/** Manual refetch function */
|
|
321
|
+
refetch: () => void
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Options for useReactiveCard hook
|
|
326
|
+
*
|
|
327
|
+
* @template T - The document type
|
|
328
|
+
*
|
|
329
|
+
* @remarks
|
|
330
|
+
* Card-specific options for single-record display.
|
|
331
|
+
*/
|
|
332
|
+
export interface ReactiveCardOptions<T> {
|
|
333
|
+
/** Collection name */
|
|
334
|
+
collection: string
|
|
335
|
+
/** Filter to find single record */
|
|
336
|
+
where: WhereClause<T>
|
|
337
|
+
/** Fields to display as key-value pairs */
|
|
338
|
+
fields?: Array<{
|
|
339
|
+
key: keyof T
|
|
340
|
+
label: string
|
|
341
|
+
format?: 'string' | 'date' | 'currency' | 'badge'
|
|
342
|
+
}>
|
|
343
|
+
/** Enable optimistic updates */
|
|
344
|
+
optimistic?: boolean
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Result of useReactiveCard hook
|
|
349
|
+
*/
|
|
350
|
+
export interface ReactiveCardResult<T> {
|
|
351
|
+
/** Single record data */
|
|
352
|
+
data: T | null
|
|
353
|
+
/** True while loading */
|
|
354
|
+
isLoading: boolean
|
|
355
|
+
/** Error if query failed */
|
|
356
|
+
error: Error | undefined
|
|
357
|
+
/** Refetch function */
|
|
358
|
+
refetch: () => void
|
|
359
|
+
/** Update the record */
|
|
360
|
+
update: (updates: Partial<T>) => Promise<void>
|
|
361
|
+
/** True while updating */
|
|
362
|
+
isUpdating: boolean
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// ============================================================================
|
|
366
|
+
// Hooks
|
|
367
|
+
// ============================================================================
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Hook for reactive data binding with TanStack DB collections
|
|
371
|
+
*
|
|
372
|
+
* Provides reactive data updates, optimistic mutations, sorting, selection,
|
|
373
|
+
* and pagination for data components like Table and List.
|
|
374
|
+
*
|
|
375
|
+
* @template T - The document type (must extend Record<string, any>)
|
|
376
|
+
*
|
|
377
|
+
* @param options - Configuration for collection, filters, and behavior
|
|
378
|
+
* @returns ReactiveDataResult with data, mutations, and state controls
|
|
379
|
+
*
|
|
380
|
+
* @remarks
|
|
381
|
+
* - Data updates automatically when collection changes (via subscriptions)
|
|
382
|
+
* - Optimistic updates show changes immediately, roll back on error
|
|
383
|
+
* - Sort state toggles direction when same field is selected
|
|
384
|
+
* - Selection supports single and multi-select modes
|
|
385
|
+
* - Pagination is handled client-side with limit/offset
|
|
386
|
+
*
|
|
387
|
+
* @example
|
|
388
|
+
* ```tsx
|
|
389
|
+
* function UserList() {
|
|
390
|
+
* const {
|
|
391
|
+
* data,
|
|
392
|
+
* isLoading,
|
|
393
|
+
* mutate,
|
|
394
|
+
* sort,
|
|
395
|
+
* setSort,
|
|
396
|
+
* selection,
|
|
397
|
+
* toggleSelect,
|
|
398
|
+
* } = useReactiveData<User>({
|
|
399
|
+
* collection: 'users',
|
|
400
|
+
* where: { status: 'active' },
|
|
401
|
+
* orderBy: { name: 'asc' },
|
|
402
|
+
* selectable: 'multi',
|
|
403
|
+
* })
|
|
404
|
+
*
|
|
405
|
+
* if (isLoading) return <Spinner />
|
|
406
|
+
*
|
|
407
|
+
* return (
|
|
408
|
+
* <List
|
|
409
|
+
* items={data.map(user => ({
|
|
410
|
+
* id: user.id,
|
|
411
|
+
* text: user.name,
|
|
412
|
+
* selected: selection.selected.has(user.id),
|
|
413
|
+
* }))}
|
|
414
|
+
* onItemClick={(id) => toggleSelect(id)}
|
|
415
|
+
* onDelete={(id) => mutate.delete({ id })}
|
|
416
|
+
* />
|
|
417
|
+
* )
|
|
418
|
+
* }
|
|
419
|
+
* ```
|
|
420
|
+
*
|
|
421
|
+
* @example
|
|
422
|
+
* ```tsx
|
|
423
|
+
* // With optimistic updates
|
|
424
|
+
* function QuickAddUser() {
|
|
425
|
+
* const { mutate, isMutating } = useReactiveData<User>({
|
|
426
|
+
* collection: 'users',
|
|
427
|
+
* optimistic: true,
|
|
428
|
+
* })
|
|
429
|
+
*
|
|
430
|
+
* const handleAdd = async () => {
|
|
431
|
+
* // UI updates immediately, rolls back if server fails
|
|
432
|
+
* await mutate.insert({
|
|
433
|
+
* id: crypto.randomUUID(),
|
|
434
|
+
* name: 'New User',
|
|
435
|
+
* status: 'active',
|
|
436
|
+
* })
|
|
437
|
+
* }
|
|
438
|
+
*
|
|
439
|
+
* return (
|
|
440
|
+
* <button onClick={handleAdd} disabled={isMutating}>
|
|
441
|
+
* {isMutating ? 'Adding...' : 'Add User'}
|
|
442
|
+
* </button>
|
|
443
|
+
* )
|
|
444
|
+
* }
|
|
445
|
+
* ```
|
|
446
|
+
*/
|
|
447
|
+
export function useReactiveData<T extends Record<string, any>>(
|
|
448
|
+
options: ReactiveDataOptions<T>
|
|
449
|
+
): ReactiveDataResult<T> {
|
|
450
|
+
const db = useDBContext()
|
|
451
|
+
const {
|
|
452
|
+
collection: collectionName,
|
|
453
|
+
where,
|
|
454
|
+
orderBy: initialOrderBy,
|
|
455
|
+
limit,
|
|
456
|
+
offset,
|
|
457
|
+
selectable = false,
|
|
458
|
+
optimistic = true,
|
|
459
|
+
primaryKey = 'id' as keyof T,
|
|
460
|
+
} = options
|
|
461
|
+
|
|
462
|
+
// Data state
|
|
463
|
+
const [data, setData] = useState<T[]>([])
|
|
464
|
+
const [isLoading, setIsLoading] = useState(true)
|
|
465
|
+
const [error, setError] = useState<Error | undefined>(undefined)
|
|
466
|
+
const [isMutating, setIsMutating] = useState(false)
|
|
467
|
+
|
|
468
|
+
// Sort state
|
|
469
|
+
const [sort, setSortState] = useState<SortState>(() => {
|
|
470
|
+
if (!initialOrderBy) return { field: null, direction: 'asc' as OrderDirection }
|
|
471
|
+
const orderClauses = Array.isArray(initialOrderBy) ? initialOrderBy : [initialOrderBy]
|
|
472
|
+
if (orderClauses.length === 0) return { field: null, direction: 'asc' as OrderDirection }
|
|
473
|
+
const [field, direction] = Object.entries(orderClauses[0])[0]
|
|
474
|
+
return { field, direction: direction as OrderDirection }
|
|
475
|
+
})
|
|
476
|
+
|
|
477
|
+
// Selection state
|
|
478
|
+
const [selection, setSelection] = useState<SelectionState>({
|
|
479
|
+
mode: selectable === true ? 'single' : selectable === false ? 'none' : selectable,
|
|
480
|
+
selected: new Set<string>(),
|
|
481
|
+
})
|
|
482
|
+
|
|
483
|
+
// Pagination state
|
|
484
|
+
const [pagination, setPagination] = useState<PaginationState>({
|
|
485
|
+
page: 1,
|
|
486
|
+
pageSize: limit ?? 20,
|
|
487
|
+
total: 0,
|
|
488
|
+
})
|
|
489
|
+
|
|
490
|
+
// Refs for tracking rollback data
|
|
491
|
+
const rollbackDataRef = useRef<T[]>([])
|
|
492
|
+
|
|
493
|
+
// Get collection
|
|
494
|
+
const collection = db.collections[collectionName] as Collection<T> | undefined
|
|
495
|
+
|
|
496
|
+
// Build orderBy from sort state
|
|
497
|
+
const currentOrderBy = sort.field
|
|
498
|
+
? { [sort.field]: sort.direction } as OrderByClause<T>
|
|
499
|
+
: initialOrderBy
|
|
500
|
+
|
|
501
|
+
// Execute query
|
|
502
|
+
const executeQuery = useCallback(async () => {
|
|
503
|
+
if (!collection) {
|
|
504
|
+
setError(new Error(`Collection '${collectionName}' not found`))
|
|
505
|
+
setIsLoading(false)
|
|
506
|
+
return
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
try {
|
|
510
|
+
const result = await collection.findMany({
|
|
511
|
+
where,
|
|
512
|
+
orderBy: currentOrderBy,
|
|
513
|
+
limit,
|
|
514
|
+
offset,
|
|
515
|
+
})
|
|
516
|
+
setData(result)
|
|
517
|
+
setPagination(prev => ({ ...prev, total: result.length }))
|
|
518
|
+
setError(undefined)
|
|
519
|
+
} catch (err) {
|
|
520
|
+
setError(err instanceof Error ? err : new Error(String(err)))
|
|
521
|
+
} finally {
|
|
522
|
+
setIsLoading(false)
|
|
523
|
+
}
|
|
524
|
+
}, [collection, collectionName, JSON.stringify(where), JSON.stringify(currentOrderBy), limit, offset])
|
|
525
|
+
|
|
526
|
+
// Initial fetch
|
|
527
|
+
useEffect(() => {
|
|
528
|
+
executeQuery()
|
|
529
|
+
}, [executeQuery])
|
|
530
|
+
|
|
531
|
+
// Subscribe to collection changes
|
|
532
|
+
useEffect(() => {
|
|
533
|
+
if (!collection) return
|
|
534
|
+
|
|
535
|
+
const unsubscribe = collection.subscribe((allData: T[]) => {
|
|
536
|
+
// Re-apply filters when data changes
|
|
537
|
+
let result = allData
|
|
538
|
+
|
|
539
|
+
if (where) {
|
|
540
|
+
result = result.filter(doc => matchesWhere(doc, where))
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
if (currentOrderBy) {
|
|
544
|
+
result = sortDocuments(result, currentOrderBy)
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
if (offset !== undefined) {
|
|
548
|
+
result = result.slice(offset)
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
if (limit !== undefined) {
|
|
552
|
+
result = result.slice(0, limit)
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
setData(result)
|
|
556
|
+
setPagination(prev => ({ ...prev, total: result.length }))
|
|
557
|
+
})
|
|
558
|
+
|
|
559
|
+
return unsubscribe
|
|
560
|
+
}, [collection, JSON.stringify(where), JSON.stringify(currentOrderBy), limit, offset])
|
|
561
|
+
|
|
562
|
+
// Mutation functions
|
|
563
|
+
const mutate = {
|
|
564
|
+
insert: useCallback(async (newData: T) => {
|
|
565
|
+
if (!collection) {
|
|
566
|
+
throw new Error(`Collection '${collectionName}' not found`)
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
setIsMutating(true)
|
|
570
|
+
|
|
571
|
+
if (optimistic) {
|
|
572
|
+
// Save for rollback
|
|
573
|
+
rollbackDataRef.current = [...data]
|
|
574
|
+
// Optimistically add to local state
|
|
575
|
+
setData(prev => [...prev, newData])
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
try {
|
|
579
|
+
await collection.insert(newData)
|
|
580
|
+
// If not optimistic, data will update via subscription
|
|
581
|
+
} catch (err) {
|
|
582
|
+
// Rollback on error
|
|
583
|
+
if (optimistic) {
|
|
584
|
+
setData(rollbackDataRef.current)
|
|
585
|
+
}
|
|
586
|
+
throw err
|
|
587
|
+
} finally {
|
|
588
|
+
setIsMutating(false)
|
|
589
|
+
}
|
|
590
|
+
}, [collection, collectionName, data, optimistic]),
|
|
591
|
+
|
|
592
|
+
update: useCallback(async (filter: Partial<T>, updates: Partial<T>) => {
|
|
593
|
+
if (!collection) {
|
|
594
|
+
throw new Error(`Collection '${collectionName}' not found`)
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
setIsMutating(true)
|
|
598
|
+
|
|
599
|
+
if (optimistic) {
|
|
600
|
+
// Save for rollback
|
|
601
|
+
rollbackDataRef.current = [...data]
|
|
602
|
+
// Optimistically update local state
|
|
603
|
+
setData(prev => prev.map(item => {
|
|
604
|
+
const matches = Object.entries(filter).every(([key, val]) => item[key] === val)
|
|
605
|
+
return matches ? { ...item, ...updates } : item
|
|
606
|
+
}))
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
try {
|
|
610
|
+
await collection.update(filter, updates)
|
|
611
|
+
} catch (err) {
|
|
612
|
+
if (optimistic) {
|
|
613
|
+
setData(rollbackDataRef.current)
|
|
614
|
+
}
|
|
615
|
+
throw err
|
|
616
|
+
} finally {
|
|
617
|
+
setIsMutating(false)
|
|
618
|
+
}
|
|
619
|
+
}, [collection, collectionName, data, optimistic]),
|
|
620
|
+
|
|
621
|
+
delete: useCallback(async (filter: { id: string } | Partial<T>) => {
|
|
622
|
+
if (!collection) {
|
|
623
|
+
throw new Error(`Collection '${collectionName}' not found`)
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
setIsMutating(true)
|
|
627
|
+
|
|
628
|
+
if (optimistic) {
|
|
629
|
+
// Save for rollback
|
|
630
|
+
rollbackDataRef.current = [...data]
|
|
631
|
+
// Optimistically remove from local state
|
|
632
|
+
setData(prev => prev.filter(item => {
|
|
633
|
+
const matches = Object.entries(filter).every(([key, val]) => item[key as keyof T] === val)
|
|
634
|
+
return !matches
|
|
635
|
+
}))
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
try {
|
|
639
|
+
await collection.delete(filter as Partial<T>)
|
|
640
|
+
} catch (err) {
|
|
641
|
+
if (optimistic) {
|
|
642
|
+
setData(rollbackDataRef.current)
|
|
643
|
+
}
|
|
644
|
+
throw err
|
|
645
|
+
} finally {
|
|
646
|
+
setIsMutating(false)
|
|
647
|
+
}
|
|
648
|
+
}, [collection, collectionName, data, optimistic]),
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// Sort control
|
|
652
|
+
const setSort = useCallback((field: string) => {
|
|
653
|
+
setSortState(prev => ({
|
|
654
|
+
field,
|
|
655
|
+
direction: prev.field === field && prev.direction === 'asc' ? 'desc' : 'asc',
|
|
656
|
+
}))
|
|
657
|
+
}, [])
|
|
658
|
+
|
|
659
|
+
// Selection controls
|
|
660
|
+
const toggleSelect = useCallback((id: string) => {
|
|
661
|
+
setSelection(prev => {
|
|
662
|
+
const newSelected = new Set(prev.selected)
|
|
663
|
+
if (prev.mode === 'single') {
|
|
664
|
+
// Single mode: toggle or replace
|
|
665
|
+
if (newSelected.has(id)) {
|
|
666
|
+
newSelected.clear()
|
|
667
|
+
} else {
|
|
668
|
+
newSelected.clear()
|
|
669
|
+
newSelected.add(id)
|
|
670
|
+
}
|
|
671
|
+
} else if (prev.mode === 'multi') {
|
|
672
|
+
// Multi mode: toggle
|
|
673
|
+
if (newSelected.has(id)) {
|
|
674
|
+
newSelected.delete(id)
|
|
675
|
+
} else {
|
|
676
|
+
newSelected.add(id)
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
return { ...prev, selected: newSelected }
|
|
680
|
+
})
|
|
681
|
+
}, [])
|
|
682
|
+
|
|
683
|
+
const selectAll = useCallback(() => {
|
|
684
|
+
if (selection.mode === 'none') return
|
|
685
|
+
const allIds = data.map(item => String(item[primaryKey]))
|
|
686
|
+
setSelection(prev => ({ ...prev, selected: new Set(allIds) }))
|
|
687
|
+
}, [data, primaryKey, selection.mode])
|
|
688
|
+
|
|
689
|
+
const clearSelection = useCallback(() => {
|
|
690
|
+
setSelection(prev => ({ ...prev, selected: new Set() }))
|
|
691
|
+
}, [])
|
|
692
|
+
|
|
693
|
+
// Pagination controls
|
|
694
|
+
const setPage = useCallback((page: number) => {
|
|
695
|
+
setPagination(prev => ({ ...prev, page }))
|
|
696
|
+
}, [])
|
|
697
|
+
|
|
698
|
+
const setPageSize = useCallback((size: number) => {
|
|
699
|
+
setPagination(prev => ({ ...prev, pageSize: size, page: 1 }))
|
|
700
|
+
}, [])
|
|
701
|
+
|
|
702
|
+
return {
|
|
703
|
+
data,
|
|
704
|
+
isLoading,
|
|
705
|
+
error,
|
|
706
|
+
refetch: executeQuery,
|
|
707
|
+
mutate,
|
|
708
|
+
isMutating,
|
|
709
|
+
sort,
|
|
710
|
+
setSort,
|
|
711
|
+
selection,
|
|
712
|
+
toggleSelect,
|
|
713
|
+
selectAll,
|
|
714
|
+
clearSelection,
|
|
715
|
+
pagination,
|
|
716
|
+
setPage,
|
|
717
|
+
setPageSize,
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/**
|
|
722
|
+
* Hook for reactive table data with TanStack DB integration
|
|
723
|
+
*
|
|
724
|
+
* Specialized version of useReactiveData for Table components with
|
|
725
|
+
* column-aware sorting and selection.
|
|
726
|
+
*
|
|
727
|
+
* @template T - The document type
|
|
728
|
+
*
|
|
729
|
+
* @param options - Table-specific options including column definitions
|
|
730
|
+
* @returns ReactiveDataResult with table-optimized behavior
|
|
731
|
+
*
|
|
732
|
+
* @example
|
|
733
|
+
* ```tsx
|
|
734
|
+
* function UsersTable() {
|
|
735
|
+
* const {
|
|
736
|
+
* data,
|
|
737
|
+
* sort,
|
|
738
|
+
* setSort,
|
|
739
|
+
* selection,
|
|
740
|
+
* toggleSelect,
|
|
741
|
+
* } = useReactiveTable<User>({
|
|
742
|
+
* collection: 'users',
|
|
743
|
+
* columns: [
|
|
744
|
+
* { key: 'name', header: 'Name', sortable: true },
|
|
745
|
+
* { key: 'email', header: 'Email' },
|
|
746
|
+
* { key: 'role', header: 'Role', sortable: true },
|
|
747
|
+
* ],
|
|
748
|
+
* selectable: 'multi',
|
|
749
|
+
* })
|
|
750
|
+
*
|
|
751
|
+
* return (
|
|
752
|
+
* <Table
|
|
753
|
+
* columns={columns}
|
|
754
|
+
* data={data}
|
|
755
|
+
* sortBy={sort.field}
|
|
756
|
+
* sortDirection={sort.direction}
|
|
757
|
+
* onSort={setSort}
|
|
758
|
+
* selectedRows={Array.from(selection.selected)}
|
|
759
|
+
* onRowSelect={toggleSelect}
|
|
760
|
+
* />
|
|
761
|
+
* )
|
|
762
|
+
* }
|
|
763
|
+
* ```
|
|
764
|
+
*/
|
|
765
|
+
export function useReactiveTable<T extends Record<string, any>>(
|
|
766
|
+
options: ReactiveTableOptions<T>
|
|
767
|
+
): ReactiveDataResult<T> {
|
|
768
|
+
return useReactiveData<T>(options)
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
/**
|
|
772
|
+
* Hook for reactive list data with TanStack DB integration
|
|
773
|
+
*
|
|
774
|
+
* Specialized version of useReactiveData for List components with
|
|
775
|
+
* item-level selection and actions.
|
|
776
|
+
*
|
|
777
|
+
* @template T - The document type
|
|
778
|
+
*
|
|
779
|
+
* @param options - List-specific options
|
|
780
|
+
* @returns ReactiveDataResult with list-optimized behavior
|
|
781
|
+
*
|
|
782
|
+
* @example
|
|
783
|
+
* ```tsx
|
|
784
|
+
* function TaskList() {
|
|
785
|
+
* const {
|
|
786
|
+
* data,
|
|
787
|
+
* mutate,
|
|
788
|
+
* toggleSelect,
|
|
789
|
+
* } = useReactiveList<Task>({
|
|
790
|
+
* collection: 'tasks',
|
|
791
|
+
* where: { completed: false },
|
|
792
|
+
* orderBy: { priority: 'desc' },
|
|
793
|
+
* labelField: 'title',
|
|
794
|
+
* selectable: 'single',
|
|
795
|
+
* })
|
|
796
|
+
*
|
|
797
|
+
* return (
|
|
798
|
+
* <List
|
|
799
|
+
* items={data.map(task => ({
|
|
800
|
+
* id: task.id,
|
|
801
|
+
* content: task.title,
|
|
802
|
+
* icon: task.priority === 'high' ? 'alert' : 'task',
|
|
803
|
+
* }))}
|
|
804
|
+
* onItemClick={(id) => toggleSelect(id)}
|
|
805
|
+
* onDelete={(id) => mutate.delete({ id })}
|
|
806
|
+
* />
|
|
807
|
+
* )
|
|
808
|
+
* }
|
|
809
|
+
* ```
|
|
810
|
+
*/
|
|
811
|
+
export function useReactiveList<T extends Record<string, any>>(
|
|
812
|
+
options: ReactiveListOptions<T>
|
|
813
|
+
): ReactiveDataResult<T> {
|
|
814
|
+
return useReactiveData<T>(options)
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/**
|
|
818
|
+
* Hook for reactive metrics computed from TanStack DB collections
|
|
819
|
+
*
|
|
820
|
+
* Aggregates collection data into metric values with optional trend
|
|
821
|
+
* computation and sparkline data.
|
|
822
|
+
*
|
|
823
|
+
* @template T - The document type
|
|
824
|
+
*
|
|
825
|
+
* @param options - Metrics configuration with aggregation definitions
|
|
826
|
+
* @returns ReactiveMetricsResult with computed metric values
|
|
827
|
+
*
|
|
828
|
+
* @example
|
|
829
|
+
* ```tsx
|
|
830
|
+
* function DashboardMetrics() {
|
|
831
|
+
* const { metrics, isLoading } = useReactiveMetrics<Order>({
|
|
832
|
+
* collection: 'orders',
|
|
833
|
+
* where: { status: 'completed' },
|
|
834
|
+
* metrics: [
|
|
835
|
+
* {
|
|
836
|
+
* key: 'total-orders',
|
|
837
|
+
* label: 'Total Orders',
|
|
838
|
+
* field: 'id',
|
|
839
|
+
* aggregate: 'count',
|
|
840
|
+
* },
|
|
841
|
+
* {
|
|
842
|
+
* key: 'revenue',
|
|
843
|
+
* label: 'Revenue',
|
|
844
|
+
* field: 'total',
|
|
845
|
+
* aggregate: 'sum',
|
|
846
|
+
* format: 'currency',
|
|
847
|
+
* },
|
|
848
|
+
* {
|
|
849
|
+
* key: 'avg-order',
|
|
850
|
+
* label: 'Avg Order',
|
|
851
|
+
* field: 'total',
|
|
852
|
+
* aggregate: 'avg',
|
|
853
|
+
* format: 'currency',
|
|
854
|
+
* },
|
|
855
|
+
* ],
|
|
856
|
+
* })
|
|
857
|
+
*
|
|
858
|
+
* if (isLoading) return <Spinner />
|
|
859
|
+
*
|
|
860
|
+
* return (
|
|
861
|
+
* <Metrics
|
|
862
|
+
* metrics={metrics.map(m => ({
|
|
863
|
+
* label: m.label,
|
|
864
|
+
* value: m.value,
|
|
865
|
+
* trend: m.trend,
|
|
866
|
+
* trendValue: m.trendValue,
|
|
867
|
+
* }))}
|
|
868
|
+
* />
|
|
869
|
+
* )
|
|
870
|
+
* }
|
|
871
|
+
* ```
|
|
872
|
+
*/
|
|
873
|
+
export function useReactiveMetrics<T extends Record<string, any>>(
|
|
874
|
+
options: ReactiveMetricsOptions<T>
|
|
875
|
+
): ReactiveMetricsResult {
|
|
876
|
+
const db = useDBContext()
|
|
877
|
+
const { collection: collectionName, where, metrics: metricDefs } = options
|
|
878
|
+
|
|
879
|
+
const [metrics, setMetrics] = useState<MetricValue[]>([])
|
|
880
|
+
const [isLoading, setIsLoading] = useState(true)
|
|
881
|
+
const [error, setError] = useState<Error | undefined>(undefined)
|
|
882
|
+
|
|
883
|
+
// Historical data for sparklines (last N values per metric)
|
|
884
|
+
const historyRef = useRef<Map<string, number[]>>(new Map())
|
|
885
|
+
|
|
886
|
+
const collection = db.collections[collectionName] as Collection<T> | undefined
|
|
887
|
+
|
|
888
|
+
const computeMetrics = useCallback(async () => {
|
|
889
|
+
if (!collection) {
|
|
890
|
+
setError(new Error(`Collection '${collectionName}' not found`))
|
|
891
|
+
setIsLoading(false)
|
|
892
|
+
return
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
try {
|
|
896
|
+
const data = await collection.findMany({ where })
|
|
897
|
+
|
|
898
|
+
const computed: MetricValue[] = metricDefs.map(def => {
|
|
899
|
+
const values: number[] = data.map(doc => {
|
|
900
|
+
const val = doc[def.field]
|
|
901
|
+
return typeof val === 'number' ? val : 0
|
|
902
|
+
})
|
|
903
|
+
|
|
904
|
+
let value: number
|
|
905
|
+
switch (def.aggregate) {
|
|
906
|
+
case 'count':
|
|
907
|
+
value = data.length
|
|
908
|
+
break
|
|
909
|
+
case 'sum':
|
|
910
|
+
value = values.reduce((a: number, b: number) => a + b, 0)
|
|
911
|
+
break
|
|
912
|
+
case 'avg':
|
|
913
|
+
value = values.length > 0 ? values.reduce((a: number, b: number) => a + b, 0) / values.length : 0
|
|
914
|
+
break
|
|
915
|
+
case 'min':
|
|
916
|
+
value = values.length > 0 ? Math.min(...values) : 0
|
|
917
|
+
break
|
|
918
|
+
case 'max':
|
|
919
|
+
value = values.length > 0 ? Math.max(...values) : 0
|
|
920
|
+
break
|
|
921
|
+
case 'latest':
|
|
922
|
+
value = values.length > 0 ? values[values.length - 1] : 0
|
|
923
|
+
break
|
|
924
|
+
default:
|
|
925
|
+
value = 0
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
// Update history for sparkline
|
|
929
|
+
const history = historyRef.current.get(def.key) || []
|
|
930
|
+
history.push(value)
|
|
931
|
+
if (history.length > 20) history.shift() // Keep last 20 values
|
|
932
|
+
historyRef.current.set(def.key, history)
|
|
933
|
+
|
|
934
|
+
// Compute trend
|
|
935
|
+
let trend: 'up' | 'down' | 'neutral' = 'neutral'
|
|
936
|
+
let trendValue: number | undefined
|
|
937
|
+
if (history.length >= 2) {
|
|
938
|
+
const prev = history[history.length - 2]
|
|
939
|
+
const current = history[history.length - 1]
|
|
940
|
+
if (prev !== 0) {
|
|
941
|
+
trendValue = ((current - prev) / prev) * 100
|
|
942
|
+
trend = trendValue > 0 ? 'up' : trendValue < 0 ? 'down' : 'neutral'
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
// Format value
|
|
947
|
+
let formattedValue: string | number = value
|
|
948
|
+
if (def.format === 'currency') {
|
|
949
|
+
formattedValue = `$${value.toLocaleString()}`
|
|
950
|
+
} else if (def.format === 'percentage') {
|
|
951
|
+
formattedValue = `${value.toFixed(1)}%`
|
|
952
|
+
}
|
|
953
|
+
if (def.unit) {
|
|
954
|
+
formattedValue = `${formattedValue} ${def.unit}`
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
return {
|
|
958
|
+
key: def.key,
|
|
959
|
+
label: def.label,
|
|
960
|
+
value: formattedValue,
|
|
961
|
+
trend,
|
|
962
|
+
trendValue,
|
|
963
|
+
sparkline: [...history],
|
|
964
|
+
}
|
|
965
|
+
})
|
|
966
|
+
|
|
967
|
+
setMetrics(computed)
|
|
968
|
+
setError(undefined)
|
|
969
|
+
} catch (err) {
|
|
970
|
+
setError(err instanceof Error ? err : new Error(String(err)))
|
|
971
|
+
} finally {
|
|
972
|
+
setIsLoading(false)
|
|
973
|
+
}
|
|
974
|
+
}, [collection, collectionName, JSON.stringify(where), JSON.stringify(metricDefs)])
|
|
975
|
+
|
|
976
|
+
// Initial computation
|
|
977
|
+
useEffect(() => {
|
|
978
|
+
computeMetrics()
|
|
979
|
+
}, [computeMetrics])
|
|
980
|
+
|
|
981
|
+
// Subscribe to changes
|
|
982
|
+
useEffect(() => {
|
|
983
|
+
if (!collection) return
|
|
984
|
+
const unsubscribe = collection.subscribe(() => {
|
|
985
|
+
computeMetrics()
|
|
986
|
+
})
|
|
987
|
+
return unsubscribe
|
|
988
|
+
}, [collection, computeMetrics])
|
|
989
|
+
|
|
990
|
+
return {
|
|
991
|
+
metrics,
|
|
992
|
+
isLoading,
|
|
993
|
+
error,
|
|
994
|
+
refetch: computeMetrics,
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
/**
|
|
999
|
+
* Hook for reactive single-record card data with TanStack DB integration
|
|
1000
|
+
*
|
|
1001
|
+
* Fetches and subscribes to a single record from a collection for
|
|
1002
|
+
* Card component display.
|
|
1003
|
+
*
|
|
1004
|
+
* @template T - The document type
|
|
1005
|
+
*
|
|
1006
|
+
* @param options - Card-specific options
|
|
1007
|
+
* @returns ReactiveCardResult with single record and update function
|
|
1008
|
+
*
|
|
1009
|
+
* @example
|
|
1010
|
+
* ```tsx
|
|
1011
|
+
* function UserProfileCard({ userId }: { userId: string }) {
|
|
1012
|
+
* const {
|
|
1013
|
+
* data,
|
|
1014
|
+
* isLoading,
|
|
1015
|
+
* update,
|
|
1016
|
+
* isUpdating,
|
|
1017
|
+
* } = useReactiveCard<User>({
|
|
1018
|
+
* collection: 'users',
|
|
1019
|
+
* where: { id: userId },
|
|
1020
|
+
* fields: [
|
|
1021
|
+
* { key: 'name', label: 'Name' },
|
|
1022
|
+
* { key: 'email', label: 'Email' },
|
|
1023
|
+
* { key: 'role', label: 'Role', format: 'badge' },
|
|
1024
|
+
* ],
|
|
1025
|
+
* })
|
|
1026
|
+
*
|
|
1027
|
+
* if (isLoading) return <Spinner />
|
|
1028
|
+
* if (!data) return <Empty message="User not found" />
|
|
1029
|
+
*
|
|
1030
|
+
* return (
|
|
1031
|
+
* <Card
|
|
1032
|
+
* title={data.name}
|
|
1033
|
+
* subtitle={data.email}
|
|
1034
|
+
* pairs={[
|
|
1035
|
+
* { key: 'Role', value: data.role },
|
|
1036
|
+
* { key: 'Status', value: data.status },
|
|
1037
|
+
* ]}
|
|
1038
|
+
* actions={[
|
|
1039
|
+
* {
|
|
1040
|
+
* label: isUpdating ? 'Saving...' : 'Edit',
|
|
1041
|
+
* action: () => update({ status: 'updated' }),
|
|
1042
|
+
* },
|
|
1043
|
+
* ]}
|
|
1044
|
+
* />
|
|
1045
|
+
* )
|
|
1046
|
+
* }
|
|
1047
|
+
* ```
|
|
1048
|
+
*/
|
|
1049
|
+
export function useReactiveCard<T extends Record<string, any>>(
|
|
1050
|
+
options: ReactiveCardOptions<T>
|
|
1051
|
+
): ReactiveCardResult<T> {
|
|
1052
|
+
const db = useDBContext()
|
|
1053
|
+
const { collection: collectionName, where, optimistic = true } = options
|
|
1054
|
+
|
|
1055
|
+
const [data, setData] = useState<T | null>(null)
|
|
1056
|
+
const [isLoading, setIsLoading] = useState(true)
|
|
1057
|
+
const [error, setError] = useState<Error | undefined>(undefined)
|
|
1058
|
+
const [isUpdating, setIsUpdating] = useState(false)
|
|
1059
|
+
|
|
1060
|
+
const rollbackRef = useRef<T | null>(null)
|
|
1061
|
+
|
|
1062
|
+
const collection = db.collections[collectionName] as Collection<T> | undefined
|
|
1063
|
+
|
|
1064
|
+
const fetchData = useCallback(async () => {
|
|
1065
|
+
if (!collection) {
|
|
1066
|
+
setError(new Error(`Collection '${collectionName}' not found`))
|
|
1067
|
+
setIsLoading(false)
|
|
1068
|
+
return
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
try {
|
|
1072
|
+
const result = await collection.findOne(where as Partial<T>)
|
|
1073
|
+
setData(result)
|
|
1074
|
+
setError(undefined)
|
|
1075
|
+
} catch (err) {
|
|
1076
|
+
setError(err instanceof Error ? err : new Error(String(err)))
|
|
1077
|
+
} finally {
|
|
1078
|
+
setIsLoading(false)
|
|
1079
|
+
}
|
|
1080
|
+
}, [collection, collectionName, JSON.stringify(where)])
|
|
1081
|
+
|
|
1082
|
+
// Initial fetch
|
|
1083
|
+
useEffect(() => {
|
|
1084
|
+
fetchData()
|
|
1085
|
+
}, [fetchData])
|
|
1086
|
+
|
|
1087
|
+
// Subscribe to changes
|
|
1088
|
+
useEffect(() => {
|
|
1089
|
+
if (!collection) return
|
|
1090
|
+
|
|
1091
|
+
const unsubscribe = collection.subscribe((allData: T[]) => {
|
|
1092
|
+
// Find matching record
|
|
1093
|
+
const found = allData.find(doc =>
|
|
1094
|
+
Object.entries(where).every(([key, val]) => doc[key as keyof T] === val)
|
|
1095
|
+
)
|
|
1096
|
+
setData(found ?? null)
|
|
1097
|
+
})
|
|
1098
|
+
|
|
1099
|
+
return unsubscribe
|
|
1100
|
+
}, [collection, JSON.stringify(where)])
|
|
1101
|
+
|
|
1102
|
+
const update = useCallback(async (updates: Partial<T>) => {
|
|
1103
|
+
if (!collection || !data) {
|
|
1104
|
+
throw new Error('Cannot update: no data or collection')
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
setIsUpdating(true)
|
|
1108
|
+
|
|
1109
|
+
if (optimistic) {
|
|
1110
|
+
rollbackRef.current = data
|
|
1111
|
+
setData(prev => prev ? { ...prev, ...updates } : null)
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
try {
|
|
1115
|
+
await collection.update(where as Partial<T>, updates)
|
|
1116
|
+
} catch (err) {
|
|
1117
|
+
if (optimistic && rollbackRef.current) {
|
|
1118
|
+
setData(rollbackRef.current)
|
|
1119
|
+
}
|
|
1120
|
+
throw err
|
|
1121
|
+
} finally {
|
|
1122
|
+
setIsUpdating(false)
|
|
1123
|
+
}
|
|
1124
|
+
}, [collection, data, where, optimistic])
|
|
1125
|
+
|
|
1126
|
+
return {
|
|
1127
|
+
data,
|
|
1128
|
+
isLoading,
|
|
1129
|
+
error,
|
|
1130
|
+
refetch: fetchData,
|
|
1131
|
+
update,
|
|
1132
|
+
isUpdating,
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
// ============================================================================
|
|
1137
|
+
// Helper Functions
|
|
1138
|
+
// ============================================================================
|
|
1139
|
+
|
|
1140
|
+
/**
|
|
1141
|
+
* Check if a value matches a field filter
|
|
1142
|
+
*/
|
|
1143
|
+
function matchesFilter<T>(value: T, filter: any): boolean {
|
|
1144
|
+
if (filter === null || filter === undefined || typeof filter !== 'object') {
|
|
1145
|
+
return value === filter
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
const ops = filter as Record<string, any>
|
|
1149
|
+
|
|
1150
|
+
if ('$eq' in ops && value !== ops.$eq) return false
|
|
1151
|
+
if ('$ne' in ops && value === ops.$ne) return false
|
|
1152
|
+
if ('$gt' in ops && !(value > ops.$gt)) return false
|
|
1153
|
+
if ('$gte' in ops && !(value >= ops.$gte)) return false
|
|
1154
|
+
if ('$lt' in ops && !(value < ops.$lt)) return false
|
|
1155
|
+
if ('$lte' in ops && !(value <= ops.$lte)) return false
|
|
1156
|
+
if ('$in' in ops && !ops.$in.includes(value)) return false
|
|
1157
|
+
if ('$nin' in ops && ops.$nin.includes(value)) return false
|
|
1158
|
+
|
|
1159
|
+
return true
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
/**
|
|
1163
|
+
* Check if a document matches a where clause
|
|
1164
|
+
*/
|
|
1165
|
+
function matchesWhere<T extends Record<string, any>>(doc: T, where: WhereClause<T>): boolean {
|
|
1166
|
+
if ('$or' in where && where.$or) {
|
|
1167
|
+
const orClauses = where.$or as WhereClause<T>[]
|
|
1168
|
+
const matchesOr = orClauses.some(clause => matchesWhere(doc, clause))
|
|
1169
|
+
if (!matchesOr) return false
|
|
1170
|
+
|
|
1171
|
+
const { $or, ...rest } = where
|
|
1172
|
+
if (Object.keys(rest).length === 0) return true
|
|
1173
|
+
return matchesWhere(doc, rest as WhereClause<T>)
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
for (const [key, filter] of Object.entries(where)) {
|
|
1177
|
+
if (key === '$or') continue
|
|
1178
|
+
const value = doc[key]
|
|
1179
|
+
if (!matchesFilter(value, filter)) {
|
|
1180
|
+
return false
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
return true
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
/**
|
|
1188
|
+
* Sort documents by orderBy clause
|
|
1189
|
+
*/
|
|
1190
|
+
function sortDocuments<T extends Record<string, any>>(
|
|
1191
|
+
docs: T[],
|
|
1192
|
+
orderBy: OrderByClause<T> | OrderByClause<T>[]
|
|
1193
|
+
): T[] {
|
|
1194
|
+
const orderClauses = Array.isArray(orderBy) ? orderBy : [orderBy]
|
|
1195
|
+
|
|
1196
|
+
return [...docs].sort((a, b) => {
|
|
1197
|
+
for (const clause of orderClauses) {
|
|
1198
|
+
const [field, direction] = Object.entries(clause)[0]
|
|
1199
|
+
const aVal = a[field]
|
|
1200
|
+
const bVal = b[field]
|
|
1201
|
+
|
|
1202
|
+
if (aVal === null || aVal === undefined) {
|
|
1203
|
+
if (bVal !== null && bVal !== undefined) return 1
|
|
1204
|
+
continue
|
|
1205
|
+
}
|
|
1206
|
+
if (bVal === null || bVal === undefined) return -1
|
|
1207
|
+
|
|
1208
|
+
let comparison = 0
|
|
1209
|
+
if (typeof aVal === 'string') {
|
|
1210
|
+
comparison = aVal.localeCompare(bVal)
|
|
1211
|
+
} else if (typeof aVal === 'number') {
|
|
1212
|
+
comparison = aVal - bVal
|
|
1213
|
+
} else if (aVal instanceof Date) {
|
|
1214
|
+
comparison = aVal.getTime() - bVal.getTime()
|
|
1215
|
+
} else {
|
|
1216
|
+
comparison = String(aVal).localeCompare(String(bVal))
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
if (comparison !== 0) {
|
|
1220
|
+
return direction === 'desc' ? -comparison : comparison
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
return 0
|
|
1224
|
+
})
|
|
1225
|
+
}
|