@seed-ship/mcp-ui-solid 5.1.0 → 5.3.0

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.
Files changed (75) hide show
  1. package/CHANGELOG.md +97 -0
  2. package/README.md +64 -13
  3. package/dist/components/ElicitationForm.cjs +51 -0
  4. package/dist/components/ElicitationForm.cjs.map +1 -0
  5. package/dist/components/ElicitationForm.d.ts +68 -0
  6. package/dist/components/ElicitationForm.d.ts.map +1 -0
  7. package/dist/components/ElicitationForm.js +51 -0
  8. package/dist/components/ElicitationForm.js.map +1 -0
  9. package/dist/components/FeedbackInline.cjs +57 -0
  10. package/dist/components/FeedbackInline.cjs.map +1 -0
  11. package/dist/components/FeedbackInline.d.ts +71 -0
  12. package/dist/components/FeedbackInline.d.ts.map +1 -0
  13. package/dist/components/FeedbackInline.js +57 -0
  14. package/dist/components/FeedbackInline.js.map +1 -0
  15. package/dist/components/index.d.ts +2 -0
  16. package/dist/components/index.d.ts.map +1 -1
  17. package/dist/components.cjs +2 -0
  18. package/dist/components.cjs.map +1 -1
  19. package/dist/components.d.cts +2 -0
  20. package/dist/components.d.ts +2 -0
  21. package/dist/components.js +2 -0
  22. package/dist/components.js.map +1 -1
  23. package/dist/index.cjs +17 -0
  24. package/dist/index.cjs.map +1 -1
  25. package/dist/index.d.cts +12 -2
  26. package/dist/index.d.ts +12 -2
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +19 -2
  29. package/dist/index.js.map +1 -1
  30. package/dist/services/chat-bus.cjs +71 -0
  31. package/dist/services/chat-bus.cjs.map +1 -1
  32. package/dist/services/chat-bus.d.ts +31 -1
  33. package/dist/services/chat-bus.d.ts.map +1 -1
  34. package/dist/services/chat-bus.js +71 -0
  35. package/dist/services/chat-bus.js.map +1 -1
  36. package/dist/services/chat-prompt-controller.cjs +83 -0
  37. package/dist/services/chat-prompt-controller.cjs.map +1 -0
  38. package/dist/services/chat-prompt-controller.d.ts +93 -0
  39. package/dist/services/chat-prompt-controller.d.ts.map +1 -0
  40. package/dist/services/chat-prompt-controller.js +83 -0
  41. package/dist/services/chat-prompt-controller.js.map +1 -0
  42. package/dist/stores/scratchpad-store.cjs +105 -77
  43. package/dist/stores/scratchpad-store.cjs.map +1 -1
  44. package/dist/stores/scratchpad-store.d.ts +88 -19
  45. package/dist/stores/scratchpad-store.d.ts.map +1 -1
  46. package/dist/stores/scratchpad-store.js +105 -77
  47. package/dist/stores/scratchpad-store.js.map +1 -1
  48. package/dist/stores/server-capabilities-store.cjs +61 -0
  49. package/dist/stores/server-capabilities-store.cjs.map +1 -0
  50. package/dist/stores/server-capabilities-store.d.ts +172 -0
  51. package/dist/stores/server-capabilities-store.d.ts.map +1 -0
  52. package/dist/stores/server-capabilities-store.js +61 -0
  53. package/dist/stores/server-capabilities-store.js.map +1 -0
  54. package/dist/types/chat-bus.d.ts +39 -0
  55. package/dist/types/chat-bus.d.ts.map +1 -1
  56. package/docs/recipes/elicitation-pseudo-spec-adapter.md +171 -0
  57. package/docs/recipes/feedback-inline-wiring.md +142 -0
  58. package/package.json +1 -1
  59. package/src/components/ElicitationForm.test.tsx +197 -0
  60. package/src/components/ElicitationForm.tsx +126 -0
  61. package/src/components/FeedbackInline.test.tsx +117 -0
  62. package/src/components/FeedbackInline.tsx +143 -0
  63. package/src/components/index.ts +4 -0
  64. package/src/index.ts +39 -1
  65. package/src/services/chat-bus.test.ts +154 -2
  66. package/src/services/chat-bus.ts +115 -0
  67. package/src/services/chat-prompt-controller.test.ts +144 -0
  68. package/src/services/chat-prompt-controller.ts +214 -0
  69. package/src/stores/scratchpad-store.test.tsx +140 -0
  70. package/src/stores/scratchpad-store.tsx +244 -0
  71. package/src/stores/server-capabilities-store.test.tsx +206 -0
  72. package/src/stores/server-capabilities-store.tsx +215 -0
  73. package/src/types/chat-bus.ts +40 -0
  74. package/tsconfig.tsbuildinfo +1 -1
  75. package/src/stores/scratchpad-store.ts +0 -126
