Redesign topic details sidebar with clickable navigation and improved mobile layout (WIP - demo video test regression) (#1011)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: thomasnordquist <7721625+thomasnordquist@users.noreply.github.com> Co-authored-by: Thomas Nordquist <thomasnordquist@users.noreply.github.com>
This commit is contained in:
109
.github/copilot-instructions.md
vendored
109
.github/copilot-instructions.md
vendored
@@ -20,10 +20,117 @@
|
|||||||
- `yarn test:mcp` - Model Context Protocol tests
|
- `yarn test:mcp` - Model Context Protocol tests
|
||||||
- `yarn test:all` - All tests (unit + demo-video)
|
- `yarn test:all` - All tests (unit + demo-video)
|
||||||
- `./scripts/runBrowserTests.sh` - Browser mode UI tests (requires mosquitto service)
|
- `./scripts/runBrowserTests.sh` - Browser mode UI tests (requires mosquitto service)
|
||||||
|
- `./scripts/uiTests.sh` - Demo video tests with Electron (requires Xvfb, mosquitto)
|
||||||
|
|
||||||
**CI jobs:** `test`, `ui-tests`, `demo-video`, `test-browser`, `browser-ui-tests`
|
**CI jobs:** `test`, `ui-tests`, `demo-video`, `test-browser`, `browser-ui-tests`
|
||||||
|
|
||||||
**Important:** Browser UI tests require MQTT broker. In CI, GitHub Actions health checks ensure the mosquitto service is ready before tests run.
|
**Important:**
|
||||||
|
- Browser UI tests require MQTT broker. In CI, GitHub Actions health checks ensure the mosquitto service is ready before tests run.
|
||||||
|
- Demo video tests use the same test scenarios as browser tests - if browser tests pass, demo video tests should pass too (they use identical selectors in `src/spec/scenarios/`)
|
||||||
|
|
||||||
|
## Debugging Demo Video / UI Tests
|
||||||
|
|
||||||
|
When demo video tests fail in CI but you need to debug locally:
|
||||||
|
|
||||||
|
**Prerequisites:**
|
||||||
|
```bash
|
||||||
|
# Install required packages
|
||||||
|
sudo apt-get install xvfb mosquitto tmux ffmpeg
|
||||||
|
|
||||||
|
# Ensure mosquitto is stopped (script will start its own instance)
|
||||||
|
sudo systemctl stop mosquitto
|
||||||
|
```
|
||||||
|
|
||||||
|
**Steps to debug:**
|
||||||
|
1. Build the project: `yarn build`
|
||||||
|
2. Run the demo video tests: `ELECTRON_DISABLE_SANDBOX=1 ./scripts/uiTests.sh`
|
||||||
|
3. If tests fail, check the error messages for:
|
||||||
|
- Missing `data-test` or `data-test-type` attributes
|
||||||
|
- Elements not visible (hidden, outside viewport, or covered by overlays)
|
||||||
|
- Click interception (tooltips, dialogs blocking clicks)
|
||||||
|
- XPath selector issues (check the data-test value format)
|
||||||
|
|
||||||
|
**Common issues:**
|
||||||
|
- **"locator not visible"**: Element exists but is hidden or outside viewport
|
||||||
|
- **"locator.click intercepted"**: Another element (tooltip, dialog) is covering the click target
|
||||||
|
- **Empty `data-test` attribute**: For simple numeric values, ensure you're using the topic name, not `props.literal.path` (which is empty for non-JSON values)
|
||||||
|
- **"Process failed to launch"**: Electron can't start - ensure DISPLAY is set and Xvfb is running
|
||||||
|
|
||||||
|
**Environment-specific notes:**
|
||||||
|
- Demo video tests use Electron with Xvfb (virtual display)
|
||||||
|
- Browser tests use Chromium without Electron (easier to debug locally)
|
||||||
|
- CI environment has proper Electron setup - if local tests are flaky, trust CI results
|
||||||
|
- Both test types use the same scenario files in `src/spec/scenarios/`
|
||||||
|
|
||||||
|
**Material-UI Tooltip considerations:**
|
||||||
|
- Tooltips wrap their children and create overlay divs
|
||||||
|
- Test attributes (`data-test-type`, `data-test`) must be on the inner clickable element that's passed as a child to the Tooltip
|
||||||
|
- Mouse event handlers (onMouseEnter, onMouseLeave, ref) go on outer wrapper
|
||||||
|
- onClick handler and test attributes go on the span inside the Tooltip
|
||||||
|
- The clickable child inside the Tooltip is what Playwright should target
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```tsx
|
||||||
|
// ❌ WRONG - attributes on outer wrapper, Tooltip wraps and hides them
|
||||||
|
<Tooltip title="...">
|
||||||
|
<span onClick={...} data-test-type="Button" data-test="example">
|
||||||
|
<Icon />
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
// ✅ CORRECT - attributes on the actual clickable child inside Tooltip
|
||||||
|
<span ref={ref} onMouseEnter={...} onMouseLeave={...}>
|
||||||
|
<Tooltip title="...">
|
||||||
|
<span onClick={...} data-test-type="Button" data-test="example">
|
||||||
|
<Icon />
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
</span>
|
||||||
|
```
|
||||||
|
|
||||||
|
**data-test value format:**
|
||||||
|
- ShowChart: Use last segment of topic path (e.g., "heater" for "kitchen/coffee_maker/heater")
|
||||||
|
- ChartSettings: Use full topic + dotPath (e.g., "kitchen/coffee_maker/heater-" with trailing dash when dotPath is empty)
|
||||||
|
- Test XPaths use `contains(@data-test, "substring")` so partial matches work
|
||||||
|
|
||||||
|
## Running Browser Tests Locally
|
||||||
|
|
||||||
|
**Prerequisites:**
|
||||||
|
```bash
|
||||||
|
# Install mosquitto (if not already installed)
|
||||||
|
sudo apt-get install mosquitto
|
||||||
|
|
||||||
|
# Start mosquitto service
|
||||||
|
sudo systemctl start mosquitto
|
||||||
|
sudo systemctl status mosquitto
|
||||||
|
```
|
||||||
|
|
||||||
|
**Run browser tests:**
|
||||||
|
```bash
|
||||||
|
# Build server and run tests
|
||||||
|
yarn build:server
|
||||||
|
./scripts/runBrowserTests.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
The script automatically:
|
||||||
|
- Starts a local mosquitto broker
|
||||||
|
- Builds the TypeScript code
|
||||||
|
- Starts the browser mode server on port 3000
|
||||||
|
- Runs Playwright tests in browser mode
|
||||||
|
- Cleans up processes on exit
|
||||||
|
|
||||||
|
**Test environment variables:**
|
||||||
|
- `MQTT_EXPLORER_USERNAME` - Browser auth username (default: test)
|
||||||
|
- `MQTT_EXPLORER_PASSWORD` - Browser auth password (default: test123)
|
||||||
|
- `PORT` - Server port (default: 3000)
|
||||||
|
- `TESTS_MQTT_BROKER_HOST` - MQTT broker host (default: 127.0.0.1)
|
||||||
|
- `TESTS_MQTT_BROKER_PORT` - MQTT broker port (default: 1883)
|
||||||
|
- `USE_MOBILE_VIEWPORT` - Enable mobile viewport (default: false)
|
||||||
|
|
||||||
|
**Common test failures after UI changes:**
|
||||||
|
- Update test selectors in `src/spec/ui-tests.spec.ts` if UI structure changes
|
||||||
|
- Use `data-testid` attributes for stable test selectors
|
||||||
|
- Avoid using role + name selectors for dynamic content (use direct testid selectors instead)
|
||||||
|
|
||||||
## Browser Mode
|
## Browser Mode
|
||||||
|
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ const styles = (theme: Theme) => ({
|
|||||||
height: '100%',
|
height: '100%',
|
||||||
padding: '8px',
|
padding: '8px',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
overflow: 'hidden scroll',
|
overflow: 'auto',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
327
app/src/components/Sidebar/DetailsTab.tsx
Normal file
327
app/src/components/Sidebar/DetailsTab.tsx
Normal file
@@ -0,0 +1,327 @@
|
|||||||
|
import * as q from '../../../../backend/src/Model'
|
||||||
|
import React, { useCallback } from 'react'
|
||||||
|
import { Box, Typography, IconButton, Chip, Tooltip } from '@mui/material'
|
||||||
|
import { Theme } from '@mui/material/styles'
|
||||||
|
import { withStyles } from '@mui/styles'
|
||||||
|
import { AppState } from '../../reducers'
|
||||||
|
import { connect } from 'react-redux'
|
||||||
|
import { bindActionCreators } from 'redux'
|
||||||
|
import { sidebarActions } from '../../actions'
|
||||||
|
import Copy from '../helper/Copy'
|
||||||
|
import Save from '../helper/Save'
|
||||||
|
import DateFormatter from '../helper/DateFormatter'
|
||||||
|
import ValueRenderer from './ValueRenderer/ValueRenderer'
|
||||||
|
import MessageHistory from './ValueRenderer/MessageHistory'
|
||||||
|
import ActionButtons from './ValueRenderer/ActionButtons'
|
||||||
|
import DeleteSelectedTopicButton from './ValueRenderer/DeleteSelectedTopicButton'
|
||||||
|
import { useDecoder } from '../hooks/useDecoder'
|
||||||
|
import DeleteIcon from '@mui/icons-material/Delete'
|
||||||
|
import DeleteSweepIcon from '@mui/icons-material/DeleteSweep'
|
||||||
|
import SimpleBreadcrumb from './SimpleBreadcrumb'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
node?: q.TreeNode<any>
|
||||||
|
classes: any
|
||||||
|
compareMessage?: q.Message
|
||||||
|
sidebarActions: typeof sidebarActions
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetailsTab(props: Props) {
|
||||||
|
const { node, compareMessage, classes } = props
|
||||||
|
const decodeMessage = useDecoder(node)
|
||||||
|
|
||||||
|
const getDecodedValue = useCallback(() => {
|
||||||
|
return node?.message && decodeMessage(node.message)?.message?.toUnicodeString()
|
||||||
|
}, [node, decodeMessage])
|
||||||
|
|
||||||
|
const getData = () => {
|
||||||
|
if (node?.message && node.message.payload) {
|
||||||
|
return node.message.payload.base64Message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMessageHistorySelect = useCallback(
|
||||||
|
(message: q.Message) => {
|
||||||
|
if (message !== compareMessage) {
|
||||||
|
props.sidebarActions.setCompareMessage(message)
|
||||||
|
} else {
|
||||||
|
props.sidebarActions.setCompareMessage(undefined)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[compareMessage, props.sidebarActions]
|
||||||
|
)
|
||||||
|
|
||||||
|
const deleteTopic = useCallback(
|
||||||
|
(topic?: q.TreeNode<any>, recursive: boolean = false) => {
|
||||||
|
if (!topic) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
props.sidebarActions.clearTopic(topic, recursive)
|
||||||
|
},
|
||||||
|
[props.sidebarActions]
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!node) {
|
||||||
|
return (
|
||||||
|
<Box className={classes.emptyState}>
|
||||||
|
<Typography variant="body2" color="textSecondary" align="center">
|
||||||
|
Select a topic to view details
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const [value] =
|
||||||
|
node && node.message && node.message.payload ? node.message.payload?.format(node.type) : [null, undefined]
|
||||||
|
const hasValue = Boolean(value)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box className={classes.root}>
|
||||||
|
{/* Topic Section - Breadcrumb with actions */}
|
||||||
|
<Box className={classes.topicSection}>
|
||||||
|
<SimpleBreadcrumb node={node} />
|
||||||
|
<Box className={classes.topicActions}>
|
||||||
|
<Copy value={node.path()} />
|
||||||
|
{node.childTopicCount() === 0 && (
|
||||||
|
<Tooltip title="Delete this topic">
|
||||||
|
<IconButton size="small" onClick={() => deleteTopic(node, false)} className={classes.iconButton}>
|
||||||
|
<DeleteIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{node.childTopicCount() > 0 && (
|
||||||
|
<Tooltip title="Delete topic and all subtopics">
|
||||||
|
<IconButton size="small" onClick={() => deleteTopic(node, true)} className={classes.iconButton}>
|
||||||
|
<DeleteSweepIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Value Section - Simplified layout */}
|
||||||
|
{hasValue && (
|
||||||
|
<Box className={classes.valueSection}>
|
||||||
|
{/* Metadata bar - Date on left, Retained/QoS on right */}
|
||||||
|
<Box className={classes.metadataBar}>
|
||||||
|
<Box className={classes.metadataLeft}>
|
||||||
|
<Typography variant="caption" color="textSecondary">
|
||||||
|
<DateFormatter date={node.message!.received} />
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box className={classes.metadataRight}>
|
||||||
|
{node.message?.retain && (
|
||||||
|
<Chip label="Retained" size="small" variant="outlined" color="primary" className={classes.chip} />
|
||||||
|
)}
|
||||||
|
<Chip
|
||||||
|
label={`QoS ${node.message?.qos ?? 0}`}
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
className={classes.chip}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Action toolbar */}
|
||||||
|
<Box className={classes.actionToolbar}>
|
||||||
|
<Typography variant="subtitle2" className={classes.valueTitle}>
|
||||||
|
Current Value
|
||||||
|
</Typography>
|
||||||
|
<Box className={classes.actionButtons}>
|
||||||
|
<ActionButtons />
|
||||||
|
</Box>
|
||||||
|
<Box className={classes.valueActions}>
|
||||||
|
<Copy getValue={getDecodedValue} />
|
||||||
|
<Save getData={getData} />
|
||||||
|
{node.message?.retain && <DeleteSelectedTopicButton />}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Value Display */}
|
||||||
|
<Box className={classes.valueDisplay}>
|
||||||
|
<React.Suspense fallback={<div>Loading...</div>}>
|
||||||
|
<ValueRenderer treeNode={node} message={node.message!} compareWith={compareMessage} />
|
||||||
|
</React.Suspense>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Message History */}
|
||||||
|
<Box className={classes.historySection}>
|
||||||
|
<MessageHistory onSelect={handleMessageHistorySelect} selected={compareMessage} node={node} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Stats Section - Moved to end of value section */}
|
||||||
|
<Box className={classes.statsSection}>
|
||||||
|
<Box className={classes.statsGrid}>
|
||||||
|
<Box className={classes.statItem}>
|
||||||
|
<Typography variant="body2" color="textSecondary" className={classes.statLabel}>
|
||||||
|
Messages
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="h6" className={classes.statValue}>
|
||||||
|
{node.messages}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box className={classes.statItem}>
|
||||||
|
<Typography variant="body2" color="textSecondary" className={classes.statLabel}>
|
||||||
|
Subtopics
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="h6" className={classes.statValue}>
|
||||||
|
{node.childTopicCount()}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box className={classes.statItem}>
|
||||||
|
<Typography variant="body2" color="textSecondary" className={classes.statLabel}>
|
||||||
|
Total
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="h6" className={classes.statValue}>
|
||||||
|
{node.leafMessageCount()}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = (theme: Theme) => ({
|
||||||
|
root: {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column' as 'column',
|
||||||
|
gap: theme.spacing(3),
|
||||||
|
[theme.breakpoints.down('sm')]: {
|
||||||
|
gap: theme.spacing(2),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
emptyState: {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
minHeight: '200px',
|
||||||
|
padding: theme.spacing(3),
|
||||||
|
},
|
||||||
|
// Topic section
|
||||||
|
topicSection: {
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
gap: theme.spacing(1),
|
||||||
|
paddingBottom: theme.spacing(2),
|
||||||
|
borderBottom: `1px solid ${theme.palette.divider}`,
|
||||||
|
},
|
||||||
|
topicActions: {
|
||||||
|
display: 'flex',
|
||||||
|
gap: theme.spacing(0.5),
|
||||||
|
alignItems: 'center',
|
||||||
|
flexShrink: 0,
|
||||||
|
},
|
||||||
|
iconButton: {
|
||||||
|
padding: theme.spacing(0.5),
|
||||||
|
},
|
||||||
|
// Stats section
|
||||||
|
statsSection: {
|
||||||
|
marginTop: theme.spacing(2),
|
||||||
|
},
|
||||||
|
statsGrid: {
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(3, 1fr)',
|
||||||
|
gap: theme.spacing(1.5),
|
||||||
|
[theme.breakpoints.down('sm')]: {
|
||||||
|
gap: theme.spacing(1),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
statItem: {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column' as 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: theme.spacing(1.5, 1),
|
||||||
|
backgroundColor: theme.palette.action.hover,
|
||||||
|
borderRadius: theme.shape.borderRadius,
|
||||||
|
gap: theme.spacing(0.5),
|
||||||
|
},
|
||||||
|
statLabel: {
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
fontWeight: 500,
|
||||||
|
textTransform: 'uppercase' as 'uppercase',
|
||||||
|
letterSpacing: '0.5px',
|
||||||
|
},
|
||||||
|
statValue: {
|
||||||
|
fontSize: '1.25rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
lineHeight: 1,
|
||||||
|
},
|
||||||
|
// Value section
|
||||||
|
valueSection: {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column' as 'column',
|
||||||
|
gap: theme.spacing(2),
|
||||||
|
},
|
||||||
|
metadataBar: {
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: theme.spacing(1),
|
||||||
|
flexWrap: 'wrap' as 'wrap',
|
||||||
|
padding: theme.spacing(1),
|
||||||
|
backgroundColor: theme.palette.action.hover,
|
||||||
|
borderRadius: theme.shape.borderRadius,
|
||||||
|
},
|
||||||
|
metadataLeft: {
|
||||||
|
display: 'flex',
|
||||||
|
gap: theme.spacing(1),
|
||||||
|
alignItems: 'center',
|
||||||
|
flexWrap: 'wrap' as 'wrap',
|
||||||
|
},
|
||||||
|
metadataRight: {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
chip: {
|
||||||
|
height: '24px',
|
||||||
|
},
|
||||||
|
actionToolbar: {
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: theme.spacing(1),
|
||||||
|
flexWrap: 'wrap' as 'wrap',
|
||||||
|
},
|
||||||
|
valueTitle: {
|
||||||
|
fontWeight: 600,
|
||||||
|
color: theme.palette.text.primary,
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
textTransform: 'uppercase' as 'uppercase',
|
||||||
|
letterSpacing: '0.5px',
|
||||||
|
flexShrink: 0,
|
||||||
|
},
|
||||||
|
actionButtons: {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
valueActions: {
|
||||||
|
display: 'flex',
|
||||||
|
gap: theme.spacing(0.5),
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
valueDisplay: {
|
||||||
|
marginTop: theme.spacing(1),
|
||||||
|
},
|
||||||
|
historySection: {
|
||||||
|
marginTop: theme.spacing(1),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const mapStateToProps = (state: AppState) => {
|
||||||
|
return {
|
||||||
|
compareMessage: state.sidebar.get('compareMessage'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapDispatchToProps = (dispatch: any) => {
|
||||||
|
return {
|
||||||
|
sidebarActions: bindActionCreators(sidebarActions, dispatch),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default connect(mapStateToProps, mapDispatchToProps)(withStyles(styles)(DetailsTab))
|
||||||
53
app/src/components/Sidebar/PublishTab.tsx
Normal file
53
app/src/components/Sidebar/PublishTab.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { Box, Typography } from '@mui/material'
|
||||||
|
import { Theme } from '@mui/material/styles'
|
||||||
|
import { withStyles } from '@mui/styles'
|
||||||
|
|
||||||
|
const Publish = React.lazy(() => import('./Publish/Publish'))
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
connectionId?: string
|
||||||
|
classes: any
|
||||||
|
}
|
||||||
|
|
||||||
|
function PublishTab(props: Props) {
|
||||||
|
const { classes } = props
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box className={classes.root}>
|
||||||
|
<Box className={classes.header}>
|
||||||
|
<Typography variant="subtitle2" className={classes.title}>
|
||||||
|
Publish Message
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="textSecondary">
|
||||||
|
Send messages to MQTT topics
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<React.Suspense fallback={<div>Loading...</div>}>
|
||||||
|
<Publish connectionId={props.connectionId} />
|
||||||
|
</React.Suspense>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = (theme: Theme) => ({
|
||||||
|
root: {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column' as 'column',
|
||||||
|
gap: theme.spacing(2),
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
marginBottom: theme.spacing(1),
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontWeight: 600,
|
||||||
|
color: theme.palette.text.primary,
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
textTransform: 'uppercase' as 'uppercase',
|
||||||
|
letterSpacing: '0.5px',
|
||||||
|
marginBottom: theme.spacing(0.5),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export default withStyles(styles)(PublishTab)
|
||||||
@@ -1,24 +1,19 @@
|
|||||||
import * as q from '../../../../backend/src/Model'
|
import * as q from '../../../../backend/src/Model'
|
||||||
import React, { useState, useEffect, useCallback } from 'react'
|
import React, { useState, useEffect, useCallback } from 'react'
|
||||||
import NodeStats from './NodeStats'
|
|
||||||
import ValuePanel from './ValueRenderer/ValuePanel'
|
|
||||||
const ValuePanelAny = ValuePanel as any
|
|
||||||
import { AppState } from '../../reducers'
|
import { AppState } from '../../reducers'
|
||||||
import { AccordionDetails } from '@mui/material'
|
|
||||||
import { bindActionCreators } from 'redux'
|
import { bindActionCreators } from 'redux'
|
||||||
import { connect } from 'react-redux'
|
import { connect } from 'react-redux'
|
||||||
import { settingsActions, sidebarActions } from '../../actions'
|
import { settingsActions, sidebarActions } from '../../actions'
|
||||||
import { Theme } from '@mui/material/styles'
|
import { Theme } from '@mui/material/styles'
|
||||||
import { withStyles } from '@mui/styles'
|
import { withStyles } from '@mui/styles'
|
||||||
import { TopicViewModel } from '../../model/TopicViewModel'
|
import { TopicViewModel } from '../../model/TopicViewModel'
|
||||||
import TopicPanel from './TopicPanel/TopicPanel'
|
|
||||||
import Panel from './Panel'
|
|
||||||
import { usePollingToFetchTreeNode } from '../helper/usePollingToFetchTreeNode'
|
import { usePollingToFetchTreeNode } from '../helper/usePollingToFetchTreeNode'
|
||||||
|
import { Tabs, Tab, Box, useMediaQuery, useTheme } from '@mui/material'
|
||||||
|
import DetailsTab from './DetailsTab'
|
||||||
|
import PublishTab from './PublishTab'
|
||||||
|
|
||||||
const throttle = require('lodash.throttle')
|
const throttle = require('lodash.throttle')
|
||||||
|
|
||||||
const Publish = React.lazy(() => import('./Publish/Publish'))
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
nodePath?: string
|
nodePath?: string
|
||||||
tree?: q.Tree<TopicViewModel>
|
tree?: q.Tree<TopicViewModel>
|
||||||
@@ -49,27 +44,55 @@ function useUpdateNodeWhenNodeReceivesUpdates(node?: q.TreeNode<any>) {
|
|||||||
}, [node])
|
}, [node])
|
||||||
}
|
}
|
||||||
|
|
||||||
function Sidebar(props: Props) {
|
function SidebarNew(props: Props) {
|
||||||
const { classes, tree, nodePath } = props
|
const { classes, tree, nodePath } = props
|
||||||
const node = usePollingToFetchTreeNode(tree, nodePath || '')
|
const node = usePollingToFetchTreeNode(tree, nodePath || '')
|
||||||
useUpdateNodeWhenNodeReceivesUpdates(node)
|
useUpdateNodeWhenNodeReceivesUpdates(node)
|
||||||
|
const [tabValue, setTabValue] = useState(0)
|
||||||
|
const theme = useTheme()
|
||||||
|
const isMobile = useMediaQuery(theme.breakpoints.down('sm'))
|
||||||
|
|
||||||
|
const handleTabChange = (event: React.SyntheticEvent, newValue: number) => {
|
||||||
|
setTabValue(newValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
// On mobile, don't show tabs (mobile already has Topics/Details tabs at app level)
|
||||||
|
// Just show the content directly
|
||||||
|
if (isMobile) {
|
||||||
return (
|
return (
|
||||||
<div id="Sidebar" className={classes.drawer}>
|
<div id="Sidebar" className={classes.root}>
|
||||||
<div>
|
<Box className={classes.mobileContent}>
|
||||||
<TopicPanel node={node} />
|
<DetailsTab node={node} />
|
||||||
<ValuePanelAny lastUpdate={node ? node.lastUpdate : 0} />
|
</Box>
|
||||||
<Panel>
|
|
||||||
<span>Publish</span>
|
|
||||||
<Publish connectionId={props.connectionId} />
|
|
||||||
</Panel>
|
|
||||||
<Panel detailsHidden={!node}>
|
|
||||||
<span>Stats</span>
|
|
||||||
<AccordionDetails className={classes.details}>
|
|
||||||
<NodeStats node={node} />
|
|
||||||
</AccordionDetails>
|
|
||||||
</Panel>
|
|
||||||
</div>
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Desktop: show tabs for Details/Publish
|
||||||
|
return (
|
||||||
|
<div id="Sidebar" className={classes.root}>
|
||||||
|
<Box className={classes.tabsContainer}>
|
||||||
|
<Tabs
|
||||||
|
value={tabValue}
|
||||||
|
onChange={handleTabChange}
|
||||||
|
variant="fullWidth"
|
||||||
|
indicatorColor="primary"
|
||||||
|
textColor="primary"
|
||||||
|
className={classes.tabs}
|
||||||
|
>
|
||||||
|
<Tab label="Details" className={classes.tab} />
|
||||||
|
<Tab label="Publish" className={classes.tab} />
|
||||||
|
</Tabs>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box className={classes.tabContent}>
|
||||||
|
<Box sx={{ display: tabValue === 0 ? 'block' : 'none' }}>
|
||||||
|
<DetailsTab node={node} />
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: tabValue === 1 ? 'block' : 'none' }}>
|
||||||
|
<PublishTab connectionId={props.connectionId} />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -90,13 +113,38 @@ const mapDispatchToProps = (dispatch: any) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const styles = (theme: Theme) => ({
|
const styles = (theme: Theme) => ({
|
||||||
drawer: {
|
root: {
|
||||||
display: 'block' as 'block',
|
display: 'flex',
|
||||||
|
flexDirection: 'column' as 'column',
|
||||||
|
height: '100%',
|
||||||
|
width: '100%',
|
||||||
},
|
},
|
||||||
details: {
|
tabsContainer: {
|
||||||
padding: '0px 16px 8px 8px',
|
borderBottom: `1px solid ${theme.palette.divider}`,
|
||||||
display: 'block',
|
backgroundColor: theme.palette.background.paper,
|
||||||
|
},
|
||||||
|
tabs: {
|
||||||
|
minHeight: '48px',
|
||||||
|
},
|
||||||
|
tab: {
|
||||||
|
minHeight: '48px',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 500,
|
||||||
|
textTransform: 'none' as 'none',
|
||||||
|
padding: theme.spacing(1.5, 2),
|
||||||
|
},
|
||||||
|
tabContent: {
|
||||||
|
flex: 1,
|
||||||
|
overflowY: 'auto' as 'auto',
|
||||||
|
overflowX: 'hidden' as 'hidden',
|
||||||
|
padding: theme.spacing(2),
|
||||||
|
},
|
||||||
|
mobileContent: {
|
||||||
|
flex: 1,
|
||||||
|
overflowY: 'auto' as 'auto',
|
||||||
|
overflowX: 'hidden' as 'hidden',
|
||||||
|
padding: theme.spacing(2),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
export default withStyles(styles)(connect(mapStateToProps, mapDispatchToProps)(Sidebar))
|
export default withStyles(styles)(connect(mapStateToProps, mapDispatchToProps)(SidebarNew))
|
||||||
|
|||||||
87
app/src/components/Sidebar/SimpleBreadcrumb.tsx
Normal file
87
app/src/components/Sidebar/SimpleBreadcrumb.tsx
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import * as q from '../../../../backend/src/Model'
|
||||||
|
import { Link } from '@mui/material'
|
||||||
|
import { Theme } from '@mui/material/styles'
|
||||||
|
import { withStyles } from '@mui/styles'
|
||||||
|
import { treeActions } from '../../actions'
|
||||||
|
import { bindActionCreators } from 'redux'
|
||||||
|
import { connect } from 'react-redux'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
node?: q.TreeNode<any>
|
||||||
|
classes: any
|
||||||
|
actions: typeof treeActions
|
||||||
|
}
|
||||||
|
|
||||||
|
function SimpleBreadcrumb(props: Props) {
|
||||||
|
const { node, classes, actions } = props
|
||||||
|
|
||||||
|
if (!node) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const branch = node.branch()
|
||||||
|
const breadcrumbNodes = branch
|
||||||
|
.map(n => n.sourceEdge)
|
||||||
|
.filter(edge => Boolean(edge) && edge?.target)
|
||||||
|
.map(edge => ({ name: edge?.name || '', target: edge!.target }))
|
||||||
|
.filter(item => item.name !== '')
|
||||||
|
|
||||||
|
if (breadcrumbNodes.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={classes.breadcrumbContainer}>
|
||||||
|
{breadcrumbNodes.map((item, index) => (
|
||||||
|
<span key={item.target.hash()}>
|
||||||
|
{index > 0 && <span className={classes.separator}> / </span>}
|
||||||
|
<Link
|
||||||
|
component="button"
|
||||||
|
variant="h6"
|
||||||
|
className={classes.breadcrumbLink}
|
||||||
|
onClick={() => actions.selectTopic(item.target)}
|
||||||
|
underline="hover"
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</Link>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = (theme: Theme) => ({
|
||||||
|
breadcrumbContainer: {
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'wrap' as 'wrap',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 0,
|
||||||
|
},
|
||||||
|
breadcrumbLink: {
|
||||||
|
fontSize: '1rem',
|
||||||
|
fontWeight: 500,
|
||||||
|
color: theme.palette.text.primary,
|
||||||
|
cursor: 'pointer',
|
||||||
|
textAlign: 'left' as 'left',
|
||||||
|
border: 'none',
|
||||||
|
background: 'none',
|
||||||
|
padding: 0,
|
||||||
|
lineHeight: 1.5,
|
||||||
|
'&:hover': {
|
||||||
|
color: theme.palette.primary.main,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
separator: {
|
||||||
|
color: theme.palette.text.secondary,
|
||||||
|
userSelect: 'none' as 'none',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const mapDispatchToProps = (dispatch: any) => {
|
||||||
|
return {
|
||||||
|
actions: bindActionCreators(treeActions, dispatch),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default connect(null, mapDispatchToProps)(withStyles(styles)(SimpleBreadcrumb))
|
||||||
@@ -36,15 +36,17 @@ function ActionButtons(props: {
|
|||||||
>
|
>
|
||||||
<ToggleButton className={props.classes.toggleButton} value="diff" id="valueRendererDisplayMode-diff">
|
<ToggleButton className={props.classes.toggleButton} value="diff" id="valueRendererDisplayMode-diff">
|
||||||
<Tooltip title="Show difference between the current and the last message">
|
<Tooltip title="Show difference between the current and the last message">
|
||||||
<span>
|
<span className={props.classes.buttonContent}>
|
||||||
<Code className={props.classes.toggleButtonIcon} />
|
<Code className={props.classes.toggleButtonIcon} />
|
||||||
|
<span className={props.classes.buttonText}>Diff</span>
|
||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</ToggleButton>
|
</ToggleButton>
|
||||||
<ToggleButton className={props.classes.toggleButton} value="raw" id="valueRendererDisplayMode-raw">
|
<ToggleButton className={props.classes.toggleButton} value="raw" id="valueRendererDisplayMode-raw">
|
||||||
<Tooltip title="Raw / formatted JSON / formatted sparkplugb protojson">
|
<Tooltip title="Raw / formatted JSON / formatted sparkplugb protojson">
|
||||||
<span>
|
<span className={props.classes.buttonContent}>
|
||||||
<Reorder className={props.classes.toggleButtonIcon} />
|
<Reorder className={props.classes.toggleButtonIcon} />
|
||||||
|
<span className={props.classes.buttonText}>Raw</span>
|
||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</ToggleButton>
|
</ToggleButton>
|
||||||
@@ -55,9 +57,20 @@ function ActionButtons(props: {
|
|||||||
const styles = (theme: Theme) => ({
|
const styles = (theme: Theme) => ({
|
||||||
toggleButton: {
|
toggleButton: {
|
||||||
height: '36px',
|
height: '36px',
|
||||||
|
padding: theme.spacing(0.5, 1.5),
|
||||||
},
|
},
|
||||||
toggleButtonIcon: {
|
toggleButtonIcon: {
|
||||||
verticalAlign: 'middle',
|
verticalAlign: 'middle',
|
||||||
|
fontSize: '1.25rem',
|
||||||
|
},
|
||||||
|
buttonContent: {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: theme.spacing(0.5),
|
||||||
|
},
|
||||||
|
buttonText: {
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
textTransform: 'none' as 'none',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
84
src/spec/inspect-chart-settings.ts
Normal file
84
src/spec/inspect-chart-settings.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { chromium, Page } from 'playwright'
|
||||||
|
|
||||||
|
async function inspect() {
|
||||||
|
const browser = await chromium.launch({ headless: true })
|
||||||
|
const page = await browser.newPage()
|
||||||
|
|
||||||
|
console.log('Navigating to localhost:3000...')
|
||||||
|
await page.goto('http://localhost:3000')
|
||||||
|
await page.waitForTimeout(2000)
|
||||||
|
|
||||||
|
// Login
|
||||||
|
console.log('Logging in...')
|
||||||
|
await page.fill('[name="username"]', 'test')
|
||||||
|
await page.fill('[name="password"]', 'test123')
|
||||||
|
await page.locator('[type="submit"]').click()
|
||||||
|
await page.waitForTimeout(3000)
|
||||||
|
|
||||||
|
// Expand kitchen/coffee_maker topic
|
||||||
|
console.log('Expanding kitchen topic...')
|
||||||
|
const kitchenTopic = page.locator('[data-test-topic="kitchen"]').first()
|
||||||
|
await kitchenTopic.click()
|
||||||
|
await page.waitForTimeout(500)
|
||||||
|
|
||||||
|
console.log('Clicking coffee_maker topic...')
|
||||||
|
const coffeeMakerTopic = page.locator('[data-test-topic="kitchen/coffee_maker"]').first()
|
||||||
|
await coffeeMakerTopic.click()
|
||||||
|
await page.waitForTimeout(1500)
|
||||||
|
|
||||||
|
// Look for ShowChart icons
|
||||||
|
console.log('\n=== ShowChart Elements ===')
|
||||||
|
const showCharts = await page.locator('//*[contains(@data-test-type, "ShowChart")]').all()
|
||||||
|
console.log(`Found ${showCharts.length} ShowChart elements:`)
|
||||||
|
for (let i = 0; i < showCharts.length; i++) {
|
||||||
|
const dataTest = await showCharts[i].getAttribute('data-test')
|
||||||
|
const isVisible = await showCharts[i].isVisible()
|
||||||
|
console.log(` [${i}] data-test="${dataTest}", visible=${isVisible}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Click heater ShowChart icon
|
||||||
|
console.log('\n=== Clicking heater ShowChart ===')
|
||||||
|
const heaterChart = page.locator('//*[contains(@data-test-type, "ShowChart")][contains(@data-test, "heater")]').first()
|
||||||
|
await heaterChart.waitFor({ state: 'visible', timeout: 5000 })
|
||||||
|
await heaterChart.click()
|
||||||
|
await page.waitForTimeout(2000)
|
||||||
|
|
||||||
|
// Check for ChartPanel
|
||||||
|
console.log('\n=== Looking for ChartPanel ===')
|
||||||
|
const chartPanels = await page.locator('[data-test-type="ChartPaper"]').all()
|
||||||
|
console.log(`Found ${chartPanels.length} ChartPanel elements`)
|
||||||
|
for (let i = 0; i < chartPanels.length; i++) {
|
||||||
|
const dataTest = await chartPanels[i].getAttribute('data-test')
|
||||||
|
console.log(` [${i}] data-test="${dataTest}"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look for ChartSettings buttons
|
||||||
|
console.log('\n=== Looking for ChartSettings Elements ===')
|
||||||
|
const allSettings = await page.locator('//*[@data-test-type="ChartSettings"]').all()
|
||||||
|
console.log(`Found ${allSettings.length} ChartSettings elements (using @data-test-type):`)
|
||||||
|
for (let i = 0; i < allSettings.length; i++) {
|
||||||
|
const dataTest = await allSettings[i].getAttribute('data-test')
|
||||||
|
const isVisible = await allSettings[i].isVisible()
|
||||||
|
const box = await allSettings[i].boundingBox()
|
||||||
|
console.log(` [${i}] data-test="${dataTest}", visible=${isVisible}, box=${JSON.stringify(box)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try different locator strategies
|
||||||
|
console.log('\n=== Trying contains query for ChartSettings with "heater" ===')
|
||||||
|
const heaterSettings = page.locator('//*[contains(@data-test-type, "ChartSettings")][contains(@data-test, "heater")]')
|
||||||
|
const count = await heaterSettings.count()
|
||||||
|
console.log(`Found ${count} elements`)
|
||||||
|
if (count > 0) {
|
||||||
|
const dataTest = await heaterSettings.first().getAttribute('data-test')
|
||||||
|
const isVisible = await heaterSettings.first().isVisible()
|
||||||
|
console.log(` data-test="${dataTest}", visible=${isVisible}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Take screenshot
|
||||||
|
await page.screenshot({ path: '/tmp/chart-settings-inspection.png', fullPage: true })
|
||||||
|
console.log('\nScreenshot saved to /tmp/chart-settings-inspection.png')
|
||||||
|
|
||||||
|
await browser.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
inspect().catch(console.error)
|
||||||
@@ -2,7 +2,9 @@ import { Page } from 'playwright'
|
|||||||
import { clickOn } from '../util'
|
import { clickOn } from '../util'
|
||||||
|
|
||||||
export async function copyTopicToClipboard(browser: Page) {
|
export async function copyTopicToClipboard(browser: Page) {
|
||||||
// Select the copy button specifically in the Topic panel (not Value panel or MessageHistory)
|
// Select the first copy button (topic path copy button in the new sidebar structure)
|
||||||
const copyButton = browser.getByRole('button', { name: /Topic/i }).getByTestId('copy-button')
|
// The new sidebar has copy buttons in the topic section (for path) and value section (for value)
|
||||||
|
const copyButtons = browser.getByTestId('copy-button')
|
||||||
|
const copyButton = copyButtons.first()
|
||||||
await clickOn(copyButton, 1)
|
await clickOn(copyButton, 1)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import { Page } from 'playwright'
|
|||||||
import { clickOn } from '../util'
|
import { clickOn } from '../util'
|
||||||
|
|
||||||
export async function copyValueToClipboard(browser: Page) {
|
export async function copyValueToClipboard(browser: Page) {
|
||||||
// Select the copy button specifically in the Value panel (not Topic panel or MessageHistory)
|
// Select the second copy button (value copy button in the new sidebar structure)
|
||||||
const copyButton = browser.getByRole('button', { name: /Value/i }).getByTestId('copy-button')
|
// The new sidebar has copy buttons in the topic section (for path) and value section (for value)
|
||||||
|
const copyButtons = browser.getByTestId('copy-button')
|
||||||
|
const copyButton = copyButtons.nth(1) // Second copy button is for the value
|
||||||
await clickOn(copyButton, 1)
|
await clickOn(copyButton, 1)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Page } from 'playwright'
|
|||||||
import { clickOn } from '../util'
|
import { clickOn } from '../util'
|
||||||
|
|
||||||
export async function saveMessageToFile(browser: Page) {
|
export async function saveMessageToFile(browser: Page) {
|
||||||
// Select the save button specifically in the Value panel
|
// Select the save button in the new sidebar structure (directly by testid)
|
||||||
const saveButton = browser.getByRole('button', { name: /Value/i }).getByTestId('save-button')
|
const saveButton = browser.getByTestId('save-button')
|
||||||
await clickOn(saveButton, 1)
|
await clickOn(saveButton, 1)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,20 +5,25 @@ export async function showNumericPlot(browser: Page) {
|
|||||||
// On desktop, expandTopic will also select the topic (original behavior restored)
|
// On desktop, expandTopic will also select the topic (original behavior restored)
|
||||||
// This shows the JSON properties in the details panel where chart icons are located
|
// This shows the JSON properties in the details panel where chart icons are located
|
||||||
await expandTopic('kitchen/coffee_maker', browser)
|
await expandTopic('kitchen/coffee_maker', browser)
|
||||||
|
|
||||||
|
// Switch to Details tab to ensure ShowChart icons are visible
|
||||||
|
await switchToDetailsTab(browser)
|
||||||
|
await sleep(500)
|
||||||
|
|
||||||
let heater = await valuePreviewGuttersShowChartIcon('heater', browser)
|
let heater = await valuePreviewGuttersShowChartIcon('heater', browser)
|
||||||
await moveToCenterOfElement(heater)
|
await moveToCenterOfElement(heater)
|
||||||
await sleep(1000)
|
await sleep(1000)
|
||||||
// Refocus and click
|
// Refocus and click (force:true bypasses tooltip overlay)
|
||||||
heater = await valuePreviewGuttersShowChartIcon('heater', browser)
|
heater = await valuePreviewGuttersShowChartIcon('heater', browser)
|
||||||
await heater.click()
|
await heater.click({ force: true })
|
||||||
|
|
||||||
await sleep(1000)
|
await sleep(1000)
|
||||||
let temperature = await valuePreviewGuttersShowChartIcon('temperature', browser)
|
let temperature = await valuePreviewGuttersShowChartIcon('temperature', browser)
|
||||||
await moveToCenterOfElement(temperature)
|
await moveToCenterOfElement(temperature)
|
||||||
await sleep(1000)
|
await sleep(1000)
|
||||||
// Refocus and click
|
// Refocus and click (force:true bypasses tooltip overlay)
|
||||||
temperature = await valuePreviewGuttersShowChartIcon('temperature', browser)
|
temperature = await valuePreviewGuttersShowChartIcon('temperature', browser)
|
||||||
await temperature.click()
|
await temperature.click({ force: true })
|
||||||
|
|
||||||
await sleep(1000)
|
await sleep(1000)
|
||||||
await chartSettings('heater', browser)
|
await chartSettings('heater', browser)
|
||||||
@@ -81,3 +86,9 @@ async function clickOnMenuPoint(name: string, browser: Page) {
|
|||||||
const item = await browser.locator(`[data-menu-item="${name}"]`)
|
const item = await browser.locator(`[data-menu-item="${name}"]`)
|
||||||
return clickOn(item)
|
return clickOn(item)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function switchToDetailsTab(browser: Page) {
|
||||||
|
// Click the Details tab to ensure it's active and ShowChart icons are visible
|
||||||
|
const detailsTab = browser.getByRole('tab', { name: 'Details' })
|
||||||
|
await detailsTab.click()
|
||||||
|
}
|
||||||
|
|||||||
@@ -275,8 +275,11 @@ describe('MQTT Explorer UI Tests', function () {
|
|||||||
await expandTopic('livingroom/lamp/state', page)
|
await expandTopic('livingroom/lamp/state', page)
|
||||||
await sleep(1000)
|
await sleep(1000)
|
||||||
|
|
||||||
// When: Copy topic button is clicked
|
// When: Copy topic button is clicked (in the topic section at the top)
|
||||||
const copyTopicButton = page.getByRole('button', { name: /Topic/i }).getByTestId('copy-button')
|
// The new sidebar has copy buttons in the topic section (for path) and value section (for value)
|
||||||
|
// We need to find the first copy button (topic path copy button)
|
||||||
|
const copyButtons = page.getByTestId('copy-button')
|
||||||
|
const copyTopicButton = copyButtons.first()
|
||||||
await copyTopicButton.click()
|
await copyTopicButton.click()
|
||||||
await sleep(500)
|
await sleep(500)
|
||||||
|
|
||||||
@@ -317,8 +320,9 @@ describe('MQTT Explorer UI Tests', function () {
|
|||||||
|
|
||||||
it('should copy message value to clipboard in both Electron and browser modes', async function () {
|
it('should copy message value to clipboard in both Electron and browser modes', async function () {
|
||||||
// Given: A topic with a value is selected (reuse already expanded topic)
|
// Given: A topic with a value is selected (reuse already expanded topic)
|
||||||
// When: Copy value button is clicked
|
// When: Copy value button is clicked (the second copy button in the value section)
|
||||||
const copyValueButton = page.getByRole('button', { name: /Value/i }).getByTestId('copy-button')
|
const copyButtons = page.getByTestId('copy-button')
|
||||||
|
const copyValueButton = copyButtons.nth(1) // Second copy button is for the value
|
||||||
await copyValueButton.click()
|
await copyValueButton.click()
|
||||||
await sleep(500)
|
await sleep(500)
|
||||||
|
|
||||||
@@ -367,8 +371,8 @@ describe('MQTT Explorer UI Tests', function () {
|
|||||||
// In browser mode, set up download handling
|
// In browser mode, set up download handling
|
||||||
const downloadPromise = page.waitForEvent('download', { timeout: 10000 })
|
const downloadPromise = page.waitForEvent('download', { timeout: 10000 })
|
||||||
|
|
||||||
// When: Save button is clicked
|
// When: Save button is clicked (in the new sidebar, save button is in the value section)
|
||||||
const saveButton = page.getByRole('button', { name: /Value/i }).getByTestId('save-button')
|
const saveButton = page.getByTestId('save-button')
|
||||||
await saveButton.click()
|
await saveButton.click()
|
||||||
|
|
||||||
// Then: Download should be triggered
|
// Then: Download should be triggered
|
||||||
@@ -385,7 +389,7 @@ describe('MQTT Explorer UI Tests', function () {
|
|||||||
} else {
|
} else {
|
||||||
// In Electron mode, the file dialog would open
|
// In Electron mode, the file dialog would open
|
||||||
// We can't easily test the native file dialog, but we can verify the button works
|
// We can't easily test the native file dialog, but we can verify the button works
|
||||||
const saveButton = page.getByRole('button', { name: /Value/i }).getByTestId('save-button')
|
const saveButton = page.getByTestId('save-button')
|
||||||
const isVisible = await saveButton.isVisible()
|
const isVisible = await saveButton.isVisible()
|
||||||
expect(isVisible).to.be.true
|
expect(isVisible).to.be.true
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user