@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.
@@ -1,10 +1,11 @@
1
1
  /**
2
- * DataPreviewSection — paginated data table with export
3
- * v4.0.1: Fixed rendering defensive guards for store proxy content
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 rows = () => content()?.rows || []
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
- const totalRows = () => rows().length
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 rows().slice(start, start + pageSize())
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, c.rows)
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(rows(), null, 2)
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
- {col.label}
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'}><ChartJSRenderer component={{ id: props.section.id, type: 'chart', position: { colStart: 1, colSpan: 12 }, params: { ...(props.section.content as ChartComponentParams), renderer: 'native', height: (props.section.content as any)?.height || '250px' } }} /></Match>
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>