diff --git a/src/components/Button/index.jsx b/src/components/Button/index.jsx index b5752d1c..9aaa60f7 100644 --- a/src/components/Button/index.jsx +++ b/src/components/Button/index.jsx @@ -50,7 +50,7 @@ export default class Button extends Component { spanProps: object, }; - handleButtonClick = () => { + handleButtonClick = e => { const { onClick, track } = this.props; if (track && process.env.GA_TRACKING_ID) { @@ -60,7 +60,7 @@ export default class Button extends Component { } if (onClick) { - onClick(); + onClick(e); } }; diff --git a/src/components/DataTable/index.jsx b/src/components/DataTable/index.jsx index bb7c9fb5..9f363b5a 100644 --- a/src/components/DataTable/index.jsx +++ b/src/components/DataTable/index.jsx @@ -1,4 +1,4 @@ -import React, { Component } from 'react'; +import React, { Component, Fragment } from 'react'; import { arrayOf, func, @@ -7,14 +7,28 @@ import { oneOf, oneOfType, object, + bool, } from 'prop-types'; +import { withStyles } from '@material-ui/core/styles'; import Table from '@material-ui/core/Table'; import TableBody from '@material-ui/core/TableBody'; import TableCell from '@material-ui/core/TableCell'; import TableHead from '@material-ui/core/TableHead'; import TableSortLabel from '@material-ui/core/TableSortLabel'; import TableRow from '@material-ui/core/TableRow'; +import TablePagination from '@material-ui/core/TablePagination'; +@withStyles(() => ({ + noDisplay: { + visibility: 'hidden', + }, + table: { + minWidth: 1020, + }, + tableWrapper: { + overflowX: 'auto', + }, +})) /** * A table to display a set of data elements. */ @@ -26,6 +40,11 @@ export default class DataTable extends Component { sortByHeader: null, sortDirection: 'desc', noItemsMessage: 'No items for this page.', + isPaginate: false, + }; + + state = { + page: 0, }; static propTypes = { @@ -66,6 +85,10 @@ export default class DataTable extends Component { * A message to display when there is no items to display. */ noItemsMessage: string, + /** + * Whether to paginate the table + */ + isPaginate: bool, }; handleHeaderClick = ({ target }) => { @@ -76,8 +99,13 @@ export default class DataTable extends Component { } }; + handleChangePage = (event, page) => { + this.setState({ page }); + }; + render() { const { + classes, items, columnsSize, renderRow, @@ -85,40 +113,69 @@ export default class DataTable extends Component { sortByHeader, sortDirection, noItemsMessage, + isPaginate, } = this.props; const colSpan = columnsSize || (headers && headers.length) || 0; + const { page } = this.state; + const rowsPerPage = 5; return ( - - {headers && ( - - - {headers.map(header => ( - - - {header} - - - ))} - - + +
+
+ {headers && ( + + + {headers.map(header => ( + + + {header} + + + ))} + + + )} + + {items.length === 0 ? ( + + + {noItemsMessage} + + + ) : ( + items + .slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage) + .map(renderRow) + )} + +
+ + {isPaginate && ( + )} - - {items.length === 0 ? ( - - - {noItemsMessage} - - - ) : ( - items.map(renderRow) - )} - - + ); } } diff --git a/src/components/HookForm/index.jsx b/src/components/HookForm/index.jsx index cc800b6c..d7a74a53 100644 --- a/src/components/HookForm/index.jsx +++ b/src/components/HookForm/index.jsx @@ -1,35 +1,44 @@ +/* eslint-disable no-plusplus */ import React, { Component, Fragment } from 'react'; import { Link } from 'react-router-dom'; -import { string, bool, func, oneOfType, object } from 'prop-types'; +import { string, bool, func, oneOfType, object, array } from 'prop-types'; import classNames from 'classnames'; import { equals, assocPath } from 'ramda'; import cloneDeep from 'lodash.clonedeep'; import CodeEditor from '@mozilla-frontend-infra/components/CodeEditor'; import Code from '@mozilla-frontend-infra/components/Code'; +import Drawer from '@material-ui/core/Drawer'; +import memoize from 'fast-memoize'; import { withStyles } from '@material-ui/core/styles'; import List from '@material-ui/core/List'; import ListItem from '@material-ui/core/ListItem'; import ListItemText from '@material-ui/core/ListItemText'; import ListSubheader from '@material-ui/core/ListSubheader'; +import TableRow from '@material-ui/core/TableRow'; +import TableCell from '@material-ui/core/TableCell'; import TextField from '@material-ui/core/TextField'; import Switch from '@material-ui/core/Switch'; import FormGroup from '@material-ui/core/FormGroup'; import FormControlLabel from '@material-ui/core/FormControlLabel'; import Grid from '@material-ui/core/Grid'; import IconButton from '@material-ui/core/IconButton'; +import InformationVariantIcon from 'mdi-react/InformationVariantIcon'; import Typography from '@material-ui/core/Typography'; import FlashIcon from 'mdi-react/FlashIcon'; import PlusIcon from 'mdi-react/PlusIcon'; import DeleteIcon from 'mdi-react/DeleteIcon'; -import LinkIcon from 'mdi-react/LinkIcon'; import ContentSaveIcon from 'mdi-react/ContentSaveIcon'; import { docs } from 'taskcluster-lib-urls'; +import Label from '@mozilla-frontend-infra/components/Label'; +import LinkIcon from 'mdi-react/LinkIcon'; +import ErrorPanel from '../ErrorPanel'; +import DataTable from '../DataTable'; import Button from '../Button'; import SpeedDial from '../SpeedDial'; import SpeedDialAction from '../SpeedDialAction'; import DialogAction from '../DialogAction'; import DateDistance from '../DateDistance'; -import { HOOKS_LAST_FIRE_TYPE } from '../../utils/constants'; +import TableCellListItem from '../TableCellListItem'; import { hook } from '../../utils/prop-types'; import removeKeys from '../../utils/removeKeys'; @@ -114,12 +123,37 @@ const initialHook = { subheader: { fontSize: theme.typography.pxToRem(16), }, + displayBlock: { + display: 'block', + }, + errorTableCell: { + whiteSpace: 'normal', + }, + errorPanel: { + maxHeight: 300, + maxWidth: '75ch', + overflowY: 'scroll', + }, + infoButton: { + marginLeft: -theme.spacing.double, + marginRight: theme.spacing.unit, + }, + headline: { + paddingLeft: theme.spacing.triple, + paddingRight: theme.spacing.triple, + }, + metadataContainer: { + paddingTop: theme.spacing.double, + paddingBottom: theme.spacing.double, + width: 400, + }, })) /** A form to view/edit/create a hook */ export default class HookForm extends Component { static defaultProps = { isNewHook: false, hook: initialHook, + hookLastFires: null, onTriggerHook: null, onCreateHook: null, onUpdateHook: null, @@ -129,8 +163,12 @@ export default class HookForm extends Component { }; static propTypes = { - /** A GraphQL hook response. Not needed when creating a new hook */ + /** Part of a GraphQL hook response containing info about that hook. + Not needed when creating a new hook */ hook: hook.isRequired, + /** Part of the same Grahql hook response as above containing info + about some last hook fired attempts */ + hookLastFires: array, /** Set to `true` when creating a new hook. */ isNewHook: bool, /** Callback function fired when a hook is triggered. */ @@ -153,6 +191,7 @@ export default class HookForm extends Component { state = { hook: null, + hookLastFires: null, // eslint-disable-next-line react/no-unused-state previousHook: null, taskInput: '', @@ -162,10 +201,15 @@ export default class HookForm extends Component { taskValidJson: true, triggerSchemaValidJson: true, validation: {}, + drawerOpen: false, + drawerData: null, }; static getDerivedStateFromProps(props, state) { - if (equals(props.hook, state.previousHook)) { + if ( + equals(props.hook, state.previousHook) && + equals(props.hookLastFires, state.hookLastFires) + ) { return null; } @@ -173,6 +217,7 @@ export default class HookForm extends Component { return { hook: props.hook, + hookLastFires: props.hookLastFires, previousHook: props.hook, taskInput: JSON.stringify( removeKeys(cloneDeep(hook.task), ['__typename']), @@ -395,6 +440,39 @@ export default class HookForm extends Component { ), }); + handleDrawerClose = () => { + this.setState({ + drawerOpen: false, + drawerData: null, + }); + }; + + handleDrawerOpen = ({ target: { name } }) => + memoize( + name => { + const { hookLastFires } = this.state; + let body; + const lastFiresLength = hookLastFires.length; + + for (let i = 0; i < lastFiresLength; i++) { + const { taskId, error } = hookLastFires[i]; + + if (taskId === name) { + body = error; + break; + } + } + + this.setState({ + drawerOpen: true, + drawerData: { headline: name, body }, + }); + }, + { + serializer: name => name, + } + )(name); + render() { const { actionLoading, @@ -412,10 +490,12 @@ export default class HookForm extends Component { triggerSchemaInput, triggerContextInput, hook, + hookLastFires, validation, + drawerOpen, + drawerData, } = this.state; - /* eslint-disable-next-line no-underscore-dangle */ - const lastFireTypeName = !isNewHook && hook.status.lastFire.__typename; + const iconSize = 16; return ( @@ -508,61 +588,79 @@ export default class HookForm extends Component { /> + + Last Fired Results + } + /> + {hookLastFires && ( + ( + + + {(hookFire.result === 'SUCCESS' && ( + + {hookFire.taskId}} + /> + + + )) || {hookFire.taskId}} + + + {hookFire.firedBy} + + + + + + + + + {(hookFire.result === 'ERROR' && ( + + )) || n/a} + + + )} + /> + )} + {!isNewHook && ( - - - - ) - } - /> - - {lastFireTypeName === HOOKS_LAST_FIRE_TYPE.SUCCESSFUL_FIRE ? ( - - - - - ) : ( - - - {JSON.stringify(hook.status.lastFire.error, null, 2)} - - ) - } - /> - - )} - - - ) : ( - 'n/a' - ) - } - /> - - + + + ) : ( + 'n/a' + ) + } + /> + )} @@ -764,6 +862,33 @@ export default class HookForm extends Component { } /> )} + +
+ + {drawerData && drawerData.headline} + + + + + ) + } + /> + + +
+
); } diff --git a/src/views/Hooks/ViewHook/hook.graphql b/src/views/Hooks/ViewHook/hook.graphql index 8db5474c..52d42ae2 100644 --- a/src/views/Hooks/ViewHook/hook.graphql +++ b/src/views/Hooks/ViewHook/hook.graphql @@ -45,5 +45,12 @@ query Hook($hookGroupId: ID!, $hookId: ID!) { tags extra } + }, + hookLastFires(hookGroupId: $hookGroupId, hookId: $hookId) { + taskId + firedBy + taskCreateTime + result + error } } diff --git a/src/views/Hooks/ViewHook/index.jsx b/src/views/Hooks/ViewHook/index.jsx index 6c22c99f..0d3cf9bd 100644 --- a/src/views/Hooks/ViewHook/index.jsx +++ b/src/views/Hooks/ViewHook/index.jsx @@ -131,6 +131,11 @@ export default class ViewHook extends Component { const { isNewHook, data } = this.props; const { error: err, dialogError, actionLoading, dialogOpen } = this.state; const error = (data && data.error) || err; + const hookLastFires = + data.hookLastFires && + data.hookLastFires.sort( + (a, b) => new Date(b.taskCreateTime) - new Date(a.taskCreateTime) + ); return ( @@ -152,6 +157,7 @@ export default class ViewHook extends Component { dialogError={dialogError} actionLoading={actionLoading} hook={data.hook} + hookLastFires={hookLastFires} dialogOpen={dialogOpen} onTriggerHook={this.handleTriggerHook} onUpdateHook={this.handleUpdateHook}