Skip to content

Commit 4f1b0e0

Browse files
fix(charts): format index-encoded pie tooltips and test dashboard refresh through the UI
Pie tooltips encoded by dimension index now use the custom formatter, resolving the measure through the encode indexes ECharts passes it. The dashboard preview test drops the mocked controls and callback-driven cases and clicks the real Refresh button to check which analytics queries are invalidated; the range and timezone math stays covered by the time tests. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S6aTRnkiu7PxYZPNPXYEMV
1 parent bf3243c commit 4f1b0e0

3 files changed

Lines changed: 60 additions & 114 deletions

File tree

‎apps/sim/components/dashboards/dashboard-preview.test.tsx‎

Lines changed: 31 additions & 110 deletions
Original file line numberDiff line numberDiff line change
@@ -4,142 +4,63 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
44
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
55
import { createRoot, type Root } from 'react-dom/client'
66
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
7-
import { useDashboardInteractions } from '@/components/dashboards/dashboard-interactions'
87
import { DashboardPreview } from '@/components/dashboards/dashboard-preview'
98
import { tableAnalyticsKeys } from '@/hooks/queries/table-analytics'
109

11-
const mocks = vi.hoisted(() => ({
12-
controls: vi.fn(),
13-
layout: vi.fn(),
14-
url: vi.fn(),
15-
timezone: vi.fn(),
16-
}))
17-
vi.mock('@/lib/core/utils/timezone', async (importOriginal) => ({
18-
...(await importOriginal<typeof import('@/lib/core/utils/timezone')>()),
19-
getBrowserTimezone: mocks.timezone,
20-
}))
21-
vi.mock('@/components/dashboards/dashboard-controls', () => ({
22-
DashboardControls: (props: unknown) => {
23-
mocks.controls(props)
24-
return null
25-
},
26-
}))
27-
vi.mock('@/components/dashboards/dashboard-layout', () => ({
28-
DashboardLayout: (props: unknown) => {
29-
mocks.layout(props, useDashboardInteractions())
30-
return null
31-
},
32-
}))
10+
/** Panels would issue analytics requests; this suite exercises the controls and query cache. */
11+
vi.mock('@/components/dashboards/dashboard-layout', () => ({ DashboardLayout: () => null }))
12+
3313
const content =
3414
'title: Example\ntime: 7d\nsource: {tableId: table-1}\nblocks: [{stat: Total, source: {aggregate: {total: {op: count}}}}]'
35-
const zoom = { from: '2026-09-20T17:26:54.766Z', to: '2026-09-21T03:49:57.697Z' }
15+
const range = { from: '2026-09-17T00:00:00.000Z', to: '2026-09-24T00:00:00.000Z' }
3616