@@ -0,0 +1,142 @@
1
+ # Recipe — Wire `<FeedbackInline>` to a feedback HTTP endpoint
2
+
3
+ > **Audience** : consumer apps that ship `<FeedbackInline>` (per-message
4
+ > thumbs-up/down) and want to persist ratings to a backend.
5
+ >
6
+ > mcp-ui's `<FeedbackInline>` is intentionally endpoint-agnostic — it
7
+ > calls `onSubmit(rating, context)` and the consumer owns the HTTP / store
8
+ > wiring. This recipe shows the most common pattern, using the Deposium
9
+ > `POST /api/feedback` endpoint as a concrete example.
10
+
11
+ ## What `<FeedbackInline>` gives you
12
+
13
+ ```tsx
14
+ <FeedbackInline
15
+ messageHash={msg.hash}
16
+ context={{ intent: msg.intent, confidenceBand: msg.band }}
17
+ onSubmit={(rating, context) => persistFeedback(rating, context)}
18
+ />
19
+ ```
20
+
21
+ The component :
22
+ - Renders two buttons (positive / negative).
23
+ - Flips to "submitted" optimistically on click — UI does NOT revert on network error (best-effort design).
24
+ - Calls `onSubmit('positive' | 'negative', context?)` exactly once.
25
+
26
+ `rating` already matches the shape Deposium expects. Mapping is direct.
27
+
28
+ ## Endpoint reference (Deposium)
29
+
30
+ `POST /api/feedback` — no auth required, behind the chat-stream / standard
31
+ middleware chain.
32
+
33
+ ### Request body
34
+
35
+ ```ts
36
+ interface FeedbackRequest {
37
+ message_hash: string // REQUIRED — message ID being rated
38
+ rating: 'positive' | 'negative' | 'partial'
39
+ confidence_band?: 'high' | 'medium' | 'low' // optional, free-form string
40
+ intent?: string // optional, e.g. 'search_query'
41
+ space_ids?: string[] | string | null
42
+ comment?: string
43
+ tenant_id?: string
44
+ }
45
+ ```
46
+
47
+ ### Response
48
+
49
+ | Status | Body |
50
+ |---|---|
51
+ | 200 | `{ ok: true, id: 'fb_<timestamp>_<rand4>' }` |
52
+ | 400 | `{ error: 'rating must be one of: positive, negative, partial' }` |
53
+
54
+ ### Side effects (worth knowing)
55
+
56
+ - `INSERT` into `logs.feedback` (PostgreSQL) — drives dashboard analytics.
57
+ - `'positive' | 'negative'` ratings also update
58
+ `logs.intent_classifications.feedback_success`. `'partial'` does **not**
59
+ propagate (intentional — neither true nor false).
60
+
61
+ ## Wiring (the recipe)
62
+
63
+ ```tsx
64
+ import { FeedbackInline, type FeedbackInlineContext } from '@seed-ship/mcp-ui-solid'
65
+
66
+ function persistFeedback(
67
+ messageHash: string,
68
+ rating: 'positive' | 'negative',
69
+ ctx?: FeedbackInlineContext
70
+ ): Promise<void> {
71
+ return fetch('/api/feedback', {
72
+ method: 'POST',
73
+ headers: { 'Content-Type': 'application/json' },
74
+ body: JSON.stringify({
75
+ message_hash: messageHash,
76
+ rating, // 'positive' | 'negative' — matches endpoint as-is
77
+ ...(ctx?.intent && { intent: ctx.intent }),
78
+ ...(ctx?.confidenceBand && { confidence_band: ctx.confidenceBand }),
79
+ ...(ctx?.tenantId && { tenant_id: ctx.tenantId }),
80
+ ...(ctx?.spaceIds && { space_ids: ctx.spaceIds }),
81
+ ...(ctx?.comment && { comment: ctx.comment }),
82
+ }),
83
+ }).then(async (res) => {
84
+ if (!res.ok) {
85
+ console.warn('[feedback] persist failed', res.status, await res.text())
86
+ }
87
+ }).catch((err) => {
88
+ // Silent failure — UI is already in the optimistic "submitted" state.
89
+ console.warn('[feedback] network error', err)
90
+ })
91
+ }
92
+
93
+ function MessageRow(props: { msg: ChatMessage }) {
94
+ return (
95
+ <div class="message-row">
96
+ <p>{props.msg.text}</p>
97
+ <FeedbackInline
98
+ messageHash={props.msg.hash}
99
+ context={{
100
+ intent: props.msg.intent,
101
+ confidenceBand: props.msg.confidenceBand,
102
+ }}
103
+ onSubmit={(rating, ctx) => persistFeedback(props.msg.hash, rating, ctx)}
104
+ />
105
+ </div>
106
+ )
107
+ }
108
+ ```
109
+
110
+ ## Variations
111
+
112
+ ### "Partial" rating
113
+
114
+ `<FeedbackInline>` emits only `'positive'` / `'negative'`. If you need a
115
+ third state (`'partial'`), build a separate UI (e.g. a star rating or a
116
+ 3-button row) and call the endpoint directly with `rating: 'partial'`.
117
+
118
+ ### Free-text comment
119
+
120
+ Add a textarea below `<FeedbackInline>` that opens after the rating click.
121
+ Send a follow-up `POST /api/feedback` with the same `message_hash` and a
122
+ `comment` field — the endpoint accepts multiple records per message.
123
+
124
+ ### Optimistic vs strict semantics
125
+
126
+ Default behavior is best-effort (UI never reverts). If you need stricter
127
+ semantics — offline retry queue, edit-rating UX — wrap `<FeedbackInline>`
128
+ in your own component and own the state externally instead of relying on
129
+ the component's internal flip.
130
+
131
+ ## Where this code lives
132
+
133
+ In your consumer app. mcp-ui ships `<FeedbackInline>` and the `onSubmit`
134
+ contract; the HTTP wiring (URL, auth, retry policy, schema mapping) is
135
+ the consumer's responsibility by design — same pattern as
136
+ `pseudo-elicit-spec-adapter`.
137
+
138
+ ## Reference
139
+
140
+ - mcp-ui component : `<FeedbackInline>` (exported from `@seed-ship/mcp-ui-solid`)
141
+ - mcp-ui types : `FeedbackInlineProps`, `FeedbackInlineContext`
142
+ - Deposium endpoint : `POST /api/feedback` — see deposium_MCPs `src/routes/feedback.ts`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seed-ship/mcp-ui-solid",
3
- "version": "5.1.0",
3
+ "version": "5.3.0",
4
4
  "description": "SolidJS components for rendering MCP-generated UI resources",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -0,0 +1,197 @@
