@seed-ship/mcp-ui-solid 6.17.0 → 6.18.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.
- package/CHANGELOG.md +28 -0
- package/README.md +50 -3
- package/dist/adapters/index.d.ts +2 -0
- package/dist/adapters/index.d.ts.map +1 -1
- package/dist/adapters/presentation.cjs +141 -0
- package/dist/adapters/presentation.cjs.map +1 -0
- package/dist/adapters/presentation.d.ts +68 -0
- package/dist/adapters/presentation.d.ts.map +1 -0
- package/dist/adapters/presentation.js +141 -0
- package/dist/adapters/presentation.js.map +1 -0
- package/dist/adapters.cjs +4 -0
- package/dist/adapters.cjs.map +1 -1
- package/dist/adapters.d.cts +2 -0
- package/dist/adapters.d.ts +2 -0
- package/dist/adapters.js +4 -0
- package/dist/adapters.js.map +1 -1
- package/dist/components/ChartJSRenderer.cjs +165 -65
- package/dist/components/ChartJSRenderer.cjs.map +1 -1
- package/dist/components/ChartJSRenderer.d.ts.map +1 -1
- package/dist/components/ChartJSRenderer.js +168 -68
- package/dist/components/ChartJSRenderer.js.map +1 -1
- package/dist/components/ComponentToolbar.cjs.map +1 -1
- package/dist/components/ComponentToolbar.d.ts.map +1 -1
- package/dist/components/ComponentToolbar.js.map +1 -1
- package/dist/components/EditableLayoutContext.cjs +17 -0
- package/dist/components/EditableLayoutContext.cjs.map +1 -0
- package/dist/components/EditableLayoutContext.d.ts +5 -0
- package/dist/components/EditableLayoutContext.d.ts.map +1 -0
- package/dist/components/EditableLayoutContext.js +17 -0
- package/dist/components/EditableLayoutContext.js.map +1 -0
- package/dist/components/EditableUIResourceRenderer.cjs +87 -84
- package/dist/components/EditableUIResourceRenderer.cjs.map +1 -1
- package/dist/components/EditableUIResourceRenderer.d.ts.map +1 -1
- package/dist/components/EditableUIResourceRenderer.js +87 -84
- package/dist/components/EditableUIResourceRenderer.js.map +1 -1
- package/dist/components/ElicitationForm.cjs.map +1 -1
- package/dist/components/ElicitationForm.js.map +1 -1
- package/dist/components/FeedbackInline.cjs +0 -1
- package/dist/components/FeedbackInline.cjs.map +1 -1
- package/dist/components/FeedbackInline.js +0 -1
- package/dist/components/FeedbackInline.js.map +1 -1
- package/dist/components/GraphRenderer.cjs.map +1 -1
- package/dist/components/GraphRenderer.d.ts.map +1 -1
- package/dist/components/GraphRenderer.js.map +1 -1
- package/dist/components/GridRenderer.cjs +12 -6
- package/dist/components/GridRenderer.cjs.map +1 -1
- package/dist/components/GridRenderer.d.ts.map +1 -1
- package/dist/components/GridRenderer.js +13 -7
- package/dist/components/GridRenderer.js.map +1 -1
- package/dist/components/MapRenderer.cjs.map +1 -1
- package/dist/components/MapRenderer.d.ts.map +1 -1
- package/dist/components/MapRenderer.js.map +1 -1
- package/dist/components/PresentationFeedback.cjs +0 -1
- package/dist/components/PresentationFeedback.cjs.map +1 -1
- package/dist/components/PresentationFeedback.js +0 -1
- package/dist/components/PresentationFeedback.js.map +1 -1
- package/dist/components/UIResourceRenderer.cjs +37 -22
- package/dist/components/UIResourceRenderer.cjs.map +1 -1
- package/dist/components/UIResourceRenderer.d.ts.map +1 -1
- package/dist/components/UIResourceRenderer.js +38 -23
- package/dist/components/UIResourceRenderer.js.map +1 -1
- package/dist/components/VerifiedText.cjs +0 -4
- package/dist/components/VerifiedText.cjs.map +1 -1
- package/dist/components/VerifiedText.d.ts.map +1 -1
- package/dist/components/VerifiedText.js +0 -4
- package/dist/components/VerifiedText.js.map +1 -1
- package/dist/components/chart-data-table.cjs +73 -0
- package/dist/components/chart-data-table.cjs.map +1 -0
- package/dist/components/chart-data-table.d.ts +23 -0
- package/dist/components/chart-data-table.d.ts.map +1 -0
- package/dist/components/chart-data-table.js +73 -0
- package/dist/components/chart-data-table.js.map +1 -0
- package/dist/context/MCPUIStringsContext.cjs +10 -9
- package/dist/context/MCPUIStringsContext.cjs.map +1 -1
- package/dist/context/MCPUIStringsContext.d.ts +17 -5
- package/dist/context/MCPUIStringsContext.d.ts.map +1 -1
- package/dist/context/MCPUIStringsContext.js +11 -10
- package/dist/context/MCPUIStringsContext.js.map +1 -1
- package/dist/hooks/createResponsiveGrid.cjs +37 -0
- package/dist/hooks/createResponsiveGrid.cjs.map +1 -0
- package/dist/hooks/createResponsiveGrid.d.ts +6 -0
- package/dist/hooks/createResponsiveGrid.d.ts.map +1 -0
- package/dist/hooks/createResponsiveGrid.js +37 -0
- package/dist/hooks/createResponsiveGrid.js.map +1 -0
- package/dist/index.cjs +1 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/mcp-ui-spec/dist/schemas.cjs +13 -1
- package/dist/mcp-ui-spec/dist/schemas.cjs.map +1 -1
- package/dist/mcp-ui-spec/dist/schemas.js +13 -1
- package/dist/mcp-ui-spec/dist/schemas.js.map +1 -1
- package/dist/services/chat-bus.cjs.map +1 -1
- package/dist/services/chat-bus.d.ts.map +1 -1
- package/dist/services/chat-bus.js.map +1 -1
- package/dist/services/chat-prompt-controller.cjs.map +1 -1
- package/dist/services/chat-prompt-controller.d.ts.map +1 -1
- package/dist/services/chat-prompt-controller.js.map +1 -1
- package/dist/services/component-registry.cjs +162 -22
- package/dist/services/component-registry.cjs.map +1 -1
- package/dist/services/component-registry.d.ts +6 -3
- package/dist/services/component-registry.d.ts.map +1 -1
- package/dist/services/component-registry.js +162 -22
- package/dist/services/component-registry.js.map +1 -1
- package/dist/services/telemetry.cjs +0 -1
- package/dist/services/telemetry.cjs.map +1 -1
- package/dist/services/telemetry.js +0 -1
- package/dist/services/telemetry.js.map +1 -1
- package/dist/services/validation.cjs +21 -7
- package/dist/services/validation.cjs.map +1 -1
- package/dist/services/validation.d.ts.map +1 -1
- package/dist/services/validation.js +21 -7
- package/dist/services/validation.js.map +1 -1
- package/dist/types/index.d.ts +43 -8
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types.d.cts +43 -8
- package/dist/types.d.ts +43 -8
- package/docs/briefs/VISUALIZATION-FOUNDATION-2026-09-14.md +105 -0
- package/package.json +4 -4
- package/src/adapters/index.ts +18 -0
- package/src/adapters/presentation.test.ts +114 -0
- package/src/adapters/presentation.ts +229 -0
- package/src/components/ChartJSRenderer.test.tsx +325 -0
- package/src/components/ChartJSRenderer.tsx +162 -27
- package/src/components/ChatPrompt.test.tsx +1 -1
- package/src/components/ComponentToolbar.tsx +1 -1
- package/src/components/EditableLayoutContext.tsx +12 -0
- package/src/components/EditableUIResourceRenderer.tsx +11 -8
- package/src/components/ElicitationForm.tsx +1 -1
- package/src/components/FeedbackInline.tsx +1 -1
- package/src/components/GraphRenderer.test.tsx +1 -2
- package/src/components/GraphRenderer.tsx +0 -2
- package/src/components/GridRenderer.tsx +6 -4
- package/src/components/MapRenderer.tsx +0 -3
- package/src/components/PresentationFeedback.tsx +1 -1
- package/src/components/ResponsiveGrid.test.tsx +166 -0
- package/src/components/TableRenderer.pagination.test.tsx +31 -0
- package/src/components/UIResourceRenderer.tsx +14 -9
- package/src/components/VerifiedText.tsx +0 -5
- package/src/components/chart-data-table.ts +97 -0
- package/src/context/MCPUIStringsContext.test.tsx +60 -0
- package/src/context/MCPUIStringsContext.tsx +31 -12
- package/src/hooks/createResponsiveGrid.ts +41 -0
- package/src/services/chat-bus.test.ts +1 -1
- package/src/services/chat-bus.ts +0 -2
- package/src/services/chat-prompt-controller.ts +0 -8
- package/src/services/component-registry.test.ts +81 -2
- package/src/services/component-registry.ts +167 -23
- package/src/services/telemetry.ts +1 -1
- package/src/services/validation.test.ts +1 -14
- package/src/services/validation.ts +23 -6
- package/src/services/visualization-validation.test.ts +42 -0
- package/src/types/index.ts +50 -9
- package/src/types/visualization-contracts.test.ts +66 -0
- package/src/utils/perf.test.ts +3 -3
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
createComparisonLayout,
|
|
4
|
+
createEvidenceLayout,
|
|
5
|
+
createGeographyLayout,
|
|
6
|
+
type ChartComponent,
|
|
7
|
+
type LinkComponent,
|
|
8
|
+
type MapComponent,
|
|
9
|
+
type MetricComponent,
|
|
10
|
+
type TableComponent,
|
|
11
|
+
type TextComponent,
|
|
12
|
+
} from './presentation'
|
|
13
|
+
|
|
14
|
+
const chart: ChartComponent = {
|
|
15
|
+
id: 'caller-chart', type: 'chart',
|
|
16
|
+
params: { type: 'scatter', data: { labels: ['A'], datasets: [{ label: 'score', data: [{ x: 1, y: 2 }] }] } },
|
|
17
|
+
metadata: { generatedAt: '2026-09-14T00:00:00.000Z', confidence: 0.8 },
|
|
18
|
+
}
|
|
19
|
+
const table: TableComponent = {
|
|
20
|
+
id: 'caller-table', type: 'table',
|
|
21
|
+
params: {
|
|
22
|
+
columns: [{ key: 'claim', label: 'Claim' }], rows: [{ claim: '[📄 CITATION 1]' }],
|
|
23
|
+
citationMap: { '1': { page: 3, file: 'source.pdf' } },
|
|
24
|
+
},
|
|
25
|
+
}
|
|
26
|
+
const map: MapComponent = {
|
|
27
|
+
id: 'caller-map', type: 'map',
|
|
28
|
+
params: { geojson: { type: 'FeatureCollection', features: [] }, fitBounds: true },
|
|
29
|
+
}
|
|
30
|
+
const summary: TextComponent = {
|
|
31
|
+
id: 'caller-summary', type: 'text',
|
|
32
|
+
params: { content: 'Caller-provided conclusion.', markdown: true },
|
|
33
|
+
}
|
|
34
|
+
const metric = (id: string): MetricComponent => ({
|
|
35
|
+
id, type: 'metric', params: { title: id, value: 1 },
|
|
36
|
+
})
|
|
37
|
+
const source = (id: string): LinkComponent => ({
|
|
38
|
+
id, type: 'link', params: { url: 'https://example.test', label: id },
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
describe('presentation recipes', () => {
|
|
42
|
+
it('creates a deterministic comparison with preserved source params and metadata', () => {
|
|
43
|
+
const metadata = { query: 'compare', generatedAt: '2026-09-14T00:00:00.000Z', totalComponents: 5 }
|
|
44
|
+
const input = { id: 'comparison-q3', chart, table, metrics: [metric('m-one'), metric('m-two')], summary, metadata }
|
|
45
|
+
const first = createComparisonLayout(input)
|
|
46
|
+
const second = createComparisonLayout(input)
|
|
47
|
+
|
|
48
|
+
expect(first).toEqual(second)
|
|
49
|
+
expect(first.components.map((component) => component.id)).toEqual(['caller-summary', 'm-one', 'm-two', 'caller-chart', 'caller-table'])
|
|
50
|
+
expect(first.components.at(-2)?.position).toEqual({ colStart: 1, colSpan: 7, rowStart: 3 })
|
|
51
|
+
expect(first.components.at(-1)?.position).toEqual({ colStart: 8, colSpan: 5, rowStart: 3 })
|
|
52
|
+
expect(first.components.at(-1)?.params).toEqual(table.params)
|
|
53
|
+
expect(first.components.at(-2)?.metadata).toEqual(chart.metadata)
|
|
54
|
+
expect(first.metadata).toEqual(metadata)
|
|
55
|
+
expect(table.position).toBeUndefined()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('creates a geography overview with map before its tabular detail', () => {
|
|
59
|
+
const layout = createGeographyLayout({ id: 'geography-q3', map, table, metrics: [metric('geo-metric')] })
|
|
60
|
+
expect(layout.components.map((component) => component.type)).toEqual(['metric', 'map', 'table'])
|
|
61
|
+
expect(layout.components[1].position).toEqual({ colStart: 1, colSpan: 8, rowStart: 2 })
|
|
62
|
+
expect(layout.components[2].position).toEqual({ colStart: 9, colSpan: 4, rowStart: 2 })
|
|
63
|
+
expect(layout.components[1].params).toEqual(map.params)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('keeps evidence source order and caller ids', () => {
|
|
67
|
+
const layout = createEvidenceLayout({ id: 'evidence-q3', summary, table, sources: [source('first'), source('second')] })
|
|
68
|
+
expect(layout.components.map((component) => component.id)).toEqual(['caller-summary', 'caller-table', 'first', 'second'])
|
|
69
|
+
expect(layout.components[2].params).toEqual(source('first').params)
|
|
70
|
+
expect(layout.components[3].params).toEqual(source('second').params)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('wraps metrics and source links onto later rows without overlapping positions', () => {
|
|
74
|
+
const comparison = createComparisonLayout({
|
|
75
|
+
id: 'many-metrics', chart, table,
|
|
76
|
+
metrics: ['one', 'two', 'three', 'four', 'five'].map(metric),
|
|
77
|
+
})
|
|
78
|
+
expect(comparison.components.slice(0, 5).map((component) => component.position)).toEqual([
|
|
79
|
+
{ colStart: 1, colSpan: 3, rowStart: 1 },
|
|
80
|
+
{ colStart: 4, colSpan: 3, rowStart: 1 },
|
|
81
|
+
{ colStart: 7, colSpan: 3, rowStart: 1 },
|
|
82
|
+
{ colStart: 10, colSpan: 3, rowStart: 1 },
|
|
83
|
+
{ colStart: 1, colSpan: 3, rowStart: 2 },
|
|
84
|
+
])
|
|
85
|
+
expect(comparison.components.at(-2)?.position?.rowStart).toBe(3)
|
|
86
|
+
|
|
87
|
+
const evidence = createEvidenceLayout({
|
|
88
|
+
id: 'many-sources', summary, table,
|
|
89
|
+
sources: ['one', 'two', 'three', 'four'].map(source),
|
|
90
|
+
})
|
|
91
|
+
expect(evidence.components.slice(2).map((component) => component.position)).toEqual([
|
|
92
|
+
{ colStart: 1, colSpan: 4, rowStart: 3 },
|
|
93
|
+
{ colStart: 5, colSpan: 4, rowStart: 3 },
|
|
94
|
+
{ colStart: 9, colSpan: 4, rowStart: 3 },
|
|
95
|
+
{ colStart: 1, colSpan: 4, rowStart: 4 },
|
|
96
|
+
])
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('rejects incompatible types and duplicate ids, including nested grid children', () => {
|
|
100
|
+
expect(() => createComparisonLayout({ id: 'bad', chart: { ...table, id: 'wrong-chart' } as unknown as ChartComponent, table })).toThrow('chart must be a chart component')
|
|
101
|
+
expect(() => createEvidenceLayout({ id: 'evidence-q3', summary, table, sources: [source('dup'), source('dup')] })).toThrow('Duplicate component id: dup')
|
|
102
|
+
const nestedGrid = {
|
|
103
|
+
id: 'nested-grid', type: 'grid', position: { colStart: 1, colSpan: 12 },
|
|
104
|
+
params: { children: [{ ...metric('nested-dup') }, { ...metric('nested-dup') }] },
|
|
105
|
+
}
|
|
106
|
+
expect(() => createComparisonLayout({ id: 'nested', chart, table, metrics: [nestedGrid as unknown as MetricComponent] })).toThrow('Duplicate component id: nested-dup')
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('rejects ids that cannot be safely used as stable component keys', () => {
|
|
110
|
+
expect(() => createGeographyLayout({ id: 'Not stable', map, table })).toThrow('Presentation layout id')
|
|
111
|
+
expect(() => createGeographyLayout({ id: undefined as unknown as string, map, table })).toThrow('Presentation layout id')
|
|
112
|
+
expect(() => createGeographyLayout({ id: 123 as unknown as string, map, table })).toThrow('Presentation layout id')
|
|
113
|
+
})
|
|
114
|
+
})
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opt-in, pure presentation recipes for existing MCP-UI components.
|
|
3
|
+
*
|
|
4
|
+
* These helpers arrange caller-supplied components only. They never fetch,
|
|
5
|
+
* aggregate, sort, infer confidence, or manufacture sources: data, params,
|
|
6
|
+
* citations and component metadata remain the caller's responsibility.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type {
|
|
10
|
+
ChartComponentParams,
|
|
11
|
+
GridPosition,
|
|
12
|
+
MapComponentParams,
|
|
13
|
+
MetricComponentParams,
|
|
14
|
+
TableComponentParams,
|
|
15
|
+
TextComponentParams,
|
|
16
|
+
UIComponent,
|
|
17
|
+
UILayout,
|
|
18
|
+
} from '../types'
|
|
19
|
+
import type { LinkComponentParams } from '@seed-ship/mcp-ui-spec'
|
|
20
|
+
|
|
21
|
+
type PresentationType = 'chart' | 'table' | 'map' | 'metric' | 'text' | 'link'
|
|
22
|
+
|
|
23
|
+
/** A recipe owns the eventual grid position, so caller positions are optional. */
|
|
24
|
+
export type PresentationComponent<T extends PresentationType, Params> = Omit<
|
|
25
|
+
UIComponent,
|
|
26
|
+
'type' | 'params' | 'position'
|
|
27
|
+
> & {
|
|
28
|
+
type: T
|
|
29
|
+
params: Params
|
|
30
|
+
position?: GridPosition
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type ChartComponent = PresentationComponent<'chart', ChartComponentParams>
|
|
34
|
+
export type TableComponent = PresentationComponent<'table', TableComponentParams>
|
|
35
|
+
export type MapComponent = PresentationComponent<'map', MapComponentParams>
|
|
36
|
+
export type MetricComponent = PresentationComponent<'metric', MetricComponentParams>
|
|
37
|
+
export type TextComponent = PresentationComponent<'text', TextComponentParams>
|
|
38
|
+
export type LinkComponent = PresentationComponent<'link', LinkComponentParams>
|
|
39
|
+
|
|
40
|
+
export interface ComparisonLayoutInput {
|
|
41
|
+
/** Stable layout id; lower-case letters, digits and hyphens only. */
|
|
42
|
+
id: string
|
|
43
|
+
chart: ChartComponent
|
|
44
|
+
table: TableComponent
|
|
45
|
+
metrics?: MetricComponent[]
|
|
46
|
+
summary?: TextComponent
|
|
47
|
+
metadata?: UILayout['metadata']
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface GeographyLayoutInput {
|
|
51
|
+
/** Stable layout id; lower-case letters, digits and hyphens only. */
|
|
52
|
+
id: string
|
|
53
|
+
map: MapComponent
|
|
54
|
+
table: TableComponent
|
|
55
|
+
metrics?: MetricComponent[]
|
|
56
|
+
summary?: TextComponent
|
|
57
|
+
metadata?: UILayout['metadata']
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface EvidenceLayoutInput {
|
|
61
|
+
/** Stable layout id; lower-case letters, digits and hyphens only. */
|
|
62
|
+
id: string
|
|
63
|
+
summary: TextComponent
|
|
64
|
+
table: TableComponent
|
|
65
|
+
sources?: LinkComponent[]
|
|
66
|
+
metadata?: UILayout['metadata']
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const ID_PATTERN = /^[a-z0-9-]+$/
|
|
70
|
+
const DEFAULT_GRID = { columns: 12, gap: '1rem' } as const
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Presents a comparison in reading order: optional context and KPIs, then a
|
|
74
|
+
* chart beside the complete table. The supplied rows and chart series keep
|
|
75
|
+
* their original order.
|
|
76
|
+
*/
|
|
77
|
+
export function createComparisonLayout(input: ComparisonLayoutInput): UILayout {
|
|
78
|
+
assertRecipeId(input.id)
|
|
79
|
+
const inputComponents: Array<PresentationComponent<PresentationType, unknown>> = [
|
|
80
|
+
input.chart, input.table, ...(input.metrics ?? []),
|
|
81
|
+
]
|
|
82
|
+
if (input.summary) inputComponents.push(input.summary)
|
|
83
|
+
assertUniqueComponentIds(inputComponents)
|
|
84
|
+
assertType(input.chart, 'chart', 'chart')
|
|
85
|
+
assertType(input.table, 'table', 'table')
|
|
86
|
+
input.metrics?.forEach((metric, index) => assertType(metric, 'metric', `metrics[${index}]`))
|
|
87
|
+
if (input.summary) assertType(input.summary, 'text', 'summary')
|
|
88
|
+
|
|
89
|
+
let row = 1
|
|
90
|
+
const components: UIComponent[] = []
|
|
91
|
+
if (input.summary) {
|
|
92
|
+
components.push(copyForLayout(input.summary, fullWidth(row++)))
|
|
93
|
+
}
|
|
94
|
+
components.push(...placeMetrics(input.metrics ?? [], row))
|
|
95
|
+
row += metricRows(input.metrics?.length ?? 0)
|
|
96
|
+
components.push(
|
|
97
|
+
copyForLayout(input.chart, { colStart: 1, colSpan: 7, rowStart: row }),
|
|
98
|
+
copyForLayout(input.table, { colStart: 8, colSpan: 5, rowStart: row })
|
|
99
|
+
)
|
|
100
|
+
return makeLayout(input.id, components, input.metadata)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Pairs a geographic overview with the corresponding inspectable table.
|
|
105
|
+
* Map and table data are passed through unchanged, including GeoJSON and
|
|
106
|
+
* citation maps.
|
|
107
|
+
*/
|
|
108
|
+
export function createGeographyLayout(input: GeographyLayoutInput): UILayout {
|
|
109
|
+
assertRecipeId(input.id)
|
|
110
|
+
const inputComponents: Array<PresentationComponent<PresentationType, unknown>> = [
|
|
111
|
+
input.map, input.table, ...(input.metrics ?? []),
|
|
112
|
+
]
|
|
113
|
+
if (input.summary) inputComponents.push(input.summary)
|
|
114
|
+
assertUniqueComponentIds(inputComponents)
|
|
115
|
+
assertType(input.map, 'map', 'map')
|
|
116
|
+
assertType(input.table, 'table', 'table')
|
|
117
|
+
input.metrics?.forEach((metric, index) => assertType(metric, 'metric', `metrics[${index}]`))
|
|
118
|
+
if (input.summary) assertType(input.summary, 'text', 'summary')
|
|
119
|
+
|
|
120
|
+
let row = 1
|
|
121
|
+
const components: UIComponent[] = []
|
|
122
|
+
if (input.summary) {
|
|
123
|
+
components.push(copyForLayout(input.summary, fullWidth(row++)))
|
|
124
|
+
}
|
|
125
|
+
components.push(...placeMetrics(input.metrics ?? [], row))
|
|
126
|
+
row += metricRows(input.metrics?.length ?? 0)
|
|
127
|
+
components.push(
|
|
128
|
+
copyForLayout(input.map, { colStart: 1, colSpan: 8, rowStart: row }),
|
|
129
|
+
copyForLayout(input.table, { colStart: 9, colSpan: 4, rowStart: row })
|
|
130
|
+
)
|
|
131
|
+
return makeLayout(input.id, components, input.metadata)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Puts the conclusion first, evidence table second, and supplied source links
|
|
136
|
+
* last. Source order is deliberately preserved: this is a presentation recipe,
|
|
137
|
+
* not a confidence or provenance ranking algorithm.
|
|
138
|
+
*/
|
|
139
|
+
export function createEvidenceLayout(input: EvidenceLayoutInput): UILayout {
|
|
140
|
+
assertRecipeId(input.id)
|
|
141
|
+
const pieces = [input.summary, input.table, ...(input.sources ?? [])]
|
|
142
|
+
assertType(input.summary, 'text', 'summary')
|
|
143
|
+
assertType(input.table, 'table', 'table')
|
|
144
|
+
input.sources?.forEach((source, index) => assertType(source, 'link', `sources[${index}]`))
|
|
145
|
+
assertUniqueComponentIds(pieces)
|
|
146
|
+
|
|
147
|
+
const components: UIComponent[] = [
|
|
148
|
+
copyForLayout(input.summary, fullWidth(1)),
|
|
149
|
+
copyForLayout(input.table, fullWidth(2)),
|
|
150
|
+
...placeSources(input.sources ?? [], 3),
|
|
151
|
+
]
|
|
152
|
+
return makeLayout(input.id, components, input.metadata)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function makeLayout(id: string, components: UIComponent[], metadata?: UILayout['metadata']): UILayout {
|
|
156
|
+
assertUniqueComponentIds(components)
|
|
157
|
+
return { id, components, grid: { ...DEFAULT_GRID }, ...(metadata ? { metadata: { ...metadata } } : {}) }
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function fullWidth(rowStart: number): GridPosition {
|
|
161
|
+
return { colStart: 1, colSpan: 12, rowStart }
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function placeMetrics(metrics: MetricComponent[], rowStart: number): UIComponent[] {
|
|
165
|
+
if (metrics.length === 0) return []
|
|
166
|
+
const span = metrics.length === 1 ? 12 : metrics.length === 2 ? 6 : metrics.length === 3 ? 4 : 3
|
|
167
|
+
return metrics.map((metric, index) => {
|
|
168
|
+
const column = (index % Math.floor(12 / span)) * span + 1
|
|
169
|
+
const row = rowStart + Math.floor(index / Math.floor(12 / span))
|
|
170
|
+
return copyForLayout(metric, { colStart: column, colSpan: span, rowStart: row })
|
|
171
|
+
})
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function metricRows(count: number): number {
|
|
175
|
+
if (count === 0) return 0
|
|
176
|
+
if (count <= 4) return 1
|
|
177
|
+
return Math.ceil(count / 4)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function placeSources(sources: LinkComponent[], rowStart: number): UIComponent[] {
|
|
181
|
+
return sources.map((source, index) => ({
|
|
182
|
+
...copyForLayout(source, {
|
|
183
|
+
colStart: (index % 3) * 4 + 1,
|
|
184
|
+
colSpan: 4,
|
|
185
|
+
rowStart: rowStart + Math.floor(index / 3),
|
|
186
|
+
}),
|
|
187
|
+
}))
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function copyForLayout(
|
|
191
|
+
component: PresentationComponent<PresentationType, unknown>,
|
|
192
|
+
position: GridPosition
|
|
193
|
+
): UIComponent {
|
|
194
|
+
return {
|
|
195
|
+
...component,
|
|
196
|
+
position: { ...position },
|
|
197
|
+
} as UIComponent
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function assertRecipeId(id: string): void {
|
|
201
|
+
if (typeof id !== 'string' || !ID_PATTERN.test(id)) {
|
|
202
|
+
throw new Error('Presentation layout id must contain only lower-case letters, digits and hyphens.')
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function assertType(component: { type: string }, expected: PresentationType, path: string): void {
|
|
207
|
+
if (component?.type !== expected) {
|
|
208
|
+
throw new Error(`${path} must be a ${expected} component.`)
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Validates source IDs recursively so nested GridRenderer children stay key-safe. */
|
|
213
|
+
function assertUniqueComponentIds(components: Array<UIComponent | PresentationComponent<PresentationType, unknown>>): void {
|
|
214
|
+
const ids = new Set<string>()
|
|
215
|
+
const visit = (component: { id: unknown; type?: unknown; params?: unknown }, path: string): void => {
|
|
216
|
+
if (!component || typeof component.id !== 'string' || !ID_PATTERN.test(component.id)) {
|
|
217
|
+
throw new Error(`${path} must have a stable lower-case component id.`)
|
|
218
|
+
}
|
|
219
|
+
if (ids.has(component.id)) throw new Error(`Duplicate component id: ${component.id}`)
|
|
220
|
+
ids.add(component.id)
|
|
221
|
+
if (component.type === 'grid') {
|
|
222
|
+
const children = (component.params as { children?: unknown })?.children
|
|
223
|
+
if (Array.isArray(children)) {
|
|
224
|
+
children.forEach((child, index) => visit(child as { id: unknown; type?: unknown; params?: unknown }, `${path}.children[${index}]`))
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
components.forEach((component, index) => visit(component, `components[${index}]`))
|
|
229
|
+
}
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
import { cleanup, fireEvent, render, waitFor } from '@solidjs/testing-library';
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { createSignal } from 'solid-js';
|
|
4
|
+
import type { UIComponent } from '../types';
|
|
5
|
+
import { MCPUIStringsProvider } from '../context/MCPUIStringsContext';
|
|
6
|
+
import { ChartJSRenderer } from './ChartJSRenderer';
|
|
7
|
+
import { ScratchpadPanel } from './ScratchpadPanel';
|
|
8
|
+
import { UIResourceRenderer } from './UIResourceRenderer';
|
|
9
|
+
import { chartToDataTable } from './chart-data-table';
|
|
10
|
+
|
|
11
|
+
const chartHarness = vi.hoisted(() => ({
|
|
12
|
+
fail: false,
|
|
13
|
+
configs: [] as unknown[],
|
|
14
|
+
instances: [] as Array<{ destroy: ReturnType<typeof vi.fn>; resize: ReturnType<typeof vi.fn> }>,
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
vi.mock('chart.js/auto', () => {
|
|
18
|
+
class FakeChart {
|
|
19
|
+
destroy = vi.fn();
|
|
20
|
+
resize = vi.fn();
|
|
21
|
+
|
|
22
|
+
constructor(_canvas: HTMLCanvasElement, config: unknown) {
|
|
23
|
+
if (chartHarness.fail) throw new Error('paint failed');
|
|
24
|
+
chartHarness.configs.push(config);
|
|
25
|
+
chartHarness.instances.push(this);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return { default: FakeChart };
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
function chartComponent(params: Record<string, unknown> = {}): UIComponent {
|
|
33
|
+
return {
|
|
34
|
+
id: 'chart-1',
|
|
35
|
+
type: 'chart',
|
|
36
|
+
position: { colStart: 1, colSpan: 6 },
|
|
37
|
+
params: {
|
|
38
|
+
type: 'bar',
|
|
39
|
+
title: 'Quarterly sales',
|
|
40
|
+
data: {
|
|
41
|
+
labels: ['Q1', 'Q2'],
|
|
42
|
+
datasets: [{ label: 'Revenue', data: [10, 20] }],
|
|
43
|
+
},
|
|
44
|
+
exportable: false,
|
|
45
|
+
...params,
|
|
46
|
+
},
|
|
47
|
+
} as UIComponent;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe('chartToDataTable', () => {
|
|
51
|
+
it('projects categorical bar and line data without dropping uneven values', () => {
|
|
52
|
+
expect(
|
|
53
|
+
chartToDataTable({
|
|
54
|
+
type: 'line',
|
|
55
|
+
data: {
|
|
56
|
+
labels: ['Jan', 'Feb'],
|
|
57
|
+
datasets: [
|
|
58
|
+
{ label: 'North', data: [1, 2] },
|
|
59
|
+
{ label: 'South', data: [3] },
|
|
60
|
+
],
|
|
61
|
+
},
|
|
62
|
+
}),
|
|
63
|
+
).toEqual({
|
|
64
|
+
columns: ['Label', 'North', 'South'],
|
|
65
|
+
rows: [
|
|
66
|
+
['Jan', 1, 3],
|
|
67
|
+
['Feb', 2, ''],
|
|
68
|
+
],
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('keeps scatter x/y and bubble x/y/r in separate cells', () => {
|
|
73
|
+
expect(
|
|
74
|
+
chartToDataTable({
|
|
75
|
+
type: 'scatter',
|
|
76
|
+
data: { datasets: [{ label: 'Observations', data: [{ x: 2, y: 8 }] }] },
|
|
77
|
+
}),
|
|
78
|
+
).toEqual({
|
|
79
|
+
columns: ['Series', 'Point', 'x', 'y'],
|
|
80
|
+
rows: [['Observations', 1, 2, 8]],
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
expect(
|
|
84
|
+
chartToDataTable({
|
|
85
|
+
type: 'bubble',
|
|
86
|
+
data: { datasets: [{ label: 'Cities', data: [{ x: 4, y: 6, r: 12 }] }] },
|
|
87
|
+
}),
|
|
88
|
+
).toEqual({
|
|
89
|
+
columns: ['Series', 'Point', 'x', 'y', 'r'],
|
|
90
|
+
rows: [['Cities', 1, 4, 6, 12]],
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe('<ChartJSRenderer>', () => {
|
|
96
|
+
beforeEach(() => {
|
|
97
|
+
chartHarness.fail = false;
|
|
98
|
+
chartHarness.configs.length = 0;
|
|
99
|
+
chartHarness.instances.length = 0;
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
afterEach(() => cleanup());
|
|
103
|
+
|
|
104
|
+
it('keeps the native chart default and exposes a titled, described canvas', async () => {
|
|
105
|
+
const { getByRole } = render(() => <ChartJSRenderer component={chartComponent()} />);
|
|
106
|
+
|
|
107
|
+
await waitFor(() => expect(chartHarness.instances).toHaveLength(1));
|
|
108
|
+
const canvas = getByRole('img', { name: 'Quarterly sales' });
|
|
109
|
+
const description = document.getElementById(canvas.getAttribute('aria-describedby')!);
|
|
110
|
+
|
|
111
|
+
expect(description?.textContent).toContain('Quarterly sales');
|
|
112
|
+
expect(description?.textContent).toContain('Exact values are available in the data view.');
|
|
113
|
+
expect(chartHarness.configs[0]).toMatchObject({
|
|
114
|
+
type: 'bar',
|
|
115
|
+
data: { labels: ['Q1', 'Q2'] },
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('uses keyboard-native buttons to toggle a successful chart to its exact data', async () => {
|
|
120
|
+
const { getByRole, getAllByRole } = render(() => (
|
|
121
|
+
<ChartJSRenderer component={chartComponent({ exportable: true })} />
|
|
122
|
+
));
|
|
123
|
+
await waitFor(() => expect(chartHarness.instances).toHaveLength(1));
|
|
124
|
+
|
|
125
|
+
const chartButton = getByRole('button', { name: 'Chart' });
|
|
126
|
+
const dataButton = getByRole('button', { name: 'Data' });
|
|
127
|
+
expect(chartButton.tagName).toBe('BUTTON');
|
|
128
|
+
expect(dataButton.tabIndex).toBe(0);
|
|
129
|
+
expect(chartButton.getAttribute('aria-pressed')).toBe('true');
|
|
130
|
+
|
|
131
|
+
fireEvent.click(dataButton);
|
|
132
|
+
expect(dataButton.getAttribute('aria-pressed')).toBe('true');
|
|
133
|
+
expect(getByRole('button', { name: 'Download chart as PNG' }).hasAttribute('disabled')).toBe(true);
|
|
134
|
+
expect(getByRole('table', { name: 'Quarterly sales — Chart data' })).toBeTruthy();
|
|
135
|
+
expect(getAllByRole('columnheader').map((cell) => cell.textContent)).toEqual([
|
|
136
|
+
'Label',
|
|
137
|
+
'Revenue',
|
|
138
|
+
]);
|
|
139
|
+
expect(getAllByRole('cell').map((cell) => cell.textContent)).toEqual(['Q1', '10', 'Q2', '20']);
|
|
140
|
+
|
|
141
|
+
fireEvent.click(chartButton);
|
|
142
|
+
await waitFor(() => expect(chartHarness.instances[0].resize).toHaveBeenCalled());
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('bounds every inline data row to the configured height and fills the expanded viewport', async () => {
|
|
146
|
+
const labels = Array.from({ length: 40 }, (_, index) => `Row ${index + 1}`);
|
|
147
|
+
const values = labels.map((_, index) => index + 1);
|
|
148
|
+
const { getByRole, getByLabelText } = render(() => (
|
|
149
|
+
<ChartJSRenderer
|
|
150
|
+
component={chartComponent({
|
|
151
|
+
height: '18rem',
|
|
152
|
+
data: { labels, datasets: [{ label: 'Revenue', data: values }] },
|
|
153
|
+
})}
|
|
154
|
+
/>
|
|
155
|
+
));
|
|
156
|
+
|
|
157
|
+
await waitFor(() => expect(chartHarness.instances).toHaveLength(1));
|
|
158
|
+
fireEvent.click(getByRole('button', { name: 'Data' }));
|
|
159
|
+
const table = getByRole('table', { name: 'Quarterly sales — Chart data' });
|
|
160
|
+
const viewport = table.parentElement!;
|
|
161
|
+
|
|
162
|
+
expect(viewport.style.height).toBe('18rem');
|
|
163
|
+
expect(viewport.style.maxHeight).toBe('70vh');
|
|
164
|
+
expect(viewport.classList.contains('overflow-auto')).toBe(true);
|
|
165
|
+
expect(table.textContent).toContain('Row 40');
|
|
166
|
+
expect(table.textContent).toContain('40');
|
|
167
|
+
|
|
168
|
+
fireEvent.click(getByLabelText('Expand to fullscreen'));
|
|
169
|
+
await waitFor(() =>
|
|
170
|
+
expect(document.querySelector('[role="dialog"][aria-label="Quarterly sales"]')).toBeTruthy(),
|
|
171
|
+
);
|
|
172
|
+
expect(viewport.style.height).toBe('');
|
|
173
|
+
expect(viewport.style.maxHeight).toBe('');
|
|
174
|
+
expect(viewport.classList.contains('flex-1')).toBe(true);
|
|
175
|
+
expect(viewport.classList.contains('min-h-0')).toBe(true);
|
|
176
|
+
expect(table.textContent).toContain('Row 40');
|
|
177
|
+
expect(chartHarness.instances).toHaveLength(1);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('uses the default data height and applies a custom class to the chart wrapper', () => {
|
|
181
|
+
const { container, getByRole } = render(() => (
|
|
182
|
+
<ChartJSRenderer component={chartComponent({ className: 'consumer-chart-shell' })} />
|
|
183
|
+
));
|
|
184
|
+
|
|
185
|
+
fireEvent.click(getByRole('button', { name: 'Data' }));
|
|
186
|
+
const table = getByRole('table', { name: 'Quarterly sales — Chart data' });
|
|
187
|
+
const wrapper = container.querySelector('.consumer-chart-shell');
|
|
188
|
+
|
|
189
|
+
expect(table.parentElement?.style.height).toBe('250px');
|
|
190
|
+
expect(wrapper).toBeTruthy();
|
|
191
|
+
expect(wrapper?.contains(table)).toBe(true);
|
|
192
|
+
expect(wrapper?.classList.contains('rounded-lg')).toBe(true);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('makes the data view available while the native chart is still loading', () => {
|
|
196
|
+
const { getByRole, queryByText } = render(() => (
|
|
197
|
+
<ChartJSRenderer component={chartComponent()} />
|
|
198
|
+
));
|
|
199
|
+
|
|
200
|
+
fireEvent.click(getByRole('button', { name: 'Data' }));
|
|
201
|
+
expect(getByRole('table', { name: 'Quarterly sales — Chart data' })).toBeTruthy();
|
|
202
|
+
expect(queryByText('Loading chart...')).toBeNull();
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('updates both Chart.js and the selected data view when props change', async () => {
|
|
206
|
+
const [component, setComponent] = createSignal(chartComponent());
|
|
207
|
+
const { getByRole } = render(() => <ChartJSRenderer component={component()} />);
|
|
208
|
+
await waitFor(() => expect(chartHarness.instances).toHaveLength(1));
|
|
209
|
+
fireEvent.click(getByRole('button', { name: 'Data' }));
|
|
210
|
+
|
|
211
|
+
setComponent(
|
|
212
|
+
chartComponent({
|
|
213
|
+
title: 'Updated sales',
|
|
214
|
+
data: { labels: ['Q3'], datasets: [{ label: 'Revenue', data: [30] }] },
|
|
215
|
+
}),
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
await waitFor(() => expect(chartHarness.instances).toHaveLength(2));
|
|
219
|
+
expect(chartHarness.instances[0].destroy).toHaveBeenCalledTimes(1);
|
|
220
|
+
expect(getByRole('table', { name: 'Updated sales — Chart data' }).textContent).toContain('30');
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('shows a localized empty state and localized view controls', async () => {
|
|
224
|
+
const component = chartComponent({
|
|
225
|
+
title: 'Vide',
|
|
226
|
+
data: { labels: [], datasets: [] },
|
|
227
|
+
});
|
|
228
|
+
const { getByRole, getByText } = render(() => (
|
|
229
|
+
<MCPUIStringsProvider
|
|
230
|
+
strings={{
|
|
231
|
+
chartView: 'Graphique',
|
|
232
|
+
chartDataView: 'Données',
|
|
233
|
+
chartViewSelector: 'Vue graphique ou données',
|
|
234
|
+
chartDataTable: 'Données du graphique',
|
|
235
|
+
chartDataSummary: 'Les valeurs exactes sont disponibles dans la vue données.',
|
|
236
|
+
chartNoData: 'Aucune donnée',
|
|
237
|
+
}}
|
|
238
|
+
>
|
|
239
|
+
<ChartJSRenderer component={component} />
|
|
240
|
+
</MCPUIStringsProvider>
|
|
241
|
+
));
|
|
242
|
+
await waitFor(() => expect(chartHarness.instances).toHaveLength(1));
|
|
243
|
+
|
|
244
|
+
expect(getByRole('group', { name: 'Vue graphique ou données' })).toBeTruthy();
|
|
245
|
+
fireEvent.click(getByRole('button', { name: 'Données' }));
|
|
246
|
+
expect(getByRole('table', { name: 'Vide — Données du graphique' })).toBeTruthy();
|
|
247
|
+
expect(getByText('Aucune donnée')).toBeTruthy();
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('retains a useful, coordinate-faithful table when Chart.js throws', async () => {
|
|
251
|
+
chartHarness.fail = true;
|
|
252
|
+
const onError = vi.fn();
|
|
253
|
+
const component = chartComponent({
|
|
254
|
+
type: 'bubble',
|
|
255
|
+
title: 'Bubbles',
|
|
256
|
+
data: { datasets: [{ label: 'Sample', data: [{ x: 1, y: 2, r: 3 }] }] },
|
|
257
|
+
});
|
|
258
|
+
const { getByText, getAllByRole } = render(() => (
|
|
259
|
+
<ChartJSRenderer component={component} onError={onError} />
|
|
260
|
+
));
|
|
261
|
+
|
|
262
|
+
await waitFor(() => expect(getByText('Chart rendering failed: paint failed')).toBeTruthy());
|
|
263
|
+
expect(onError).toHaveBeenCalledTimes(1);
|
|
264
|
+
expect(getAllByRole('columnheader').map((cell) => cell.textContent)).toEqual([
|
|
265
|
+
'Series',
|
|
266
|
+
'Point',
|
|
267
|
+
'x',
|
|
268
|
+
'y',
|
|
269
|
+
'r',
|
|
270
|
+
]);
|
|
271
|
+
expect(getAllByRole('cell').map((cell) => cell.textContent)).toEqual([
|
|
272
|
+
'Sample',
|
|
273
|
+
'1',
|
|
274
|
+
'1',
|
|
275
|
+
'2',
|
|
276
|
+
'3',
|
|
277
|
+
]);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it('destroys the Chart.js instance on unmount', async () => {
|
|
281
|
+
const view = render(() => <ChartJSRenderer component={chartComponent()} />);
|
|
282
|
+
await waitFor(() => expect(chartHarness.instances).toHaveLength(1));
|
|
283
|
+
|
|
284
|
+
view.unmount();
|
|
285
|
+
expect(chartHarness.instances[0].destroy).toHaveBeenCalledTimes(1);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it('exposes the data view through UIResourceRenderer native charts', async () => {
|
|
289
|
+
const { getByRole } = render(() => (
|
|
290
|
+
<UIResourceRenderer content={chartComponent({ renderer: 'native' })} />
|
|
291
|
+
));
|
|
292
|
+
|
|
293
|
+
await waitFor(() => expect(getByRole('group', { name: 'Chart or data view' })).toBeTruthy());
|
|
294
|
+
fireEvent.click(getByRole('button', { name: 'Data' }));
|
|
295
|
+
expect(getByRole('table', { name: 'Quarterly sales — Chart data' })).toBeTruthy();
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it('exposes the data view through ScratchpadPanel chart sections', async () => {
|
|
299
|
+
const { getByRole } = render(() => (
|
|
300
|
+
<ScratchpadPanel
|
|
301
|
+
state={{
|
|
302
|
+
id: 'scratch-1',
|
|
303
|
+
title: 'Analysis',
|
|
304
|
+
status: 'ready',
|
|
305
|
+
filters: {},
|
|
306
|
+
agentMessages: [],
|
|
307
|
+
sections: [
|
|
308
|
+
{
|
|
309
|
+
id: 'section-chart',
|
|
310
|
+
title: 'Sales',
|
|
311
|
+
type: 'chart',
|
|
312
|
+
editable: false,
|
|
313
|
+
source: 'agent',
|
|
314
|
+
content: chartComponent().params,
|
|
315
|
+
},
|
|
316
|
+
],
|
|
317
|
+
}}
|
|
318
|
+
/>
|
|
319
|
+
));
|
|
320
|
+
|
|
321
|
+
await waitFor(() => expect(getByRole('group', { name: 'Chart or data view' })).toBeTruthy());
|
|
322
|
+
fireEvent.click(getByRole('button', { name: 'Data' }));
|
|
323
|
+
expect(getByRole('table', { name: 'Quarterly sales — Chart data' })).toBeTruthy();
|
|
324
|
+
});
|
|
325
|
+
});
|