37-
describe('dashboard view state', () => {
17+
describe('dashboard refresh', () => {
3818
let root: Root
19+
let container: HTMLDivElement
3920
let client: QueryClient
4021
beforeEach(() => {
41-
mocks.timezone.mockReturnValue('America/Los_Angeles')
4222
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
4323
client = new QueryClient()
44-
root = createRoot(document.createElement('div'))
24+
container = document.createElement('div')
25+
document.body.append(container)
26+
root = createRoot(container)
4527
})
4628
afterEach(() => {
4729
act(() => root.unmount())
30+
container.remove()
4831
client.clear()
49-
vi.unstubAllGlobals()
5032
})
51-
async function render(searchParams = '') {
52-
await act(async () =>
53-
root.render(
54-
<QueryClientProvider client={client}>
55-
<NuqsTestingAdapter hasMemory searchParams={searchParams} onUrlUpdate={mocks.url}>
56-
<DashboardPreview content={content} workspaceId='workspace-1' fileId='example' />
57-
</NuqsTestingAdapter>
58-
</QueryClientProvider>
59-
)
60-
)
61-
}
62-
const controls = () => mocks.controls.mock.lastCall![0]
63-
const layout = () => mocks.layout.mock.lastCall![0]
64-
const interactions = () => mocks.layout.mock.lastCall![1]
6533

66-
it.each(['America/Los_Angeles', 'Asia/Tokyo'])(
67-
'defaults to the viewer timezone %s and allows UTC without changing the interval',
68-
async (timezone) => {
69-
mocks.timezone.mockReturnValue(timezone)
70-
await render()
71-
expect(controls().zone).toBe('local')
72-
expect(interactions().timeZone).toBe(timezone)
73-
const original = layout().range
74-
await act(async () => controls().onZoneChange('utc'))
75-
expect(interactions().timeZone).toBe('UTC')
76-
expect(layout().range).toEqual(original)
77-
await vi.waitFor(() => {
78-
expect(mocks.url.mock.lastCall![0].searchParams.get('dash-example-zone')).toBe('utc')
79-
})
80-
await act(async () => controls().onZoneChange('local'))
81-
expect(interactions().timeZone).toBe(timezone)
82-
await vi.waitFor(() => {
83-
expect(mocks.url.mock.lastCall![0].searchParams.has('dash-example-zone')).toBe(false)
84-
})
85-
}
86-
)
87-
88-
it('sets exact shared bounds without navigation and leaves zoom by choosing a preset', async () => {
89-
await render()
90-
await act(async () => interactions().onZoom(zoom))
91-
expect(layout().range).toEqual(zoom)
92-
expect(controls().period).toBe('custom')
93-
await vi.waitFor(() => expect(mocks.url.mock.lastCall?.[0].options.shallow).toBe(true))
94-
await act(async () => controls().onPeriodChange('7d'))
95-
expect(controls().period).toBe('7d')
96-
expect(Date.parse(layout().range.to) - Date.parse(layout().range.from)).toBe(7 * 86400000)
97-
})
98-
it('commits the calendar dates and custom mode together, preserving the view on invalid input', async () => {
99-
await render('?dash-example-range=30d&dash-example-zone=utc')
100-
expect(controls().zone).toBe('utc')
101-
await act(async () => {
102-
expect(controls().onCalendarChange('2026-09-17T00:00', '2026-09-23T23:59:59')).toBe(true)
103-
})
104-
expect(controls().period).toBe('custom')
105-
expect(layout().range).toEqual({
106-
from: '2026-09-17T00:00:00.000Z',
107-
to: '2026-09-24T00:00:00.000Z',
108-
})
109-
const selected = layout().range
110-
await act(async () => {
111-
expect(controls().onCalendarChange('invalid', 'invalid')).toBe(false)
112-
})
113-
expect(layout().range).toEqual(selected)
114-
})
115-
it('keeps exact bounds through repeated zooms and timezone changes', async () => {
116-
await render(
117-
'?dash-example-range=custom&dash-example-from=2026-09-17T00:00:00&dash-example-to=2026-09-24T00:00:00'
118-
)
119-
await act(async () => interactions().onZoom(zoom))
120-
const selected = { ...zoom, to: '2026-09-20T18:00:00.000Z' }
121-
await act(async () => interactions().onZoom(selected))
122-
expect(layout().range).toEqual(selected)
123-
await act(async () => controls().onZoneChange('utc'))
124-
expect(layout().range).toEqual(selected)
125-
expect(controls().zone).toBe('utc')
126-
})
127-
it('refreshes custom-range queries only for this workspace and this dashboard tables', async () => {
34+
it('refreshes only this workspace and this dashboard tables', async () => {
12835
const key = (workspaceId: string, tableId: string) =>
12936
tableAnalyticsKeys.query(tableId, {
13037
workspaceId,
131-
query: { ...zoom, aggregate: { total: { op: 'count' } } },
38+
query: { ...range, aggregate: { total: { op: 'count' } } },
13239
})
13340
const matching = key('workspace-1', 'table-1')
13441
const otherTable = key('workspace-1', 'other-table')
13542
const otherWorkspace = key('workspace-2', 'table-1')
13643
for (const queryKey of [matching, otherTable, otherWorkspace]) client.setQueryData(queryKey, {})
137-
await render(
138-
'?dash-example-range=custom&dash-example-from=2026-09-17T00:00:00&dash-example-to=2026-09-24T00:00:00'
44+
await act(async () =>
45+
root.render(
46+
<QueryClientProvider client={client}>
47+
<NuqsTestingAdapter
48+
hasMemory
49+
searchParams='?dash-example-range=custom&dash-example-from=2026-09-17T00:00:00&dash-example-to=2026-09-24T00:00:00&dash-example-zone=utc'
50+
>
51+
<DashboardPreview content={content} workspaceId='workspace-1' fileId='example' />
52+
</NuqsTestingAdapter>
53+
</QueryClientProvider>
54+
)
13955
)
140-
const original = layout().range
141-
await act(async () => controls().onRefresh())
142-
expect(layout().range).toEqual(original)
56+
const refresh = await vi.waitFor(() => {
57+
const button = container.querySelector<HTMLButtonElement>(
58+
'button[aria-label="Refresh dashboard"]'
59+
)
60+
if (!button) throw new Error('Refresh button not rendered')
61+
return button
62+
})
63+
await act(async () => refresh.click())
14364
expect(client.getQueryState(matching)?.isInvalidated).toBe(true)
14465
expect(client.getQueryState(otherTable)?.isInvalidated).toBe(false)
14566
expect(client.getQueryState(otherWorkspace)?.isInvalidated).toBe(false)

‎apps/sim/lib/charts/theme.test.ts‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
/** @vitest-environment node */
2+
import { toRecord } from '@sim/utils/object'
23
import { describe, expect, it } from 'vitest'
34
import { applyChartTooltipDefaults, formatBarTooltip, formatPieTooltip } from '@/lib/charts/theme'
45

@@ -77,6 +78,24 @@ describe('pie tooltips', () => {
7778
).toMatchObject({ formatter: '{b}: {d}%' })
7879
})
7980

81+
it('formats pies encoded by dimension index with the encoded measure', () => {
82+
const option = applyChartTooltipDefaults({
83+
series: [{ type: 'pie', encode: { itemName: 0, value: 1 } }],
84+
})
85+
const formatter = toRecord(option.tooltip).formatter
86+
if (typeof formatter !== 'function') throw new Error('Expected the pie tooltip formatter')
87+
expect(
88+
formatter({
89+
seriesIndex: 0,
90+
name: 'Human resolved',
91+
value: ['Human resolved', 437],
92+
dimensionNames: ['topic', 'tickets'],
93+
encode: { value: [1] },
94+
percent: 25,
95+
})
96+
).toBe('Human resolved: 437 (25%)')
97+
})
98+
8099
it('keeps native formatting for pies with automatic encodings', () => {
81100
expect(
82101
applyChartTooltipDefaults({

‎apps/sim/lib/charts/theme.ts‎

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -75,12 +75,18 @@ export function applyChartTooltipDefaults(option: Record<string, unknown>) {
7575
const encoded = toRecord(toRecord(entry).encode).value
7676
return Array.isArray(encoded) ? encoded[0] : encoded
7777
})
78-
/** Native formatting handles automatic encodings; table charts supply named measures. */
79-
if (!valueFields.every((field): field is string => typeof field === 'string')) return option
78+
/**
79+
* Native formatting handles automatic encodings. Table charts name their measure; indexed
80+
* encodings resolve through the encode and dimension names ECharts passes the formatter.
81+
*/
82+
if (!valueFields.every((field) => typeof field === 'string' || typeof field === 'number'))
83+
return option
8084
option.tooltip = mapTooltipEntries(option.tooltip, (tooltip) => ({
8185
trigger: 'item',
82-
formatter: (params: unknown) =>
83-
formatPieTooltip(params, valueFields[Number(toRecord(params).seriesIndex)]),
86+
formatter: (params: unknown) => {
87+
const field = valueFields[Number(toRecord(params).seriesIndex)]
88+
return formatPieTooltip(params, typeof field === 'string' ? field : undefined)
89+
},
8490
...tooltip,
8591
}))
8692
}

0 commit comments

Comments
 (0)