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
This commit is contained in:
Felix Edel 2020-06-24 08:47:54 +02:00
parent 18e55acb61
commit 403a828b86
No known key found for this signature in database
GPG Key ID: E95717A102DD3030
7 changed files with 613 additions and 127 deletions

46
web/src/Misc.jsx Normal file
View File

@ -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 }

View File

@ -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 }

View File

@ -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)

View File

@ -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)

View File

@ -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 }

View File

@ -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;

View File

@ -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>
{buildset && <Buildset buildset={buildset}/>}
</PageSection>
<PageSection variant={PageSectionVariants.light}>
<Title headingLevel="h3">
<BuildIcon
style={{
marginRight: 'var(--pf-global--spacer--sm)',
verticalAlign: '-0.1em',
}}
/>{' '}
Builds
</Title>
{buildsContent}
</PageSection>
</>
)
}
}
export default connect(state => ({
export default connect((state) => ({
tenant: state.tenant,
remoteData: state.build,
}))(BuildsetPage)