1
+ /**
2
+ * Tests for ElicitationForm — v5.3.0
3
+ *
4
+ * Coverage focus : the inverse mapping (ChatPromptResponse → spec content)
5
+ * that this wrapper owns. The forward mapping (spec → ChatPromptConfig) is
6
+ * already covered by `chat-bus.test.ts elicitationToPromptConfig`.
7
+ */
8
+
9
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
10
+ import { render, fireEvent, cleanup } from '@solidjs/testing-library'
11
+ import { ElicitationForm } from './ElicitationForm'
12
+ import type { ElicitationEvent } from '../types/chat-bus'
13
+
14
+ describe('ElicitationForm — v5.3.0', () => {
15
+ beforeEach(() => {
16
+ cleanup()
17
+ })
18
+
19
+ it('boolean property → confirm UI → onAccept gets { propName: true }', () => {
20
+ const onAccept = vi.fn()
21
+ const event: ElicitationEvent = {
22
+ message: 'Proceed?',
23
+ requestedSchema: {
24
+ type: 'object',
25
+ properties: {
26
+ consent: { type: 'boolean', description: 'I agree' },
27
+ },
28
+ required: ['consent'],
29
+ },
30
+ }
31
+
32
+ const { getByText } = render(() => (
33
+ <ElicitationForm event={event} onAccept={onAccept} onCancel={() => {}} />
34
+ ))
35
+
36
+ // ChatPrompt renders a Confirm button — find and click it.
37
+ const confirmBtn = getByText('Confirm') as HTMLElement
38
+ fireEvent.click(confirmBtn)
39
+
40
+ expect(onAccept).toHaveBeenCalledTimes(1)
41
+ expect(onAccept).toHaveBeenCalledWith({ consent: true })
42
+ })
43
+
44
+ it('single enum property → choice UI → onAccept gets { propName: enumValue }', () => {
45
+ const onAccept = vi.fn()
46
+ const event: ElicitationEvent = {
47
+ message: 'Pick a tier',
48
+ requestedSchema: {
49
+ type: 'object',
50
+ properties: {
51
+ tier: {
52
+ type: 'string',
53
+ enum: ['free', 'pro', 'enterprise'],
54
+ enumNames: ['Free', 'Pro', 'Enterprise'],
55
+ },
56
+ },
57
+ },
58
+ }
59
+
60
+ const { getByText } = render(() => (
61
+ <ElicitationForm event={event} onAccept={onAccept} />
62
+ ))
63
+
64
+ fireEvent.click(getByText('Pro') as HTMLElement)
65
+
66
+ expect(onAccept).toHaveBeenCalledWith({ tier: 'pro' })
67
+ })
68
+
69
+ it('numeric enum property → onAccept coerces value to number', () => {
70
+ const onAccept = vi.fn()
71
+ const event: ElicitationEvent = {
72
+ message: 'Pick a level',
73
+ requestedSchema: {
74
+ type: 'object',
75
+ properties: {
76
+ level: { type: 'integer', enum: [1, 2, 3] },
77
+ },
78
+ },
79
+ }
80
+
81
+ const { getByText } = render(() => (
82
+ <ElicitationForm event={event} onAccept={onAccept} />
83
+ ))
84
+
85
+ fireEvent.click(getByText('2') as HTMLElement)
86
+
87
+ expect(onAccept).toHaveBeenCalledWith({ level: 2 })
88
+ })
89
+
90
+ it('multi-property schema → form UI → onAccept gets formValues unchanged', () => {
91
+ const onAccept = vi.fn()
92
+ const event: ElicitationEvent = {
93
+ message: 'Tenant scope required',
94
+ requestedSchema: {
95
+ type: 'object',
96
+ properties: {
97
+ tenant_id: { type: 'string', title: 'Tenant ID' },
98
+ space_id: { type: 'string', title: 'Space ID', default: 'default' },
99
+ },
100
+ required: ['tenant_id', 'space_id'],
101
+ },
102
+ }
103
+
104
+ const { container, getByText } = render(() => (
105
+ <ElicitationForm event={event} onAccept={onAccept} />
106
+ ))
107
+
108
+ const tenantInput = container.querySelector('input[name="tenant_id"]') as HTMLInputElement
109
+ const spaceInput = container.querySelector('input[name="space_id"]') as HTMLInputElement
110
+ expect(tenantInput).toBeTruthy()
111
+ expect(spaceInput).toBeTruthy()
112
+
113
+ fireEvent.input(tenantInput, { target: { value: 'acme-co' } })
114
+ fireEvent.input(spaceInput, { target: { value: 'prod' } })
115
+
116
+ const submitBtn = getByText('Submit') as HTMLElement
117
+ fireEvent.click(submitBtn)
118
+
119
+ expect(onAccept).toHaveBeenCalledTimes(1)
120
+ const [content] = onAccept.mock.calls[0]
121
+ expect(content).toMatchObject({ tenant_id: 'acme-co', space_id: 'prod' })
122
+ })
123
+
124
+ it('X dismiss → onCancel fires, onAccept does NOT', () => {
125
+ const onAccept = vi.fn()
126
+ const onCancel = vi.fn()
127
+ const event: ElicitationEvent = {
128
+ message: 'Pick a tier',
129
+ requestedSchema: {
130
+ type: 'object',
131
+ properties: {
132
+ tier: { type: 'string', enum: ['a', 'b'] },
133
+ },
134
+ },
135
+ }
136
+
137
+ const { container } = render(() => (
138
+ <ElicitationForm event={event} onAccept={onAccept} onCancel={onCancel} />
139
+ ))
140
+
141
+ const dismissBtn = container.querySelector('[aria-label="Dismiss"]') as HTMLElement
142
+ fireEvent.click(dismissBtn)
143
+
144
+ expect(onCancel).toHaveBeenCalledTimes(1)
145
+ expect(onAccept).not.toHaveBeenCalled()
146
+ })
147
+
148
+ it('onDecline takes precedence over onCancel when provided', () => {
149
+ const onAccept = vi.fn()
150
+ const onCancel = vi.fn()
151
+ const onDecline = vi.fn()
152
+ const event: ElicitationEvent = {
153
+ message: 'Proceed?',
154
+ requestedSchema: {
155
+ type: 'object',
156
+ properties: { consent: { type: 'boolean' } },
157
+ },
158
+ }
159
+
160
+ const { getByText } = render(() => (
161
+ <ElicitationForm
162
+ event={event}
163
+ onAccept={onAccept}
164
+ onCancel={onCancel}
165
+ onDecline={onDecline}
166
+ dismissLabel="Decline"
167
+ />
168
+ ))
169
+
170
+ fireEvent.click(getByText('Decline') as HTMLElement)
171
+
172
+ expect(onDecline).toHaveBeenCalledTimes(1)
173
+ expect(onCancel).not.toHaveBeenCalled()
174
+ expect(onAccept).not.toHaveBeenCalled()
175
+ })
176
+
177
+ it('confirm cancel button → onCancel fires (dismissed=true via cancel button)', () => {
178
+ const onAccept = vi.fn()
179
+ const onCancel = vi.fn()
180
+ const event: ElicitationEvent = {
181
+ message: 'Proceed?',
182
+ requestedSchema: {
183
+ type: 'object',
184
+ properties: { consent: { type: 'boolean' } },
185
+ },
186
+ }
187
+
188
+ const { getByText } = render(() => (
189
+ <ElicitationForm event={event} onAccept={onAccept} onCancel={onCancel} />
190
+ ))
191
+
192
+ fireEvent.click(getByText('Cancel') as HTMLElement)
193
+
194
+ expect(onCancel).toHaveBeenCalledTimes(1)
195
+ expect(onAccept).not.toHaveBeenCalled()
196
+ })
197
+ })
@@ -0,0 +1,126 @@
1
+ /**
2
+ * ElicitationForm — schema-driven renderer for MCP `elicitation/create` requests
3
+ *
4
+ * @experimental
5
+ * @since v5.3.0
6
+ *
7
+ * Thin wrapper over `<ChatPrompt>` + `elicitationToPromptConfig()` that
8
+ * accepts a spec-shaped `ElicitationEvent` (MCP 2025-06-18) and exposes a
9
+ * spec-shaped `onAccept(content)` callback whose payload is ready to send
10
+ * back as the `accept` outcome of an `elicitation/create` reply.
11
+ *
12
+ * The mapping (boolean → confirm, single enum ≤4 → choice, else → form) is
13
+ * delegated to the `elicitationToPromptConfig` helper — same rules, same
14
+ * tests. This component owns the inverse mapping : extracting a spec-shaped
15
+ * `Record<string, unknown>` from the `ChatPromptResponse`.
16
+ *
17
+ * ## Outcome semantics (per MCP spec 2025-06-18)
18
+ *
19
+ * | User action | Callback fired | Payload |
20
+ * |---------------------------------------|--------------------|----------------------------------|
21
+ * | Submit form / pick choice / confirm | `onAccept(content)`| `{ [propName]: value, ... }` |
22
+ * | X icon / Cancel button | `onCancel()` *or* `onDecline()` if provided | none |
23
+ *
24
+ * mcp-ui's `<ChatPrompt>` does not natively distinguish "decline" (explicit
25
+ * refusal) from "cancel" (passive close). To surface a decline action,
26
+ * pass `dismissLabel="Decline"` and route the callback via `onDecline`.
27
+ *
28
+ * @example
29
+ * ```tsx
30
+ * bus.events.on('onElicitation', ({ elicitation }) => {
31
+ * render(() => (
32
+ * <ElicitationForm
33
+ * event={elicitation}
34
+ * onAccept={(content) => sendElicitationReply({ action: 'accept', content })}
35
+ * onCancel={() => sendElicitationReply({ action: 'cancel' })}
36
+ * />
37
+ * ), mountPoint)
38
+ * })
39
+ * ```
40
+ */
41
+
42
+ import { Component } from 'solid-js'
43
+ import { ChatPrompt } from './ChatPrompt'
44
+ import { elicitationToPromptConfig } from '../services/chat-bus'
45
+ import type {
46
+ ChatPromptResponse,
47
+ ElicitationEvent,
48
+ ElicitationPropertySchema,
49
+ } from '../types/chat-bus'
50
+
51
+ export interface ElicitationFormProps {
52
+ /** MCP `elicitation/create` request payload to render. */
53
+ event: ElicitationEvent
54
+ /**
55
+ * Called when user submits a valid response. `content` is keyed by the
56
+ * elicitation `requestedSchema.properties` names — ready to send back as
57
+ * the `accept` outcome of an `elicitation/create` reply.
58
+ */
59
+ onAccept: (content: Record<string, unknown>) => void
60
+ /** Called when user dismisses (X icon, confirm-cancel button). */
61
+ onCancel?: () => void
62
+ /**
63
+ * Optional explicit decline action. When provided, takes precedence over
64
+ * `onCancel` on dismiss. Pair with `dismissLabel="Decline"` to surface as
65
+ * a decline action in the UI.
66
+ */
67
+ onDecline?: () => void
68
+ /** Label on the dismiss button (default: X icon). */
69
+ dismissLabel?: string
70
+ }
71
+
72
+ /**
73
+ * @experimental
74
+ * Schema-driven renderer for MCP `elicitation/create` requests.
75
+ */
76
+ export const ElicitationForm: Component<ElicitationFormProps> = (props) => {
77
+ const config = () => elicitationToPromptConfig(props.event)
78
+
79
+ const handleSubmit = (response: ChatPromptResponse): void => {
80
+ if (response.dismissed) {
81
+ ;(props.onDecline ?? props.onCancel)?.()
82
+ return
83
+ }
84
+ props.onAccept(extractContent(response, props.event))
85
+ }
86
+
87
+ return <ChatPrompt config={config()} dismissLabel={props.dismissLabel} onSubmit={handleSubmit} />
88
+ }
89
+
90
+ function extractContent(
91
+ response: ChatPromptResponse,
92
+ event: ElicitationEvent
93
+ ): Record<string, unknown> {
94
+ // Form: response.value is already a Record keyed by property names.
95
+ if (typeof response.value !== 'string') {
96
+ return response.value
97
+ }
98
+
99
+ const propEntries = Object.entries(event.requestedSchema.properties)
100
+
101
+ // Single-property cases (boolean confirm or single enum choice).
102
+ if (propEntries.length === 1) {
103
+ const [name, schema] = propEntries[0]
104
+ return { [name]: coerceScalar(response.value, schema) }
105
+ }
106
+
107
+ // Multi-property string response — shouldn't happen since the helper
108
+ // routes multi-prop schemas to 'form'. Fall back gracefully.
109
+ console.warn(
110
+ '[MCP-UI] ElicitationForm: received string value for multi-property schema. Falling back to _value.'
111
+ )
112
+ return { _value: response.value }
113
+ }
114
+
115
+ function coerceScalar(value: string, schema: ElicitationPropertySchema): unknown {
116
+ // Confirm always emits the literal 'confirmed' on accept (cancel path is
117
+ // trapped earlier by `dismissed: true`). Map to boolean true.
118
+ if (schema.type === 'boolean') return true
119
+
120
+ if (schema.type === 'number' || schema.type === 'integer') {
121
+ const n = Number(value)
122
+ return Number.isFinite(n) ? n : value
123
+ }
124
+
125
+ return value
126
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Tests for FeedbackInline component — v5.2.0
3
+ */
4
+
5
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
6
+ import { render, fireEvent, cleanup } from '@solidjs/testing-library'
7
+ import { FeedbackInline } from './FeedbackInline'
8
+
9
+ describe('FeedbackInline — v5.2.0', () => {
10
+ beforeEach(() => {
11
+ cleanup()
12
+ })
13
+
14
+ it('renders two rating buttons by default', () => {
15
+ const { container } = render(() => <FeedbackInline onSubmit={() => {}} />)
16
+ const positive = container.querySelector('[data-feedback-inline-rating="positive"]')
17
+ const negative = container.querySelector('[data-feedback-inline-rating="negative"]')
18
+ expect(positive).toBeDefined()
19
+ expect(negative).toBeDefined()
20
+ })
21
+
22
+ it('click thumb-up calls onSubmit with positive + context and shows ack', () => {
23
+ const onSubmit = vi.fn()
24
+ const { container, getByText } = render(() => (
25
+ <FeedbackInline
26
+ onSubmit={onSubmit}
27
+ context={{ intent: 'search', confidenceBand: 'high' }}
28
+ />
29
+ ))
30
+ const positive = container.querySelector(
31
+ '[data-feedback-inline-rating="positive"]'
32
+ ) as HTMLElement
33
+ fireEvent.click(positive)
34
+
35
+ expect(onSubmit).toHaveBeenCalledTimes(1)
36
+ expect(onSubmit).toHaveBeenCalledWith('positive', {
37
+ intent: 'search',
38
+ confidenceBand: 'high',
39
+ })
40
+ expect(getByText('Merci !')).toBeDefined()
41
+ })
42
+
43
+ it('click thumb-down calls onSubmit with negative and shows negative ack', () => {
44
+ const onSubmit = vi.fn()
45
+ const { container, getByText } = render(() => <FeedbackInline onSubmit={onSubmit} />)
46
+ const negative = container.querySelector(
47
+ '[data-feedback-inline-rating="negative"]'
48
+ ) as HTMLElement
49
+ fireEvent.click(negative)
50
+
51
+ expect(onSubmit).toHaveBeenCalledWith('negative', undefined)
52
+ expect(getByText("Noté, on s'améliore")).toBeDefined()
53
+ })
54
+
55
+ it('second click after rating is a no-op (final state)', () => {
56
+ const onSubmit = vi.fn()
57
+ const { container } = render(() => <FeedbackInline onSubmit={onSubmit} />)
58
+ const positive = container.querySelector(
59
+ '[data-feedback-inline-rating="positive"]'
60
+ ) as HTMLElement
61
+ fireEvent.click(positive)
62
+ // After first click, buttons are replaced by ack text — there's no button
63
+ // to click, but verify onSubmit isn't called again via any stale ref
64
+ expect(onSubmit).toHaveBeenCalledTimes(1)
65
+ expect(container.querySelector('[data-feedback-inline-rating]')).toBeNull()
66
+ })
67
+
68
+ it('custom positiveAck / negativeAck text is rendered', () => {
69
+ const { container, getByText, unmount } = render(() => (
70
+ <FeedbackInline
71
+ onSubmit={() => {}}
72
+ positiveAck="Thanks!"
73
+ negativeAck="Got it, sorry"
74
+ />
75
+ ))
76
+ const positive = container.querySelector(
77
+ '[data-feedback-inline-rating="positive"]'
78
+ ) as HTMLElement
79
+ fireEvent.click(positive)
80
+ expect(getByText('Thanks!')).toBeDefined()
81
+ unmount()
82
+
83
+ const second = render(() => (
84
+ <FeedbackInline
85
+ onSubmit={() => {}}
86
+ positiveAck="Thanks!"
87
+ negativeAck="Got it, sorry"
88
+ />
89
+ ))
90
+ const negative = second.container.querySelector(
91
+ '[data-feedback-inline-rating="negative"]'
92
+ ) as HTMLElement
93
+ fireEvent.click(negative)
94
+ expect(second.getByText('Got it, sorry')).toBeDefined()
95
+ })
96
+
97
+ it('onSubmit promise rejection is swallowed (best-effort, UI still flips)', () => {
98
+ const onSubmit = vi.fn().mockRejectedValue(new Error('network down'))
99
+ const { container, getByText } = render(() => <FeedbackInline onSubmit={onSubmit} />)
100
+ const positive = container.querySelector(
101
+ '[data-feedback-inline-rating="positive"]'
102
+ ) as HTMLElement
103
+ // Should not throw even though onSubmit rejects
104
+ expect(() => fireEvent.click(positive)).not.toThrow()
105
+ expect(getByText('Merci !')).toBeDefined()
106
+ })
107
+
108
+ it('works without messageHash or context', () => {
109
+ const onSubmit = vi.fn()
110
+ const { container } = render(() => <FeedbackInline onSubmit={onSubmit} />)
111
+ const positive = container.querySelector(
112
+ '[data-feedback-inline-rating="positive"]'
113
+ ) as HTMLElement
114
+ fireEvent.click(positive)
115
+ expect(onSubmit).toHaveBeenCalledWith('positive', undefined)
116
+ })
117
+ })