@seed-ship/mcp-ui-solid 4.0.1 → 4.0.3
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/components/DataPreviewSection.cjs +88 -17
- package/dist/components/DataPreviewSection.cjs.map +1 -1
- package/dist/components/DataPreviewSection.d.ts +3 -2
- package/dist/components/DataPreviewSection.d.ts.map +1 -1
- package/dist/components/DataPreviewSection.js +89 -18
- package/dist/components/DataPreviewSection.js.map +1 -1
- package/dist/components/ScratchpadPanel.cjs +45 -33
- package/dist/components/ScratchpadPanel.cjs.map +1 -1
- package/dist/components/ScratchpadPanel.js +45 -33
- package/dist/components/ScratchpadPanel.js.map +1 -1
- package/package.json +1 -1
- package/src/components/DataPreviewSection.tsx +88 -13
- package/src/components/ScratchpadPanel.tsx +26 -16
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* DataPreviewSection — paginated data table with export
|
|
3
|
-
* v4.0.
|
|
2
|
+
* DataPreviewSection — paginated, sortable data table with export
|
|
3
|
+
* v4.0.3: Sortable columns (click header: asc → desc → reset)
|
|
4
4
|
*
|
|
5
5
|
* @experimental
|
|
6
6
|
*
|
|
7
7
|
* Features:
|
|
8
|
+
* - Sortable columns (type-aware: number, string, date)
|
|
8
9
|
* - Column types (number right-aligned, string left-aligned)
|
|
9
10
|
* - Pagination (configurable page size)
|
|
10
11
|
* - CSV / JSON export buttons
|
|
@@ -19,6 +20,8 @@ export interface DataPreviewSectionProps {
|
|
|
19
20
|
content: DataPreviewContent
|
|
20
21
|
}
|
|
21
22
|
|
|
23
|
+
type SortDir = 'asc' | 'desc' | null
|
|
24
|
+
|
|
22
25
|
/** Format a number for display (French locale) */
|
|
23
26
|
function formatNumber(value: unknown, format?: string): string {
|
|
24
27
|
if (typeof value !== 'number' || !isFinite(value)) return String(value ?? '')
|
|
@@ -42,6 +45,33 @@ function formatCell(value: unknown, col: DataPreviewColumn): string {
|
|
|
42
45
|
return String(value)
|
|
43
46
|
}
|
|
44
47
|
|
|
48
|
+
/** Compare two values for sorting, type-aware */
|
|
49
|
+
function compareValues(a: unknown, b: unknown, type?: string): number {
|
|
50
|
+
if (a == null && b == null) return 0
|
|
51
|
+
if (a == null) return 1
|
|
52
|
+
if (b == null) return -1
|
|
53
|
+
|
|
54
|
+
if (type === 'number') {
|
|
55
|
+
const na = typeof a === 'number' ? a : Number(a)
|
|
56
|
+
const nb = typeof b === 'number' ? b : Number(b)
|
|
57
|
+
if (isNaN(na) && isNaN(nb)) return 0
|
|
58
|
+
if (isNaN(na)) return 1
|
|
59
|
+
if (isNaN(nb)) return -1
|
|
60
|
+
return na - nb
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (type === 'date') {
|
|
64
|
+
const da = new Date(String(a)).getTime()
|
|
65
|
+
const db = new Date(String(b)).getTime()
|
|
66
|
+
if (isNaN(da) && isNaN(db)) return 0
|
|
67
|
+
if (isNaN(da)) return 1
|
|
68
|
+
if (isNaN(db)) return -1
|
|
69
|
+
return da - db
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return String(a).localeCompare(String(b), 'fr', { sensitivity: 'base' })
|
|
73
|
+
}
|
|
74
|
+
|
|
45
75
|
/** Generate CSV from columns + rows */
|
|
46
76
|
function toCSV(columns: DataPreviewColumn[], rows: Record<string, unknown>[]): string {
|
|
47
77
|
const header = columns.map(c => `"${c.label.replace(/"/g, '""')}"`).join(';')
|
|
@@ -75,12 +105,10 @@ function resolveContent(raw: unknown): DataPreviewContent | null {
|
|
|
75
105
|
if (!raw || typeof raw !== 'object') return null
|
|
76
106
|
const obj = raw as Record<string, unknown>
|
|
77
107
|
|
|
78
|
-
// Direct shape: { columns: [...], rows: [...] }
|
|
79
108
|
if (Array.isArray(obj.columns) && Array.isArray(obj.rows)) {
|
|
80
109
|
return obj as unknown as DataPreviewContent
|
|
81
110
|
}
|
|
82
111
|
|
|
83
|
-
// Wrapped shape: { content: { columns: [...], rows: [...] } }
|
|
84
112
|
if (obj.content && typeof obj.content === 'object') {
|
|
85
113
|
const inner = obj.content as Record<string, unknown>
|
|
86
114
|
if (Array.isArray(inner.columns) && Array.isArray(inner.rows)) {
|
|
@@ -104,27 +132,56 @@ export function DataPreviewSection(props: DataPreviewSectionProps) {
|
|
|
104
132
|
})
|
|
105
133
|
|
|
106
134
|
const columns = () => content()?.columns || []
|
|
107
|
-
const
|
|
135
|
+
const rawRows = () => content()?.rows || []
|
|
108
136
|
const pageSize = () => content()?.pageSize || 25
|
|
109
137
|
const [page, setPage] = createSignal(0)
|
|
138
|
+
const [sortKey, setSortKey] = createSignal<string | null>(null)
|
|
139
|
+
const [sortDir, setSortDir] = createSignal<SortDir>(null)
|
|
140
|
+
|
|
141
|
+
const handleSort = (key: string) => {
|
|
142
|
+
if (sortKey() === key) {
|
|
143
|
+
// Cycle: asc → desc → reset
|
|
144
|
+
if (sortDir() === 'asc') setSortDir('desc')
|
|
145
|
+
else { setSortKey(null); setSortDir(null) }
|
|
146
|
+
} else {
|
|
147
|
+
setSortKey(key)
|
|
148
|
+
setSortDir('asc')
|
|
149
|
+
}
|
|
150
|
+
setPage(0)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const sortedRows = createMemo(() => {
|
|
154
|
+
const r = rawRows()
|
|
155
|
+
const key = sortKey()
|
|
156
|
+
const dir = sortDir()
|
|
157
|
+
if (!key || !dir) return r
|
|
110
158
|
|
|
111
|
-
|
|
159
|
+
const col = columns().find(c => c.key === key)
|
|
160
|
+
const type = col?.type
|
|
161
|
+
|
|
162
|
+
return [...r].sort((a, b) => {
|
|
163
|
+
const cmp = compareValues(a[key], b[key], type)
|
|
164
|
+
return dir === 'desc' ? -cmp : cmp
|
|
165
|
+
})
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
const totalRows = () => sortedRows().length
|
|
112
169
|
const totalPages = () => Math.max(1, Math.ceil(totalRows() / pageSize()))
|
|
113
170
|
|
|
114
171
|
const pagedRows = createMemo(() => {
|
|
115
172
|
const start = page() * pageSize()
|
|
116
|
-
return
|
|
173
|
+
return sortedRows().slice(start, start + pageSize())
|
|
117
174
|
})
|
|
118
175
|
|
|
119
176
|
const handleExportCSV = () => {
|
|
120
177
|
const c = content()
|
|
121
178
|
if (!c) return
|
|
122
|
-
const csv = toCSV(c.columns,
|
|
179
|
+
const csv = toCSV(c.columns, sortedRows())
|
|
123
180
|
downloadFile(csv, 'data-export.csv', 'text/csv;charset=utf-8')
|
|
124
181
|
}
|
|
125
182
|
|
|
126
183
|
const handleExportJSON = () => {
|
|
127
|
-
const json = JSON.stringify(
|
|
184
|
+
const json = JSON.stringify(sortedRows(), null, 2)
|
|
128
185
|
downloadFile(json, 'data-export.json', 'application/json')
|
|
129
186
|
}
|
|
130
187
|
|
|
@@ -134,6 +191,11 @@ export function DataPreviewSection(props: DataPreviewSectionProps) {
|
|
|
134
191
|
return 'left'
|
|
135
192
|
}
|
|
136
193
|
|
|
194
|
+
const sortIndicator = (key: string) => {
|
|
195
|
+
if (sortKey() !== key) return '\u2195' // ↕ neutral
|
|
196
|
+
return sortDir() === 'asc' ? '\u2191' : '\u2193' // ↑ or ↓
|
|
197
|
+
}
|
|
198
|
+
|
|
137
199
|
return (
|
|
138
200
|
<Show when={content()} fallback={
|
|
139
201
|
<div class="text-xs text-amber-600 dark:text-amber-400 p-2">
|
|
@@ -160,14 +222,14 @@ export function DataPreviewSection(props: DataPreviewSectionProps) {
|
|
|
160
222
|
<button
|
|
161
223
|
class="px-2 py-1 text-xs rounded border border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
|
162
224
|
onClick={handleExportCSV}
|
|
163
|
-
title="Export CSV"
|
|
225
|
+
title="Export CSV (sorted)"
|
|
164
226
|
>
|
|
165
227
|
CSV
|
|
166
228
|
</button>
|
|
167
229
|
<button
|
|
168
230
|
class="px-2 py-1 text-xs rounded border border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
|
169
231
|
onClick={handleExportJSON}
|
|
170
|
-
title="Export JSON"
|
|
232
|
+
title="Export JSON (sorted)"
|
|
171
233
|
>
|
|
172
234
|
JSON
|
|
173
235
|
</button>
|
|
@@ -183,10 +245,23 @@ export function DataPreviewSection(props: DataPreviewSectionProps) {
|
|
|
183
245
|
<For each={columns()}>
|
|
184
246
|
{(col) => (
|
|
185
247
|
<th
|
|
186
|
-
class="px-3 py-2 font-medium text-xs text-gray-600 dark:text-gray-400 uppercase tracking-wider border-b border-gray-200 dark:border-gray-700"
|
|
248
|
+
class="px-3 py-2 font-medium text-xs text-gray-600 dark:text-gray-400 uppercase tracking-wider border-b border-gray-200 dark:border-gray-700 cursor-pointer select-none hover:bg-gray-100 dark:hover:bg-gray-700/50 transition-colors"
|
|
187
249
|
style={{ "text-align": columnAlign(col) }}
|
|
250
|
+
onClick={() => handleSort(col.key)}
|
|
251
|
+
title={`Sort by ${col.label}`}
|
|
188
252
|
>
|
|
189
|
-
|
|
253
|
+
<span class="inline-flex items-center gap-1">
|
|
254
|
+
{col.label}
|
|
255
|
+
<span
|
|
256
|
+
class="text-[10px] leading-none"
|
|
257
|
+
classList={{
|
|
258
|
+
'opacity-30': sortKey() !== col.key,
|
|
259
|
+
'opacity-100 text-blue-600 dark:text-blue-400': sortKey() === col.key,
|
|
260
|
+
}}
|
|
261
|
+
>
|
|
262
|
+
{sortIndicator(col.key)}
|
|
263
|
+
</span>
|
|
264
|
+
</span>
|
|
190
265
|
</th>
|
|
191
266
|
)}
|
|
192
267
|
</For>
|
|
@@ -342,6 +342,16 @@ export const ScratchpadPanel: Component<ScratchpadPanelProps> = (props) => {
|
|
|
342
342
|
)
|
|
343
343
|
}
|
|
344
344
|
|
|
345
|
+
// ─── Helpers ────────────────────────────────────────────────
|
|
346
|
+
|
|
347
|
+
/** Parse content that may arrive as a JSON string from SSE transport */
|
|
348
|
+
function parseContent(content: unknown): unknown {
|
|
349
|
+
if (typeof content === 'string') {
|
|
350
|
+
try { return JSON.parse(content) } catch { return content }
|
|
351
|
+
}
|
|
352
|
+
return content
|
|
353
|
+
}
|
|
354
|
+
|
|
345
355
|
// ─── Section Renderer ────────────────────────────────────────
|
|
346
356
|
|
|
347
357
|
const SectionRenderer: Component<{
|
|
@@ -356,23 +366,23 @@ const SectionRenderer: Component<{
|
|
|
356
366
|
<div class="px-4 py-3">
|
|
357
367
|
<h4 class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-2">{props.section.title}</h4>
|
|
358
368
|
<Switch>
|
|
359
|
-
<Match when={props.section.type === 'data'}><DataSection content={props.section.content} /></Match>
|
|
360
|
-
<Match when={props.section.type === 'filter'}><InteractiveFilterSection content={props.section.content} filters={props.filters} onFilterChange={props.onFilterChange} /></Match>
|
|
369
|
+
<Match when={props.section.type === 'data'}><DataSection content={parseContent(props.section.content)} /></Match>
|
|
370
|
+
<Match when={props.section.type === 'filter'}><InteractiveFilterSection content={parseContent(props.section.content)} filters={props.filters} onFilterChange={props.onFilterChange} /></Match>
|
|
361
371
|
<Match when={props.section.type === 'message'}><p class="text-sm text-gray-700 dark:text-gray-300">{String(props.section.content)}</p></Match>
|
|
362
|
-
<Match when={props.section.type === 'action'}><ActionSection content={props.section.content} onAction={props.onAction} /></Match>
|
|
363
|
-
<Match when={props.section.type === 'steps'}><EnrichedStepsSection content={props.section.content} onAction={props.onAction} onFilterChange={props.onFilterChange} /></Match>
|
|
364
|
-
<Match when={props.section.type === 'form'}><EmbeddedFormSection content={props.section.content} sectionId={props.section.id} onAction={props.onAction} onSubmit={props.onSubmit} /></Match>
|
|
365
|
-
<Match when={props.section.type === 'understanding'}><UnderstandingSection content={props.section.content} /></Match>
|
|
366
|
-
<Match when={props.section.type === 'feedback'}><FeedbackSection content={props.section.content} onAction={props.onAction} /></Match>
|
|
367
|
-
<Match when={props.section.type === 'prompt'}><PromptSection content={props.section.content} onAction={props.onAction} /></Match>
|
|
368
|
-
<Match when={props.section.type === 'stepper'}><StepperProgressSection content={props.section.content} /></Match>
|
|
369
|
-
<Match when={props.section.type === 'error'}><ErrorSectionRenderer content={props.section.content} onAction={props.onAction} /></Match>
|
|
370
|
-
<Match when={props.section.type === 'source_card'}><SourceCardSection content={props.section.content} /></Match>
|
|
371
|
-
<Match when={props.section.type === 'diff'}><DiffSection content={props.section.content} /></Match>
|
|
372
|
-
<Match when={props.section.type === 'verified_text'}><VerifiedText {...(props.section.content as VerifiedTextContent)} onHallucinationClick={(h) => props.onAction?.('hallucination_click', h)} /></Match>
|
|
373
|
-
<Match when={props.section.type === 'data_preview'}><DataPreviewSection content={props.section.content as DataPreviewContent} /></Match>
|
|
374
|
-
<Match when={props.section.type === 'map'}>{(() => { const c = props.section.content as MapSectionContent; return <MapRenderer params={{ geojson: c.geojson, center: c.center, zoom: c.zoom, geojsonStyle: c.style, popup: c.popup, layers: c.layers, height: c.height || '300px', fitBounds: true }} /> })()}</Match>
|
|
375
|
-
<Match when={props.section.type === 'chart'}
|
|
372
|
+
<Match when={props.section.type === 'action'}><ActionSection content={parseContent(props.section.content)} onAction={props.onAction} /></Match>
|
|
373
|
+
<Match when={props.section.type === 'steps'}><EnrichedStepsSection content={parseContent(props.section.content)} onAction={props.onAction} onFilterChange={props.onFilterChange} /></Match>
|
|
374
|
+
<Match when={props.section.type === 'form'}><EmbeddedFormSection content={parseContent(props.section.content)} sectionId={props.section.id} onAction={props.onAction} onSubmit={props.onSubmit} /></Match>
|
|
375
|
+
<Match when={props.section.type === 'understanding'}><UnderstandingSection content={parseContent(props.section.content)} /></Match>
|
|
376
|
+
<Match when={props.section.type === 'feedback'}><FeedbackSection content={parseContent(props.section.content)} onAction={props.onAction} /></Match>
|
|
377
|
+
<Match when={props.section.type === 'prompt'}><PromptSection content={parseContent(props.section.content)} onAction={props.onAction} /></Match>
|
|
378
|
+
<Match when={props.section.type === 'stepper'}><StepperProgressSection content={parseContent(props.section.content)} /></Match>
|
|
379
|
+
<Match when={props.section.type === 'error'}><ErrorSectionRenderer content={parseContent(props.section.content)} onAction={props.onAction} /></Match>
|
|
380
|
+
<Match when={props.section.type === 'source_card'}><SourceCardSection content={parseContent(props.section.content)} /></Match>
|
|
381
|
+
<Match when={props.section.type === 'diff'}><DiffSection content={parseContent(props.section.content)} /></Match>
|
|
382
|
+
<Match when={props.section.type === 'verified_text'}><VerifiedText {...(parseContent(props.section.content) as VerifiedTextContent)} onHallucinationClick={(h) => props.onAction?.('hallucination_click', h)} /></Match>
|
|
383
|
+
<Match when={props.section.type === 'data_preview'}><DataPreviewSection content={parseContent(props.section.content) as DataPreviewContent} /></Match>
|
|
384
|
+
<Match when={props.section.type === 'map'}>{(() => { const c = parseContent(props.section.content) as MapSectionContent; return <MapRenderer params={{ geojson: c.geojson, center: c.center, zoom: c.zoom, geojsonStyle: c.style, popup: c.popup, layers: c.layers, height: c.height || '300px', fitBounds: true }} /> })()}</Match>
|
|
385
|
+
<Match when={props.section.type === 'chart'}>{(() => { const c = parseContent(props.section.content) as ChartComponentParams; return <ChartJSRenderer component={{ id: props.section.id, type: 'chart', position: { colStart: 1, colSpan: 12 }, params: { ...c, renderer: 'native', height: (c as any)?.height || '250px' } }} /> })()}</Match>
|
|
376
386
|
<Match when={true}><pre class="text-xs text-gray-500 overflow-auto">{JSON.stringify(props.section.content, null, 2)}</pre></Match>
|
|
377
387
|
</Switch>
|
|
378
388
|
</div>
|