web UI: allow a privileged user to re-enqueue a change
Add a "re-enqueue" action command on a Buildset page, displayed only if the currently logged in user is an admin on the tenant. By clicking this command, the user can re-enqueue the change with the same parameters as the buildset. Redux: turned the API error notifications into a more generic notification system, that can now include success notifications. Change-Id: I05b6b3deb912b121df8de207944d9ec26e7c92d1
This commit is contained in:
parent
3152171aa5
commit
c82619174c
|
@ -64,7 +64,7 @@ import { Fetching } from './containers/Fetching'
|
|||
import SelectTz from './containers/timezone/SelectTz'
|
||||
import ConfigModal from './containers/config/Config'
|
||||
import logo from './images/logo.svg'
|
||||
import { clearError } from './actions/errors'
|
||||
import { clearNotification } from './actions/notifications'
|
||||
import { fetchConfigErrorsAction } from './actions/configErrors'
|
||||
import { routes } from './routes'
|
||||
import { setTenantAction } from './actions/tenant'
|
||||
|
@ -72,7 +72,7 @@ import { configureAuthFromTenant, configureAuthFromInfo } from './actions/auth'
|
|||
|
||||
class App extends React.Component {
|
||||
static propTypes = {
|
||||
errors: PropTypes.array,
|
||||
notifications: PropTypes.array,
|
||||
configErrors: PropTypes.array,
|
||||
info: PropTypes.object,
|
||||
tenant: PropTypes.object,
|
||||
|
@ -235,21 +235,34 @@ class App extends React.Component {
|
|||
})
|
||||
}
|
||||
|
||||
renderErrors = (errors) => {
|
||||
renderNotifications = (notifications) => {
|
||||
return (
|
||||
<ToastNotificationList>
|
||||
{errors.map(error => (
|
||||
<TimedToastNotification
|
||||
key={error.id}
|
||||
type='error'
|
||||
onDismiss={() => { this.props.dispatch(clearError(error.id)) }}
|
||||
>
|
||||
<span title={moment.utc(error.date).tz(this.props.timezone).format()}>
|
||||
<strong>{error.text}</strong> ({error.status})
|
||||
{error.url}
|
||||
</span>
|
||||
</TimedToastNotification>
|
||||
))}
|
||||
{notifications.map(notification => {
|
||||
let notificationBody
|
||||
if (notification.type === 'error') {
|
||||
notificationBody = (
|
||||
<>
|
||||
<strong>{notification.text}</strong> {notification.status}
|
||||
{notification.url}
|
||||
</>
|
||||
)
|
||||
} else {
|
||||
notificationBody = (<span>{notification.text}</span>)
|
||||
}
|
||||
return (
|
||||
<TimedToastNotification
|
||||
key={notification.id}
|
||||
type={notification.type}
|
||||
onDismiss={() => { this.props.dispatch(clearNotification(notification.id)) }}
|
||||
>
|
||||
<span title={moment.utc(notification.date).tz(this.props.timezone).format()}>
|
||||
{notificationBody}
|
||||
</span>
|
||||
</TimedToastNotification>
|
||||
)
|
||||
}
|
||||
)}
|
||||
</ToastNotificationList>
|
||||
)
|
||||
}
|
||||
|
@ -318,7 +331,7 @@ class App extends React.Component {
|
|||
|
||||
render() {
|
||||
const { isKebabDropdownOpen } = this.state
|
||||
const { errors, configErrors, tenant } = this.props
|
||||
const { notifications, configErrors, tenant } = this.props
|
||||
|
||||
const nav = this.renderMenu()
|
||||
|
||||
|
@ -440,7 +453,7 @@ class App extends React.Component {
|
|||
|
||||
return (
|
||||
<React.Fragment>
|
||||
{errors.length > 0 && this.renderErrors(errors)}
|
||||
{notifications.length > 0 && this.renderNotifications(notifications)}
|
||||
{this.renderConfigErrors(configErrors)}
|
||||
<Page header={pageHeader}>
|
||||
<ErrorBoundary>
|
||||
|
@ -455,7 +468,7 @@ class App extends React.Component {
|
|||
// This connect the info state from the store to the info property of the App.
|
||||
export default withRouter(connect(
|
||||
state => ({
|
||||
errors: state.errors,
|
||||
notifications: state.notifications,
|
||||
configErrors: state.configErrors,
|
||||
info: state.info,
|
||||
tenant: state.tenant,
|
||||
|
|
|
@ -13,9 +13,14 @@
|
|||
// under the License.
|
||||
|
||||
export const ADMIN_DEQUEUE_FAIL = 'ADMIN_DEQUEUE_FAIL'
|
||||
|
||||
export const ADMIN_ENQUEUE_FAIL = 'ADMIN_ENQUEUE_FAIL'
|
||||
|
||||
export const addDequeueError = error => ({
|
||||
type: ADMIN_DEQUEUE_FAIL,
|
||||
error: error
|
||||
notification: error
|
||||
})
|
||||
|
||||
export const addEnqueueError = error => ({
|
||||
type: ADMIN_ENQUEUE_FAIL,
|
||||
notification: error
|
||||
})
|
||||
|
|
|
@ -12,38 +12,39 @@
|
|||
// License for the specific language governing permissions and limitations
|
||||
// under the License.
|
||||
|
||||
export const ADD_ERROR = 'ADD_ERROR'
|
||||
export const CLEAR_ERROR = 'CLEAR_ERROR'
|
||||
export const CLEAR_ERRORS = 'CLEAR_ERRORS'
|
||||
export const ADD_NOTIFICATION = 'ADD_NOTIFICATION'
|
||||
export const CLEAR_NOTIFICATION = 'CLEAR_NOTIFICATION'
|
||||
export const CLEAR_NOTIFICATIONS = 'CLEAR_NOTIFICATIONS'
|
||||
|
||||
let errorId = 0
|
||||
let notificationId = 0
|
||||
|
||||
export const addError = error => ({
|
||||
type: ADD_ERROR,
|
||||
id: errorId++,
|
||||
error
|
||||
export const addNotification = notification => ({
|
||||
type: ADD_NOTIFICATION,
|
||||
id: notificationId++,
|
||||
notification
|
||||
})
|
||||
|
||||
export const addApiError = error => {
|
||||
const d = {
|
||||
url: (error && error.request && error.request.responseURL) || error.url
|
||||
url: (error && error.request && error.request.responseURL) || error.url,
|
||||
type: 'error',
|
||||
}
|
||||
if (error.response) {
|
||||
d.text = error.response.statusText
|
||||
d.status = error.response.status
|
||||
} else {
|
||||
d.status = 'Unable to fetch URL, check your network connectivity,'
|
||||
+ ' browser plugins, ad-blockers, or try to refresh this page'
|
||||
+ ' browser plugins, ad-blockers, or try to refresh this page'
|
||||
d.text = error.message
|
||||
}
|
||||
return addError(d)
|
||||
return addNotification(d)
|
||||
}
|
||||
|
||||
export const clearError = id => ({
|
||||
type: CLEAR_ERROR,
|
||||
export const clearNotification = id => ({
|
||||
type: CLEAR_NOTIFICATION,
|
||||
id
|
||||
})
|
||||
|
||||
export const clearErrors = () => ({
|
||||
type: CLEAR_ERRORS
|
||||
export const clearNotifications = () => ({
|
||||
type: CLEAR_NOTIFICATIONS
|
||||
})
|
|
@ -181,7 +181,7 @@ function fetchUserAuthorizations(apiPrefix, token) {
|
|||
return res
|
||||
}
|
||||
|
||||
function dequeue (apiPrefix, projectName, pipeline, change, token) {
|
||||
function dequeue(apiPrefix, projectName, pipeline, change, token) {
|
||||
const instance = Axios.create({
|
||||
baseURL: apiUrl
|
||||
})
|
||||
|
@ -195,7 +195,7 @@ function dequeue (apiPrefix, projectName, pipeline, change, token) {
|
|||
)
|
||||
return res
|
||||
}
|
||||
function dequeue_ref (apiPrefix, projectName, pipeline, ref, token) {
|
||||
function dequeue_ref(apiPrefix, projectName, pipeline, ref, token) {
|
||||
const instance = Axios.create({
|
||||
baseURL: apiUrl
|
||||
})
|
||||
|
@ -210,6 +210,37 @@ function dequeue_ref (apiPrefix, projectName, pipeline, ref, token) {
|
|||
return res
|
||||
}
|
||||
|
||||
function enqueue(apiPrefix, projectName, pipeline, change, token) {
|
||||
const instance = Axios.create({
|
||||
baseURL: apiUrl
|
||||
})
|
||||
instance.defaults.headers.common['Authorization'] = 'Bearer ' + token
|
||||
let res = instance.post(
|
||||
apiPrefix + 'project/' + projectName + '/enqueue',
|
||||
{
|
||||
pipeline: pipeline,
|
||||
change: change,
|
||||
}
|
||||
)
|
||||
return res
|
||||
}
|
||||
function enqueue_ref(apiPrefix, projectName, pipeline, ref, oldrev, newrev, token) {
|
||||
const instance = Axios.create({
|
||||
baseURL: apiUrl
|
||||
})
|
||||
instance.defaults.headers.common['Authorization'] = 'Bearer ' + token
|
||||
let res = instance.post(
|
||||
apiPrefix + 'project/' + projectName + '/enqueue',
|
||||
{
|
||||
pipeline: pipeline,
|
||||
ref: ref,
|
||||
oldrev: oldrev,
|
||||
newrev: newrev,
|
||||
}
|
||||
)
|
||||
return res
|
||||
}
|
||||
|
||||
export {
|
||||
apiUrl,
|
||||
getHomepageUrl,
|
||||
|
@ -235,4 +266,6 @@ export {
|
|||
fetchUserAuthorizations,
|
||||
dequeue,
|
||||
dequeue_ref,
|
||||
enqueue,
|
||||
enqueue_ref,
|
||||
}
|
||||
|
|
|
@ -14,7 +14,7 @@
|
|||
|
||||
import React, { useState } from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import { connect } from 'react-redux'
|
||||
import { connect, useDispatch } from 'react-redux'
|
||||
import { Link } from 'react-router-dom'
|
||||
import {
|
||||
Button,
|
||||
|
@ -23,6 +23,8 @@ import {
|
|||
List,
|
||||
ListItem,
|
||||
Title,
|
||||
Modal,
|
||||
ModalVariant,
|
||||
} from '@patternfly/react-core'
|
||||
import {
|
||||
CodeIcon,
|
||||
|
@ -33,16 +35,19 @@ import {
|
|||
StreamIcon,
|
||||
OutlinedCalendarAltIcon,
|
||||
OutlinedClockIcon,
|
||||
RedoAltIcon,
|
||||
} from '@patternfly/react-icons'
|
||||
import * as moment from 'moment'
|
||||
import 'moment-duration-format'
|
||||
|
||||
import { buildExternalLink } from '../../Misc'
|
||||
import { BuildResultBadge, BuildResultWithIcon, IconProperty } from './Misc'
|
||||
import { enqueue, enqueue_ref } from '../../api'
|
||||
import { addNotification, addApiError } from '../../actions/notifications'
|
||||
import { ChartModal } from '../charts/ChartModal'
|
||||
import BuildsetGanttChart from '../charts/GanttChart'
|
||||
|
||||
function Buildset({ buildset, timezone, tenant }) {
|
||||
function Buildset({ buildset, timezone, tenant, user }) {
|
||||
const buildset_link = buildExternalLink(buildset)
|
||||
const [isGanttChartModalOpen, setIsGanttChartModalOpen] = useState(false)
|
||||
|
||||
|
@ -127,13 +132,92 @@ function Buildset({ buildset, timezone, tenant }) {
|
|||
)
|
||||
}
|
||||
|
||||
const [showEnqueueModal, setShowEnqueueModal] = useState(false)
|
||||
const dispatch = useDispatch()
|
||||
|
||||
function renderEnqueueButton() {
|
||||
const value = (<span style={{
|
||||
cursor: 'pointer',
|
||||
color: 'var(--pf-global--primary-color--100)'
|
||||
}}
|
||||
title="Re-enqueue this change"
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
setShowEnqueueModal(true)
|
||||
}}
|
||||
>
|
||||
Re-enqueue buildset
|
||||
</span>)
|
||||
return (
|
||||
<IconProperty
|
||||
WrapElement={ListItem}
|
||||
icon={<RedoAltIcon />}
|
||||
value={value}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function enqueueConfirm() {
|
||||
let changeId = buildset.change ? buildset.change + ',' + buildset.patchset : buildset.newrev
|
||||
setShowEnqueueModal(false)
|
||||
if (/^[0-9a-f]{40}$/.test(changeId)) {
|
||||
const oldrev = '0000000000000000000000000000000000000000'
|
||||
enqueue_ref(tenant.apiPrefix, buildset.project, buildset.pipeline, buildset.ref, oldrev, changeId, user.token)
|
||||
.then(() => {
|
||||
dispatch(addNotification(
|
||||
{
|
||||
text: 'Change queued successfully.',
|
||||
type: 'success',
|
||||
status: '',
|
||||
url: '',
|
||||
}))
|
||||
})
|
||||
.catch(error => {
|
||||
dispatch(addApiError(error))
|
||||
})
|
||||
} else {
|
||||
enqueue(tenant.apiPrefix, buildset.project, buildset.pipeline, changeId, user.token)
|
||||
.then(() => {
|
||||
dispatch(addNotification(
|
||||
{
|
||||
text: 'Change queued successfully.',
|
||||
type: 'success',
|
||||
status: '',
|
||||
url: '',
|
||||
}))
|
||||
})
|
||||
.catch(error => {
|
||||
dispatch(addApiError(error))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function renderEnqueueModal() {
|
||||
let changeId = buildset.change ? buildset.change + ',' + buildset.patchset : buildset.newrev
|
||||
const title = 'You are about to re-enqueue a change'
|
||||
return (
|
||||
<Modal
|
||||
variant={ModalVariant.small}
|
||||
// titleIconVariant={BullhornIcon}
|
||||
isOpen={showEnqueueModal}
|
||||
title={title}
|
||||
onClose={() => { setShowEnqueueModal(false) }}
|
||||
actions={[
|
||||
<Button key="deq_confirm" variant="primary" onClick={enqueueConfirm}>Confirm</Button>,
|
||||
<Button key="deq_cancel" variant="link" onClick={() => { setShowEnqueueModal(false) }}>Cancel</Button>,
|
||||
]}>
|
||||
<p>Please confirm that you want to re-enqueue <strong>all jobs</strong> for change <strong>{changeId}</strong> (project <strong>{buildset.project}</strong>) on pipeline <strong>{buildset.pipeline}</strong>.</p>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Title headingLevel="h2">
|
||||
<BuildResultWithIcon result={buildset.result} size="md">
|
||||
Buildset result
|
||||
</BuildResultWithIcon>
|
||||
<BuildResultBadge result={buildset.result} />
|
||||
<BuildResultBadge result={buildset.result} />
|
||||
</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
|
||||
|
@ -216,6 +300,10 @@ function Buildset({ buildset, timezone, tenant }) {
|
|||
</>
|
||||
}
|
||||
/>
|
||||
{(user.isAdmin && user.scope.indexOf(tenant.name) !== -1) &&
|
||||
<>
|
||||
{renderEnqueueButton()}
|
||||
</>}
|
||||
</List>
|
||||
</FlexItem>
|
||||
</Flex>
|
||||
|
@ -228,6 +316,7 @@ function Buildset({ buildset, timezone, tenant }) {
|
|||
setIsGanttChartModalOpen(false)
|
||||
}}
|
||||
/>
|
||||
{renderEnqueueModal()}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
@ -236,9 +325,11 @@ Buildset.propTypes = {
|
|||
buildset: PropTypes.object,
|
||||
tenant: PropTypes.object,
|
||||
timezone: PropTypes.string,
|
||||
user: PropTypes.object,
|
||||
}
|
||||
|
||||
export default connect((state) => ({
|
||||
tenant: state.tenant,
|
||||
timezone: state.timezone,
|
||||
user: state.user,
|
||||
}))(Buildset)
|
||||
|
|
|
@ -31,7 +31,7 @@ import {
|
|||
import { dequeue, dequeue_ref } from '../../api'
|
||||
import { addDequeueError } from '../../actions/adminActions'
|
||||
|
||||
import { addError } from '../../actions/errors'
|
||||
import { addNotification } from '../../actions/notifications'
|
||||
|
||||
import LineAngleImage from '../../images/line-angle.png'
|
||||
import LineTImage from '../../images/line-t.png'
|
||||
|
@ -73,10 +73,11 @@ class Change extends React.Component {
|
|||
this.props.dispatch(addDequeueError(error))
|
||||
})
|
||||
} else {
|
||||
this.props.dispatch(addError({
|
||||
this.props.dispatch(addNotification({
|
||||
url: null,
|
||||
status: 'Invalid change ' + changeRef + ' on project ' + projectName,
|
||||
text: ''
|
||||
text: '',
|
||||
type: 'error'
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
|
|
@ -18,7 +18,7 @@ import auth from './auth'
|
|||
import configErrors from './configErrors'
|
||||
import change from './change'
|
||||
import component from './component'
|
||||
import errors from './errors'
|
||||
import notifications from './notifications'
|
||||
import build from './build'
|
||||
import info from './info'
|
||||
import job from './job'
|
||||
|
@ -42,7 +42,7 @@ const reducers = {
|
|||
change,
|
||||
component,
|
||||
configErrors,
|
||||
errors,
|
||||
notifications,
|
||||
info,
|
||||
job,
|
||||
jobs,
|
||||
|
|
|
@ -13,34 +13,34 @@
|
|||
// under the License.
|
||||
|
||||
import {
|
||||
ADD_ERROR,
|
||||
CLEAR_ERROR,
|
||||
CLEAR_ERRORS,
|
||||
ADD_NOTIFICATION,
|
||||
CLEAR_NOTIFICATION,
|
||||
CLEAR_NOTIFICATIONS,
|
||||
addApiError,
|
||||
} from '../actions/errors'
|
||||
} from '../actions/notifications'
|
||||
|
||||
|
||||
export default (state = [], action) => {
|
||||
// Intercept API failure
|
||||
if (action.error && action.type.match(/.*_FETCH_FAIL$/)) {
|
||||
action = addApiError(action.error)
|
||||
if (action.notification && action.type.match(/.*_FETCH_FAIL$/)) {
|
||||
action = addApiError(action.notification)
|
||||
}
|
||||
// Intercept Admin API failures
|
||||
if (action.error && action.type.match(/ADMIN_.*_FAIL$/)) {
|
||||
action = addApiError(action.error)
|
||||
if (action.notification && action.type.match(/ADMIN_.*_FAIL$/)) {
|
||||
action = addApiError(action.notification)
|
||||
}
|
||||
switch (action.type) {
|
||||
case ADD_ERROR:
|
||||
if (state.filter(error => (
|
||||
error.url === action.error.url &&
|
||||
error.status === action.error.status)).length > 0)
|
||||
case ADD_NOTIFICATION:
|
||||
if (state.filter(notification => (
|
||||
notification.url === action.notification.url &&
|
||||
notification.status === action.notification.status)).length > 0)
|
||||
return state
|
||||
return [
|
||||
...state,
|
||||
{ ...action.error, id: action.id, date: Date.now() }]
|
||||
case CLEAR_ERROR:
|
||||
{ ...action.notification, id: action.id, date: Date.now() }]
|
||||
case CLEAR_NOTIFICATION:
|
||||
return state.filter(item => (item.id !== action.id))
|
||||
case CLEAR_ERRORS:
|
||||
case CLEAR_NOTIFICATIONS:
|
||||
return []
|
||||
default:
|
||||
return state
|
Loading…
Reference in New Issue