Add About dialog to sidebar with license compliance tests (#971)

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:
Copilot
2026-01-26 23:48:29 +01:00
committed by GitHub
parent 9e79d3fa33
commit d392fe7342
7 changed files with 369 additions and 3 deletions

View File

@@ -21,6 +21,12 @@ export const toggleSettingsVisibility = () => (dispatch: Dispatch<any>) => {
})
}
export const toggleAboutDialogVisibility = () => (dispatch: Dispatch<any>) => {
dispatch({
type: ActionTypes.toggleAboutDialogVisibility,
})
}
export const requestConfirmation = (title: string, inquiry: string) => (dispatch: Dispatch<any>) => {
return new Promise(resolve => {
const confirmationRequest = {

View File

@@ -0,0 +1,133 @@
import { expect } from 'chai'
import 'mocha'
import * as fs from 'fs'
import * as path from 'path'
/**
* AboutDialog License Compliance Tests
*
* These tests verify that the About dialog properly displays required
* attribution information as mandated by the CC-BY-ND-4.0 license.
*
* CC-BY-ND-4.0 (Creative Commons Attribution-NoDerivatives 4.0 International):
* - BY (Attribution): Must credit the original author (Thomas Nordquist)
* - ND (NoDerivatives): Cannot create derivative works without permission
* - Requires: Author name, license notice, and LICENSE NOTICE comment
*/
describe('AboutDialog License Compliance', () => {
const aboutDialogPath = path.join(__dirname, 'AboutDialog.tsx')
let aboutDialogContent: string
before(() => {
aboutDialogContent = fs.readFileSync(aboutDialogPath, 'utf-8')
})
it('should contain the license notice in the component file', () => {
expect(aboutDialogContent).to.include('LICENSE NOTICE')
expect(aboutDialogContent).to.include('CC-BY-ND-4.0')
})
it('should display author attribution (Thomas Nordquist)', () => {
// Verify the author is displayed in the component
expect(aboutDialogContent).to.match(/Author.*Thomas Nordquist/)
})
it('should display CC-BY-ND-4.0 license', () => {
// Verify the license is displayed in the component
expect(aboutDialogContent).to.match(/License.*CC-BY-ND-4.0/)
})
it('should have data-testid attributes for license verification', () => {
// These attributes allow automated testing of the rendered component
expect(aboutDialogContent).to.include('data-testid="about-author"')
expect(aboutDialogContent).to.include('data-testid="about-license"')
})
describe('License Violation Detection', () => {
it('removing author attribution violates CC-BY-ND-4.0 license', () => {
// CC-BY-ND-4.0 Attribution (BY) requirement:
// Must credit the original author "Thomas Nordquist"
const hasAuthor = aboutDialogContent.includes('Thomas Nordquist')
if (!hasAuthor) {
throw new Error(
'LICENSE VIOLATION: Author attribution "Thomas Nordquist" is missing. ' +
'This violates the CC-BY-ND-4.0 Attribution (BY) requirement. ' +
'The author must be properly credited in the About dialog.'
)
}
expect(hasAuthor).to.be.true
})
it('removing license notice violates CC-BY-ND-4.0 license', () => {
// CC-BY-ND-4.0 requires the license identifier to be displayed
const hasLicense = aboutDialogContent.includes('CC-BY-ND-4.0')
if (!hasLicense) {
throw new Error(
'LICENSE VIOLATION: License notice "CC-BY-ND-4.0" is missing. ' +
'This violates CC-BY-ND-4.0 license notice requirements. ' +
'The license identifier must be displayed in the About dialog.'
)
}
expect(hasLicense).to.be.true
})
it('removing LICENSE NOTICE comment violates CC-BY-ND-4.0 license', () => {
// CC-BY-ND-4.0 requires attribution notice in source code
const hasLicenseNotice = aboutDialogContent.includes('LICENSE NOTICE')
if (!hasLicenseNotice) {
throw new Error(
'LICENSE VIOLATION: LICENSE NOTICE comment is missing from source code. ' +
'This violates CC-BY-ND-4.0 source code attribution requirements. ' +
'The LICENSE NOTICE comment must be retained in the component source.'
)
}
expect(hasLicenseNotice).to.be.true
})
})
})
/**
* AboutDialog Functionality Tests
*
* These tests verify that the About dialog is accessible and functional.
*/
describe('AboutDialog Accessibility', () => {
const detailsTabPath = path.join(__dirname, 'Sidebar', 'DetailsTab.tsx')
const appPath = path.join(__dirname, 'App.tsx')
it('should be accessible from the DetailsTab component', () => {
const detailsTabContent = fs.readFileSync(detailsTabPath, 'utf-8')
// Verify the About button exists in DetailsTab
expect(detailsTabContent).to.include('About')
// Verify it triggers the toggle action
expect(detailsTabContent).to.include('toggleAboutDialogVisibility')
})
it('should be integrated in the App component', () => {
const appContent = fs.readFileSync(appPath, 'utf-8')
// Verify AboutDialog is imported
expect(appContent).to.include('AboutDialog')
// Verify it's rendered with state
expect(appContent).to.include('aboutDialogVisible')
})
it('should have About button with Info icon in DetailsTab', () => {
const detailsTabContent = fs.readFileSync(detailsTabPath, 'utf-8')
// Verify the button text
expect(detailsTabContent).to.include('About MQTT Explorer')
// Verify Info icon is used
expect(detailsTabContent).to.match(/import.*Info.*from.*@mui\/icons-material/)
})
})

View File

@@ -0,0 +1,136 @@
import React from 'react'
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
Typography,
Link,
Avatar,
Box,
Divider,
} from '@mui/material'
import { rendererRpc, getAppVersion } from '../../../events'
import FavoriteIcon from '@mui/icons-material/Favorite'
// Fallback version if RPC call fails (e.g., in browser mode during initialization)
const FALLBACK_VERSION = '0.4.0-beta.5'
interface AboutDialogProps {
open: boolean
onClose: () => void
}
/**
* About Dialog Component
*
* This component displays application information including version, author, and license.
*
* LICENSE NOTICE (CC-BY-ND-4.0):
* This component is licensed under Creative Commons Attribution-NoDerivatives 4.0 International.
*
* REQUIRED ATTRIBUTION:
* - Author: Thomas Nordquist
* - License: CC-BY-ND-4.0
*
* RESTRICTIONS:
* - BY (Attribution): You must give appropriate credit to the author
* - ND (NoDerivatives): You may not create derivative works without permission
*
* Removing or modifying this attribution violates the license terms.
* For full license text: https://creativecommons.org/licenses/by-nd/4.0/legalcode
*/
export function AboutDialog(props: AboutDialogProps) {
const [version, setVersion] = React.useState<string>(FALLBACK_VERSION)
React.useEffect(() => {
// Fetch version from backend
rendererRpc
.call(getAppVersion, undefined, 5000)
.then(v => setVersion(v))
.catch(() => {
// Fallback to hardcoded version if RPC fails
console.warn('Failed to fetch app version, using fallback')
})
}, [])
return (
<Dialog open={props.open} onClose={props.onClose} maxWidth="sm" fullWidth>
<DialogTitle>About MQTT Explorer</DialogTitle>
<DialogContent>
<Typography variant="body1" gutterBottom>
<strong>Version:</strong> {version}
</Typography>
<Typography variant="body1" gutterBottom data-testid="about-license">
<strong>License:</strong> CC-BY-ND-4.0
</Typography>
<Typography variant="body1" gutterBottom>
<strong>Description:</strong> Explore your message queues
</Typography>
<Divider sx={{ my: 2 }} />
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }} data-testid="about-author">
<Avatar
src="https://github.com/thomasnordquist.png"
alt="Thomas Nordquist"
sx={{ width: 56, height: 56 }}
/>
<Box>
<Typography variant="subtitle1" sx={{ fontWeight: 500 }}>
Thomas Nordquist
</Typography>
<Link
href="https://github.com/thomasnordquist"
target="_blank"
rel="noopener noreferrer"
sx={{ display: 'block', fontSize: '0.875rem' }}
>
@thomasnordquist
</Link>
<Link
href="https://paypal.me/ThomasNordquist"
target="_blank"
rel="noopener noreferrer"
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.5,
fontSize: '0.875rem',
mt: 0.5,
}}
>
<FavoriteIcon sx={{ fontSize: '1rem', color: 'error.main' }} />
Support via PayPal
</Link>
</Box>
</Box>
<Divider sx={{ my: 2 }} />
<Typography variant="body1" gutterBottom>
<strong>Homepage:</strong>{' '}
<Link href="https://thomasnordquist.github.io/MQTT-Explorer/" target="_blank" rel="noopener noreferrer">
https://thomasnordquist.github.io/MQTT-Explorer/
</Link>
</Typography>
<Typography variant="body1" gutterBottom>
<strong>Bug Report:</strong>{' '}
<Link
href="https://github.com/thomasnordquist/MQTT-Explorer/issues"
target="_blank"
rel="noopener noreferrer"
>
https://github.com/thomasnordquist/MQTT-Explorer/issues
</Link>
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={props.onClose} color="primary" variant="contained">
Close
</Button>
</DialogActions>
</Dialog>
)
}

