@seed-ship/mcp-ui-solid 4.0.2 → 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/package.json +1 -1
- package/src/components/DataPreviewSection.tsx +88 -13
- 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>
|