From d392fe734232bee5da870a69645dc39a6cf2aa4c Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 26 Jan 2026 23:48:29 +0100 Subject: [PATCH] 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 --- app/src/actions/Global.ts | 6 + app/src/components/AboutDialog.spec.ts | 133 +++++++++++++++++++++ app/src/components/AboutDialog.tsx | 136 ++++++++++++++++++++++ app/src/components/App.tsx | 7 ++ app/src/components/Sidebar/DetailsTab.tsx | 40 ++++++- app/src/components/Sidebar/Sidebar.tsx | 44 ++++++- app/src/reducers/Global.ts | 6 + 7 files changed, 369 insertions(+), 3 deletions(-) create mode 100644 app/src/components/AboutDialog.spec.ts create mode 100644 app/src/components/AboutDialog.tsx diff --git a/app/src/actions/Global.ts b/app/src/actions/Global.ts index 38c0435..ed03729 100644 --- a/app/src/actions/Global.ts +++ b/app/src/actions/Global.ts @@ -21,6 +21,12 @@ export const toggleSettingsVisibility = () => (dispatch: Dispatch) => { }) } +export const toggleAboutDialogVisibility = () => (dispatch: Dispatch) => { + dispatch({ + type: ActionTypes.toggleAboutDialogVisibility, + }) +} + export const requestConfirmation = (title: string, inquiry: string) => (dispatch: Dispatch) => { return new Promise(resolve => { const confirmationRequest = { diff --git a/app/src/components/AboutDialog.spec.ts b/app/src/components/AboutDialog.spec.ts new file mode 100644 index 0000000..109b7b3 --- /dev/null +++ b/app/src/components/AboutDialog.spec.ts @@ -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/) + }) +}) diff --git a/app/src/components/AboutDialog.tsx b/app/src/components/AboutDialog.tsx new file mode 100644 index 0000000..24acdc2 --- /dev/null +++ b/app/src/components/AboutDialog.tsx @@ -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(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 ( + + About MQTT Explorer + + + Version: {version} + + + License: CC-BY-ND-4.0 + + + Description: Explore your message queues + + + + + + + + + Thomas Nordquist + + + @thomasnordquist + + + + Support via PayPal + + + + + + + + Homepage:{' '} + + https://thomasnordquist.github.io/MQTT-Explorer/ + + + + Bug Report:{' '} + + https://github.com/thomasnordquist/MQTT-Explorer/issues + + + + + + + + ) +} diff --git a/app/src/components/App.tsx b/app/src/components/App.tsx index e385df4..5f22ce0 100644 --- a/app/src/components/App.tsx +++ b/app/src/components/App.tsx @@ -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 + aboutDialogVisible: boolean } class App extends React.PureComponent { @@ -75,6 +77,10 @@ class App extends React.PureComponent { + this.props.actions.toggleAboutDialogVisibility()} + /> {this.renderNotification()} }> @@ -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'), } } diff --git a/app/src/components/Sidebar/DetailsTab.tsx b/app/src/components/Sidebar/DetailsTab.tsx index 3233731..545b7c0 100644 --- a/app/src/components/Sidebar/DetailsTab.tsx +++ b/app/src/components/Sidebar/DetailsTab.tsx @@ -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) { Select a topic to view details + + {/* About Button - always show even when no topic selected */} + + + ) } @@ -180,6 +195,19 @@ function DetailsTab(props: Props) { )} + + {/* About Section - always visible at bottom */} + + + ) } @@ -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), } } diff --git a/app/src/components/Sidebar/Sidebar.tsx b/app/src/components/Sidebar/Sidebar.tsx index 4675069..15515e1 100644 --- a/app/src/components/Sidebar/Sidebar.tsx +++ b/app/src/components/Sidebar/Sidebar.tsx @@ -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 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 ( +