View File

@@ -6,6 +6,7 @@ import Notification from './Layout/Notification'
import React from 'react'
import TitleBar from './Layout/TitleBar'
import UpdateNotifier from './UpdateNotifier'
import { AboutDialog } from './AboutDialog'
import { AppState } from '../reducers'
import { bindActionCreators } from 'redux'
import { ConfirmationRequest } from '../reducers/Global'
@@ -28,6 +29,7 @@ interface Props {
settingsActions: typeof settingsActions
launching: boolean
confirmationRequests: Array<ConfirmationRequest>
aboutDialogVisible: boolean
}
class App extends React.PureComponent<Props, {}> {
@@ -75,6 +77,10 @@ class App extends React.PureComponent<Props, {}> {
<CssBaseline />
<ErrorBoundary>
<ConfirmationDialog confirmationRequests={this.props.confirmationRequests} />
<AboutDialog
open={this.props.aboutDialogVisible}
onClose={() => this.props.actions.toggleAboutDialogVisibility()}
/>
{this.renderNotification()}
<React.Suspense fallback={<div></div>}>
<Settings {...anyProps} />
@@ -158,6 +164,7 @@ const mapStateToProps = (state: AppState) => {
highlightTopicUpdates: state.settings.get('highlightTopicUpdates'),
launching: state.globalState.get('launching'),
confirmationRequests: state.globalState.get('confirmationRequests'),
aboutDialogVisible: state.globalState.get('aboutDialogVisible'),
}
}

View File

@@ -1,12 +1,12 @@
import * as q from '../../../../backend/src/Model'
import React, { useCallback } from 'react'
import { Box, Typography, IconButton, Chip, Tooltip } from '@mui/material'
import { Box, Typography, IconButton, Chip, Tooltip, Button } 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 { sidebarActions, globalActions } from '../../actions'
import Copy from '../helper/Copy'
import Save from '../helper/Save'
import DateFormatter from '../helper/DateFormatter'
@@ -17,6 +17,7 @@ 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 Info from '@mui/icons-material/Info'
import SimpleBreadcrumb from './SimpleBreadcrumb'
interface Props {
@@ -24,6 +25,7 @@ interface Props {
classes: any
compareMessage?: q.Message
sidebarActions: typeof sidebarActions
globalActions: typeof globalActions
}
function DetailsTab(props: Props) {
@@ -67,6 +69,19 @@ function DetailsTab(props: Props) {
<Typography variant="body2" color="textSecondary" align="center">
Select a topic to view details
</Typography>
{/* About Button - always show even when no topic selected */}
<Box className={classes.aboutSection}>
<Button
variant="outlined"
size="small"
startIcon={<Info />}
onClick={() => props.globalActions.toggleAboutDialogVisibility()}
fullWidth
>
About MQTT Explorer
</Button>
</Box>
</Box>
)
}
@@ -180,6 +195,19 @@ function DetailsTab(props: Props) {
</Box>
</Box>
)}
{/* About Section - always visible at bottom */}
<Box className={classes.aboutSection}>
<Button
variant="outlined"
size="small"
startIcon={<Info />}
onClick={() => props.globalActions.toggleAboutDialogVisibility()}
fullWidth
>
About MQTT Explorer
</Button>
</Box>
</Box>
)
}
@@ -195,10 +223,17 @@ const styles = (theme: Theme) => ({
},
emptyState: {
display: 'flex',
flexDirection: 'column' as 'column',
alignItems: 'center',
justifyContent: 'center',
minHeight: '200px',
padding: theme.spacing(3),
gap: theme.spacing(3),
},
aboutSection: {
marginTop: theme.spacing(3),
paddingTop: theme.spacing(2),
borderTop: `1px solid ${theme.palette.divider}`,
},
// Topic section
topicSection: {
@@ -321,6 +356,7 @@ const mapStateToProps = (state: AppState) => {
const mapDispatchToProps = (dispatch: any) => {
return {
sidebarActions: bindActionCreators(sidebarActions, dispatch),
globalActions: bindActionCreators(globalActions, dispatch),
}
}

View File

@@ -1,16 +1,24 @@
import * as q from '../../../../backend/src/Model'
import React, { useState, useEffect, useCallback } from 'react'
import { AppState } from '../../reducers'
<<<<<<< HEAD
import { AccordionDetails, Button } from '@mui/material'
=======
>>>>>>> origin/master
import { bindActionCreators } from 'redux'
import { connect } from 'react-redux'
import { settingsActions, sidebarActions } from '../../actions'
import { globalActions, settingsActions, sidebarActions } from '../../actions'
import { Theme } from '@mui/material/styles'
import { withStyles } from '@mui/styles'
import { TopicViewModel } from '../../model/TopicViewModel'
import { usePollingToFetchTreeNode } from '../helper/usePollingToFetchTreeNode'
<<<<<<< HEAD
import Info from '@mui/icons-material/Info'
=======
import { Tabs, Tab, Box, useMediaQuery, useTheme } from '@mui/material'
import DetailsTab from './DetailsTab'
import PublishTab from './PublishTab'
>>>>>>> origin/master
const throttle = require('lodash.throttle')
@@ -18,6 +26,7 @@ interface Props {
nodePath?: string
tree?: q.Tree<TopicViewModel>
actions: typeof sidebarActions
globalActions: typeof globalActions
settingsActions: typeof settingsActions
classes: any
connectionId?: string
@@ -52,6 +61,37 @@ function SidebarNew(props: Props) {
const theme = useTheme()
const isMobile = useMediaQuery(theme.breakpoints.down('sm'))
<<<<<<< HEAD
return (
<div id="Sidebar" className={classes.drawer}>
<div>
<TopicPanel node={node} />
<ValuePanelAny lastUpdate={node ? node.lastUpdate : 0} />
<Panel>
<span>Publish</span>
<Publish connectionId={props.connectionId} />
</Panel>
<Panel detailsHidden={!node}>
<span>Stats</span>
<AccordionDetails className={classes.details}>
<NodeStats node={node} />
</AccordionDetails>
</Panel>
<Panel>
<span>About</span>
<AccordionDetails className={classes.details}>
<Button
variant="text"
size="small"
startIcon={<Info />}
onClick={() => props.globalActions.toggleAboutDialogVisibility()}
fullWidth
>
About MQTT Explorer
</Button>
</AccordionDetails>
</Panel>
=======
const handleTabChange = (event: React.SyntheticEvent, newValue: number) => {
setTabValue(newValue)
}
@@ -64,6 +104,7 @@ function SidebarNew(props: Props) {
<Box className={classes.mobileContent}>
<DetailsTab node={node} />
</Box>
>>>>>>> origin/master
</div>
)
}
@@ -108,6 +149,7 @@ const mapStateToProps = (state: AppState) => {
const mapDispatchToProps = (dispatch: any) => {
return {
actions: bindActionCreators(sidebarActions, dispatch),
globalActions: bindActionCreators(globalActions, dispatch),
settingsActions: bindActionCreators(settingsActions, dispatch),
}
}

View File

@@ -11,6 +11,7 @@ export enum ActionTypes {
toggleSettingsVisibility = 'TOGGLE_SETTINGS_VISIBILITY',
requestConfirmation = 'REQUEST_CONFIRMATION',
removeConfirmationRequest = 'REMOVE_CONFIRMATION_REQUEST',
toggleAboutDialogVisibility = 'TOGGLE_ABOUT_DIALOG_VISIBILITY',
}
export interface ConfirmationRequest {
@@ -36,6 +37,7 @@ interface GlobalStateInterface {
launching: boolean
settingsVisible: boolean
confirmationRequests: Array<ConfirmationRequest>
aboutDialogVisible: boolean
}
export type GlobalState = Record<GlobalStateInterface>
@@ -48,6 +50,7 @@ const initialStateFactory = Record<GlobalStateInterface>({
launching: true,
settingsVisible: false,
confirmationRequests: [],
aboutDialogVisible: false,
})
export const globalState: Reducer<Record<GlobalStateInterface>, GlobalAction> = (
@@ -63,6 +66,9 @@ export const globalState: Reducer<Record<GlobalStateInterface>, GlobalAction> =
case ActionTypes.toggleSettingsVisibility:
return state.set('settingsVisible', !state.get('settingsVisible'))
case ActionTypes.toggleAboutDialogVisibility:
return state.set('aboutDialogVisible', !state.get('aboutDialogVisible'))
case ActionTypes.showError:
return state.set('error', action.error)