@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.
@@ -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>