From 59cd5de78baa31150958e6d0d6733407c0e95805 Mon Sep 17 00:00:00 2001 From: Tobias Urdin Date: Wed, 15 Mar 2023 23:36:45 +0000 Subject: web: add dark mode and theme selection This adds a theme selection in the preferences in the config modal and adds a new dark theme. Removes the line.png image and instead uses CSS linear-gradient that is available in all browsers since around 2018, also fixes the 15 pixels spacing issue that is there today. You can select between three different themes. Auto will use your system preference to choose either the light or dark theme, changes dynamically based on your system preference. Light is the current theme. Dark is the theme added by this patch series. The UX this changes is that if somebody has their system preferences set to dark, for example in Mac OS X that is in System Settings -> Appearance -> Dark the user will get the Zuul web UI in dark by default and same for the opposite. This uses a poor man's dark mode for swagger-ui as per the comment in [1]. [1] https://github.com/swagger-api/swagger-ui/issues/5327#issuecomment-742375520 Change-Id: I01cf32f3decdb885307a76eb79d644667bbbf9a3 --- releasenotes/notes/dark-mode-e9b1cca960d4b906.yaml | 9 ++ web/src/Misc.jsx | 28 ++++- web/src/containers/autohold/HeldBuildList.jsx | 1 - web/src/containers/build/Artifact.jsx | 25 +++-- web/src/containers/build/BuildOutput.jsx | 30 +++-- web/src/containers/build/BuildOutput.test.jsx | 8 +- web/src/containers/build/Buildset.jsx | 8 +- web/src/containers/build/Console.jsx | 32 ++++-- web/src/containers/charts/GanttChart.jsx | 24 ++-- web/src/containers/config/Config.jsx | 62 ++++++++++- web/src/containers/job/JobVariant.jsx | 14 ++- web/src/containers/jobgraph/JobGraphDisplay.jsx | 26 ++++- web/src/containers/jobs/Jobs.jsx | 1 + web/src/containers/project/ProjectVariant.jsx | 6 +- web/src/containers/status/Change.jsx | 10 +- web/src/containers/status/ChangePanel.jsx | 20 ++-- web/src/containers/timezone/SelectTz.jsx | 1 + web/src/images/line.png | Bin 183 -> 0 bytes web/src/index.css | 124 ++++++++++++++++++++- web/src/pages/Autohold.jsx | 10 +- web/src/pages/Build.jsx | 8 +- web/src/pages/Buildset.jsx | 6 +- web/src/pages/Buildsets.jsx | 8 +- web/src/pages/ConfigErrors.jsx | 23 ++-- web/src/pages/FreezeJob.jsx | 5 +- web/src/pages/Job.jsx | 6 +- web/src/pages/Jobs.jsx | 8 +- web/src/pages/OpenApi.jsx | 6 +- web/src/pages/Project.jsx | 4 +- web/src/pages/Semaphore.jsx | 6 +- web/src/pages/Status.jsx | 3 +- web/src/pages/Stream.jsx | 11 +- web/src/reducers/preferences.js | 23 ++-- 33 files changed, 443 insertions(+), 113 deletions(-) create mode 100644 releasenotes/notes/dark-mode-e9b1cca960d4b906.yaml delete mode 100644 web/src/images/line.png diff --git a/releasenotes/notes/dark-mode-e9b1cca960d4b906.yaml b/releasenotes/notes/dark-mode-e9b1cca960d4b906.yaml new file mode 100644 index 000000000..997626af2 --- /dev/null +++ b/releasenotes/notes/dark-mode-e9b1cca960d4b906.yaml @@ -0,0 +1,9 @@ +--- +features: + - | + Added a new dark theme for the Zuul web interface. + - | + Added theme selection for the Zuul web interface. The default theme is set + to Auto which means your system/browsers preference determines if the Light + or Dark theme should be used. Either can be explicitly set in the settings + for the web interface by clicking the cogs in the top right. diff --git a/web/src/Misc.jsx b/web/src/Misc.jsx index dae5a84bf..1186f42d3 100644 --- a/web/src/Misc.jsx +++ b/web/src/Misc.jsx @@ -120,4 +120,30 @@ IconProperty.propTypes = { const ConditionalWrapper = ({ condition, wrapper, children }) => condition ? wrapper(children) : children -export { IconProperty, removeHash, ExternalLink, buildExternalLink, buildExternalTableLink, ConditionalWrapper } +function resolveDarkMode(theme) { + let darkMode = false + + if (theme === 'Auto') { + let matchMedia = window.matchMedia || function () { + return { + matches: false, + } + } + + darkMode = matchMedia('(prefers-color-scheme: dark)').matches + } else if (theme === 'Dark') { + darkMode = true + } + + return darkMode +} + +function setDarkMode(darkMode) { + if (darkMode) { + document.documentElement.classList.add('pf-theme-dark') + } else { + document.documentElement.classList.remove('pf-theme-dark') + } +} + +export { IconProperty, removeHash, ExternalLink, buildExternalLink, buildExternalTableLink, ConditionalWrapper, resolveDarkMode, setDarkMode } diff --git a/web/src/containers/autohold/HeldBuildList.jsx b/web/src/containers/autohold/HeldBuildList.jsx index d2f45dd20..0aef3a804 100644 --- a/web/src/containers/autohold/HeldBuildList.jsx +++ b/web/src/containers/autohold/HeldBuildList.jsx @@ -62,7 +62,6 @@ class HeldBuildList extends React.Component { to={`${tenant.linkPrefix}/build/${node.build}`} style={{ textDecoration: 'none', - color: 'var(--pf-global--disabled-color--100)', }} > diff --git a/web/src/containers/build/Artifact.jsx b/web/src/containers/build/Artifact.jsx index 3793222d9..0ada93a0c 100644 --- a/web/src/containers/build/Artifact.jsx +++ b/web/src/containers/build/Artifact.jsx @@ -18,15 +18,16 @@ import { TreeView, } from 'patternfly-react' import ReactJson from 'react-json-view' - +import { connect } from 'react-redux' class Artifact extends React.Component { static propTypes = { - artifact: PropTypes.object.isRequired + artifact: PropTypes.object.isRequired, + preferences: PropTypes.object, } render() { - const { artifact } = this.props + const { artifact, preferences } = this.props return ( @@ -41,7 +42,8 @@ class Artifact extends React.Component { collapsed={true} sortKeys={true} enableClipboard={false} - displayDataTypes={false}/> + displayDataTypes={false} + theme={preferences.darkMode ? 'tomorrow' : 'rjv-default'}/> :artifact.metadata[key].toString()} @@ -54,17 +56,18 @@ class Artifact extends React.Component { class ArtifactList extends React.Component { static propTypes = { - artifacts: PropTypes.array.isRequired + artifacts: PropTypes.array.isRequired, + preferences: PropTypes.object, } render() { - const { artifacts } = this.props + const { artifacts, preferences } = this.props const nodes = artifacts.map((artifact, index) => { const node = {text: {artifact.name}, icon: null} if (artifact.metadata) { - node['nodes']= [{text: , + node['nodes']= [{text: , icon: ''}] } return node @@ -83,4 +86,10 @@ class ArtifactList extends React.Component { } } -export default ArtifactList +function mapStateToProps(state) { + return { + preferences: state.preferences, + } +} + +export default connect(mapStateToProps)(ArtifactList) diff --git a/web/src/containers/build/BuildOutput.jsx b/web/src/containers/build/BuildOutput.jsx index 1098ed2c7..58f0e13b5 100644 --- a/web/src/containers/build/BuildOutput.jsx +++ b/web/src/containers/build/BuildOutput.jsx @@ -13,6 +13,7 @@ // under the License. import * as React from 'react' +import { connect } from 'react-redux' import { Fragment } from 'react' import ReAnsi from '@softwarefactory-project/re-ansi' import PropTypes from 'prop-types' @@ -73,6 +74,7 @@ class BuildOutputLabel extends React.Component { class BuildOutput extends React.Component { static propTypes = { output: PropTypes.object, + preferences: PropTypes.object, } renderHosts (hosts) { @@ -109,8 +111,12 @@ class BuildOutput extends React.Component { renderFailedTask (host, task) { const max_lines = 42 + let zuulOutputClass = 'zuul-build-output' + if (this.props.preferences.darkMode) { + zuulOutputClass = 'zuul-build-output-dark' + } return ( - +  Task {task.name}  @@ -119,25 +125,25 @@ class BuildOutput extends React.Component { {task.invocation && task.invocation.module_args && task.invocation.module_args._raw_params && ( -
+            
               {task.invocation.module_args._raw_params}
             
)} {task.msg && ( -
{task.msg}
+
{task.msg}
)} {task.exception && ( -
{task.exception}
+
{task.exception}
)} {task.stdout_lines && task.stdout_lines.length > 0 && ( {task.stdout_lines.length > max_lines && (
-
+                  
                     
                   
)} -
+              
                 
               
@@ -146,12 +152,12 @@ class BuildOutput extends React.Component { {task.stderr_lines.length > max_lines && (
-
+                  
                     
                   
)} -
+              
                 
               
@@ -177,4 +183,10 @@ class BuildOutput extends React.Component { } -export default BuildOutput +function mapStateToProps(state) { + return { + preferences: state.preferences, + } +} + +export default connect(mapStateToProps)(BuildOutput) diff --git a/web/src/containers/build/BuildOutput.test.jsx b/web/src/containers/build/BuildOutput.test.jsx index c76236a2e..defa342c5 100644 --- a/web/src/containers/build/BuildOutput.test.jsx +++ b/web/src/containers/build/BuildOutput.test.jsx @@ -14,6 +14,8 @@ import React from 'react' import ReactDOM from 'react-dom' +import { Provider } from 'react-redux' +import configureStore from '../../store' import BuildOutput from './BuildOutput' const fakeOutput = (width, height) => ({ @@ -31,7 +33,11 @@ it('BuildOutput renders big task', () => { const div = document.createElement('div') const output = fakeOutput(512, 1024) const begin = performance.now() - ReactDOM.render(, div, () => { + const store = configureStore() + ReactDOM.render( + + + , div, () => { const end = performance.now() console.log('Render took ' + (end - begin) + ' milliseconds.') }) diff --git a/web/src/containers/build/Buildset.jsx b/web/src/containers/build/Buildset.jsx index 2ca70549d..5492b7a13 100644 --- a/web/src/containers/build/Buildset.jsx +++ b/web/src/containers/build/Buildset.jsx @@ -47,7 +47,7 @@ import { addNotification, addApiError } from '../../actions/notifications' import { ChartModal } from '../charts/ChartModal' import BuildsetGanttChart from '../charts/GanttChart' -function Buildset({ buildset, timezone, tenant, user }) { +function Buildset({ buildset, timezone, tenant, user, preferences }) { const buildset_link = buildExternalLink(buildset) const [isGanttChartModalOpen, setIsGanttChartModalOpen] = useState(false) @@ -319,7 +319,9 @@ function Buildset({ buildset, timezone, tenant, user }) { value={ <> Message: -
{buildset.message}
+
+
{buildset.message}
+
} /> @@ -349,10 +351,12 @@ Buildset.propTypes = { tenant: PropTypes.object, timezone: PropTypes.string, user: PropTypes.object, + preferences: PropTypes.object, } export default connect((state) => ({ tenant: state.tenant, timezone: state.timezone, user: state.user, + preferences: state.preferences, }))(Buildset) diff --git a/web/src/containers/build/Console.jsx b/web/src/containers/build/Console.jsx index 9cd10df92..826ebe3cd 100644 --- a/web/src/containers/build/Console.jsx +++ b/web/src/containers/build/Console.jsx @@ -18,6 +18,7 @@ import * as React from 'react' import ReAnsi from '@softwarefactory-project/re-ansi' import PropTypes from 'prop-types' import ReactJson from 'react-json-view' +import { connect } from 'react-redux' import { Button, @@ -60,6 +61,7 @@ class TaskOutput extends React.Component { static propTypes = { data: PropTypes.object, include: PropTypes.array, + preferences: PropTypes.object, } renderResults(value) { @@ -130,7 +132,8 @@ class TaskOutput extends React.Component { name={null} sortKeys={true} enableClipboard={false} - displayDataTypes={false}/> + displayDataTypes={false} + theme={this.props.preferences.darkMode ? 'tomorrow' : 'rjv-default'}/>
) } else { @@ -142,7 +145,7 @@ class TaskOutput extends React.Component { } return ( -
+
{ret &&
{key}
} {ret && ret}
@@ -170,6 +173,7 @@ class HostTask extends React.Component { errorIds: PropTypes.object, taskPath: PropTypes.array, displayPath: PropTypes.array, + preferences: PropTypes.object, } state = { @@ -290,7 +294,7 @@ class HostTask extends React.Component { ) - const content = + const content = let item = null if (interestingKeys) { @@ -354,7 +358,7 @@ class HostTask extends React.Component { isOpen={this.state.showModal} onClose={this.close} description={modalDescription}> - + ) @@ -367,6 +371,7 @@ class PlayBook extends React.Component { errorIds: PropTypes.object, taskPath: PropTypes.array, displayPath: PropTypes.array, + preferences: PropTypes.object, } constructor(props) { @@ -404,8 +409,8 @@ class PlayBook extends React.Component { dataListCells.push( - {playbook.phase[0].toUpperCase() + playbook.phase.slice(1)} playbook< - /strong> + {playbook.phase[0].toUpperCase() + playbook.phase.slice(1)} playbook + ) dataListCells.push( @@ -463,7 +468,8 @@ class PlayBook extends React.Component { taskPath={taskPath.concat([ idx.toString(), idx2.toString(), hostname])} displayPath={displayPath} task={task} host={host} - errorIds={errorIds}/> + errorIds={errorIds} + preferences={this.props.preferences}/> ))))} @@ -484,6 +490,7 @@ class Console extends React.Component { errorIds: PropTypes.object, output: PropTypes.array, displayPath: PropTypes.array, + preferences: PropTypes.object, } render () { @@ -492,7 +499,7 @@ class Console extends React.Component { return (
- + { @@ -500,6 +507,7 @@ class Console extends React.Component { )) } @@ -509,5 +517,11 @@ class Console extends React.Component { } } +function mapStateToProps(state) { + return { + preferences: state.preferences, + } +} + -export default Console +export default connect(mapStateToProps)(Console) diff --git a/web/src/containers/charts/GanttChart.jsx b/web/src/containers/charts/GanttChart.jsx index 5ac065fce..f677d83b8 100644 --- a/web/src/containers/charts/GanttChart.jsx +++ b/web/src/containers/charts/GanttChart.jsx @@ -26,7 +26,7 @@ import { buildResultLegendData, buildsBarStyle } from './Misc' function BuildsetGanttChart(props) { - const { builds, timezone } = props + const { builds, timezone, preferences } = props const sortedByStartTime = builds.sort((a, b) => { if (a.start_time > b.start_time) { return -1 @@ -64,6 +64,10 @@ function BuildsetGanttChart(props) { const chartLegend = buildResultLegendData.filter((legend) => { return uniqueResults.indexOf(legend.name) > -1 }) + let horizontalLegendTextColor = '#000' + if (preferences.darkMode) { + horizontalLegendTextColor = '#ccc' + } return (
@@ -81,10 +85,9 @@ function BuildsetGanttChart(props) { legendOrientation='horizontal' legendPosition='top' legendData={legendData} - legendComponent={} - + legendComponent={} > - + + style={{ tickLabels: { angle: -25, padding: 1, verticalAnchor: 'middle', textAnchor: 'end', fill: horizontalLegendTextColor } }} + /> } + } labels={({ datum }) => `${datum.result}\nStarted ${datum.started}\nEnded ${datum.ended}`} /> - +
) @@ -120,8 +124,10 @@ function BuildsetGanttChart(props) { BuildsetGanttChart.propTypes = { builds: PropTypes.array.isRequired, timezone: PropTypes.string, + preferences: PropTypes.object, } export default connect((state) => ({ timezone: state.timezone, -}))(BuildsetGanttChart) \ No newline at end of file + preferences: state.preferences, +}))(BuildsetGanttChart) diff --git a/web/src/containers/config/Config.jsx b/web/src/containers/config/Config.jsx index 3d402a116..652d6702f 100644 --- a/web/src/containers/config/Config.jsx +++ b/web/src/containers/config/Config.jsx @@ -18,10 +18,14 @@ import { ButtonVariant, Modal, ModalVariant, - Switch + Switch, + Select, + SelectOption, + SelectVariant } from '@patternfly/react-core' import { CogIcon } from '@patternfly/react-icons' import { setPreference } from '../../actions/preferences' +import { resolveDarkMode, setDarkMode } from '../../Misc' class ConfigModal extends React.Component { @@ -39,6 +43,8 @@ class ConfigModal extends React.Component { this.state = { isModalOpen: false, autoReload: false, + theme: 'Auto', + isThemeOpen: false, } this.handleModalToggle = () => { this.setState(({ isModalOpen }) => ({ @@ -47,9 +53,39 @@ class ConfigModal extends React.Component { this.resetState() } + this.handleEscape = () => { + if (this.state.isThemeOpen) { + this.setState(({ isThemeOpen }) => ({ + isThemeOpen: !isThemeOpen, + })) + } else { + this.handleModalToggle() + } + } + + this.handleThemeToggle = () => { + this.setState(({ isThemeOpen }) => ({ + isThemeOpen: !isThemeOpen, + })) + } + + this.handleThemeSelect = (event, selection) => { + this.setState({ + theme: selection, + isThemeOpen: false + }) + } + + this.handleTheme = () => { + let darkMode = resolveDarkMode(this.state.theme) + setDarkMode(darkMode) + } + this.handleSave = () => { this.handleModalToggle() this.props.dispatch(setPreference('autoReload', this.state.autoReload)) + this.props.dispatch(setPreference('theme', this.state.theme)) + this.handleTheme() } this.handleAutoReload = () => { @@ -62,11 +98,12 @@ class ConfigModal extends React.Component { resetState() { this.setState({ autoReload: this.props.preferences.autoReload, + theme: this.props.preferences.theme, }) } render() { - const { isModalOpen, autoReload } = this.state + const { isModalOpen, autoReload, theme, isThemeOpen } = this.state return (
+
{rows.map(item => ( @@ -75,12 +75,14 @@ function ProjectVariant(props) { ProjectVariant.propTypes = { tenant: PropTypes.object, - variant: PropTypes.object.isRequired + variant: PropTypes.object.isRequired, + preferences: PropTypes.object, } function mapStateToProps(state) { return { tenant: state.tenant, + preferences: state.preferences, } } diff --git a/web/src/containers/status/Change.jsx b/web/src/containers/status/Change.jsx index ac0a4e6e8..b2ab50c5b 100644 --- a/web/src/containers/status/Change.jsx +++ b/web/src/containers/status/Change.jsx @@ -48,7 +48,8 @@ class Change extends React.Component { pipeline: PropTypes.object, tenant: PropTypes.object, user: PropTypes.object, - dispatch: PropTypes.func + dispatch: PropTypes.func, + preferences: PropTypes.object } state = { @@ -268,7 +269,11 @@ class Change extends React.Component { for (i = 0; i < queue._tree_columns; i++) { let className = '' if (i < change._tree.length && change._tree[i] !== null) { - className = ' zuul-change-row-line' + if (this.props.preferences.darkMode) { + className = ' zuul-change-row-line-dark' + } else { + className = ' zuul-change-row-line' + } } row.push(
@@ -313,4 +318,5 @@ class Change extends React.Component { export default connect(state => ({ tenant: state.tenant, user: state.user, + preferences: state.preferences, }))(Change) diff --git a/web/src/containers/status/ChangePanel.jsx b/web/src/containers/status/ChangePanel.jsx index dd4fc27e5..4c8c20469 100644 --- a/web/src/containers/status/ChangePanel.jsx +++ b/web/src/containers/status/ChangePanel.jsx @@ -25,7 +25,8 @@ class ChangePanel extends React.Component { static propTypes = { globalExpanded: PropTypes.bool.isRequired, change: PropTypes.object.isRequired, - tenant: PropTypes.object + tenant: PropTypes.object, + preferences: PropTypes.object } constructor () { @@ -126,7 +127,7 @@ class ChangePanel extends React.Component { const interesting_jobs = change.jobs.filter(j => this.jobStrResult(j) !== 'skipped') let jobPercent = (100 / interesting_jobs.length).toFixed(2) return ( -
+
{change.jobs.map((job, idx) => { let result = this.jobStrResult(job) if (['queued', 'waiting', 'skipped'].includes(result)) { @@ -204,7 +205,7 @@ class ChangePanel extends React.Component { } return ( -
-
    +
      {interestingJobs.map((job, idx) => ( -
    • +
    • {this.renderJob(job, times.jobs[job.name])}
    • ))} @@ -389,8 +390,8 @@ class ChangePanel extends React.Component { } const times = this.calculateTimes(change) const header = ( -
      -
      +
      @@ -422,4 +423,7 @@ class ChangePanel extends React.Component { } } -export default connect(state => ({tenant: state.tenant}))(ChangePanel) +export default connect(state => ({ + tenant: state.tenant, + preferences: state.preferences, +}))(ChangePanel) diff --git a/web/src/containers/timezone/SelectTz.jsx b/web/src/containers/timezone/SelectTz.jsx index 576645f6c..0740aaed6 100644 --- a/web/src/containers/timezone/SelectTz.jsx +++ b/web/src/containers/timezone/SelectTz.jsx @@ -111,6 +111,7 @@ class SelectTz extends React.Component {