Skip to content

Commit be03e9c

Browse files
ryan-williamsclaude
andcommitted
fix: table device dropdown switches when selected device is deselected
The data table dropdown was checking against `deviceAggregations` (all devices with data) instead of `displayDeviceAggregations` (currently selected devices). When a device was deselected, the dropdown would still show it. Changes: - useEffect checks `displayDeviceAggregations` to detect deselected devices - DataTable receives only `displayDeviceAggregations` for dropdown options - `selectedDeviceAggregation` lookup uses filtered list Added e2e tests for device selection behavior. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 6c3adbc commit be03e9c

File tree

2 files changed

+157
-7
lines changed

2 files changed

+157
-7
lines changed

www/src/components/AwairChart.tsx

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -195,20 +195,20 @@ export const AwairChart = memo(function AwairChart(
195195
// Track which device to show in the DataTable (defaults to first device)
196196
const [selectedDeviceIdForTable, setSelectedDeviceIdForTable] = useState<number | undefined>(undefined)
197197

198-
// Auto-select first device when deviceAggregations changes, or reset if selected device no longer available
198+
// Auto-select first device when displayDeviceAggregations changes, or reset if selected device no longer displayed
199199
useEffect(() => {
200-
if (deviceAggregations.length === 0) return
201-
const selectedStillAvailable = deviceAggregations.some(d => d.deviceId === selectedDeviceIdForTable)
200+
if (displayDeviceAggregations.length === 0) return
201+
const selectedStillAvailable = displayDeviceAggregations.some(d => d.deviceId === selectedDeviceIdForTable)
202202
if (!selectedStillAvailable) {
203-
setSelectedDeviceIdForTable(deviceAggregations[0].deviceId)
203+
setSelectedDeviceIdForTable(displayDeviceAggregations[0].deviceId)
204204
}
205-
}, [deviceAggregations, selectedDeviceIdForTable])
205+
}, [displayDeviceAggregations, selectedDeviceIdForTable])
206206

207207
// Table page size - persisted in URL
208208
const [tablePageSize, setTablePageSize] = useUrlState('p', intFromList([10, 20, 50, 100, 200] as const, 20))
209209

210210
// Get the selected device's aggregated data for the table
211-
const selectedDeviceAggregation = deviceAggregations.find(d => d.deviceId === selectedDeviceIdForTable)
211+
const selectedDeviceAggregation = displayDeviceAggregations.find(d => d.deviceId === selectedDeviceIdForTable)
212212
const aggregatedData = selectedDeviceAggregation?.aggregatedData || []
213213

