@seed-ship/mcp-ui-solid 4.0.0 → 4.0.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seed-ship/mcp-ui-solid",
3
- "version": "4.0.0",
3
+ "version": "4.0.1",
4
4
  "description": "SolidJS components for rendering MCP-generated UI resources",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * DataPreviewSection — paginated data table with export
3
- * v3.1.0: Replaces LLM-generated markdown tables with exact source data
3
+ * v4.0.1: Fixed rendering defensive guards for store proxy content
4
4
  *
5
5
  * @experimental
6
6
  *
@@ -22,7 +22,6 @@ export interface DataPreviewSectionProps {
22
22
  /** Format a number for display (French locale) */
23
23
  function formatNumber(value: unknown, format?: string): string {
24
24
  if (typeof value !== 'number' || !isFinite(value)) return String(value ?? '')
25
- // Simple formatting: use locale
26
25
  if (format === 'percent') return `${(value * 100).toFixed(1)}%`
27
26
  if (format === 'currency') return `${value.toLocaleString('fr-FR')} EUR`
28
27
  if (Number.isInteger(value)) return value.toLocaleString('fr-FR')
@@ -31,7 +30,7 @@ function formatNumber(value: unknown, format?: string): string {
31
30
 
32
31
  /** Format a cell value based on column type */
33
32
  function formatCell(value: unknown, col: DataPreviewColumn): string {
34
- if (value == null) return ''
33
+ if (value == null) return '\u2014'
35
34
  if (col.type === 'number') return formatNumber(value, col.format)
36
35
  if (col.type === 'date' && typeof value === 'string') {
37
36
  try {
@@ -68,26 +67,64 @@ function downloadFile(content: string, filename: string, mimeType: string) {
68
67
  URL.revokeObjectURL(url)
69
68
  }
70
69
 
70
+ /**
71
+ * Extract a valid DataPreviewContent from props.content.
72
+ * Handles: direct DataPreviewContent, or wrapped in an extra layer.
73
+ */
74
+ function resolveContent(raw: unknown): DataPreviewContent | null {
75
+ if (!raw || typeof raw !== 'object') return null
76
+ const obj = raw as Record<string, unknown>
77
+
78
+ // Direct shape: { columns: [...], rows: [...] }
79
+ if (Array.isArray(obj.columns) && Array.isArray(obj.rows)) {
80
+ return obj as unknown as DataPreviewContent
81
+ }
82
+
83
+ // Wrapped shape: { content: { columns: [...], rows: [...] } }
84
+ if (obj.content && typeof obj.content === 'object') {
85
+ const inner = obj.content as Record<string, unknown>
86
+ if (Array.isArray(inner.columns) && Array.isArray(inner.rows)) {
87
+ return inner as unknown as DataPreviewContent
88
+ }
89
+ }
90
+
91
+ return null
92
+ }
93
+
71
94
  export function DataPreviewSection(props: DataPreviewSectionProps) {
72
- const content = () => props.content
73
- const pageSize = () => content().pageSize || 25
95
+ const content = createMemo(() => {
96
+ const resolved = resolveContent(props.content)
97
+ if (!resolved) {
98
+ console.warn(
99
+ '[MCP-UI] DataPreviewSection: invalid content — expected { columns: [...], rows: [...] }, got:',
100
+ props.content
101
+ )
102
+ }
103
+ return resolved
104
+ })
105
+
106
+ const columns = () => content()?.columns || []
107
+ const rows = () => content()?.rows || []
108
+ const pageSize = () => content()?.pageSize || 25
74
109
  const [page, setPage] = createSignal(0)
75
110
 
76
- const totalRows = () => content().rows.length
111
+ const totalRows = () => rows().length
77
112
  const totalPages = () => Math.max(1, Math.ceil(totalRows() / pageSize()))
78
113
 
79
114
  const pagedRows = createMemo(() => {
80
115
  const start = page() * pageSize()
81
- return content().rows.slice(start, start + pageSize())
116
+ return rows().slice(start, start + pageSize())
82
117
  })
83
118
 
84
119
  const handleExportCSV = () => {
85
- const csv = toCSV(content().columns, content().rows)
120
+ const c = content()
121
+ if (!c) return
122
+ const csv = toCSV(c.columns, c.rows)
86
123
  downloadFile(csv, 'data-export.csv', 'text/csv;charset=utf-8')
87
124
  }
88
125
 
89
126
  const handleExportJSON = () => {
90
- const json = JSON.stringify(content().rows, null, 2)
127
+ const json = JSON.stringify(rows(), null, 2)
91
128
  downloadFile(json, 'data-export.json', 'application/json')
92
129
  }
93
130
 
@@ -98,109 +135,117 @@ export function DataPreviewSection(props: DataPreviewSectionProps) {
98
135
  }
99
136
 
100
137
  return (
101
- <div class="data-preview-section">
102
- {/* Header with source + export */}
103
- <div class="flex items-center justify-between mb-2">
104
- <div class="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
105
- <Show when={content().source}>
106
- <span class="font-medium">{content().source}</span>
107
- </Show>
108
- <Show when={content().freshness}>
109
- <span class="px-1.5 py-0.5 rounded bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300">
110
- {content().freshness}
111
- </span>
112
- </Show>
113
- </div>
114
-
115
- <Show when={content().exportable !== false}>
116
- <div class="flex items-center gap-1">
117
- <button
118
- 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"
119
- onClick={handleExportCSV}
120
- title="Export CSV"
121
- >
122
- CSV
123
- </button>
124
- <button
125
- 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"
126
- onClick={handleExportJSON}
127
- title="Export JSON"
128
- >
129
- JSON
130
- </button>
131
- </div>
132
- </Show>
138
+ <Show when={content()} fallback={
139
+ <div class="text-xs text-amber-600 dark:text-amber-400 p-2">
140
+ [DataPreviewSection] Invalid content format
133
141
  </div>
134
-
135
- {/* Table */}
136
- <div class="overflow-x-auto rounded border border-gray-200 dark:border-gray-700">
137
- <table class="w-full text-sm">
138
- <thead>
139
- <tr class="bg-gray-50 dark:bg-gray-800">
140
- <For each={content().columns}>
141
- {(col) => (
142
- <th
143
- 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"
144
- style={{ "text-align": columnAlign(col) }}
145
- >
146
- {col.label}
147
- </th>
148
- )}
149
- </For>
150
- </tr>
151
- </thead>
152
- <tbody>
153
- <For each={pagedRows()}>
154
- {(row, i) => (
155
- <tr
156
- class="border-b border-gray-100 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors"
157
- classList={{ 'bg-gray-25 dark:bg-gray-850': i() % 2 === 1 }}
142
+ }>
143
+ {(c) => (
144
+ <div class="data-preview-section">
145
+ {/* Header with source + export */}
146
+ <div class="flex items-center justify-between mb-2">
147
+ <div class="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
148
+ <Show when={c().source}>
149
+ <span class="font-medium">{c().source}</span>
150
+ </Show>
151
+ <Show when={c().freshness}>
152
+ <span class="px-1.5 py-0.5 rounded bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300">
153
+ {c().freshness}
154
+ </span>
155
+ </Show>
156
+ </div>
157
+
158
+ <Show when={c().exportable !== false}>
159
+ <div class="flex items-center gap-1">
160
+ <button
161
+ 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
+ onClick={handleExportCSV}
163
+ title="Export CSV"
164
+ >
165
+ CSV
166
+ </button>
167
+ <button
168
+ 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
+ onClick={handleExportJSON}
170
+ title="Export JSON"
158
171
  >
159
- <For each={content().columns}>
172
+ JSON
173
+ </button>
174
+ </div>
175
+ </Show>
176
+ </div>
177
+
178
+ {/* Table */}
179
+ <div class="overflow-x-auto rounded border border-gray-200 dark:border-gray-700">
180
+ <table class="w-full text-sm">
181
+ <thead>
182
+ <tr class="bg-gray-50 dark:bg-gray-800">
183
+ <For each={columns()}>
160
184
  {(col) => (
161
- <td
162
- class="px-3 py-2 text-gray-800 dark:text-gray-200"
185
+ <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"
163
187
  style={{ "text-align": columnAlign(col) }}
164
188
  >
165
- {formatCell(row[col.key], col)}
166
- </td>
189
+ {col.label}
190
+ </th>
167
191
  )}
168
192
  </For>
169
193
  </tr>
170
- )}
171
- </For>
172
- </tbody>
173
- </table>
174
- </div>
194
+ </thead>
195
+ <tbody>
196
+ <For each={pagedRows()}>
197
+ {(row, i) => (
198
+ <tr
199
+ class="border-b border-gray-100 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors"
200
+ classList={{ 'bg-gray-25 dark:bg-gray-850': i() % 2 === 1 }}
201
+ >
202
+ <For each={columns()}>
203
+ {(col) => (
204
+ <td
205
+ class="px-3 py-2 text-gray-800 dark:text-gray-200"
206
+ style={{ "text-align": columnAlign(col) }}
207
+ >
208
+ {formatCell(row[col.key], col)}
209
+ </td>
210
+ )}
211
+ </For>
212
+ </tr>
213
+ )}
214
+ </For>
215
+ </tbody>
216
+ </table>
217
+ </div>
218
+
219
+ {/* Footer: pagination + row count */}
220
+ <div class="flex items-center justify-between mt-2 text-xs text-gray-500 dark:text-gray-400">
221
+ <span>
222
+ {c().totalRows
223
+ ? `${totalRows()} / ${c().totalRows!.toLocaleString('fr-FR')} rows`
224
+ : `${totalRows()} row${totalRows() !== 1 ? 's' : ''}`}
225
+ </span>
175
226
 
176
- {/* Footer: pagination + row count */}
177
- <div class="flex items-center justify-between mt-2 text-xs text-gray-500 dark:text-gray-400">
178
- <span>
179
- {content().totalRows
180
- ? `${totalRows()} / ${content().totalRows!.toLocaleString('fr-FR')} rows`
181
- : `${totalRows()} row${totalRows() !== 1 ? 's' : ''}`}
182
- </span>
183
-
184
- <Show when={totalPages() > 1}>
185
- <div class="flex items-center gap-1">
186
- <button
187
- class="px-2 py-1 rounded hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-40 transition-colors"
188
- disabled={page() === 0}
189
- onClick={() => setPage(p => p - 1)}
190
- >
191
- &laquo;
192
- </button>
193
- <span>{page() + 1} / {totalPages()}</span>
194
- <button
195
- class="px-2 py-1 rounded hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-40 transition-colors"
196
- disabled={page() >= totalPages() - 1}
197
- onClick={() => setPage(p => p + 1)}
198
- >
199
- &raquo;
200
- </button>
227
+ <Show when={totalPages() > 1}>
228
+ <div class="flex items-center gap-1">
229
+ <button
230
+ class="px-2 py-1 rounded hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-40 transition-colors"
231
+ disabled={page() === 0}
232
+ onClick={() => setPage(p => p - 1)}
233
+ >
234
+ &laquo;
235
+ </button>
236
+ <span>{page() + 1} / {totalPages()}</span>
237
+ <button
238
+ class="px-2 py-1 rounded hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-40 transition-colors"
239
+ disabled={page() >= totalPages() - 1}
240
+ onClick={() => setPage(p => p + 1)}
241
+ >
242
+ &raquo;
243
+ </button>
244
+ </div>
245
+ </Show>
201
246
  </div>
202
- </Show>
203
- </div>
204
- </div>
247
+ </div>
248
+ )}
249
+ </Show>
205
250
  )
206
251
  }
@@ -644,7 +644,15 @@ const ActionSection: Component<{
644
644
  content: unknown
645
645
  onAction?: (action: string, data?: unknown) => void
646
646
  }> = (props) => {
647
- const actions = () => Array.isArray(props.content) ? props.content as Array<{ label: string; value?: string; action?: string; variant?: string; icon?: string }> : []
647
+ const actions = () => {
648
+ if (Array.isArray(props.content)) return props.content as Array<{ label: string; value?: string; action?: string; variant?: string; icon?: string }>
649
+ const obj = props.content as Record<string, unknown> | null
650
+ if (obj && Array.isArray(obj.actions)) {
651
+ console.warn('[MCP-UI] ActionSection: content should be an array, got { actions: [...] }. Unwrapping automatically.')
652
+ return obj.actions as Array<{ label: string; value?: string; action?: string; variant?: string; icon?: string }>
653
+ }
654
+ return []
655
+ }
648
656
  return (
649
657
  <div class="flex flex-wrap gap-2">
650
658
  <For each={actions()}>