basalt-ui 1.23.1 → 1.25.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/AGENTS.md +11 -6
- package/CHANGELOG.md +21 -0
- package/MIGRATING.md +138 -4
- package/README.md +123 -32
- package/agent/rules/basalt-data.md +104 -12
- package/agent/rules/basalt-query.md +57 -0
- package/agent/skills/basalt-app/SKILL.md +7 -0
- package/dist/cli/index.d.ts +4 -2
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +101 -17
- package/dist/dashboard/empty-state.d.ts +6 -2
- package/dist/dashboard/empty-state.d.ts.map +1 -1
- package/dist/dashboard/empty-state.js +1 -1
- package/dist/dashboard/index.d.ts +1 -0
- package/dist/dashboard/index.d.ts.map +1 -1
- package/dist/dashboard/index.js +8 -0
- package/dist/dashboard/query-state.d.ts +96 -0
- package/dist/dashboard/query-state.d.ts.map +1 -0
- package/dist/dashboard/query-state.js +123 -0
- package/dist/dashboard/query-state.test.d.ts +2 -0
- package/dist/dashboard/query-state.test.d.ts.map +1 -0
- package/dist/data/data-table.d.ts +125 -8
- package/dist/data/data-table.d.ts.map +1 -1
- package/dist/data/data-table.js +121 -13
- package/dist/data/data-table.test.d.ts +2 -0
- package/dist/data/data-table.test.d.ts.map +1 -0
- package/dist/data/table.d.ts +2 -2
- package/dist/data/table.d.ts.map +1 -1
- package/dist/guard/index.d.ts.map +1 -1
- package/dist/guard/index.js +21 -6
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -1
- package/dist/query/error-message.d.ts +41 -0
- package/dist/query/error-message.d.ts.map +1 -0
- package/dist/query/error-message.js +30 -0
- package/dist/query/error-message.test.d.ts +2 -0
- package/dist/query/error-message.test.d.ts.map +1 -0
- package/dist/query/index.d.ts +1 -19
- package/dist/query/index.d.ts.map +1 -1
- package/dist/query/index.js +2 -14
- package/dist/surfaces.d.ts +1 -1
- package/dist/surfaces.js +1 -1
- package/llms.txt +3 -3
- package/package.json +1 -1
- package/src/cli/check-theme.test.ts +34 -0
- package/src/cli/index.ts +222 -44
- package/src/cli/tokens-css.test.ts +73 -6
- package/src/cli/toolchain-wiring.test.ts +146 -0
- package/src/dashboard/empty-state.tsx +9 -3
- package/src/dashboard/index.ts +11 -0
- package/src/dashboard/query-state.test.tsx +152 -0
- package/src/dashboard/query-state.tsx +272 -0
- package/src/data/data-table.test.tsx +349 -0
- package/src/data/data-table.tsx +359 -42
- package/src/data/table.ts +8 -1
- package/src/guard/check-source.test.ts +141 -0
- package/src/guard/index.ts +102 -8
- package/src/index.ts +9 -0
- package/src/query/error-message.test.ts +67 -0
- package/src/query/error-message.ts +70 -0
- package/src/query/index.ts +2 -34
- package/src/surfaces.ts +1 -1
package/src/dashboard/index.ts
CHANGED
|
@@ -9,3 +9,14 @@ export {
|
|
|
9
9
|
DangerZone,
|
|
10
10
|
type DangerZoneProps,
|
|
11
11
|
} from './settings-section'
|
|
12
|
+
export {
|
|
13
|
+
QueryState,
|
|
14
|
+
type QueryStateProps,
|
|
15
|
+
type QueryStateLike,
|
|
16
|
+
type QueryStateVariant,
|
|
17
|
+
type QueryEmptyCopy,
|
|
18
|
+
LoadingState,
|
|
19
|
+
type LoadingStateProps,
|
|
20
|
+
ErrorState,
|
|
21
|
+
type ErrorStateProps,
|
|
22
|
+
} from './query-state'
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `QueryState` exists because shipping only `EmptyState` produced a correctness bug in a real
|
|
3
|
+
* consumer: a 500 rendered "No images", a dropped connection rendered "Share not found — it may
|
|
4
|
+
* have been deleted". Every assertion here is aimed at that failure mode rather than at chrome.
|
|
5
|
+
*
|
|
6
|
+
* 1. A FAILED query never renders the empty copy. This is the bug, stated as a test.
|
|
7
|
+
* 2. A failed query WITH cached data keeps the page and adds a banner — a background refetch
|
|
8
|
+
* failing must not blank a screen that already works.
|
|
9
|
+
* 3. `fetchStatus: 'idle'` with no data is EMPTY, not loading (the `enabled: false` case);
|
|
10
|
+
* `'fetching'` with no data is loading.
|
|
11
|
+
* 4. A result missing a branch flag THROWS. `QueryStateLike` is a structural subset on purpose,
|
|
12
|
+
* so the compiler stops policing it — and every field it can lose fails silently in the
|
|
13
|
+
* direction of a false claim about the data.
|
|
14
|
+
*/
|
|
15
|
+
import { MantineProvider } from '@mantine/core'
|
|
16
|
+
import { render, screen } from '@testing-library/react'
|
|
17
|
+
import { describe, expect, test } from 'bun:test'
|
|
18
|
+
import type { ReactNode } from 'react'
|
|
19
|
+
import { QueryState } from './query-state'
|
|
20
|
+
import type { QueryStateLike } from './query-state'
|
|
21
|
+
|
|
22
|
+
type Row = { id: number }
|
|
23
|
+
|
|
24
|
+
function result(over: Partial<QueryStateLike<Row[]>>): QueryStateLike<Row[]> {
|
|
25
|
+
return {
|
|
26
|
+
data: undefined,
|
|
27
|
+
isError: false,
|
|
28
|
+
error: null,
|
|
29
|
+
fetchStatus: 'idle',
|
|
30
|
+
refetch: () => undefined,
|
|
31
|
+
...over,
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const EMPTY = { title: 'No images match', description: 'Widen the capture dates.' }
|
|
36
|
+
|
|
37
|
+
function renderState(query: QueryStateLike<Row[]>, extra?: ReactNode): HTMLElement {
|
|
38
|
+
const { container } = render(
|
|
39
|
+
<MantineProvider>
|
|
40
|
+
<QueryState query={query} empty={EMPTY} errorTitle="Could not load images">
|
|
41
|
+
{(rows) => <div data-testid="body">{rows.length} rows</div>}
|
|
42
|
+
</QueryState>
|
|
43
|
+
{extra}
|
|
44
|
+
</MantineProvider>,
|
|
45
|
+
)
|
|
46
|
+
return container
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe('the branch a 500 must not take', () => {
|
|
50
|
+
test('an errored query renders the server message, never the empty copy', () => {
|
|
51
|
+
renderState(
|
|
52
|
+
result({ isError: true, error: { status: 500, value: { message: 'index is rebuilding' } } }),
|
|
53
|
+
)
|
|
54
|
+
expect(screen.getByText('index is rebuilding')).toBeTruthy()
|
|
55
|
+
expect(screen.queryByText('No images match')).toBeNull()
|
|
56
|
+
expect(screen.getByRole('button', { name: 'Retry' })).toBeTruthy()
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test('an opaque 500 envelope falls back to the errorTitle copy plus the status', () => {
|
|
60
|
+
render(
|
|
61
|
+
<MantineProvider>
|
|
62
|
+
<QueryState
|
|
63
|
+
query={result({ isError: true, error: { status: 500, value: {} } })}
|
|
64
|
+
errorTitle="Could not load images"
|
|
65
|
+
errorFallback="The library did not answer."
|
|
66
|
+
>
|
|
67
|
+
{() => null}
|
|
68
|
+
</QueryState>
|
|
69
|
+
</MantineProvider>,
|
|
70
|
+
)
|
|
71
|
+
expect(screen.getByText('The library did not answer. (HTTP 500)')).toBeTruthy()
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
test('retry calls refetch', () => {
|
|
75
|
+
let calls = 0
|
|
76
|
+
renderState(result({ isError: true, error: new Error('down'), refetch: () => ++calls }))
|
|
77
|
+
;(screen.getByRole('button', { name: 'Retry' }) as HTMLButtonElement).click()
|
|
78
|
+
expect(calls).toBe(1)
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
describe('cached data survives a failed refresh', () => {
|
|
83
|
+
test('error WITH data renders the body plus a banner, not the error page', () => {
|
|
84
|
+
renderState(result({ data: [{ id: 1 }], isError: true, error: new Error('refresh failed') }))
|
|
85
|
+
expect(screen.getByTestId('body').textContent).toBe('1 rows')
|
|
86
|
+
expect(screen.getByText('Showing cached data')).toBeTruthy()
|
|
87
|
+
})
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
describe('empty vs loading', () => {
|
|
91
|
+
test('no data + idle is EMPTY (the enabled:false case)', () => {
|
|
92
|
+
renderState(result({ fetchStatus: 'idle' }))
|
|
93
|
+
expect(screen.getByText('No images match')).toBeTruthy()
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
test('no data + fetching is LOADING', () => {
|
|
97
|
+
renderState(result({ fetchStatus: 'fetching' }))
|
|
98
|
+
expect(screen.queryByText('No images match')).toBeNull()
|
|
99
|
+
expect(screen.getByLabelText('Loading')).toBeTruthy()
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
test('an empty array is empty; a populated one renders children', () => {
|
|
103
|
+
renderState(result({ data: [] }))
|
|
104
|
+
expect(screen.getByText('No images match')).toBeTruthy()
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
test('isEmpty overrides the default test', () => {
|
|
108
|
+
render(
|
|
109
|
+
<MantineProvider>
|
|
110
|
+
<QueryState query={result({ data: [] })} empty={EMPTY} isEmpty={() => false}>
|
|
111
|
+
{(rows) => <div data-testid="body">{rows.length} rows</div>}
|
|
112
|
+
</QueryState>
|
|
113
|
+
</MantineProvider>,
|
|
114
|
+
)
|
|
115
|
+
expect(screen.getByTestId('body').textContent).toBe('0 rows')
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
test('omitting `empty` renders nothing rather than inventing copy', () => {
|
|
119
|
+
render(
|
|
120
|
+
<MantineProvider>
|
|
121
|
+
<QueryState query={result({ data: [] })}>{() => <div>rows</div>}</QueryState>
|
|
122
|
+
</MantineProvider>,
|
|
123
|
+
)
|
|
124
|
+
expect(screen.queryByText('rows')).toBeNull()
|
|
125
|
+
})
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
describe('a malformed result throws instead of asserting absence', () => {
|
|
129
|
+
const cases: [string, unknown][] = [
|
|
130
|
+
['missing isError', { data: undefined, error: null, fetchStatus: 'idle', refetch: () => 0 }],
|
|
131
|
+
[
|
|
132
|
+
'misspelled fetchStatus',
|
|
133
|
+
{ data: undefined, isError: false, error: null, fetchStatus: 'pending', refetch: () => 0 },
|
|
134
|
+
],
|
|
135
|
+
['missing refetch', { data: undefined, isError: false, error: null, fetchStatus: 'idle' }],
|
|
136
|
+
['not an object at all', ['a', 'b']],
|
|
137
|
+
]
|
|
138
|
+
|
|
139
|
+
for (const [name, bad] of cases) {
|
|
140
|
+
test(name, () => {
|
|
141
|
+
expect(() =>
|
|
142
|
+
render(
|
|
143
|
+
<MantineProvider>
|
|
144
|
+
<QueryState query={bad as QueryStateLike<Row[]>} empty={EMPTY}>
|
|
145
|
+
{() => null}
|
|
146
|
+
</QueryState>
|
|
147
|
+
</MantineProvider>,
|
|
148
|
+
),
|
|
149
|
+
).toThrow(/QueryState: `query`/)
|
|
150
|
+
})
|
|
151
|
+
}
|
|
152
|
+
})
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QueryState — the one wrapper a page puts around an async result, and the sibling `EmptyState`
|
|
3
|
+
* shipped without.
|
|
4
|
+
*
|
|
5
|
+
* The argument is correctness, not convenience. A framework that ships only the EMPTY branch
|
|
6
|
+
* steers every consumer into rendering "nothing here" for "the server failed": image-share's
|
|
7
|
+
* library rendered *No images* on a 500, and a share detail rendered *Share not found — it may
|
|
8
|
+
* have been deleted* on a dropped connection, until 204 hand-rolled lines were written to stop it.
|
|
9
|
+
* basalt already owned both ends of that file — `EmptyState` and `toErrorMessage` — and nothing in
|
|
10
|
+
* between.
|
|
11
|
+
*
|
|
12
|
+
* Component, not a hook: the product IS the branch precedence below. A hook returning
|
|
13
|
+
* `{ status, data }` would hand every call site the same four-way switch back, which is exactly
|
|
14
|
+
* the re-derivation this exists to delete. `LoadingState` / `ErrorState` ship alongside as the
|
|
15
|
+
* escape hatch for a page that must place its branches in different DOM positions.
|
|
16
|
+
*/
|
|
17
|
+
import { Alert, Button, Center, Group, Loader, Stack, Text } from '@mantine/core'
|
|
18
|
+
import type { ReactNode } from 'react'
|
|
19
|
+
import { toErrorMessage } from '../query/error-message'
|
|
20
|
+
import { EmptyState } from './empty-state'
|
|
21
|
+
|
|
22
|
+
/** `'page'` = a full route body (generous padding). `'section'` = a card/panel region (compact). */
|
|
23
|
+
export type QueryStateVariant = 'page' | 'section'
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The structural subset of a TanStack `UseQueryResult` these components read.
|
|
27
|
+
*
|
|
28
|
+
* Typed as a SUBSET rather than as `UseQueryResult<T>` on purpose: a composed, derived or
|
|
29
|
+
* hand-rolled result must be passable without a cast, and
|
|
30
|
+
* these components stay independent of which `@tanstack/react-query` major a consumer pins. This
|
|
31
|
+
* is NOT a claim that the root barrel is free of that package — `./connectivity` imports
|
|
32
|
+
* `onlineManager` as a value and is re-exported from `src/index.ts`. The cost of the subset is
|
|
33
|
+
* that the compiler stops policing the shape — which is why {@link QueryState} asserts it at
|
|
34
|
+
* runtime.
|
|
35
|
+
*/
|
|
36
|
+
export type QueryStateLike<TData> = {
|
|
37
|
+
data: TData | undefined
|
|
38
|
+
isError: boolean
|
|
39
|
+
error: unknown
|
|
40
|
+
fetchStatus: 'fetching' | 'paused' | 'idle'
|
|
41
|
+
refetch: () => unknown
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Copy for the empty branch. Rendered through `EmptyState`, so it looks like every other one. */
|
|
45
|
+
export type QueryEmptyCopy = {
|
|
46
|
+
title: string
|
|
47
|
+
/** Optional — a one-word empty panel should not have to invent a sentence. */
|
|
48
|
+
description?: string
|
|
49
|
+
/** Optional glyph slot, rendered faint. */
|
|
50
|
+
icon?: ReactNode
|
|
51
|
+
/** Call-to-action below the copy (e.g. "Reset filters"). */
|
|
52
|
+
action?: ReactNode
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ── LoadingState ──────────────────────────────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
export type LoadingStateProps = {
|
|
58
|
+
variant?: QueryStateVariant
|
|
59
|
+
/** Accessible name for the spinner. Say what is loading. */
|
|
60
|
+
label?: string
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** A spinner on its own — `variant="page"` centres it in a generous block, `'section'` is bare. */
|
|
64
|
+
export function LoadingState({
|
|
65
|
+
variant = 'page',
|
|
66
|
+
label = 'Loading',
|
|
67
|
+
}: LoadingStateProps): ReactNode {
|
|
68
|
+
const loader = <Loader size="sm" aria-label={label} />
|
|
69
|
+
if (variant === 'section') return loader
|
|
70
|
+
return <Center py={64}>{loader}</Center>
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ── ErrorState ────────────────────────────────────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
export type ErrorStateProps = {
|
|
76
|
+
/** Whatever was thrown — the raw Eden/fetch envelope is fine, it gets decoded. */
|
|
77
|
+
error: unknown
|
|
78
|
+
/** Alert heading. Say what failed, e.g. `Could not load images`. */
|
|
79
|
+
title?: string
|
|
80
|
+
/** Shown only when the server body carries no readable message. */
|
|
81
|
+
fallback?: string
|
|
82
|
+
onRetry?: () => void
|
|
83
|
+
/** Disables/spins the retry button while a refetch is in flight. */
|
|
84
|
+
retrying?: boolean
|
|
85
|
+
variant?: QueryStateVariant
|
|
86
|
+
/** Extra controls beside Retry (e.g. a "Back to shares" link). */
|
|
87
|
+
action?: ReactNode
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The error branch: the REAL server message (via `toErrorMessage`) plus a retry. Usable on its own,
|
|
92
|
+
* outside a query — an auth gate or a boundary fallback renders it directly.
|
|
93
|
+
*/
|
|
94
|
+
export function ErrorState({
|
|
95
|
+
error,
|
|
96
|
+
title = 'Something went wrong',
|
|
97
|
+
fallback = 'The request failed.',
|
|
98
|
+
onRetry,
|
|
99
|
+
retrying = false,
|
|
100
|
+
variant = 'page',
|
|
101
|
+
action,
|
|
102
|
+
}: ErrorStateProps): ReactNode {
|
|
103
|
+
const alert = (
|
|
104
|
+
<Alert color="red" variant="light" title={title}>
|
|
105
|
+
<Stack gap="sm">
|
|
106
|
+
<Text size="sm">{toErrorMessage(error, fallback)}</Text>
|
|
107
|
+
{(onRetry ?? action) !== undefined && (
|
|
108
|
+
<Group gap="xs">
|
|
109
|
+
{onRetry && (
|
|
110
|
+
<Button size="xs" variant="default" loading={retrying} onClick={onRetry}>
|
|
111
|
+
Retry
|
|
112
|
+
</Button>
|
|
113
|
+
)}
|
|
114
|
+
{action}
|
|
115
|
+
</Group>
|
|
116
|
+
)}
|
|
117
|
+
</Stack>
|
|
118
|
+
</Alert>
|
|
119
|
+
)
|
|
120
|
+
if (variant === 'section') return alert
|
|
121
|
+
return <Stack py="md">{alert}</Stack>
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ── QueryState ────────────────────────────────────────────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
/** `[]`, `{ data: [], total }` (the common pagination envelope) and null all count as empty. */
|
|
127
|
+
function defaultIsEmpty(data: unknown): boolean {
|
|
128
|
+
if (data === null || data === undefined) return true
|
|
129
|
+
if (Array.isArray(data)) return data.length === 0
|
|
130
|
+
if (typeof data === 'object' && Array.isArray((data as { data?: unknown }).data)) {
|
|
131
|
+
return (data as { data: unknown[] }).data.length === 0
|
|
132
|
+
}
|
|
133
|
+
return false
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const FETCH_STATUSES = new Set(['fetching', 'paused', 'idle'])
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* `QueryStateLike` is a structural subset, so TypeScript cannot protect a hand-composed result the
|
|
140
|
+
* way it protects a real `UseQueryResult` — and every field it drops fails SILENTLY in the exact
|
|
141
|
+
* direction this component exists to prevent. A missing `isError` renders "no data" over a 500. A
|
|
142
|
+
* misspelled `fetchStatus` never leaves the spinner. A missing `refetch` renders a Retry button
|
|
143
|
+
* that does nothing. Each of those is a false claim about the data, so it throws.
|
|
144
|
+
*/
|
|
145
|
+
function assertQueryStateLike(query: unknown): asserts query is QueryStateLike<unknown> {
|
|
146
|
+
const bad = (detail: string): never => {
|
|
147
|
+
throw new Error(
|
|
148
|
+
`QueryState: \`query\` ${detail}. It must carry { data, isError, error, fetchStatus, refetch } — ` +
|
|
149
|
+
'a TanStack UseQueryResult does; a hand-composed one must spell every field, because a ' +
|
|
150
|
+
'missing branch flag renders a false claim about the data instead of an error.',
|
|
151
|
+
)
|
|
152
|
+
}
|
|
153
|
+
if (query === null || typeof query !== 'object')
|
|
154
|
+
bad(`is ${query === null ? 'null' : typeof query}`)
|
|
155
|
+
const q = query as Record<string, unknown>
|
|
156
|
+
if (!('data' in q)) bad('has no `data` key')
|
|
157
|
+
if (typeof q['isError'] !== 'boolean') bad('has no boolean `isError`')
|
|
158
|
+
if (typeof q['fetchStatus'] !== 'string' || !FETCH_STATUSES.has(q['fetchStatus']))
|
|
159
|
+
bad(`has fetchStatus=${JSON.stringify(q['fetchStatus'])}, not 'fetching' | 'paused' | 'idle'`)
|
|
160
|
+
if (typeof q['refetch'] !== 'function') bad('has no `refetch()`')
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export type QueryStateProps<TData> = {
|
|
164
|
+
query: QueryStateLike<TData>
|
|
165
|
+
/** A render function gets `data` narrowed to non-undefined; a plain node also works. */
|
|
166
|
+
children: ReactNode | ((data: TData) => ReactNode)
|
|
167
|
+
/** Omit to render nothing when the result is empty. */
|
|
168
|
+
empty?: QueryEmptyCopy
|
|
169
|
+
/** Override the default `[]` / `{ data: [] }` emptiness test — `() => false` disables it. */
|
|
170
|
+
isEmpty?: (data: TData) => boolean
|
|
171
|
+
/** Alert heading on the error branch — say what failed. */
|
|
172
|
+
errorTitle?: string
|
|
173
|
+
/** Shown only when the server body carries no readable message. */
|
|
174
|
+
errorFallback?: string
|
|
175
|
+
/** Extra controls beside Retry on the error branch. */
|
|
176
|
+
errorAction?: ReactNode
|
|
177
|
+
variant?: QueryStateVariant
|
|
178
|
+
/** Replace the default spinner (e.g. with a skeleton grid). */
|
|
179
|
+
loading?: ReactNode
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Renders loading / error-with-retry / empty / children for a query result, in that order.
|
|
184
|
+
* Branch precedence is the whole point, and it is deliberate:
|
|
185
|
+
*
|
|
186
|
+
* - error AND no cached data → the full error state (the page cannot render)
|
|
187
|
+
* - error WITH cached data → children, plus a compact "showing cached data" banner on top
|
|
188
|
+
* (a background refetch failing must not blank a page that already works)
|
|
189
|
+
* - no data, fetch idle → empty (this is an `enabled: false` query, not a pending one)
|
|
190
|
+
* - no data, fetching → `loading` (or the default spinner)
|
|
191
|
+
*
|
|
192
|
+
* @example
|
|
193
|
+
* <QueryState
|
|
194
|
+
* query={q}
|
|
195
|
+
* errorTitle="Could not load images"
|
|
196
|
+
* empty={{ title: 'No images match', description: 'Widen the capture dates.' }}
|
|
197
|
+
* >
|
|
198
|
+
* {(data) => <ImageGrid images={data.rows} />}
|
|
199
|
+
* </QueryState>
|
|
200
|
+
*/
|
|
201
|
+
export function QueryState<TData>({
|
|
202
|
+
query,
|
|
203
|
+
children,
|
|
204
|
+
empty,
|
|
205
|
+
isEmpty,
|
|
206
|
+
errorTitle = 'Could not load',
|
|
207
|
+
errorFallback = 'The request failed.',
|
|
208
|
+
errorAction,
|
|
209
|
+
variant = 'page',
|
|
210
|
+
loading,
|
|
211
|
+
}: QueryStateProps<TData>): ReactNode {
|
|
212
|
+
assertQueryStateLike(query)
|
|
213
|
+
const { data, isError, error, fetchStatus } = query
|
|
214
|
+
const retrying = fetchStatus === 'fetching'
|
|
215
|
+
const retry = (): void => void query.refetch()
|
|
216
|
+
|
|
217
|
+
if (isError && data === undefined) {
|
|
218
|
+
return (
|
|
219
|
+
<ErrorState
|
|
220
|
+
error={error}
|
|
221
|
+
title={errorTitle}
|
|
222
|
+
fallback={errorFallback}
|
|
223
|
+
onRetry={retry}
|
|
224
|
+
retrying={retrying}
|
|
225
|
+
variant={variant}
|
|
226
|
+
{...(errorAction !== undefined && { action: errorAction })}
|
|
227
|
+
/>
|
|
228
|
+
)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (data === undefined) {
|
|
232
|
+
if (fetchStatus === 'idle' && !isError) return empty ? renderEmpty(empty, variant) : null
|
|
233
|
+
return loading ?? <LoadingState variant={variant} />
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const emptyNow = isEmpty ? isEmpty(data) : defaultIsEmpty(data)
|
|
237
|
+
const body = emptyNow
|
|
238
|
+
? empty
|
|
239
|
+
? renderEmpty(empty, variant)
|
|
240
|
+
: null
|
|
241
|
+
: typeof children === 'function'
|
|
242
|
+
? children(data)
|
|
243
|
+
: children
|
|
244
|
+
|
|
245
|
+
if (!isError) return body
|
|
246
|
+
|
|
247
|
+
return (
|
|
248
|
+
<Stack gap="sm">
|
|
249
|
+
<ErrorState
|
|
250
|
+
error={error}
|
|
251
|
+
title="Showing cached data"
|
|
252
|
+
fallback="The last refresh failed."
|
|
253
|
+
onRetry={retry}
|
|
254
|
+
retrying={retrying}
|
|
255
|
+
variant="section"
|
|
256
|
+
/>
|
|
257
|
+
{body}
|
|
258
|
+
</Stack>
|
|
259
|
+
)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function renderEmpty(empty: QueryEmptyCopy, variant: QueryStateVariant): ReactNode {
|
|
263
|
+
return (
|
|
264
|
+
<EmptyState
|
|
265
|
+
title={empty.title}
|
|
266
|
+
variant={variant}
|
|
267
|
+
{...(empty.description !== undefined && { description: empty.description })}
|
|
268
|
+
{...(empty.icon !== undefined && { icon: empty.icon })}
|
|
269
|
+
{...(empty.action !== undefined && { action: empty.action })}
|
|
270
|
+
/>
|
|
271
|
+
)
|
|
272
|
+
}
|