214214
// Calculate time range in minutes for aggregation control
@@ -1221,7 +1221,7 @@ export const AwairChart = memo(function AwairChart(
12211221
fullDataStartTime={tableMetadata.fullDataStartTime}
12221222
fullDataEndTime={tableMetadata.fullDataEndTime}
12231223
windowMinutes={selectedWindow.minutes}
1224-
deviceAggregations={deviceAggregations}
1224+
deviceAggregations={displayDeviceAggregations}
12251225
selectedDeviceId={selectedDeviceIdForTable}
12261226
onDeviceChange={setSelectedDeviceIdForTable}
12271227
timeRange={timeRangeFromProps}
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import * as fs from 'fs'
2+
import * as path from 'path'
3+
import { fileURLToPath } from 'url'
4+
import { test, expect } from '@playwright/test'
5+
6+
const __filename = fileURLToPath(import.meta.url)
7+
const __dirname = path.dirname(__filename)
8+
9+
test.describe('Device Selection', () => {
10+
test.beforeEach(async ({ page }) => {
11+
page.on('console', msg => console.log('Browser console:', msg.text()))
12+
page.on('pageerror', error => console.error('Page error:', error))
13+
14+
// Intercept S3 requests and serve local snapshot files
15+
await page.route('**/*.parquet', async route => {
16+
const request = route.request()
17+
const url = request.url()
18+
const method = request.method()
19+
20+
let filePath: string | null = null
21+
const testDataMonths = ['2025-06', '2025-07', '2025-08', '2025-09', '2025-10', '2025-11']
22+
const isTestDataMonth = testDataMonths.some(m => url.includes(`/${m}.parquet`))
23+
24+
if (url.includes('awair-17617/') && isTestDataMonth) {
25+
filePath = path.join(__dirname, '../../test-data/awair-17617.parquet')
26+
} else if (url.includes('awair-17617/')) {
27+
await route.fulfill({ status: 404 })
28+
return
29+
} else if (url.includes('awair-137496/') && isTestDataMonth) {
30+
filePath = path.join(__dirname, '../../test-data/awair-137496.parquet')
31+
} else if (url.includes('awair-137496/')) {
32+
await route.fulfill({ status: 404 })
33+
return
34+
} else if (url.includes('devices.parquet')) {
35+
filePath = path.join(__dirname, '../../test-data/devices.parquet')
36+
}
37+
38+
if (filePath) {
39+
const stats = fs.statSync(filePath)
40+
const fileSize = stats.size
41+
42+
if (method === 'HEAD') {
43+
await route.fulfill({
44+
status: 200,
45+
headers: {
46+
'Content-Length': fileSize.toString(),
47+
'Accept-Ranges': 'bytes',
48+
'Content-Type': 'application/octet-stream',
49+
},
50+
})
51+
return
52+
}
53+
54+
const rangeHeader = request.headers()['range']
55+
if (rangeHeader) {
56+
const match = rangeHeader.match(/bytes=(\d+)-(\d*)/)
57+
if (match) {
58+
const start = parseInt(match[1])
59+
const end = match[2] ? parseInt(match[2]) : fileSize - 1
60+
const buffer = Buffer.alloc(end - start + 1)
61+
const fd = fs.openSync(filePath, 'r')
62+
fs.readSync(fd, buffer, 0, buffer.length, start)
63+
fs.closeSync(fd)
64+
65+
await route.fulfill({
66+
status: 206,
67+
headers: {
68+
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
69+
'Content-Length': buffer.length.toString(),
70+
'Accept-Ranges': 'bytes',
71+
'Content-Type': 'application/octet-stream',
72+
},
73+
body: buffer,
74+
})
75+
return
76+
}
77+
}
78+
79+
const buffer = fs.readFileSync(filePath)
80+
await route.fulfill({
81+
status: 200,
82+
contentType: 'application/octet-stream',
83+
body: buffer,
84+
headers: {
85+
'Content-Length': buffer.length.toString(),
86+
'Accept-Ranges': 'bytes',
87+
},
88+
})
89+
} else {
90+
await route.continue()
91+
}
92+
})
93+
})
94+
95+
test('table device dropdown hides when deselecting leaves one device', async ({ page }) => {
96+
// Start with both Gym and BR devices selected
97+
await page.goto('/?d=gym+br&t=251129T1740')
98+
await page.waitForSelector('.data-table', { timeout: 30000 })
99+
100+
// Device dropdown should be visible with 2 devices
101+
const deviceDropdown = page.locator('#device-select')
102+
await expect(deviceDropdown).toBeVisible()
103+
104+
// Should have 2 options
105+
const options = await deviceDropdown.locator('option').all()
106+
expect(options.length).toBe(2)
107+
108+
// Deselect BR device
109+
const brCheckbox = page.locator('.device-checkboxes label:has(span.name:text("BR")) input[type="checkbox"]')
110+
await brCheckbox.click()
111+
await page.waitForTimeout(500)
112+
113+
// Dropdown should be hidden when only 1 device selected
114+
await expect(deviceDropdown).toBeHidden()
115+
116+
// Gym checkbox should still be checked
117+
const gymCheckbox = page.locator('.device-checkboxes label:has(span.name:text("Gym")) input[type="checkbox"]')
118+
await expect(gymCheckbox).toBeChecked()
119+
})
120+
121+
test('table shows remaining device data after deselecting other device', async ({ page }) => {
122+
// Start with both devices, Gym showing in dropdown
123+
await page.goto('/?d=gym+br&t=251129T1740')
124+
await page.waitForSelector('.data-table', { timeout: 30000 })
125+
126+
const deviceDropdown = page.locator('#device-select')
127+
128+
// Select Gym in the dropdown so we know what's showing
129+
await deviceDropdown.selectOption('17617')
130+
await page.waitForTimeout(300)
131+
132+
// Verify Gym is selected
133+
expect(await deviceDropdown.inputValue()).toBe('17617')
134+
135+
// Deselect Gym (the device currently shown in dropdown)
136+
const gymCheckbox = page.locator('.device-checkboxes label:has(span.name:text("Gym")) input[type="checkbox"]')
137+
await gymCheckbox.click()
138+
await page.waitForTimeout(500)
139+
140+
// Dropdown should be hidden (only 1 device left)
141+
await expect(deviceDropdown).toBeHidden()
142+
143+
// BR should be checked (remaining device)
144+
const brCheckbox = page.locator('.device-checkboxes label:has(span.name:text("BR")) input[type="checkbox"]')
145+
await expect(brCheckbox).toBeChecked()
146+
147+
// Gym should be unchecked
148+
await expect(gymCheckbox).not.toBeChecked()
149+
})
150+
})

0 commit comments

Comments
 (0)