diff options
author | Felix Edel <felix.edel@bmw.de> | 2020-06-24 08:47:54 +0200 |
---|---|---|
committer | Felix Edel <felix.edel@bmw.de> | 2020-07-15 09:06:04 +0200 |
commit | 403a828b86b9c7fe1d2f0a9646fad32b480888f3 (patch) | |
tree | 154229c6e5ae2f372c024b20deafaf538908a75d /web/src | |
parent | 18e55acb61eb25c71e365210dba27aa3cceae7a1 (diff) | |
download | zuul-403a828b86b9c7fe1d2f0a9646fad32b480888f3.tar.gz |
PF4: Update buildset result page (new layout and styling)
This updates the buildset result page with Patternfly 4 components and
a new design based on inspirations from other CI systems, Patternfly 4
demos and my own ideas.
I chose the buildset result page because it doesn't contain too much
elements und therefore is a good starting point. If you like the
design, I will continue with that also for other pages like the build
result pages or the various "list" pages like builds and buildsets.
Change-Id: I978ee448ddf6e22e4a6ec8211204f694932eaa4e
Diffstat (limited to 'web/src')
-rw-r--r-- | web/src/Misc.jsx | 46 | ||||
-rw-r--r-- | web/src/containers/Errors.jsx | 53 | ||||
-rw-r--r-- | web/src/containers/build/BuildList.jsx | 115 | ||||
-rw-r--r-- | web/src/containers/build/Buildset.jsx | 221 | ||||
-rw-r--r-- | web/src/containers/build/Misc.jsx | 177 | ||||
-rw-r--r-- | web/src/index.css | 23 | ||||
-rw-r--r-- | web/src/pages/Buildset.jsx | 105 |
7 files changed, 613 insertions, 127 deletions
diff --git a/web/src/Misc.jsx b/web/src/Misc.jsx new file mode 100644 index 000000000..a2e9eb0c0 --- /dev/null +++ b/web/src/Misc.jsx @@ -0,0 +1,46 @@ +// Copyright 2020 BMW Group +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. You may obtain +// a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations +// under the License. + +import * as React from 'react' +import PropTypes from 'prop-types' +import { ExternalLinkAltIcon } from '@patternfly/react-icons' + +function ExternalLink(props) { + const { target } = props + + return ( + <a href={target}> + <span> + {props.children} + {/* As we want the icon to be smaller than "sm", we have to specify the + font-size directly */} + <ExternalLinkAltIcon + style={{ + marginLeft: 'var(--pf-global--spacer--xs)', + color: 'var(--pf-global--Color--400)', + fontSize: 'var(--pf-global--icon--FontSize--sm)', + verticalAlign: 'super', + }} + /> + </span> + </a> + ) +} + +ExternalLink.propTypes = { + target: PropTypes.string, + children: PropTypes.node, +} + +export { ExternalLink } diff --git a/web/src/containers/Errors.jsx b/web/src/containers/Errors.jsx new file mode 100644 index 000000000..dcd3040e5 --- /dev/null +++ b/web/src/containers/Errors.jsx @@ -0,0 +1,53 @@ +// Copyright 2020 BMW Group +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. You may obtain +// a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations +// under the License. + +import * as React from 'react' +import PropTypes from 'prop-types' +import { Link } from 'react-router-dom' + +import { + Button, + EmptyState, + EmptyStateIcon, + EmptyStatePrimary, + EmptyStateVariant, + Title, +} from '@patternfly/react-core' + +function EmptyPage(props) { + const { title, icon, linkTarget, linkText } = props + + return ( + <EmptyState variant={EmptyStateVariant.small}> + <EmptyStateIcon icon={icon} /> + <Title headingLevel="h4" size="lg"> + {title} + </Title> + <EmptyStatePrimary> + <Link to={linkTarget}> + <Button variant="link">{linkText}</Button> + </Link> + </EmptyStatePrimary> + </EmptyState> + ) +} + +EmptyPage.propTypes = { + title: PropTypes.string.isRequired, + icon: PropTypes.func.isRequired, + linkTarget: PropTypes.string.isRequired, + linkText: PropTypes.string.isRequired, +} + +export { EmptyPage } diff --git a/web/src/containers/build/BuildList.jsx b/web/src/containers/build/BuildList.jsx new file mode 100644 index 000000000..b0a3bc3f5 --- /dev/null +++ b/web/src/containers/build/BuildList.jsx @@ -0,0 +1,115 @@ +// Copyright 2020 BMW Group +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. You may obtain +// a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations +// under the License. + +import * as React from 'react' +import PropTypes from 'prop-types' +import { connect } from 'react-redux' +import { Link } from 'react-router-dom' +import { + DataList, + DataListCell, + DataListItem, + DataListItemRow, + DataListItemCells, +} from '@patternfly/react-core' +import { OutlinedClockIcon } from '@patternfly/react-icons' +import 'moment-duration-format' +import * as moment from 'moment' + +import { BuildResult, BuildResultWithIcon, IconProperty } from './Misc' + +class BuildList extends React.Component { + static propTypes = { + builds: PropTypes.array, + tenant: PropTypes.object, + } + + // TODO (felix): Add a property "isCompact" to be used on the buildresult + // page. Without this flag we might then even use this (with more + // information) on the /builds page. + + constructor() { + super() + this.state = { + selectedBuildId: null, + } + } + + handleSelectDataListItem = (buildId) => { + this.setState({ + selectedBuildId: buildId, + }) + } + + render() { + const { builds, tenant } = this.props + const { selectedBuildId } = this.state + return ( + <DataList + className="zuul-build-list" + isCompact + selectedDataListItemId={selectedBuildId} + onSelectDataListItem={this.handleSelectDataListItem} + style={{ fontSize: 'var(--pf-global--FontSize--md)' }} + > + {builds.map((build) => ( + <DataListItem key={build.uuid || build.job_name} id={build.uuid}> + <Link + to={`${tenant.linkPrefix}/build/${build.uuid}`} + style={{ + textDecoration: 'none', + color: build.voting + ? 'inherit' + : 'var(--pf-global--disabled-color--100)', + }} + > + <DataListItemRow> + <DataListItemCells + dataListCells={[ + <DataListCell key={build.uuid} width={3}> + <BuildResultWithIcon + result={build.result} + colored={build.voting} + size="sm" + > + {build.job_name} + {!build.voting && ' (non-voting)'} + </BuildResultWithIcon> + </DataListCell>, + <DataListCell key={`${build.uuid}-time`}> + <IconProperty + icon={<OutlinedClockIcon />} + value={moment + .duration(build.duration, 'seconds') + .format('h [hr] m [min] s [sec]')} + /> + </DataListCell>, + <DataListCell key={`${build.uuid}-result`}> + <BuildResult + result={build.result} + colored={build.voting} + /> + </DataListCell>, + ]} + /> + </DataListItemRow> + </Link> + </DataListItem> + ))} + </DataList> + ) + } +} + +export default connect((state) => ({ tenant: state.tenant }))(BuildList) diff --git a/web/src/containers/build/Buildset.jsx b/web/src/containers/build/Buildset.jsx index 42d4f1e48..f6eb0d587 100644 --- a/web/src/containers/build/Buildset.jsx +++ b/web/src/containers/build/Buildset.jsx @@ -15,117 +15,122 @@ import * as React from 'react' import PropTypes from 'prop-types' import { connect } from 'react-redux' -import { Link } from 'react-router-dom' -import { Panel } from 'react-bootstrap' -import * as moment from 'moment' -import 'moment-duration-format' +import { Flex, FlexItem, List, ListItem, Title } from '@patternfly/react-core' +import { + CodeIcon, + CodeBranchIcon, + OutlinedCommentDotsIcon, + CubeIcon, + FingerprintIcon, + StreamIcon, +} from '@patternfly/react-icons' +import { ExternalLink } from '../../Misc' +import { BuildResultBadge, BuildResultWithIcon, IconProperty } from './Misc' -class Buildset extends React.Component { - static propTypes = { - buildset: PropTypes.object, - tenant: PropTypes.object, - } +function Buildset(props) { + const { buildset, fetchable } = props - render () { - const { buildset } = this.props - const rows = [] - const myColumns = [ - 'change', 'project', 'branch', 'pipeline', 'result', 'message', 'event_id' - ] - const buildRows = [] - const buildColumns = [ - 'job', 'result', 'voting', 'duration' - ] - - myColumns.forEach(column => { - let label = column - let value = buildset[column] - if (column === 'change') { - value = ( - <a href={buildset.ref_url}> - {buildset.change},{buildset.patchset} - </a> - ) - } - if (column === 'event_id') { - label = 'event id' - } - if (value) { - rows.push({key: label, value: value}) - } - }) - - if (buildset.builds) { - buildset.builds.forEach(build => { - const row = [] - buildColumns.forEach(column => { - if (column === 'job') { - row.push(build.job_name) - } else if (column === 'duration') { - row.push(moment.duration(build.duration, 'seconds') - .format('h [hr] m [min] s [sec]')) - } else if (column === 'voting') { - row.push(build.voting ? 'true' : 'false') - } else if (column === 'result') { - row.push(<Link - to={this.props.tenant.linkPrefix + '/build/' + build.uuid}> - {build.result} - </Link>) - } else { - row.push(build[column]) - } - }) - buildRows.push(row) - }) - } - - return ( - <React.Fragment> - <Panel> - <Panel.Heading>Buildset result {buildset.uuid}</Panel.Heading> - <Panel.Body> - <table className="table table-striped table-bordered"> - <tbody> - {rows.map(item => ( - <tr key={item.key}> - <td>{item.key}</td> - <td>{item.value}</td> - </tr> - ))} - </tbody> - </table> - </Panel.Body> - </Panel> - {buildset.builds && - <Panel> - <Panel.Heading>Builds</Panel.Heading> - <Panel.Body> - <table className="table table-striped table-bordered"> - <thead> - <tr> - {buildColumns.map(item => ( - <td key={item}>{item}</td> - ))} - </tr> - </thead> - <tbody> - {buildset.builds.map((item, idx) => ( - <tr key={idx} className={item.result === 'SUCCESS' ? 'success': 'warning'}> - {buildRows[idx].map((item, idx) => ( - <td key={idx}>{item}</td> - ))} - </tr> - ))} - </tbody> - </table> - </Panel.Body> - </Panel> - } - </React.Fragment> - ) - } + return ( + <> + <Title headingLevel="h2"> + <BuildResultWithIcon result={buildset.result} size="md"> + Buildset result + </BuildResultWithIcon> + <BuildResultBadge result={buildset.result} /> + {fetchable} + </Title> + {/* We handle the spacing for the body and the flex items by ourselves + so they go hand in hand. By default, the flex items' spacing only + affects left/right margin, but not top or bottom (which looks + awkward when the items are stacked at certain breakpoints) */} + <Flex className="zuul-build-attributes"> + <Flex flex={{ default: 'flex_1' }}> + <FlexItem> + <List style={{ listStyle: 'none' }}> + {/* TODO (felix): It would be cool if we could differentiate + between the SVC system (Github, Gitlab, Gerrit), so we could + show the respective icon here (GithubIcon, GitlabIcon, + GitIcon - AFAIK the Gerrit icon is not very popular among + icon frameworks like fontawesome */} + {buildset.change && ( + <IconProperty + WrapElement={ListItem} + icon={<CodeIcon />} + value={ + <ExternalLink target={buildset.ref_url}> + <strong>Change </strong> + {buildset.change},{buildset.patchset} + </ExternalLink> + } + /> + )} + {/* TODO (felix): Link to project page in Zuul */} + <IconProperty + WrapElement={ListItem} + icon={<CubeIcon />} + value={ + <> + <strong>Project </strong> {buildset.project} + </> + } + /> + <IconProperty + WrapElement={ListItem} + icon={<CodeBranchIcon />} + value={ + <> + <strong>Branch </strong> {buildset.branch} + </> + } + /> + <IconProperty + WrapElement={ListItem} + icon={<StreamIcon />} + value={ + <> + <strong>Pipeline </strong> {buildset.pipeline} + </> + } + /> + <IconProperty + WrapElement={ListItem} + icon={<FingerprintIcon />} + value={ + <span> + <strong>UUID </strong> {buildset.uuid} <br /> + <strong>Event ID </strong> {buildset.event_id} <br /> + </span> + } + /> + </List> + </FlexItem> + </Flex> + <Flex flex={{ default: 'flex_1' }}> + <FlexItem> + <List style={{ listStyle: 'none' }}> + <IconProperty + WrapElement={ListItem} + icon={<OutlinedCommentDotsIcon />} + value={ + <> + <strong>Message:</strong> + <pre>{buildset.message}</pre> + </> + } + /> + </List> + </FlexItem> + </Flex> + </Flex> + </> + ) } +Buildset.propTypes = { + buildset: PropTypes.object, + tenant: PropTypes.object, + fetchable: PropTypes.node, +} -export default connect(state => ({tenant: state.tenant}))(Buildset) +export default connect((state) => ({ tenant: state.tenant }))(Buildset) diff --git a/web/src/containers/build/Misc.jsx b/web/src/containers/build/Misc.jsx new file mode 100644 index 000000000..f77f7de7a --- /dev/null +++ b/web/src/containers/build/Misc.jsx @@ -0,0 +1,177 @@ +// Copyright 2020 BMW Group +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. You may obtain +// a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations +// under the License. + +import * as React from 'react' +import PropTypes from 'prop-types' +import { Label } from '@patternfly/react-core' +import { + CheckIcon, + ExclamationIcon, + QuestionIcon, + TimesIcon, +} from '@patternfly/react-icons' + +const RESULT_ICON_CONFIGS = { + SUCCESS: { + icon: CheckIcon, + color: 'var(--pf-global--success-color--100)', + badgeColor: 'green', + }, + FAILURE: { + icon: TimesIcon, + color: 'var(--pf-global--danger-color--100)', + badgeColor: 'red', + }, + RETRY_LIMIT: { + icon: TimesIcon, + color: 'var(--pf-global--danger-color--100)', + badgeColor: 'red', + }, + SKIPPED: { + icon: QuestionIcon, + color: 'var(--pf-global--info-color--100)', + badgeColor: 'blue', + }, + ABORTED: { + icon: QuestionIcon, + color: 'var(--pf-global--info-color--100)', + badgeColor: 'yellow', + }, + MERGER_FAILURE: { + icon: ExclamationIcon, + color: 'var(--pf-global--warning-color--100)', + badgeColor: 'orange', + }, + NODE_FAILURE: { + icon: ExclamationIcon, + color: 'var(--pf-global--warning-color--100)', + badgeColor: 'orange', + }, + TIMED_OUT: { + icon: ExclamationIcon, + color: 'var(--pf-global--warning-color--100)', + badgeColor: 'orange', + }, + POST_FAILURE: { + icon: ExclamationIcon, + color: 'var(--pf-global--warning-color--100)', + badgeColor: 'orange', + }, + CONFIG_ERROR: { + icon: ExclamationIcon, + color: 'var(--pf-global--warning-color--100)', + badgeColor: 'orange', + }, +} + +const DEFAULT_RESULT_ICON_CONFIG = { + icon: ExclamationIcon, + color: 'var(--pf-global--warning-color--100)', + badgeColor: 'orange', +} + +function BuildResult(props) { + const { result, colored = true } = props + const iconConfig = RESULT_ICON_CONFIGS[result] || DEFAULT_RESULT_ICON_CONFIG + const color = colored ? iconConfig.color : 'inherit' + + return <span style={{ color: color }}>{result}</span> +} + +BuildResult.propTypes = { + result: PropTypes.string, + colored: PropTypes.bool, +} + +function BuildResultBadge(props) { + const { result } = props + const iconConfig = RESULT_ICON_CONFIGS[result] || DEFAULT_RESULT_ICON_CONFIG + const color = iconConfig.badgeColor + + return ( + <Label + color={color} + style={{ + marginLeft: 'var(--pf-global--spacer--sm)', + verticalAlign: '0.15em', + }} + > + {result} + </Label> + ) +} + +BuildResultBadge.propTypes = { + result: PropTypes.string, +} + +function BuildResultWithIcon(props) { + const { result, colored = true, size = 'sm' } = props + const iconConfig = RESULT_ICON_CONFIGS[result] || DEFAULT_RESULT_ICON_CONFIG + + // Define the verticalAlign based on the size + let verticalAlign = '-0.2em' + + if (size === 'md') { + verticalAlign = '-0.35em' + } + + const Icon = iconConfig.icon + const color = colored ? iconConfig.color : 'inherit' + + return ( + <span style={{ color: color }}> + <Icon + size={size} + style={{ + marginRight: 'var(--pf-global--spacer--sm)', + verticalAlign: verticalAlign, + }} + /> + {props.children} + </span> + ) +} + +BuildResultWithIcon.propTypes = { + result: PropTypes.string, + colored: PropTypes.bool, + size: PropTypes.string, + children: PropTypes.node, +} + +function IconProperty(props) { + const { icon, value, WrapElement = 'span' } = props + return ( + <WrapElement style={{ marginLeft: '25px' }}> + <span + style={{ + marginRight: 'var(--pf-global--spacer--sm)', + marginLeft: '-25px', + }} + > + {icon} + </span> + <span>{value}</span> + </WrapElement> + ) +} + +IconProperty.propTypes = { + icon: PropTypes.node, + value: PropTypes.oneOfType([PropTypes.string, PropTypes.node]), + WrapElement: PropTypes.func, +} + +export { BuildResult, BuildResultBadge, BuildResultWithIcon, IconProperty } diff --git a/web/src/index.css b/web/src/index.css index 4c0b67ca8..235e82fcb 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -8,6 +8,10 @@ body { padding: 0; } +.pf-c-title { + padding-bottom: 10px; +} + a.refresh { cursor: pointer; border-bottom-style: none; @@ -30,6 +34,25 @@ a.refresh { color: var(--pf-global--Color--dark-100); } +/* Build Lists */ +.pf-c-data-list__item.pf-m-selectable:hover:not(.pf-m-selected), +.pf-c-data-list__item.pf-m-selectable:focus:not(.pf-m-selected) { + /* Improve the hover/focus effect of selected lines */ + --pf-c-data-list__item--before--BackgroundColor: var( + --pf-c-data-list__item--m-selected--before--BackgroundColor + ); + font-weight: bold; +} + +/* + * Build/Buildset result page + */ +.zuul-build-attributes > .pf-l-flex > * { + padding-bottom: var(--pf-global--spacer--sm); + padding-left: var(--pf-global--spacer--sm); + padding-right: var(--pf-global--spacer--sm); +} + /* Status page */ .zuul-change { margin-bottom: 10px; diff --git a/web/src/pages/Buildset.jsx b/web/src/pages/Buildset.jsx index 3440cb326..b3a188e25 100644 --- a/web/src/pages/Buildset.jsx +++ b/web/src/pages/Buildset.jsx @@ -15,13 +15,22 @@ import * as React from 'react' import { connect } from 'react-redux' import PropTypes from 'prop-types' -import { PageSection, PageSectionVariants } from '@patternfly/react-core' +import { + EmptyState, + EmptyStateIcon, + EmptyStateVariant, + PageSection, + PageSectionVariants, + Title, +} from '@patternfly/react-core' +import { BuildIcon } from '@patternfly/react-icons' import { fetchBuildsetIfNeeded } from '../actions/build' -import { Fetchable } from '../containers/Fetching' +import { EmptyPage } from '../containers/Errors' +import { Fetchable, Fetching } from '../containers/Fetching' +import BuildList from '../containers/build/BuildList' import Buildset from '../containers/build/Buildset' - class BuildsetPage extends React.Component { static propTypes = { match: PropTypes.object.isRequired, @@ -31,42 +40,100 @@ class BuildsetPage extends React.Component { } updateData = (force) => { - this.props.dispatch(fetchBuildsetIfNeeded( - this.props.tenant, this.props.match.params.buildsetId, force)) + this.props.dispatch( + fetchBuildsetIfNeeded( + this.props.tenant, + this.props.match.params.buildsetId, + force + ) + ) } - componentDidMount () { + componentDidMount() { document.title = 'Zuul Buildset' if (this.props.tenant.name) { this.updateData() } } - componentDidUpdate (prevProps) { + componentDidUpdate(prevProps) { if (this.props.tenant.name !== prevProps.tenant.name) { this.updateData() } } - render () { - const { remoteData } = this.props - + render() { + const { remoteData, tenant } = this.props const buildset = remoteData.buildsets[this.props.match.params.buildsetId] + + // Initial page load + if (!buildset && remoteData.isFetching) { + return <Fetching /> + } + + // Fetching finished, but no buildset found + if (!buildset) { + // TODO (felix): Provide some generic error (404?) page. Can we somehow + // identify the error here? + return ( + <EmptyPage + title="This buildset does not exist" + icon={BuildIcon} + linkTarget={`${tenant.linkPrefix}/buildsets`} + linkText="Show all buildsets" + /> + ) + } + + // Return the build list or an empty state if no builds are part of the + // buildset. + const buildsContent = buildset.builds ? ( + <BuildList builds={buildset.builds} /> + ) : ( + <> + {/* Using an hr above the empty state ensures that the space between + heading (builds) and empty state is filled and the empty state + doesn't look like it's lost in space. */} + <hr /> + <EmptyState variant={EmptyStateVariant.small}> + <EmptyStateIcon icon={BuildIcon} /> + <Title headingLevel="h4" size="lg"> + This buildset does not contain any builds + </Title> + </EmptyState> + </> + ) + + const fetchable = ( + <Fetchable + isFetching={remoteData.isFetching} + fetchCallback={this.updateData} + /> + ) + return ( - <PageSection variant={PageSectionVariants.light}> - <PageSection style={{paddingRight: '5px'}}> - <Fetchable - isFetching={remoteData.isFetching} - fetchCallback={this.updateData} - /> + <> + <PageSection variant={PageSectionVariants.light}> + <Buildset buildset={buildset} fetchable={fetchable} /> + </PageSection> + <PageSection variant={PageSectionVariants.light}> + <Title headingLevel="h3"> + <BuildIcon + style={{ + marginRight: 'var(--pf-global--spacer--sm)', + verticalAlign: '-0.1em', + }} + />{' '} + Builds + </Title> + {buildsContent} </PageSection> - {buildset && <Buildset buildset={buildset}/>} - </PageSection> + </> ) } } -export default connect(state => ({ +export default connect((state) => ({ tenant: state.tenant, remoteData: state.build, }))(BuildsetPage) |