@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.
Files changed (98) hide show
  1. package/dist/{chunk-PHYEC3EG.cjs → chunk-4TFUB2CR.cjs} +26 -26
  2. package/dist/{chunk-PHYEC3EG.cjs.map → chunk-4TFUB2CR.cjs.map} +1 -1
  3. package/dist/{chunk-ZVFH4LTD.cjs → chunk-6SBAF5VG.cjs} +5 -5
  4. package/dist/{chunk-ZVFH4LTD.cjs.map → chunk-6SBAF5VG.cjs.map} +1 -1
  5. package/dist/{chunk-5WZGZ6B2.js → chunk-AUGM74CO.js} +4 -4
  6. package/dist/{chunk-REURWDBC.js → chunk-BC74IKAX.js} +4705 -4482
  7. package/dist/chunk-BC74IKAX.js.map +1 -0
  8. package/dist/{chunk-Z3V42W7P.js → chunk-BTMYUK4P.js} +2 -2
  9. package/dist/{chunk-HQITFULS.cjs → chunk-EO7MUGY4.cjs} +9 -9
  10. package/dist/{chunk-HQITFULS.cjs.map → chunk-EO7MUGY4.cjs.map} +1 -1
  11. package/dist/{chunk-ZTRRB2Y2.js → chunk-G4XU7BYU.js} +2 -2
  12. package/dist/{chunk-J5KX5K5T.js → chunk-GXOUXAXU.js} +2 -2
  13. package/dist/{chunk-F3BH5B7M.cjs → chunk-HWK5CH5N.cjs} +11 -11
  14. package/dist/{chunk-F3BH5B7M.cjs.map → chunk-HWK5CH5N.cjs.map} +1 -1
  15. package/dist/{chunk-KRKNTK5Y.js → chunk-ITEPRN6L.js} +2 -2
  16. package/dist/{chunk-RILRBUDL.cjs → chunk-K6MITU6M.cjs} +62 -62
  17. package/dist/{chunk-RILRBUDL.cjs.map → chunk-K6MITU6M.cjs.map} +1 -1
  18. package/dist/{chunk-C4QKMOQK.cjs → chunk-KTD2MGYF.cjs} +7 -7
  19. package/dist/{chunk-C4QKMOQK.cjs.map → chunk-KTD2MGYF.cjs.map} +1 -1
  20. package/dist/{chunk-OW33LYO2.js → chunk-NETEA5E4.js} +4 -4
  21. package/dist/{chunk-6QJ2QRQE.js → chunk-QBDHNUQX.js} +4 -4
  22. package/dist/{chunk-RB6HX4KC.cjs → chunk-QL7S3CH4.cjs} +26 -26
  23. package/dist/{chunk-RB6HX4KC.cjs.map → chunk-QL7S3CH4.cjs.map} +1 -1
  24. package/dist/{chunk-XJ6VL3AP.cjs → chunk-QW6SPNPT.cjs} +37 -37
  25. package/dist/{chunk-XJ6VL3AP.cjs.map → chunk-QW6SPNPT.cjs.map} +1 -1
  26. package/dist/{chunk-NTHTORJ3.cjs → chunk-RAYDQGJV.cjs} +59 -59
  27. package/dist/{chunk-NTHTORJ3.cjs.map → chunk-RAYDQGJV.cjs.map} +1 -1
  28. package/dist/{chunk-KA4ILVCX.js → chunk-SDNMOAT4.js} +2 -2
  29. package/dist/{chunk-XOIKQW2H.js → chunk-SMD67GJP.js} +2 -2
  30. package/dist/{chunk-NZYUSFMI.cjs → chunk-TJSJPOPL.cjs} +573 -350
  31. package/dist/chunk-TJSJPOPL.cjs.map +1 -0
  32. package/dist/{chunk-MD5E5BKO.js → chunk-UBK5HIMH.js} +4 -4
  33. package/dist/{chunk-BVHMDHQY.cjs → chunk-UU4DW7JX.cjs} +31 -31
  34. package/dist/{chunk-BVHMDHQY.cjs.map → chunk-UU4DW7JX.cjs.map} +1 -1
  35. package/dist/{chunk-RR6XPEEG.cjs → chunk-W6GDTB4R.cjs} +15 -15
  36. package/dist/{chunk-RR6XPEEG.cjs.map → chunk-W6GDTB4R.cjs.map} +1 -1
  37. package/dist/{chunk-2BMYCH3D.js → chunk-WLRBZXFB.js} +2 -2
  38. package/dist/components/case-studies/index.cjs +8 -8
  39. package/dist/components/case-studies/index.js +2 -2
  40. package/dist/components/chat/index.cjs +2 -2
  41. package/dist/components/chat/index.js +1 -1
  42. package/dist/components/contact/index.cjs +3 -3
  43. package/dist/components/contact/index.js +2 -2
  44. package/dist/components/docs/index.cjs +5 -5
  45. package/dist/components/docs/index.js +4 -4
  46. package/dist/components/embeds/index.cjs +3 -3
  47. package/dist/components/embeds/index.js +2 -2
  48. package/dist/components/faq/index.cjs +3 -3
  49. package/dist/components/faq/index.js +2 -2
  50. package/dist/components/features/index.cjs +2 -2
  51. package/dist/components/features/index.js +1 -1
  52. package/dist/components/help-center-pages/index.cjs +20 -20
  53. package/dist/components/help-center-pages/index.js +11 -11
  54. package/dist/components/index.cjs +144 -130
  55. package/dist/components/index.cjs.map +1 -1
  56. package/dist/components/index.js +23 -9
  57. package/dist/components/index.js.map +1 -1
  58. package/dist/components/navigation/index.cjs +2 -2
  59. package/dist/components/navigation/index.js +1 -1
  60. package/dist/components/onboarding-guides/index.cjs +5 -5
  61. package/dist/components/onboarding-guides/index.js +4 -4
  62. package/dist/components/related-content/index.cjs +3 -3
  63. package/dist/components/related-content/index.js +2 -2
  64. package/dist/components/tickets/index.cjs +5 -5
  65. package/dist/components/tickets/index.js +4 -4
  66. package/dist/components/ui/assignee-dropdown.d.ts.map +1 -1
  67. package/dist/components/ui/index.cjs +16 -2
  68. package/dist/components/ui/index.cjs.map +1 -1
  69. package/dist/components/ui/index.d.ts +2 -0
  70. package/dist/components/ui/index.d.ts.map +1 -1
  71. package/dist/components/ui/index.js +15 -1
  72. package/dist/components/ui/searchable-select.d.ts +43 -0
  73. package/dist/components/ui/searchable-select.d.ts.map +1 -0
  74. package/dist/components/ui/test-run.d.ts +74 -0
  75. package/dist/components/ui/test-run.d.ts.map +1 -0
  76. package/dist/index.cjs +16 -2
  77. package/dist/index.cjs.map +1 -1
  78. package/dist/index.js +15 -1
  79. package/package.json +1 -1
  80. package/src/components/ui/assignee-dropdown.tsx +36 -91
  81. package/src/components/ui/index.ts +2 -0
  82. package/src/components/ui/searchable-select.tsx +186 -0
  83. package/src/components/ui/test-run.tsx +282 -0
  84. package/src/stories/SearchableSelect.stories.tsx +139 -0
  85. package/src/stories/TestRun.stories.tsx +118 -0
  86. package/dist/chunk-NZYUSFMI.cjs.map +0 -1
  87. package/dist/chunk-REURWDBC.js.map +0 -1
  88. /package/dist/{chunk-5WZGZ6B2.js.map → chunk-AUGM74CO.js.map} +0 -0
  89. /package/dist/{chunk-Z3V42W7P.js.map → chunk-BTMYUK4P.js.map} +0 -0
  90. /package/dist/{chunk-ZTRRB2Y2.js.map → chunk-G4XU7BYU.js.map} +0 -0
  91. /package/dist/{chunk-J5KX5K5T.js.map → chunk-GXOUXAXU.js.map} +0 -0
  92. /package/dist/{chunk-KRKNTK5Y.js.map → chunk-ITEPRN6L.js.map} +0 -0
  93. /package/dist/{chunk-OW33LYO2.js.map → chunk-NETEA5E4.js.map} +0 -0
  94. /package/dist/{chunk-6QJ2QRQE.js.map → chunk-QBDHNUQX.js.map} +0 -0
  95. /package/dist/{chunk-KA4ILVCX.js.map → chunk-SDNMOAT4.js.map} +0 -0
  96. /package/dist/{chunk-XOIKQW2H.js.map → chunk-SMD67GJP.js.map} +0 -0
  97. /package/dist/{chunk-MD5E5BKO.js.map → chunk-UBK5HIMH.js.map} +0 -0
  98. /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
+ };