@seed-ship/mcp-ui-solid 6.5.0 → 6.6.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/CHANGELOG.md +161 -0
- package/README.md +37 -0
- package/dist/adapters/connector.cjs +112 -0
- package/dist/adapters/connector.cjs.map +1 -0
- package/dist/adapters/connector.d.ts +71 -0
- package/dist/adapters/connector.d.ts.map +1 -0
- package/dist/adapters/connector.js +112 -0
- package/dist/adapters/connector.js.map +1 -0
- package/dist/adapters/index.d.ts +18 -0
- package/dist/adapters/index.d.ts.map +1 -0
- package/dist/adapters.cjs +6 -0
- package/dist/adapters.cjs.map +1 -0
- package/dist/adapters.d.cts +18 -0
- package/dist/adapters.d.ts +18 -0
- package/dist/adapters.js +6 -0
- package/dist/adapters.js.map +1 -0
- package/dist/components/ActionGroupRenderer.cjs +12 -3
- package/dist/components/ActionGroupRenderer.cjs.map +1 -1
- package/dist/components/ActionGroupRenderer.d.ts.map +1 -1
- package/dist/components/ActionGroupRenderer.js +12 -3
- package/dist/components/ActionGroupRenderer.js.map +1 -1
- package/dist/components/ExpandableWrapper.cjs +24 -6
- package/dist/components/ExpandableWrapper.cjs.map +1 -1
- package/dist/components/ExpandableWrapper.d.ts.map +1 -1
- package/dist/components/ExpandableWrapper.js +24 -6
- package/dist/components/ExpandableWrapper.js.map +1 -1
- package/dist/components/FeedbackInline.cjs +6 -2
- package/dist/components/FeedbackInline.cjs.map +1 -1
- package/dist/components/FeedbackInline.d.ts +2 -2
- package/dist/components/FeedbackInline.d.ts.map +1 -1
- package/dist/components/FeedbackInline.js +7 -3
- package/dist/components/FeedbackInline.js.map +1 -1
- package/dist/components/PresentationFeedback.cjs +207 -0
- package/dist/components/PresentationFeedback.cjs.map +1 -0
- package/dist/components/PresentationFeedback.d.ts +113 -0
- package/dist/components/PresentationFeedback.d.ts.map +1 -0
- package/dist/components/PresentationFeedback.js +207 -0
- package/dist/components/PresentationFeedback.js.map +1 -0
- package/dist/components/StreamingUIRenderer.cjs +82 -195
- package/dist/components/StreamingUIRenderer.cjs.map +1 -1
- package/dist/components/StreamingUIRenderer.d.ts +25 -5
- package/dist/components/StreamingUIRenderer.d.ts.map +1 -1
- package/dist/components/StreamingUIRenderer.js +84 -197
- package/dist/components/StreamingUIRenderer.js.map +1 -1
- package/dist/components/UIResourceRenderer.cjs +22 -15
- package/dist/components/UIResourceRenderer.cjs.map +1 -1
- package/dist/components/UIResourceRenderer.d.ts.map +1 -1
- package/dist/components/UIResourceRenderer.js +22 -15
- package/dist/components/UIResourceRenderer.js.map +1 -1
- package/dist/components/index.d.ts +2 -0
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components.cjs +3 -0
- package/dist/components.cjs.map +1 -1
- package/dist/components.d.cts +2 -0
- package/dist/components.d.ts +2 -0
- package/dist/components.js +3 -0
- package/dist/components.js.map +1 -1
- package/dist/context/MCPActionContext.cjs +4 -1
- package/dist/context/MCPActionContext.cjs.map +1 -1
- package/dist/context/MCPActionContext.d.ts +13 -1
- package/dist/context/MCPActionContext.d.ts.map +1 -1
- package/dist/context/MCPActionContext.js +4 -1
- package/dist/context/MCPActionContext.js.map +1 -1
- package/dist/context/MCPUIStringsContext.cjs +38 -0
- package/dist/context/MCPUIStringsContext.cjs.map +1 -0
- package/dist/context/MCPUIStringsContext.d.ts +95 -0
- package/dist/context/MCPUIStringsContext.d.ts.map +1 -0
- package/dist/context/MCPUIStringsContext.js +38 -0
- package/dist/context/MCPUIStringsContext.js.map +1 -0
- package/dist/index.cjs +8 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +5 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -1
- package/dist/mcp-ui-spec/dist/schemas.cjs +103 -0
- package/dist/mcp-ui-spec/dist/schemas.cjs.map +1 -1
- package/dist/mcp-ui-spec/dist/schemas.js +103 -0
- package/dist/mcp-ui-spec/dist/schemas.js.map +1 -1
- package/docs/briefs/ROADMAP-opendata-macro-mcpui.md +912 -0
- package/package.json +17 -5
- package/src/adapters/connector.test.ts +165 -0
- package/src/adapters/connector.ts +234 -0
- package/src/adapters/index.ts +24 -0
- package/src/components/ActionGroupRenderer.test.tsx +1 -0
- package/src/components/ActionGroupRenderer.tsx +19 -4
- package/src/components/ActionSubmit.test.tsx +188 -0
- package/src/components/ExpandableWrapper.test.tsx +5 -2
- package/src/components/ExpandableWrapper.tsx +8 -6
- package/src/components/FeedbackInline.test.tsx +6 -3
- package/src/components/FeedbackInline.tsx +8 -6
- package/src/components/PresentationFeedback.test.tsx +163 -0
- package/src/components/PresentationFeedback.tsx +326 -0
- package/src/components/StreamingUIRenderer.parity.test.tsx +158 -0
- package/src/components/StreamingUIRenderer.tsx +42 -166
- package/src/components/UIResourceRenderer.tsx +19 -6
- package/src/components/index.ts +10 -0
- package/src/context/MCPActionContext.tsx +17 -1
- package/src/context/MCPUIStringsContext.test.tsx +116 -0
- package/src/context/MCPUIStringsContext.tsx +128 -0
- package/src/index.ts +27 -0
- package/tsconfig.tsbuildinfo +1 -1
- package/vite.config.ts +1 -0
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v6.6.1 — `action: 'submit'` reaches the host executor outside a <form>.
|
|
3
|
+
*
|
|
4
|
+
* Before v6.6.1, `submit` actions were inert : `ActionGroupRenderer` only
|
|
5
|
+
* branched on `tool-call` / `link`, and the standalone `action` renderer
|
|
6
|
+
* emitted a native `type="submit"` button that did nothing outside a real
|
|
7
|
+
* `<form>`. This file pins the fix — a full integration test through the
|
|
8
|
+
* real `useAction` → `MCPActionContext` → host `executor` path (no mocks).
|
|
9
|
+
*
|
|
10
|
+
* Covers both render surfaces :
|
|
11
|
+
* - `<ActionGroupRenderer>` (action-group)
|
|
12
|
+
* - `<UIResourceRenderer content={{ type: 'action', ... }}>` (standalone)
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
16
|
+
import { render, screen, fireEvent, waitFor, cleanup } from '@solidjs/testing-library'
|
|
17
|
+
import { ActionGroupRenderer } from './ActionGroupRenderer'
|
|
18
|
+
import { UIResourceRenderer } from './UIResourceRenderer'
|
|
19
|
+
import { MCPActionProvider } from '../context/MCPActionContext'
|
|
20
|
+
import type { ActionRequest, ActionResult } from '../context/MCPActionContext'
|
|
21
|
+
import type { UIComponent } from '../types'
|
|
22
|
+
|
|
23
|
+
// The connector "feedback format" payload from the bug report.
|
|
24
|
+
const SUBMIT_PARAMS = {
|
|
25
|
+
submit_url: '/api/connector-render-feedback',
|
|
26
|
+
feedback_kind: 'presentation',
|
|
27
|
+
connector_id: 'clinicaltrials',
|
|
28
|
+
tool_name: 'clinicaltrials_search',
|
|
29
|
+
render_kind: 'clinical_trial_search',
|
|
30
|
+
preferred_layout_options: ['table', 'cards', 'bar'],
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function makeExecutor() {
|
|
34
|
+
const calls: ActionRequest[] = []
|
|
35
|
+
const executor = vi.fn(async (req: ActionRequest): Promise<ActionResult> => {
|
|
36
|
+
calls.push(req)
|
|
37
|
+
return { success: true, timestamp: new Date().toISOString(), toolName: req.toolName }
|
|
38
|
+
})
|
|
39
|
+
return { executor, calls }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe('action: submit reaches the host executor (v6.6.1)', () => {
|
|
43
|
+
beforeEach(() => cleanup())
|
|
44
|
+
|
|
45
|
+
it('ActionGroupRenderer routes a submit action to the executor', async () => {
|
|
46
|
+
const { executor, calls } = makeExecutor()
|
|
47
|
+
const component: UIComponent = {
|
|
48
|
+
id: 'ag',
|
|
49
|
+
type: 'action-group',
|
|
50
|
+
position: { colStart: 1, colSpan: 12 },
|
|
51
|
+
params: {
|
|
52
|
+
actions: [
|
|
53
|
+
{ label: 'Feedback format', action: 'submit', params: SUBMIT_PARAMS },
|
|
54
|
+
],
|
|
55
|
+
},
|
|
56
|
+
} as UIComponent
|
|
57
|
+
|
|
58
|
+
render(() => (
|
|
59
|
+
<MCPActionProvider executor={executor}>
|
|
60
|
+
<ActionGroupRenderer component={component} />
|
|
61
|
+
</MCPActionProvider>
|
|
62
|
+
))
|
|
63
|
+
|
|
64
|
+
fireEvent.click(screen.getByRole('button', { name: 'Feedback format' }))
|
|
65
|
+
|
|
66
|
+
await waitFor(() => expect(executor).toHaveBeenCalledTimes(1))
|
|
67
|
+
const req = calls[0]
|
|
68
|
+
// The action KIND is preserved — host can tell it apart from a tool call.
|
|
69
|
+
expect(req.action).toBe('submit')
|
|
70
|
+
// The full params payload survives intact.
|
|
71
|
+
expect(req.params).toEqual(SUBMIT_PARAMS)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('a submit action is NOT executed as a tool call', async () => {
|
|
75
|
+
const { executor, calls } = makeExecutor()
|
|
76
|
+
const component: UIComponent = {
|
|
77
|
+
id: 'ag',
|
|
78
|
+
type: 'action-group',
|
|
79
|
+
position: { colStart: 1, colSpan: 12 },
|
|
80
|
+
params: {
|
|
81
|
+
actions: [{ label: 'Send', action: 'submit', params: SUBMIT_PARAMS }],
|
|
82
|
+
},
|
|
83
|
+
} as UIComponent
|
|
84
|
+
|
|
85
|
+
render(() => (
|
|
86
|
+
<MCPActionProvider executor={executor}>
|
|
87
|
+
<ActionGroupRenderer component={component} />
|
|
88
|
+
</MCPActionProvider>
|
|
89
|
+
))
|
|
90
|
+
fireEvent.click(screen.getByRole('button', { name: 'Send' }))
|
|
91
|
+
|
|
92
|
+
await waitFor(() => expect(executor).toHaveBeenCalledTimes(1))
|
|
93
|
+
// action is 'submit', never silently coerced to 'tool-call'
|
|
94
|
+
expect(calls[0].action).not.toBe('tool-call')
|
|
95
|
+
expect(calls[0].action).toBe('submit')
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('standalone action component (UIResourceRenderer) routes submit to the executor', async () => {
|
|
99
|
+
const { executor, calls } = makeExecutor()
|
|
100
|
+
const component: UIComponent = {
|
|
101
|
+
id: 'act',
|
|
102
|
+
type: 'action',
|
|
103
|
+
position: { colStart: 1, colSpan: 12 },
|
|
104
|
+
params: { label: 'Feedback format', action: 'submit', params: SUBMIT_PARAMS },
|
|
105
|
+
} as UIComponent
|
|
106
|
+
|
|
107
|
+
render(() => (
|
|
108
|
+
<MCPActionProvider executor={executor}>
|
|
109
|
+
<UIResourceRenderer content={component} />
|
|
110
|
+
</MCPActionProvider>
|
|
111
|
+
))
|
|
112
|
+
|
|
113
|
+
fireEvent.click(screen.getByRole('button', { name: 'Feedback format' }))
|
|
114
|
+
|
|
115
|
+
await waitFor(() => expect(executor).toHaveBeenCalledTimes(1))
|
|
116
|
+
expect(calls[0].action).toBe('submit')
|
|
117
|
+
expect(calls[0].params).toEqual(SUBMIT_PARAMS)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('standalone submit button is type="button" — not a native form submit', () => {
|
|
121
|
+
const component: UIComponent = {
|
|
122
|
+
id: 'act',
|
|
123
|
+
type: 'action',
|
|
124
|
+
position: { colStart: 1, colSpan: 12 },
|
|
125
|
+
params: { label: 'Send', action: 'submit', params: {} },
|
|
126
|
+
} as UIComponent
|
|
127
|
+
|
|
128
|
+
const { container } = render(() => (
|
|
129
|
+
<MCPActionProvider executor={makeExecutor().executor}>
|
|
130
|
+
<UIResourceRenderer content={component} />
|
|
131
|
+
</MCPActionProvider>
|
|
132
|
+
))
|
|
133
|
+
const btn = container.querySelector('button')
|
|
134
|
+
// Must NOT rely on a surrounding <form> — JS-handled, type=button.
|
|
135
|
+
expect(btn?.getAttribute('type')).toBe('button')
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('tool-call actions still work unchanged', async () => {
|
|
139
|
+
const { executor, calls } = makeExecutor()
|
|
140
|
+
const component: UIComponent = {
|
|
141
|
+
id: 'ag',
|
|
142
|
+
type: 'action-group',
|
|
143
|
+
position: { colStart: 1, colSpan: 12 },
|
|
144
|
+
params: {
|
|
145
|
+
actions: [
|
|
146
|
+
{ label: 'Run', action: 'tool-call', toolName: 'do_thing', params: { a: 1 } },
|
|
147
|
+
],
|
|
148
|
+
},
|
|
149
|
+
} as UIComponent
|
|
150
|
+
|
|
151
|
+
render(() => (
|
|
152
|
+
<MCPActionProvider executor={executor}>
|
|
153
|
+
<ActionGroupRenderer component={component} />
|
|
154
|
+
</MCPActionProvider>
|
|
155
|
+
))
|
|
156
|
+
fireEvent.click(screen.getByRole('button', { name: 'Run' }))
|
|
157
|
+
|
|
158
|
+
await waitFor(() => expect(executor).toHaveBeenCalledTimes(1))
|
|
159
|
+
expect(calls[0].toolName).toBe('do_thing')
|
|
160
|
+
expect(calls[0].params).toEqual({ a: 1 })
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('a disabled submit action does not call the executor', async () => {
|
|
164
|
+
const { executor } = makeExecutor()
|
|
165
|
+
const component: UIComponent = {
|
|
166
|
+
id: 'ag',
|
|
167
|
+
type: 'action-group',
|
|
168
|
+
position: { colStart: 1, colSpan: 12 },
|
|
169
|
+
params: {
|
|
170
|
+
actions: [
|
|
171
|
+
{ label: 'Send', action: 'submit', params: SUBMIT_PARAMS, disabled: true },
|
|
172
|
+
],
|
|
173
|
+
},
|
|
174
|
+
} as UIComponent
|
|
175
|
+
|
|
176
|
+
render(() => (
|
|
177
|
+
<MCPActionProvider executor={executor}>
|
|
178
|
+
<ActionGroupRenderer component={component} />
|
|
179
|
+
</MCPActionProvider>
|
|
180
|
+
))
|
|
181
|
+
const btn = screen.getByRole('button', { name: 'Send' })
|
|
182
|
+
expect(btn.hasAttribute('disabled')).toBe(true)
|
|
183
|
+
fireEvent.click(btn)
|
|
184
|
+
// Give any async handler a tick — nothing should fire.
|
|
185
|
+
await new Promise((r) => setTimeout(r, 10))
|
|
186
|
+
expect(executor).not.toHaveBeenCalled()
|
|
187
|
+
})
|
|
188
|
+
})
|
|
@@ -143,7 +143,7 @@ describe('ExpandableWrapper', () => {
|
|
|
143
143
|
expect(writeText).toHaveBeenCalledWith(testData)
|
|
144
144
|
})
|
|
145
145
|
|
|
146
|
-
it('uses default title "Expanded
|
|
146
|
+
it('uses default title "Expanded view" when no title provided', async () => {
|
|
147
147
|
const { getByLabelText, getByText } = render(() => (
|
|
148
148
|
<ExpandableWrapper>
|
|
149
149
|
<div>Content</div>
|
|
@@ -152,7 +152,10 @@ describe('ExpandableWrapper', () => {
|
|
|
152
152
|
|
|
153
153
|
fireEvent.click(getByLabelText('Expand to fullscreen'))
|
|
154
154
|
|
|
155
|
-
|
|
155
|
+
// v6.6.0: default heading comes from MCPUIStrings.expandedView (D2).
|
|
156
|
+
// Also unified to a single casing — the pre-v6.6.0 code had
|
|
157
|
+
// 'Expanded View' as the heading but 'Expanded view' as the aria-label.
|
|
158
|
+
expect(getByText('Expanded view')).toBeDefined()
|
|
156
159
|
})
|
|
157
160
|
|
|
158
161
|
it('expanded content area is scrollable (overflow-auto)', async () => {
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import { Component, Show, createSignal, createEffect, onCleanup, JSX, createContext, useContext, Accessor } from 'solid-js'
|
|
10
10
|
import { Portal } from 'solid-js/web'
|
|
11
|
+
import { useMCPUIStrings } from '../context/MCPUIStringsContext'
|
|
11
12
|
|
|
12
13
|
/** Context for child components to know if they're in expanded/fullscreen view */
|
|
13
14
|
const ExpandedContext = createContext<Accessor<boolean>>(() => false)
|
|
@@ -50,6 +51,7 @@ export interface ExpandableWrapperProps {
|
|
|
50
51
|
export const ExpandableWrapper: Component<ExpandableWrapperProps> = (props) => {
|
|
51
52
|
const [isExpanded, setIsExpanded] = createSignal(false)
|
|
52
53
|
const [copied, setCopied] = createSignal(false)
|
|
54
|
+
const strings = useMCPUIStrings()
|
|
53
55
|
let dialogRef: HTMLDivElement | undefined
|
|
54
56
|
let contentRef: HTMLDivElement | undefined
|
|
55
57
|
let inlineSlotRef: HTMLDivElement | undefined
|
|
@@ -133,7 +135,7 @@ export const ExpandableWrapper: Component<ExpandableWrapperProps> = (props) => {
|
|
|
133
135
|
? 'opacity-60 hover:opacity-100'
|
|
134
136
|
: 'opacity-0 group-hover:opacity-70 hover:!opacity-100'
|
|
135
137
|
} p-1.5 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700 transition-all shadow-sm`}
|
|
136
|
-
title=
|
|
138
|
+
title={strings.expand}
|
|
137
139
|
aria-label="Expand to fullscreen"
|
|
138
140
|
>
|
|
139
141
|
<svg class="w-3.5 h-3.5 text-gray-500 dark:text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
@@ -150,7 +152,7 @@ export const ExpandableWrapper: Component<ExpandableWrapperProps> = (props) => {
|
|
|
150
152
|
onClick={handleBackdropClick}
|
|
151
153
|
role="dialog"
|
|
152
154
|
aria-modal="true"
|
|
153
|
-
aria-label={props.title ||
|
|
155
|
+
aria-label={props.title || strings.expandedView}
|
|
154
156
|
tabIndex={-1}
|
|
155
157
|
ref={dialogRef}
|
|
156
158
|
>
|
|
@@ -163,7 +165,7 @@ export const ExpandableWrapper: Component<ExpandableWrapperProps> = (props) => {
|
|
|
163
165
|
{/* Header */}
|
|
164
166
|
<div class="flex items-center justify-between px-4 py-3 border-b border-gray-200 dark:border-gray-700 flex-shrink-0">
|
|
165
167
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white truncate">
|
|
166
|
-
{props.title ||
|
|
168
|
+
{props.title || strings.expandedView}
|
|
167
169
|
</h2>
|
|
168
170
|
<div class="flex items-center gap-2">
|
|
169
171
|
{/* Copy button */}
|
|
@@ -171,8 +173,8 @@ export const ExpandableWrapper: Component<ExpandableWrapperProps> = (props) => {
|
|
|
171
173
|
<button
|
|
172
174
|
onClick={handleCopy}
|
|
173
175
|
class="p-1.5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
|
174
|
-
title={props.copyLabel ||
|
|
175
|
-
aria-label={props.copyLabel ||
|
|
176
|
+
title={props.copyLabel || strings.copyToClipboard}
|
|
177
|
+
aria-label={props.copyLabel || strings.copyToClipboard}
|
|
176
178
|
>
|
|
177
179
|
<Show
|
|
178
180
|
when={!copied()}
|
|
@@ -192,7 +194,7 @@ export const ExpandableWrapper: Component<ExpandableWrapperProps> = (props) => {
|
|
|
192
194
|
<button
|
|
193
195
|
onClick={handleClose}
|
|
194
196
|
class="p-1.5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
|
195
|
-
aria-label=
|
|
197
|
+
aria-label={strings.closeExpandedView}
|
|
196
198
|
>
|
|
197
199
|
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
198
200
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
|
@@ -37,7 +37,8 @@ describe('FeedbackInline — v5.2.0', () => {
|
|
|
37
37
|
intent: 'search',
|
|
38
38
|
confidenceBand: 'high',
|
|
39
39
|
})
|
|
40
|
-
|
|
40
|
+
// v6.6.0: default ack is now EN (MCPUIStrings.feedbackPositiveAck) — R4.
|
|
41
|
+
expect(getByText('Thanks!')).toBeDefined()
|
|
41
42
|
})
|
|
42
43
|
|
|
43
44
|
it('click thumb-down calls onSubmit with negative and shows negative ack', () => {
|
|
@@ -49,7 +50,8 @@ describe('FeedbackInline — v5.2.0', () => {
|
|
|
49
50
|
fireEvent.click(negative)
|
|
50
51
|
|
|
51
52
|
expect(onSubmit).toHaveBeenCalledWith('negative', undefined)
|
|
52
|
-
|
|
53
|
+
// v6.6.0: default ack is now EN (MCPUIStrings.feedbackNegativeAck) — R4.
|
|
54
|
+
expect(getByText("Noted — we'll improve")).toBeDefined()
|
|
53
55
|
})
|
|
54
56
|
|
|
55
57
|
it('second click after rating is a no-op (final state)', () => {
|
|
@@ -102,7 +104,8 @@ describe('FeedbackInline — v5.2.0', () => {
|
|
|
102
104
|
) as HTMLElement
|
|
103
105
|
// Should not throw even though onSubmit rejects
|
|
104
106
|
expect(() => fireEvent.click(positive)).not.toThrow()
|
|
105
|
-
|
|
107
|
+
// v6.6.0: default ack is now EN (MCPUIStrings.feedbackPositiveAck) — R4.
|
|
108
|
+
expect(getByText('Thanks!')).toBeDefined()
|
|
106
109
|
})
|
|
107
110
|
|
|
108
111
|
it('works without messageHash or context', () => {
|
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
*/
|
|
42
42
|
|
|
43
43
|
import { Component, Show, createSignal } from 'solid-js'
|
|
44
|
+
import { useMCPUIStrings } from '../context/MCPUIStringsContext'
|
|
44
45
|
|
|
45
46
|
export interface FeedbackInlineContext {
|
|
46
47
|
intent?: string
|
|
@@ -59,9 +60,9 @@ export interface FeedbackInlineProps {
|
|
|
59
60
|
onSubmit: (rating: 'positive' | 'negative', context?: FeedbackInlineContext) => void | Promise<void>
|
|
60
61
|
/** Extra context forwarded to `onSubmit`. */
|
|
61
62
|
context?: FeedbackInlineContext
|
|
62
|
-
/** Ack text shown after positive rating.
|
|
63
|
+
/** Ack text shown after positive rating. Defaults to `MCPUIStrings.feedbackPositiveAck` ('Thanks!' in EN). */
|
|
63
64
|
positiveAck?: string
|
|
64
|
-
/** Ack text shown after negative rating.
|
|
65
|
+
/** Ack text shown after negative rating. Defaults to `MCPUIStrings.feedbackNegativeAck`. */
|
|
65
66
|
negativeAck?: string
|
|
66
67
|
/** Extra Tailwind classes on the container. */
|
|
67
68
|
class?: string
|
|
@@ -73,6 +74,7 @@ export interface FeedbackInlineProps {
|
|
|
73
74
|
*/
|
|
74
75
|
export const FeedbackInline: Component<FeedbackInlineProps> = (props) => {
|
|
75
76
|
const [rating, setRating] = createSignal<'positive' | 'negative' | null>(null)
|
|
77
|
+
const strings = useMCPUIStrings()
|
|
76
78
|
|
|
77
79
|
const handle = (value: 'positive' | 'negative') => {
|
|
78
80
|
if (rating() !== null) return // already submitted, final state
|
|
@@ -98,8 +100,8 @@ export const FeedbackInline: Component<FeedbackInlineProps> = (props) => {
|
|
|
98
100
|
fallback={
|
|
99
101
|
<span class="text-[11px] text-deposium-slate-500">
|
|
100
102
|
{rating() === 'positive'
|
|
101
|
-
? (props.positiveAck ??
|
|
102
|
-
: (props.negativeAck ??
|
|
103
|
+
? (props.positiveAck ?? strings.feedbackPositiveAck)
|
|
104
|
+
: (props.negativeAck ?? strings.feedbackNegativeAck)}
|
|
103
105
|
</span>
|
|
104
106
|
}
|
|
105
107
|
>
|
|
@@ -107,7 +109,7 @@ export const FeedbackInline: Component<FeedbackInlineProps> = (props) => {
|
|
|
107
109
|
type="button"
|
|
108
110
|
onClick={() => handle('positive')}
|
|
109
111
|
class="p-1 rounded hover:bg-green-500/10 text-deposium-slate-500 hover:text-green-500 transition-colors"
|
|
110
|
-
title=
|
|
112
|
+
title={strings.feedbackUseful}
|
|
111
113
|
aria-label="Mark response as useful"
|
|
112
114
|
data-feedback-inline-rating="positive"
|
|
113
115
|
>
|
|
@@ -124,7 +126,7 @@ export const FeedbackInline: Component<FeedbackInlineProps> = (props) => {
|
|
|
124
126
|
type="button"
|
|
125
127
|
onClick={() => handle('negative')}
|
|
126
128
|
class="p-1 rounded hover:bg-red-500/10 text-deposium-slate-500 hover:text-red-500 transition-colors"
|
|
127
|
-
title=
|
|
129
|
+
title={strings.feedbackNotUseful}
|
|
128
130
|
aria-label="Mark response as not useful"
|
|
129
131
|
data-feedback-inline-rating="negative"
|
|
130
132
|
>
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v6.6.0 — PresentationFeedback (R3 / D9 of ROADMAP-opendata-macro-mcpui).
|
|
3
|
+
*
|
|
4
|
+
* Coverage:
|
|
5
|
+
* 1. Resting step shows the verdict buttons
|
|
6
|
+
* 2. "Clear" submits a `readable` verdict immediately
|
|
7
|
+
* 3. "Not clear" opens the detail step (does NOT submit yet)
|
|
8
|
+
* 4. Detail step assembles problems + preferredLayout + comment into the payload
|
|
9
|
+
* 5. base fields (connectorId/toolName/queryHash/renderKind/layoutType) are carried
|
|
10
|
+
* 6. Submission is best-effort — a rejected onSubmit promise does not throw
|
|
11
|
+
* 7. Labels are overridable via the `labels` prop
|
|
12
|
+
* 8. The layout picker is hidden when no options are given
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
16
|
+
import { render, cleanup, fireEvent } from '@solidjs/testing-library'
|
|
17
|
+
import { PresentationFeedback } from './PresentationFeedback'
|
|
18
|
+
import type { ConnectorRenderFeedback } from '@seed-ship/mcp-ui-spec'
|
|
19
|
+
|
|
20
|
+
const BASE = {
|
|
21
|
+
connectorId: 'datagouv',
|
|
22
|
+
toolName: 'datagouv.search',
|
|
23
|
+
queryHash: 'a1b2c3d4',
|
|
24
|
+
renderKind: 'primary',
|
|
25
|
+
layoutType: 'table',
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('PresentationFeedback (v6.6.0)', () => {
|
|
29
|
+
beforeEach(() => cleanup())
|
|
30
|
+
|
|
31
|
+
it('resting step shows both verdict buttons', () => {
|
|
32
|
+
const { container } = render(() => (
|
|
33
|
+
<PresentationFeedback {...BASE} onSubmit={() => {}} />
|
|
34
|
+
))
|
|
35
|
+
expect(container.querySelector('[data-presentation-feedback-verdict="readable"]')).toBeTruthy()
|
|
36
|
+
expect(
|
|
37
|
+
container.querySelector('[data-presentation-feedback-verdict="not_readable"]')
|
|
38
|
+
).toBeTruthy()
|
|
39
|
+
expect(container.querySelector('[data-presentation-feedback-step="idle"]')).toBeTruthy()
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('"Clear" submits a readable verdict immediately', () => {
|
|
43
|
+
const onSubmit = vi.fn()
|
|
44
|
+
const { container } = render(() => (
|
|
45
|
+
<PresentationFeedback {...BASE} onSubmit={onSubmit} />
|
|
46
|
+
))
|
|
47
|
+
fireEvent.click(container.querySelector('[data-presentation-feedback-verdict="readable"]')!)
|
|
48
|
+
expect(onSubmit).toHaveBeenCalledTimes(1)
|
|
49
|
+
const payload = onSubmit.mock.calls[0][0] as ConnectorRenderFeedback
|
|
50
|
+
expect(payload.verdict).toBe('readable')
|
|
51
|
+
expect(payload.connectorId).toBe('datagouv')
|
|
52
|
+
expect(payload.queryHash).toBe('a1b2c3d4')
|
|
53
|
+
expect(payload.renderKind).toBe('primary')
|
|
54
|
+
expect(payload.layoutType).toBe('table')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('"Not clear" opens the detail step without submitting', () => {
|
|
58
|
+
const onSubmit = vi.fn()
|
|
59
|
+
const { container } = render(() => (
|
|
60
|
+
<PresentationFeedback {...BASE} onSubmit={onSubmit} />
|
|
61
|
+
))
|
|
62
|
+
fireEvent.click(container.querySelector('[data-presentation-feedback-verdict="not_readable"]')!)
|
|
63
|
+
expect(onSubmit).not.toHaveBeenCalled()
|
|
64
|
+
expect(container.querySelector('[data-presentation-feedback-step="detail"]')).toBeTruthy()
|
|
65
|
+
expect(container.querySelector('[data-presentation-feedback-submit]')).toBeTruthy()
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('detail step assembles problems + preferredLayout + comment', () => {
|
|
69
|
+
const onSubmit = vi.fn()
|
|
70
|
+
const { container } = render(() => (
|
|
71
|
+
<PresentationFeedback
|
|
72
|
+
{...BASE}
|
|
73
|
+
onSubmit={onSubmit}
|
|
74
|
+
preferredLayoutOptions={['table', 'bar', 'map']}
|
|
75
|
+
/>
|
|
76
|
+
))
|
|
77
|
+
fireEvent.click(container.querySelector('[data-presentation-feedback-verdict="not_readable"]')!)
|
|
78
|
+
fireEvent.click(container.querySelector('[data-presentation-feedback-problem="too_raw"]')!)
|
|
79
|
+
fireEvent.click(
|
|
80
|
+
container.querySelector('[data-presentation-feedback-problem="wrong_columns"]')!
|
|
81
|
+
)
|
|
82
|
+
fireEvent.click(container.querySelector('[data-presentation-feedback-layout="bar"]')!)
|
|
83
|
+
const textarea = container.querySelector('[data-presentation-feedback-comment]') as HTMLTextAreaElement
|
|
84
|
+
fireEvent.input(textarea, { target: { value: 'prefer a chart' } })
|
|
85
|
+
fireEvent.click(container.querySelector('[data-presentation-feedback-submit]')!)
|
|
86
|
+
|
|
87
|
+
expect(onSubmit).toHaveBeenCalledTimes(1)
|
|
88
|
+
const payload = onSubmit.mock.calls[0][0] as ConnectorRenderFeedback
|
|
89
|
+
expect(payload.verdict).toBe('not_readable')
|
|
90
|
+
expect(payload.problems).toEqual(['too_raw', 'wrong_columns'])
|
|
91
|
+
expect(payload.preferredLayout).toBe('bar')
|
|
92
|
+
expect(payload.comment).toBe('prefer a chart')
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('deselecting a problem chip removes it from the payload', () => {
|
|
96
|
+
const onSubmit = vi.fn()
|
|
97
|
+
const { container } = render(() => (
|
|
98
|
+
<PresentationFeedback {...BASE} onSubmit={onSubmit} />
|
|
99
|
+
))
|
|
100
|
+
fireEvent.click(container.querySelector('[data-presentation-feedback-verdict="not_readable"]')!)
|
|
101
|
+
const chip = container.querySelector('[data-presentation-feedback-problem="wrong_chart"]')!
|
|
102
|
+
fireEvent.click(chip) // select
|
|
103
|
+
fireEvent.click(chip) // deselect
|
|
104
|
+
fireEvent.click(container.querySelector('[data-presentation-feedback-submit]')!)
|
|
105
|
+
const payload = onSubmit.mock.calls[0][0] as ConnectorRenderFeedback
|
|
106
|
+
expect(payload.problems).toBeUndefined()
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('flips to the ack step after submitting', () => {
|
|
110
|
+
const { container } = render(() => (
|
|
111
|
+
<PresentationFeedback {...BASE} onSubmit={() => {}} />
|
|
112
|
+
))
|
|
113
|
+
fireEvent.click(container.querySelector('[data-presentation-feedback-verdict="readable"]')!)
|
|
114
|
+
expect(container.querySelector('[data-presentation-feedback-ack]')).toBeTruthy()
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('submission is best-effort — a rejected onSubmit promise does not throw', () => {
|
|
118
|
+
const onSubmit = vi.fn(() => Promise.reject(new Error('network down')))
|
|
119
|
+
const { container } = render(() => (
|
|
120
|
+
<PresentationFeedback {...BASE} onSubmit={onSubmit} />
|
|
121
|
+
))
|
|
122
|
+
expect(() =>
|
|
123
|
+
fireEvent.click(container.querySelector('[data-presentation-feedback-verdict="readable"]')!)
|
|
124
|
+
).not.toThrow()
|
|
125
|
+
expect(container.querySelector('[data-presentation-feedback-ack]')).toBeTruthy()
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('labels are overridable via the labels prop', () => {
|
|
129
|
+
const { container } = render(() => (
|
|
130
|
+
<PresentationFeedback
|
|
131
|
+
{...BASE}
|
|
132
|
+
onSubmit={() => {}}
|
|
133
|
+
labels={{ readable: 'Lisible', notReadable: 'Pas lisible' }}
|
|
134
|
+
/>
|
|
135
|
+
))
|
|
136
|
+
const readableBtn = container.querySelector(
|
|
137
|
+
'[data-presentation-feedback-verdict="readable"]'
|
|
138
|
+
)
|
|
139
|
+
expect(readableBtn?.textContent).toBe('Lisible')
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('hides the layout picker when no options are given', () => {
|
|
143
|
+
const { container } = render(() => (
|
|
144
|
+
<PresentationFeedback {...BASE} onSubmit={() => {}} />
|
|
145
|
+
))
|
|
146
|
+
fireEvent.click(container.querySelector('[data-presentation-feedback-verdict="not_readable"]')!)
|
|
147
|
+
expect(container.querySelector('[data-presentation-feedback-layout]')).toBeNull()
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('omits queryHash from the payload when not provided', () => {
|
|
151
|
+
const onSubmit = vi.fn()
|
|
152
|
+
const { container } = render(() => (
|
|
153
|
+
<PresentationFeedback
|
|
154
|
+
connectorId="c"
|
|
155
|
+
toolName="t"
|
|
156
|
+
onSubmit={onSubmit}
|
|
157
|
+
/>
|
|
158
|
+
))
|
|
159
|
+
fireEvent.click(container.querySelector('[data-presentation-feedback-verdict="readable"]')!)
|
|
160
|
+
const payload = onSubmit.mock.calls[0][0] as ConnectorRenderFeedback
|
|
161
|
+
expect(payload.queryHash).toBeUndefined()
|
|
162
|
+
})
|
|
163
|
+
})
|