@flamingo-stack/openframe-frontend-core 0.0.486 → 0.0.487
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/dist/{chunk-PHYEC3EG.cjs → chunk-4TFUB2CR.cjs} +26 -26
- package/dist/{chunk-PHYEC3EG.cjs.map → chunk-4TFUB2CR.cjs.map} +1 -1
- package/dist/{chunk-ZVFH4LTD.cjs → chunk-6SBAF5VG.cjs} +5 -5
- package/dist/{chunk-ZVFH4LTD.cjs.map → chunk-6SBAF5VG.cjs.map} +1 -1
- package/dist/{chunk-5WZGZ6B2.js → chunk-AUGM74CO.js} +4 -4
- package/dist/{chunk-REURWDBC.js → chunk-BC74IKAX.js} +4705 -4482
- package/dist/chunk-BC74IKAX.js.map +1 -0
- package/dist/{chunk-Z3V42W7P.js → chunk-BTMYUK4P.js} +2 -2
- package/dist/{chunk-HQITFULS.cjs → chunk-EO7MUGY4.cjs} +9 -9
- package/dist/{chunk-HQITFULS.cjs.map → chunk-EO7MUGY4.cjs.map} +1 -1
- package/dist/{chunk-ZTRRB2Y2.js → chunk-G4XU7BYU.js} +2 -2
- package/dist/{chunk-J5KX5K5T.js → chunk-GXOUXAXU.js} +2 -2
- package/dist/{chunk-F3BH5B7M.cjs → chunk-HWK5CH5N.cjs} +11 -11
- package/dist/{chunk-F3BH5B7M.cjs.map → chunk-HWK5CH5N.cjs.map} +1 -1
- package/dist/{chunk-KRKNTK5Y.js → chunk-ITEPRN6L.js} +2 -2
- package/dist/{chunk-RILRBUDL.cjs → chunk-K6MITU6M.cjs} +62 -62
- package/dist/{chunk-RILRBUDL.cjs.map → chunk-K6MITU6M.cjs.map} +1 -1
- package/dist/{chunk-C4QKMOQK.cjs → chunk-KTD2MGYF.cjs} +7 -7
- package/dist/{chunk-C4QKMOQK.cjs.map → chunk-KTD2MGYF.cjs.map} +1 -1
- package/dist/{chunk-OW33LYO2.js → chunk-NETEA5E4.js} +4 -4
- package/dist/{chunk-6QJ2QRQE.js → chunk-QBDHNUQX.js} +4 -4
- package/dist/{chunk-RB6HX4KC.cjs → chunk-QL7S3CH4.cjs} +26 -26
- package/dist/{chunk-RB6HX4KC.cjs.map → chunk-QL7S3CH4.cjs.map} +1 -1
- package/dist/{chunk-XJ6VL3AP.cjs → chunk-QW6SPNPT.cjs} +37 -37
- package/dist/{chunk-XJ6VL3AP.cjs.map → chunk-QW6SPNPT.cjs.map} +1 -1
- package/dist/{chunk-NTHTORJ3.cjs → chunk-RAYDQGJV.cjs} +59 -59
- package/dist/{chunk-NTHTORJ3.cjs.map → chunk-RAYDQGJV.cjs.map} +1 -1
- package/dist/{chunk-KA4ILVCX.js → chunk-SDNMOAT4.js} +2 -2
- package/dist/{chunk-XOIKQW2H.js → chunk-SMD67GJP.js} +2 -2
- package/dist/{chunk-NZYUSFMI.cjs → chunk-TJSJPOPL.cjs} +573 -350
- package/dist/chunk-TJSJPOPL.cjs.map +1 -0
- package/dist/{chunk-MD5E5BKO.js → chunk-UBK5HIMH.js} +4 -4
- package/dist/{chunk-BVHMDHQY.cjs → chunk-UU4DW7JX.cjs} +31 -31
- package/dist/{chunk-BVHMDHQY.cjs.map → chunk-UU4DW7JX.cjs.map} +1 -1
- package/dist/{chunk-RR6XPEEG.cjs → chunk-W6GDTB4R.cjs} +15 -15
- package/dist/{chunk-RR6XPEEG.cjs.map → chunk-W6GDTB4R.cjs.map} +1 -1
- package/dist/{chunk-2BMYCH3D.js → chunk-WLRBZXFB.js} +2 -2
- package/dist/components/case-studies/index.cjs +8 -8
- package/dist/components/case-studies/index.js +2 -2
- package/dist/components/chat/index.cjs +2 -2
- package/dist/components/chat/index.js +1 -1
- package/dist/components/contact/index.cjs +3 -3
- package/dist/components/contact/index.js +2 -2
- package/dist/components/docs/index.cjs +5 -5
- package/dist/components/docs/index.js +4 -4
- package/dist/components/embeds/index.cjs +3 -3
- package/dist/components/embeds/index.js +2 -2
- package/dist/components/faq/index.cjs +3 -3
- package/dist/components/faq/index.js +2 -2
- package/dist/components/features/index.cjs +2 -2
- package/dist/components/features/index.js +1 -1
- package/dist/components/help-center-pages/index.cjs +20 -20
- package/dist/components/help-center-pages/index.js +11 -11
- package/dist/components/index.cjs +144 -130
- package/dist/components/index.cjs.map +1 -1
- package/dist/components/index.js +23 -9
- package/dist/components/index.js.map +1 -1
- package/dist/components/navigation/index.cjs +2 -2
- package/dist/components/navigation/index.js +1 -1
- package/dist/components/onboarding-guides/index.cjs +5 -5
- package/dist/components/onboarding-guides/index.js +4 -4
- package/dist/components/related-content/index.cjs +3 -3
- package/dist/components/related-content/index.js +2 -2
- package/dist/components/tickets/index.cjs +5 -5
- package/dist/components/tickets/index.js +4 -4
- package/dist/components/ui/assignee-dropdown.d.ts.map +1 -1
- package/dist/components/ui/index.cjs +16 -2
- package/dist/components/ui/index.cjs.map +1 -1
- package/dist/components/ui/index.d.ts +2 -0
- package/dist/components/ui/index.d.ts.map +1 -1
- package/dist/components/ui/index.js +15 -1
- package/dist/components/ui/searchable-select.d.ts +43 -0
- package/dist/components/ui/searchable-select.d.ts.map +1 -0
- package/dist/components/ui/test-run.d.ts +74 -0
- package/dist/components/ui/test-run.d.ts.map +1 -0
- package/dist/index.cjs +16 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +15 -1
- package/package.json +1 -1
- package/src/components/ui/assignee-dropdown.tsx +36 -91
- package/src/components/ui/index.ts +2 -0
- package/src/components/ui/searchable-select.tsx +186 -0
- package/src/components/ui/test-run.tsx +282 -0
- package/src/stories/SearchableSelect.stories.tsx +139 -0
- package/src/stories/TestRun.stories.tsx +118 -0
- package/dist/chunk-NZYUSFMI.cjs.map +0 -1
- package/dist/chunk-REURWDBC.js.map +0 -1
- /package/dist/{chunk-5WZGZ6B2.js.map → chunk-AUGM74CO.js.map} +0 -0
- /package/dist/{chunk-Z3V42W7P.js.map → chunk-BTMYUK4P.js.map} +0 -0
- /package/dist/{chunk-ZTRRB2Y2.js.map → chunk-G4XU7BYU.js.map} +0 -0
- /package/dist/{chunk-J5KX5K5T.js.map → chunk-GXOUXAXU.js.map} +0 -0
- /package/dist/{chunk-KRKNTK5Y.js.map → chunk-ITEPRN6L.js.map} +0 -0
- /package/dist/{chunk-OW33LYO2.js.map → chunk-NETEA5E4.js.map} +0 -0
- /package/dist/{chunk-6QJ2QRQE.js.map → chunk-QBDHNUQX.js.map} +0 -0
- /package/dist/{chunk-KA4ILVCX.js.map → chunk-SDNMOAT4.js.map} +0 -0
- /package/dist/{chunk-XOIKQW2H.js.map → chunk-SMD67GJP.js.map} +0 -0
- /package/dist/{chunk-MD5E5BKO.js.map → chunk-UBK5HIMH.js.map} +0 -0
- /package/dist/{chunk-2BMYCH3D.js.map → chunk-WLRBZXFB.js.map} +0 -0
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import type { ColumnDef, Row } from '@tanstack/react-table'
|
|
4
|
+
import * as React from 'react'
|
|
5
|
+
import { SearchIcon } from '../icons-v2-generated'
|
|
6
|
+
import { cn } from '../../utils/cn'
|
|
7
|
+
import { formatTime } from '../../utils/format-date'
|
|
8
|
+
import { DataTable } from './data-table'
|
|
9
|
+
import { useDataTable } from './data-table/use-data-table'
|
|
10
|
+
import { NoData } from './no-data'
|
|
11
|
+
import type { QueryResultRow } from './query-report-table'
|
|
12
|
+
import { ScrollShadow } from './scroll-fade'
|
|
13
|
+
|
|
14
|
+
/*
|
|
15
|
+
* Live test-run building blocks: the UI (timing stats, one-row skeleton,
|
|
16
|
+
* results table) and the run state machine for "run a query/script somewhere
|
|
17
|
+
* and stream tabular results back" flows. The transport is NOT part of this
|
|
18
|
+
* module — consumers own campaign creation (Fleet live queries, script runs,
|
|
19
|
+
* ...) and pass a `TestRunCampaignState`-shaped object into
|
|
20
|
+
* `useTestRunState`.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
export function formatTestRunDuration(ms: number): string {
|
|
24
|
+
const totalSeconds = Math.floor(ms / 1000)
|
|
25
|
+
const hours = Math.floor(totalSeconds / 3600)
|
|
26
|
+
const minutes = Math.floor((totalSeconds % 3600) / 60)
|
|
27
|
+
const seconds = totalSeconds % 60
|
|
28
|
+
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const RESULT_COLUMN_MIN_WIDTH = 176
|
|
32
|
+
const SKELETON_COLUMNS = 6
|
|
33
|
+
|
|
34
|
+
/** Column-bar skeleton (header cells + one card row): fixed 160px columns on
|
|
35
|
+
every breakpoint, scrolling horizontally when they do not fit. Bars use
|
|
36
|
+
bg-ods-bg-surface like the table skeletons — the bg-ods-skeleton token is
|
|
37
|
+
the same color as the card background and would be invisible on the row. */
|
|
38
|
+
export function TestResultsSkeleton() {
|
|
39
|
+
return (
|
|
40
|
+
<ScrollShadow axis="horizontal">
|
|
41
|
+
{/* No gap between the header bars and the row card — the real table's
|
|
42
|
+
header and body sit flush. */}
|
|
43
|
+
<div className="flex w-max min-w-full flex-col">
|
|
44
|
+
<div className="flex items-center gap-[var(--spacing-system-mf)] px-[var(--spacing-system-mf)]">
|
|
45
|
+
{Array.from({ length: SKELETON_COLUMNS }).map((_, i) => (
|
|
46
|
+
<div key={`skeleton-header-${i}`} className="flex h-12 w-[160px] shrink-0 items-center">
|
|
47
|
+
<div className="h-4 w-3/4 rounded-sm bg-ods-bg-surface animate-pulse" />
|
|
48
|
+
</div>
|
|
49
|
+
))}
|
|
50
|
+
</div>
|
|
51
|
+
<div className="rounded-[6px] border border-ods-border bg-ods-card overflow-hidden animate-pulse">
|
|
52
|
+
{/* Same INNER row heights as the DataTable (66/78px + the 1px card
|
|
53
|
+
border on each side = 68/80px total, per design). */}
|
|
54
|
+
<div className="flex h-[66px] md:h-[78px] items-center gap-[var(--spacing-system-mf)] px-[var(--spacing-system-mf)]">
|
|
55
|
+
{Array.from({ length: SKELETON_COLUMNS }).map((_, i) => (
|
|
56
|
+
<div key={`skeleton-cell-${i}`} className="w-[160px] shrink-0">
|
|
57
|
+
<div className="h-5 w-3/4 rounded-sm bg-ods-bg-surface" />
|
|
58
|
+
</div>
|
|
59
|
+
))}
|
|
60
|
+
</div>
|
|
61
|
+
</div>
|
|
62
|
+
</div>
|
|
63
|
+
</ScrollShadow>
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Live test results rendered with the core DataTable (identical header,
|
|
69
|
+
* skeleton, and card-row styling to the standard tables). Columns are derived
|
|
70
|
+
* from the keys of the returned rows; wide result sets scroll horizontally.
|
|
71
|
+
* Unlike the default tables, headers stay visible on every breakpoint.
|
|
72
|
+
*/
|
|
73
|
+
export function TestResultsTable({ rows, loading }: { rows: QueryResultRow[]; loading: boolean }) {
|
|
74
|
+
const columnKeys = React.useMemo(() => (rows.length > 0 ? Object.keys(rows[0]) : []), [rows])
|
|
75
|
+
|
|
76
|
+
const columns = React.useMemo<ColumnDef<QueryResultRow>[]>(() => {
|
|
77
|
+
return columnKeys.map(key => ({
|
|
78
|
+
id: key,
|
|
79
|
+
accessorFn: (row: QueryResultRow) => row[key],
|
|
80
|
+
header: key,
|
|
81
|
+
enableSorting: false,
|
|
82
|
+
cell: ({ row }: { row: Row<QueryResultRow> }) => {
|
|
83
|
+
const value = row.original[key]
|
|
84
|
+
return (
|
|
85
|
+
<span className="text-h4 text-ods-text-primary truncate">
|
|
86
|
+
{value === null || value === undefined ? '-' : String(value)}
|
|
87
|
+
</span>
|
|
88
|
+
)
|
|
89
|
+
},
|
|
90
|
+
meta: {
|
|
91
|
+
width: 'flex-1 min-w-[176px]',
|
|
92
|
+
// Show headers on every breakpoint; headerClassName sizes the cells
|
|
93
|
+
// below lg where meta.width is not applied.
|
|
94
|
+
alwaysShowHeader: true,
|
|
95
|
+
headerClassName: 'flex-1 min-w-[176px]',
|
|
96
|
+
},
|
|
97
|
+
}))
|
|
98
|
+
}, [columnKeys])
|
|
99
|
+
|
|
100
|
+
const table = useDataTable<QueryResultRow>({
|
|
101
|
+
data: rows,
|
|
102
|
+
columns,
|
|
103
|
+
getRowId: (_row: QueryResultRow, index: number) => String(index),
|
|
104
|
+
enableSorting: false,
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
if (loading) {
|
|
108
|
+
return <TestResultsSkeleton />
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return (
|
|
112
|
+
<ScrollShadow axis="horizontal">
|
|
113
|
+
<div style={{ minWidth: Math.max(1, columnKeys.length) * RESULT_COLUMN_MIN_WIDTH }}>
|
|
114
|
+
<DataTable table={table}>
|
|
115
|
+
{/* "flex" overrides the header's default "hidden md:flex" so the
|
|
116
|
+
headers render on mobile too. */}
|
|
117
|
+
<DataTable.Header className="flex flex-col" />
|
|
118
|
+
<DataTable.Body loading={false} />
|
|
119
|
+
</DataTable>
|
|
120
|
+
</div>
|
|
121
|
+
</ScrollShadow>
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface TestRunResultsProps {
|
|
126
|
+
isActive: boolean
|
|
127
|
+
displayRows: QueryResultRow[]
|
|
128
|
+
firstError: string | null
|
|
129
|
+
className?: string
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Error line + empty state + result table for a finished/running test run. */
|
|
133
|
+
export function TestRunResults({ isActive, displayRows, firstError, className }: TestRunResultsProps) {
|
|
134
|
+
return (
|
|
135
|
+
<div className={cn('flex flex-col gap-[var(--spacing-system-xsf)]', className)}>
|
|
136
|
+
{firstError && (
|
|
137
|
+
<p role="alert" className="text-h6 text-ods-error">
|
|
138
|
+
{firstError}
|
|
139
|
+
</p>
|
|
140
|
+
)}
|
|
141
|
+
{!isActive && displayRows.length === 0 ? (
|
|
142
|
+
// With zero rows the error line alone explains the outcome; a
|
|
143
|
+
// "No results returned" empty state next to it would mislead.
|
|
144
|
+
// Otherwise: fixed-height empty state matching the 1-row
|
|
145
|
+
// skeleton/result height (48px header + 80px row, no gap) so the
|
|
146
|
+
// block does not jump between loading/result/empty.
|
|
147
|
+
!firstError && (
|
|
148
|
+
<NoData icon={<SearchIcon />} title="No results returned" className="h-[128px] justify-center !py-0" />
|
|
149
|
+
)
|
|
150
|
+
) : (
|
|
151
|
+
<TestResultsTable rows={displayRows} loading={isActive && displayRows.length === 0} />
|
|
152
|
+
)}
|
|
153
|
+
</div>
|
|
154
|
+
)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Started / Duration stat cell shown in a test-run controls row. */
|
|
158
|
+
export function TimingStat({ value, label, className }: { value: string; label: string; className?: string }) {
|
|
159
|
+
return (
|
|
160
|
+
<div className={cn('flex flex-col justify-center min-w-0', className)}>
|
|
161
|
+
<span className="text-h4 text-ods-text-secondary truncate">{value}</span>
|
|
162
|
+
<span className="text-h6 text-ods-text-secondary truncate">{label}</span>
|
|
163
|
+
</div>
|
|
164
|
+
)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The transport-side contract `useTestRunState` observes. Matches the shape
|
|
169
|
+
* of a live-campaign hook (e.g. Fleet live queries in OpenFrame) without
|
|
170
|
+
* depending on any concrete backend.
|
|
171
|
+
*/
|
|
172
|
+
export interface TestRunCampaignState {
|
|
173
|
+
isRunning: boolean
|
|
174
|
+
startedAt: Date | null
|
|
175
|
+
/** '' before the first run, then 'pending' → 'finished'. */
|
|
176
|
+
campaignStatus: string
|
|
177
|
+
results: QueryResultRow[]
|
|
178
|
+
errors: Array<{ error: string }>
|
|
179
|
+
stopCampaign: () => void
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* State machine for one live test run: wraps a campaign-shaped object with
|
|
184
|
+
* the run/stop/reset flow, elapsed-duration ticking, and the display flags
|
|
185
|
+
* test panels render from. The consumer starts the actual campaign inside the
|
|
186
|
+
* `start` callback passed to `run`:
|
|
187
|
+
*
|
|
188
|
+
* ```tsx
|
|
189
|
+
* const campaign = useLiveCampaign() // app-owned transport
|
|
190
|
+
* const test = useTestRunState(campaign)
|
|
191
|
+
* const handleRun = () => test.run(() => campaign.startCampaign(sql, [hostId]))
|
|
192
|
+
* ```
|
|
193
|
+
*/
|
|
194
|
+
export function useTestRunState(campaign: TestRunCampaignState) {
|
|
195
|
+
// Sticky "a run has been started" flag: campaignStatus resets to '' for a
|
|
196
|
+
// moment when a new run starts, which would flash the initial Run button
|
|
197
|
+
// between "Test Again" clicks without it.
|
|
198
|
+
const [hasRun, setHasRun] = React.useState(false)
|
|
199
|
+
// True while the campaign is being created (before isRunning flips) —
|
|
200
|
+
// without it the button would flash "Test Again" right after a Run click.
|
|
201
|
+
const [isStarting, setIsStarting] = React.useState(false)
|
|
202
|
+
const [durationMs, setDurationMs] = React.useState(0)
|
|
203
|
+
|
|
204
|
+
React.useEffect(() => {
|
|
205
|
+
if (!campaign.startedAt || !campaign.isRunning) {
|
|
206
|
+
if (campaign.startedAt && !campaign.isRunning) {
|
|
207
|
+
setDurationMs(Date.now() - campaign.startedAt.getTime())
|
|
208
|
+
}
|
|
209
|
+
return
|
|
210
|
+
}
|
|
211
|
+
setDurationMs(Date.now() - campaign.startedAt.getTime())
|
|
212
|
+
const interval = setInterval(() => {
|
|
213
|
+
setDurationMs(Date.now() - campaign.startedAt!.getTime())
|
|
214
|
+
}, 1000)
|
|
215
|
+
return () => clearInterval(interval)
|
|
216
|
+
}, [campaign.startedAt, campaign.isRunning])
|
|
217
|
+
|
|
218
|
+
const run = React.useCallback(async (start: () => Promise<unknown>) => {
|
|
219
|
+
setHasRun(true)
|
|
220
|
+
setDurationMs(0)
|
|
221
|
+
setIsStarting(true)
|
|
222
|
+
try {
|
|
223
|
+
await start()
|
|
224
|
+
} finally {
|
|
225
|
+
setIsStarting(false)
|
|
226
|
+
}
|
|
227
|
+
}, [])
|
|
228
|
+
|
|
229
|
+
// Stop/reset also drop isStarting so the Stop button doesn't stay "active"
|
|
230
|
+
// for the moment between the user's click and the pending start promise
|
|
231
|
+
// settling (its finally clears the flag again — harmless).
|
|
232
|
+
const stop = React.useCallback(() => {
|
|
233
|
+
setIsStarting(false)
|
|
234
|
+
campaign.stopCampaign()
|
|
235
|
+
}, [campaign.stopCampaign])
|
|
236
|
+
|
|
237
|
+
/** Stop anything in flight and clear the run state (panel close/cancel). */
|
|
238
|
+
const reset = React.useCallback(() => {
|
|
239
|
+
setIsStarting(false)
|
|
240
|
+
campaign.stopCampaign()
|
|
241
|
+
setDurationMs(0)
|
|
242
|
+
setHasRun(false)
|
|
243
|
+
}, [campaign.stopCampaign])
|
|
244
|
+
|
|
245
|
+
const isActive = campaign.isRunning || isStarting
|
|
246
|
+
const isFinished = campaign.campaignStatus === 'finished'
|
|
247
|
+
const showResults = isActive || (hasRun && isFinished)
|
|
248
|
+
|
|
249
|
+
const firstError = isFinished && campaign.errors.length > 0 ? campaign.errors[0].error : null
|
|
250
|
+
|
|
251
|
+
// Started/Duration show zeros until the current run's timing is real:
|
|
252
|
+
// hasRun=false after a reset, and campaignStatus resets while the next
|
|
253
|
+
// run is being created, so stale values never linger on screen.
|
|
254
|
+
const showTiming = hasRun && campaign.startedAt !== null && (campaign.isRunning || isFinished)
|
|
255
|
+
|
|
256
|
+
const startedLabel = showTiming && campaign.startedAt ? formatTime(campaign.startedAt) : '00:00 PM'
|
|
257
|
+
const durationLabel = showTiming ? formatTestRunDuration(durationMs) : '00:00:00'
|
|
258
|
+
|
|
259
|
+
// Humanize raw result keys for the table headers (snake_case -> spaced
|
|
260
|
+
// words); the row data itself is shown exactly as returned.
|
|
261
|
+
const displayRows = React.useMemo<QueryResultRow[]>(
|
|
262
|
+
() =>
|
|
263
|
+
campaign.results.map(row =>
|
|
264
|
+
Object.fromEntries(Object.entries(row).map(([key, value]) => [key.replace(/_/g, ' '), value])),
|
|
265
|
+
),
|
|
266
|
+
[campaign.results],
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
return {
|
|
270
|
+
hasRun,
|
|
271
|
+
isActive,
|
|
272
|
+
isFinished,
|
|
273
|
+
showResults,
|
|
274
|
+
firstError,
|
|
275
|
+
startedLabel,
|
|
276
|
+
durationLabel,
|
|
277
|
+
displayRows,
|
|
278
|
+
run,
|
|
279
|
+
stop,
|
|
280
|
+
reset,
|
|
281
|
+
}
|
|
282
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
import { SearchableSelect, type SearchableSelectProps } from '../components/ui/searchable-select';
|
|
4
|
+
import { SquareAvatar } from '../components/ui/square-avatar';
|
|
5
|
+
import { MonitorIcon, UserPlusIcon } from '../components/icons-v2-generated';
|
|
6
|
+
|
|
7
|
+
const meta = {
|
|
8
|
+
title: 'UI/SearchableSelect',
|
|
9
|
+
component: SearchableSelect,
|
|
10
|
+
argTypes: {
|
|
11
|
+
align: {
|
|
12
|
+
control: 'select',
|
|
13
|
+
options: ['start', 'center', 'end'],
|
|
14
|
+
},
|
|
15
|
+
isLoading: { control: 'boolean' },
|
|
16
|
+
disabled: { control: 'boolean' },
|
|
17
|
+
},
|
|
18
|
+
parameters: {
|
|
19
|
+
docs: {
|
|
20
|
+
description: {
|
|
21
|
+
component:
|
|
22
|
+
'Select-style dropdown whose first item is a search field filtering the option list - ' +
|
|
23
|
+
'the ticket assignee "Search users..." pattern generalized for any entity. The default ' +
|
|
24
|
+
'trigger matches SelectTrigger; pass a custom trigger for icon-button use cases ' +
|
|
25
|
+
'(AssigneeDropdown compact is built on this component).',
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
} satisfies Meta<typeof SearchableSelect>;
|
|
30
|
+
|
|
31
|
+
export default meta;
|
|
32
|
+
type Story = StoryObj<typeof meta>;
|
|
33
|
+
|
|
34
|
+
const DEVICES = [
|
|
35
|
+
{ value: '1', label: "John's MacBook Pro" },
|
|
36
|
+
{ value: '2', label: 'Design-Studio-iMac' },
|
|
37
|
+
{ value: '3', label: 'Front-Desk-PC' },
|
|
38
|
+
{ value: '4', label: 'QA-Windows-11' },
|
|
39
|
+
{ value: '5', label: 'Server-Room-Linux' },
|
|
40
|
+
{ value: '6', label: "Olivia's ThinkPad" },
|
|
41
|
+
{ value: '7', label: 'Warehouse-Scanner-01' },
|
|
42
|
+
{ value: '8', label: 'Reception-Tablet' },
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
function ControlledSelect(props: Partial<SearchableSelectProps>) {
|
|
46
|
+
const [value, setValue] = useState<string | null>(props.value ?? null);
|
|
47
|
+
return (
|
|
48
|
+
<SearchableSelect
|
|
49
|
+
options={DEVICES}
|
|
50
|
+
placeholder="Select Device"
|
|
51
|
+
searchPlaceholder="Search for Device"
|
|
52
|
+
emptyText="No devices found"
|
|
53
|
+
{...props}
|
|
54
|
+
value={value}
|
|
55
|
+
onValueChange={setValue}
|
|
56
|
+
/>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Default select-like trigger; the search input is the first dropdown item. */
|
|
61
|
+
export const DeviceSelect: Story = {
|
|
62
|
+
args: {
|
|
63
|
+
options: DEVICES,
|
|
64
|
+
onValueChange: () => {},
|
|
65
|
+
placeholder: 'Select Device',
|
|
66
|
+
searchPlaceholder: 'Search for Device',
|
|
67
|
+
emptyText: 'No devices found',
|
|
68
|
+
},
|
|
69
|
+
render: args => (
|
|
70
|
+
<div className="w-[320px] bg-ods-bg p-[var(--spacing-system-mf)]">
|
|
71
|
+
<ControlledSelect {...args} />
|
|
72
|
+
</div>
|
|
73
|
+
),
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/** Options can carry a leading icon/avatar node. */
|
|
77
|
+
export const WithOptionIcons: Story = {
|
|
78
|
+
args: {
|
|
79
|
+
options: DEVICES.map(d => ({
|
|
80
|
+
...d,
|
|
81
|
+
icon: <MonitorIcon className="size-5 shrink-0 text-ods-text-secondary" />,
|
|
82
|
+
})),
|
|
83
|
+
onValueChange: () => {},
|
|
84
|
+
placeholder: 'Select Device',
|
|
85
|
+
searchPlaceholder: 'Search for Device',
|
|
86
|
+
emptyText: 'No devices found',
|
|
87
|
+
},
|
|
88
|
+
render: args => (
|
|
89
|
+
<div className="w-[320px] bg-ods-bg p-[var(--spacing-system-mf)]">
|
|
90
|
+
<ControlledSelect {...args} />
|
|
91
|
+
</div>
|
|
92
|
+
),
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/** Custom icon-button trigger - the AssigneeDropdown compact use case. */
|
|
96
|
+
export const CustomTrigger: Story = {
|
|
97
|
+
args: {
|
|
98
|
+
options: ['Roman Smith', 'Mike Johnson', 'Olivia Chen', 'Ava Martinez'].map((name, i) => ({
|
|
99
|
+
value: String(i + 1),
|
|
100
|
+
label: name,
|
|
101
|
+
icon: <SquareAvatar alt={name} fallback={name} size="sm" variant="round" className="h-6 w-6 shrink-0" />,
|
|
102
|
+
})),
|
|
103
|
+
onValueChange: () => {},
|
|
104
|
+
searchPlaceholder: 'Search users...',
|
|
105
|
+
emptyText: 'No users found',
|
|
106
|
+
align: 'end',
|
|
107
|
+
contentClassName: 'w-72',
|
|
108
|
+
trigger: (
|
|
109
|
+
<button
|
|
110
|
+
type="button"
|
|
111
|
+
aria-label="Assign user"
|
|
112
|
+
className="size-8 rounded-full border border-ods-border flex items-center justify-center shrink-0 text-ods-text-secondary hover:text-ods-accent hover:border-ods-accent transition-colors"
|
|
113
|
+
>
|
|
114
|
+
<UserPlusIcon className="size-4" />
|
|
115
|
+
</button>
|
|
116
|
+
),
|
|
117
|
+
},
|
|
118
|
+
render: args => (
|
|
119
|
+
<div className="flex w-[320px] justify-end bg-ods-bg p-[var(--spacing-system-mf)]">
|
|
120
|
+
<ControlledSelect {...args} />
|
|
121
|
+
</div>
|
|
122
|
+
),
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
/** Loading state shown inside the dropdown. */
|
|
126
|
+
export const Loading: Story = {
|
|
127
|
+
args: {
|
|
128
|
+
options: [],
|
|
129
|
+
onValueChange: () => {},
|
|
130
|
+
placeholder: 'Select Device',
|
|
131
|
+
searchPlaceholder: 'Search for Device',
|
|
132
|
+
isLoading: true,
|
|
133
|
+
},
|
|
134
|
+
render: args => (
|
|
135
|
+
<div className="w-[320px] bg-ods-bg p-[var(--spacing-system-mf)]">
|
|
136
|
+
<ControlledSelect {...args} />
|
|
137
|
+
</div>
|
|
138
|
+
),
|
|
139
|
+
};
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
|
|
2
|
+
import { TestResultsSkeleton, TestRunResults, TimingStat } from '../components/ui/test-run';
|
|
3
|
+
|
|
4
|
+
const meta = {
|
|
5
|
+
title: 'UI/TestRun',
|
|
6
|
+
component: TestRunResults,
|
|
7
|
+
parameters: {
|
|
8
|
+
docs: {
|
|
9
|
+
description: {
|
|
10
|
+
component:
|
|
11
|
+
'Building blocks for live test-run panels ("run a query somewhere, stream tabular results back"): ' +
|
|
12
|
+
'timing stats, a one-row column skeleton, and a results table with always-visible headers and ' +
|
|
13
|
+
'horizontal scroll fades. Pair with useTestRunState, which wraps an app-owned campaign ' +
|
|
14
|
+
'(e.g. a Fleet live query hook) with the run/stop/reset state machine.',
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
} satisfies Meta<typeof TestRunResults>;
|
|
19
|
+
|
|
20
|
+
export default meta;
|
|
21
|
+
type Story = StoryObj<typeof meta>;
|
|
22
|
+
|
|
23
|
+
const RESULT_ROWS = [
|
|
24
|
+
{
|
|
25
|
+
'host display name': 'workstation-01',
|
|
26
|
+
pid: '1234',
|
|
27
|
+
name: 'chrome',
|
|
28
|
+
path: '/Applications/Google Chrome.app',
|
|
29
|
+
state: 'R',
|
|
30
|
+
uid: '501',
|
|
31
|
+
'resident size': '245760000',
|
|
32
|
+
threads: '42',
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
'host display name': 'workstation-01',
|
|
36
|
+
pid: '5678',
|
|
37
|
+
name: 'node',
|
|
38
|
+
path: '/usr/local/bin/node',
|
|
39
|
+
state: 'S',
|
|
40
|
+
uid: '501',
|
|
41
|
+
'resident size': '98304000',
|
|
42
|
+
threads: '12',
|
|
43
|
+
},
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
/** Finished run with rows: DataTable styling, headers on all breakpoints. */
|
|
47
|
+
export const WithResults: Story = {
|
|
48
|
+
args: {
|
|
49
|
+
isActive: false,
|
|
50
|
+
displayRows: RESULT_ROWS,
|
|
51
|
+
firstError: null,
|
|
52
|
+
},
|
|
53
|
+
render: args => (
|
|
54
|
+
<div className="max-w-[720px] bg-ods-bg p-[var(--spacing-system-mf)]">
|
|
55
|
+
<TestRunResults {...args} />
|
|
56
|
+
</div>
|
|
57
|
+
),
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/** Running: one-row skeleton with fixed 160px columns. */
|
|
61
|
+
export const Running: Story = {
|
|
62
|
+
args: {
|
|
63
|
+
isActive: true,
|
|
64
|
+
displayRows: [],
|
|
65
|
+
firstError: null,
|
|
66
|
+
},
|
|
67
|
+
render: args => (
|
|
68
|
+
<div className="max-w-[720px] bg-ods-bg p-[var(--spacing-system-mf)]">
|
|
69
|
+
<TestRunResults {...args} />
|
|
70
|
+
</div>
|
|
71
|
+
),
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/** Finished with zero rows: fixed-height empty state (no layout jump). */
|
|
75
|
+
export const EmptyResult: Story = {
|
|
76
|
+
args: {
|
|
77
|
+
isActive: false,
|
|
78
|
+
displayRows: [],
|
|
79
|
+
firstError: null,
|
|
80
|
+
},
|
|
81
|
+
render: args => (
|
|
82
|
+
<div className="max-w-[720px] bg-ods-bg p-[var(--spacing-system-mf)]">
|
|
83
|
+
<TestRunResults {...args} />
|
|
84
|
+
</div>
|
|
85
|
+
),
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/** Finished with an error: the alert line replaces the empty state. */
|
|
89
|
+
export const WithError: Story = {
|
|
90
|
+
args: {
|
|
91
|
+
isActive: false,
|
|
92
|
+
displayRows: [],
|
|
93
|
+
firstError: 'no such table: procesess (near "procesess": syntax error)',
|
|
94
|
+
},
|
|
95
|
+
render: args => (
|
|
96
|
+
<div className="max-w-[720px] bg-ods-bg p-[var(--spacing-system-mf)]">
|
|
97
|
+
<TestRunResults {...args} />
|
|
98
|
+
</div>
|
|
99
|
+
),
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/** Timing cells + standalone skeleton, as composed by app test panels. */
|
|
103
|
+
export const TimingAndSkeleton: Story = {
|
|
104
|
+
args: {
|
|
105
|
+
isActive: true,
|
|
106
|
+
displayRows: [],
|
|
107
|
+
firstError: null,
|
|
108
|
+
},
|
|
109
|
+
render: () => (
|
|
110
|
+
<div className="flex max-w-[720px] flex-col gap-[var(--spacing-system-m)] bg-ods-bg p-[var(--spacing-system-mf)]">
|
|
111
|
+
<div className="flex items-center gap-[var(--spacing-system-m)]">
|
|
112
|
+
<TimingStat value="02:15 PM" label="Started" className="flex-1" />
|
|
113
|
+
<TimingStat value="00:00:05" label="Duration" className="flex-1" />
|
|
114
|
+
</div>
|
|
115
|
+
<TestResultsSkeleton />
|
|
116
|
+
</div>
|
|
117
|
+
),
|
|
118
|
+
};
|