diff --git a/config/router.config.js b/config/router.config.js index e874831a2..5501fc9e7 100644 --- a/config/router.config.js +++ b/config/router.config.js @@ -74,6 +74,16 @@ module.exports = [ name: 'profile', component: './Profile', }, + { + path: '/result', + name: 'result', + component: './Result', + }, + { + path: '/expiring-results', + name: 'expiring-results', + component: './ExpiringResults', + }, { path: '/exception/403', name: 'exception-403', diff --git a/mock/api.js b/mock/api.js index 051cc820a..9ced9f538 100644 --- a/mock/api.js +++ b/mock/api.js @@ -15,15 +15,23 @@ export const mockControllers = new Array(DEFAULT_SIZE).fill().map((value, index) })); export const mockResults = hostname => - new Array(DEFAULT_SIZE).fill().map((value, index) => ({ + new Array(DEFAULT_SIZE).fill().map(() => ({ '@metadata.controller_dir': hostname, config: casual.word, controller: hostname, end: moment.utc(), - // Since dataset id is a long hex string, removed "-" characters here to make it look like real data - id: casual.uuid.replace(/-/g, ''), - result: `${index}${hostname.slice(0, -6)}${index}`, - start: moment.utc(), + id: createUniqueKey(), + result: `${casual.word}.${casual.word}.${casual.word}`, + start: moment.utc().subtract(Math.random() * 10 + 10, 'days'), + serverMetadata: { + dashboard: { + saved: false, + seen: false, + }, + 'dataset.access': 'public', + 'dataset.owner': 'roger@example.com', + 'server.deletion': moment.utc().add('days', Math.random() * 10 + 10), + }, })); export const mockSamples = { @@ -330,7 +338,8 @@ export default { 'POST /api/v1/controllers/list': mockControllers, 'POST /api/v1/datasets/list': (req, res) => { const data = {}; - data[req.body.controller] = mockResults(req.body.controller); + const controller = req.body.controller || 'mock-controller'; + data[controller] = mockResults(controller); res.send(data); }, 'POST /api/v1/datasets/detail': (req, res) => { diff --git a/src/common/menu.js b/src/common/menu.js index 748593061..865d89aad 100644 --- a/src/common/menu.js +++ b/src/common/menu.js @@ -27,6 +27,21 @@ export const menuData = [ }, ], }, + { + name: 'Overview', + icon: 'overview', + path: '/overview', + routes: [ + { + name: 'Expiring Results', + path: '/expiring-results', + }, + { + name: 'result', + path: '/result', + }, + ], + }, { name: 'Search', path: '/search', diff --git a/src/components/AuthLayout/index.js b/src/components/AuthLayout/index.js index ebbe0e4c9..075f1aa31 100644 --- a/src/components/AuthLayout/index.js +++ b/src/components/AuthLayout/index.js @@ -240,7 +240,7 @@ class AuthLayout extends Component { diff --git a/src/components/LoginForm/index.js b/src/components/LoginForm/index.js new file mode 100644 index 000000000..f268dcd0e --- /dev/null +++ b/src/components/LoginForm/index.js @@ -0,0 +1,123 @@ +import React, { useState, useEffect } from 'react'; +import { + Form, + FormGroup, + TextInput, + Checkbox, + ActionGroup, + Button, + Title, +} from '@patternfly/react-core'; +import { connect } from 'dva'; +import styles from './index.less'; + +const mapStateToProps = state => { + const { auth } = state; + return { auth }; +}; + +const LoginForm = props => { + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [errors, setErrors] = useState({ + email: '', + }); + const [btnDisabled, setBtnDisabled] = useState(true); + + const handleUsernameChange = val => { + setUsername(val); + setErrors({ + ...errors, + }); + }; + + const handleLoginSubmit = () => { + const { dispatch } = props; + dispatch({ + type: 'auth/loginUser', + payload: { + username, + password, + }, + }); + }; + + /* eslint-disable no-restricted-syntax */ + const validateForm = () => { + if (username.trim() === '' || password.trim() === '') { + return false; + } + for (const dep of Object.entries(errors)) { + if (dep[1].length > 0) { + return false; + } + } + // if we reach here, it means + // we have covered all of the edge cases. + return true; + }; + + useEffect( + () => { + if (validateForm()) { + setBtnDisabled(false); + } else setBtnDisabled(true); + }, + [username, password] + ); + + const form = ( +
+
+ + +

{errors.email}

+
+ + setPassword(val)} + /> + + + + + + + +
+
+ ); + return {form}; +}; + +export default connect(mapStateToProps)(LoginForm); diff --git a/src/pages/LoginHandler/index.less b/src/components/LoginForm/index.less similarity index 66% rename from src/pages/LoginHandler/index.less rename to src/components/LoginForm/index.less index e3566fcc9..648ca6016 100644 --- a/src/pages/LoginHandler/index.less +++ b/src/components/LoginForm/index.less @@ -1,5 +1,5 @@ .section { - padding: 5% 10% 5% 10% !important; + padding: 5% 10% 5% 10%; } .btn { @@ -7,10 +7,6 @@ color: white; } -.inlineLink { - font-size: var(--pf-global--FontSize--xl); -} - .pf-c-form__group .pf-c-form__label { font-size: 50px !important; } diff --git a/src/components/LoginHint/index.js b/src/components/LoginHint/index.js index d1b9a3f5e..5c05f0a97 100644 --- a/src/components/LoginHint/index.js +++ b/src/components/LoginHint/index.js @@ -9,7 +9,7 @@ import styles from './index.less'; store, auth: auth.auth, })) -class Overview extends Component { +class LoginHint extends Component { navigateToAuth = () => { const { dispatch } = this.props; dispatch(routerRedux.push(`/auth`)); @@ -49,4 +49,4 @@ class Overview extends Component { } } -export default Overview; +export default LoginHint; diff --git a/src/components/LoginModal/index.js b/src/components/LoginModal/index.js new file mode 100644 index 000000000..b7b721b77 --- /dev/null +++ b/src/components/LoginModal/index.js @@ -0,0 +1,94 @@ +import React from 'react'; +import { + Modal, + ModalVariant, + Button, + TextContent, + Text, + TextVariants, +} from '@patternfly/react-core'; +import { routerRedux } from 'dva/router'; +import { connect } from 'dva'; +import LoginForm from '@/components/LoginForm'; + +@connect(auth => ({ + auth: auth.auth, +})) +class LoginModal extends React.Component { + constructor(props) { + super(props); + this.state = { + isModalOpen: false, + modalView: false, + }; + } + + componentDidMount() { + this.handleModalToggle(); + } + + handleModalToggle = () => { + this.setState(({ isModalOpen }) => ({ + isModalOpen: !isModalOpen, + })); + }; + + handleModalCancel = () => { + const { dispatch } = this.props; + this.setState(({ isModalOpen }) => ({ + isModalOpen: !isModalOpen, + })); + dispatch(routerRedux.push(`/`)); + }; + + handleLoginModal = () => { + this.setState({ + modalView: true, + }); + }; + + handleSignupModal = () => { + const { dispatch } = this.props; + this.setState(({ isModalOpen }) => ({ + isModalOpen: !isModalOpen, + })); + dispatch(routerRedux.push(`/signup`)); + }; + + render() { + const { isModalOpen, modalView } = this.state; + const loginAction = ( +
+ + + This action requires login. Please login to Pbench Dashboard to continue. + + + + + +
+ ); + const modalContent = !modalView ? loginAction : ; + return ( + + + {modalContent} + + + ); + } +} + +export default LoginModal; diff --git a/src/components/RowSelection/index.js b/src/components/RowSelection/index.js index adfdea5c1..e0f36ee34 100644 --- a/src/components/RowSelection/index.js +++ b/src/components/RowSelection/index.js @@ -1,22 +1,59 @@ -import React, { PureComponent } from 'react'; -import PropTypes from 'prop-types'; - +import React from 'react'; +import { Dropdown, DropdownItem, DropdownSeparator, DropdownToggle } from '@patternfly/react-core'; +import CaretDownIcon from '@patternfly/react-icons/dist/js/icons/caret-down-icon'; import Button from '../Button'; -export default class RowSelection extends PureComponent { - static propTypes = { - selectedItems: PropTypes.array.isRequired, - compareActionName: PropTypes.string.isRequired, - onCompare: PropTypes.func.isRequired, - style: PropTypes.object, +export default class RowSelection extends React.Component { + constructor(props) { + super(props); + this.state = { + isManageRunDropdownOpen: false, + }; + } + + onSelectManageRunDropdown = () => { + const { isManageRunDropdownOpen } = this.state; + this.setState({ + isManageRunDropdownOpen: !isManageRunDropdownOpen, + }); }; - static defaultProps = { - style: {}, + onToggleManageRunDropdown = isManageRunDropdownOpen => { + this.setState({ + isManageRunDropdownOpen, + }); }; render() { - const { selectedItems, compareActionName, onCompare, style } = this.props; + const { + selectedItems, + compareActionName, + onCompare, + saveRuns, + removeResultFromSeen, + favoriteResult, + deleteResult, + style, + } = this.props; + const { isManageRunDropdownOpen } = this.state; + + const manageRunDropdown = [ + + {' '} + Save Runs + , + + {' '} + Mark as unread + , + + Mark Favorited + , + , + + Delete runs + , + ]; return (
@@ -27,6 +64,21 @@ export default class RowSelection extends PureComponent { disabled={!(selectedItems > 0)} style={{ marginRight: 8 }} /> + + Manage runs + + } + isOpen={isManageRunDropdownOpen} + dropdownItems={manageRunDropdown} + style={{ display: selectedItems === 0 ? 'none' : 'inline-block', float: 'right' }} + /> {selectedItems > 0 ? `Selected ${selectedItems} items` : ''} diff --git a/src/components/Table/index.js b/src/components/Table/index.js index fd00ba1fc..8a7261d14 100644 --- a/src/components/Table/index.js +++ b/src/components/Table/index.js @@ -62,7 +62,18 @@ function fuzzyTextFilterFn(rows, id, filterValue) { fuzzyTextFilterFn.autoRemove = val => !val; -function Table({ columns, data, isCheckable, onCompare, loadingData, onRowClick }) { +function Table({ + columns, + data, + isCheckable, + onCompare, + saveRuns, + removeResultFromSeen, + favoriteResult, + deleteResult, + loadingData, + onRowClick, +}) { const filterTypes = React.useMemo( () => ({ fuzzyText: fuzzyTextFilterFn, @@ -164,6 +175,12 @@ function Table({ columns, data, isCheckable, onCompare, loadingData, onRowClick selectedItems={Object.keys(selectedRowIds).length} compareActionName="Compare" onCompare={() => onCompare(selectedFlatRows)} + saveRuns={() => saveRuns(selectedFlatRows)} + removeResultFromSeen={() => + removeResultFromSeen(selectedFlatRows.map(item => item.original.result)) + } + favoriteResult={() => favoriteResult(selectedFlatRows.map(item => item.original.result))} + deleteResult={() => deleteResult(selectedFlatRows.map(item => item.original.result))} /> )} {loadingData ? ( diff --git a/src/e2e/search.e2e.js b/src/e2e/search.e2e.js index 52c26809b..9822afc0c 100644 --- a/src/e2e/search.e2e.js +++ b/src/e2e/search.e2e.js @@ -14,7 +14,7 @@ beforeAll(async () => { await page.goto('http://localhost:8000/dashboard/'); await page.click('#nav-toggle > svg'); - await page.click('#page-sidebar > div > nav > ul > li:nth-child(2) > a'); + await page.click('#page-sidebar > div > nav > ul > li:nth-child(3) > a'); await page.click('#nav-toggle > svg'); // Intercept network requests diff --git a/src/models/dashboard.js b/src/models/dashboard.js index e4d61fc5b..54241efbc 100644 --- a/src/models/dashboard.js +++ b/src/models/dashboard.js @@ -84,7 +84,6 @@ export default { const tocTree = Object.keys(tocResult) .map(path => path.split('/').slice(1)) .reduce((items, path) => insertTocTreeData(tocResult, items, path), []); - yield put({ type: 'getTocResult', payload: tocTree, @@ -168,6 +167,12 @@ export default { payload, }); }, + *updateResults({ payload }, { put }) { + yield put({ + type: 'modifyResults', + payload, + }); + }, }, reducers: { @@ -207,22 +212,16 @@ export default { iterations: payload, }; }, - modifySelectedControllers(state, { payload }) { - return { - ...state, - selectedControllers: payload, - }; - }, - modifySelectedResults(state, { payload }) { + modifyConfigCategories(state, { payload }) { return { ...state, - selectedResults: payload, + iterationParams: payload, }; }, - modifyConfigCategories(state, { payload }) { + modifyResults(state, { payload }) { return { ...state, - iterationParams: payload, + results: payload, }; }, }, diff --git a/src/models/user.js b/src/models/user.js index f70823395..97d98490b 100644 --- a/src/models/user.js +++ b/src/models/user.js @@ -4,27 +4,22 @@ export default { state: { favoriteControllers: [], favoriteResults: [], - // user: {}, + seenResults: [], }, effects: { - // *loadUser({ payload }, { put }) { - // yield put({ - // type: 'modifyUser', - // payload, - // }); - // }, - // *logoutUser({ put }) { - // yield put({ - // type: 'removeUser', - // }); - // }, *favoriteController({ payload }, { put }) { yield put({ type: 'modifyFavoritedControllers', payload, }); }, + *markResultSeen({ payload }, { put }) { + yield put({ + type: 'modifySeenResults', + payload, + }); + }, *removeControllerFromFavorites({ payload }, { put }) { yield put({ type: 'removeFavoriteController', @@ -43,6 +38,12 @@ export default { payload, }); }, + *removeResultFromSeen({ payload }, { put }) { + yield put({ + type: 'removeSeenResults', + payload, + }); + }, }, reducers: { @@ -64,10 +65,18 @@ export default { favoriteControllers: [...state.favoriteControllers, payload], }; }, + modifySeenResults(state, { payload }) { + return { + ...state, + seenResults: [...state.seenResults, payload], + }; + }, modifyFavoritedResults(state, { payload }) { return { ...state, - favoriteResults: [...state.favoriteResults, payload], + favoriteResults: Array.isArray(payload) + ? [...state.favoriteResults, ...payload] + : [...state.favoriteResults, payload], }; }, removeFavoriteController(state, { payload }) { @@ -82,5 +91,11 @@ export default { favoriteResults: state.favoriteResults.filter(item => item !== payload), }; }, + removeSeenResults(state, { payload }) { + return { + ...state, + seenResults: state.seenResults.filter(item => !payload.includes(item)), + }; + }, }, }; diff --git a/src/pages/ExpiringResults/index.js b/src/pages/ExpiringResults/index.js new file mode 100644 index 000000000..b5aa56c3f --- /dev/null +++ b/src/pages/ExpiringResults/index.js @@ -0,0 +1,343 @@ +import React, { Component } from 'react'; +import { routerRedux } from 'dva/router'; +import { connect } from 'dva'; +import { + Grid, + GridItem, + Card, + TextContent, + Text, + TextVariants, + Button, + Progress, + ProgressSize, + ProgressMeasureLocation, + ProgressVariant, + Tooltip, + Modal, + Alert, + AlertGroup, +} from '@patternfly/react-core'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faStar, faTrash } from '@fortawesome/free-solid-svg-icons'; +import { EllipsisVIcon } from '@patternfly/react-icons'; +import { formatDate, getDiffDays, getDiffDate } from '../../utils/moment_constants'; +import Table from '@/components/Table'; +import styles from './index.less'; + +@connect(({ global, user, dashboard }) => ({ + selectedDateRange: global.selectedDateRange, + selectedControllers: global.selectedControllers, + user: user.user, + seenResults: user.seenResults, + favoriteResults: user.favoriteResults, + results: dashboard.results, +})) +class ExpiringResults extends Component { + constructor(props) { + super(props); + this.state = { + totalResultData: [], + toBeDeletedData: [], + isModalOpen: false, + actionMessage: '', + }; + } + + componentDidMount() { + const { dispatch, selectedControllers, selectedDateRange } = this.props; + dispatch({ + type: 'dashboard/fetchResults', + payload: { + selectedDateRange, + controller: selectedControllers, + }, + }).then(() => { + const { results } = this.props; + this.setState({ + totalResultData: results[selectedControllers[0] || 'mock-controller'], + }); + }); + } + + favoriteResult = row => { + const { dispatch } = this.props; + // dispatch an action to favorite controller + dispatch({ + type: 'user/favoriteResult', + payload: row, + }); + }; + + unfavoriteResult = row => { + const { dispatch } = this.props; + // dispatch an action to favorite controller + dispatch({ + type: 'user/removeResultFromFavorites', + payload: row, + }); + }; + + deleteResult = (e, rows) => { + // Stop propagation from going to the next page + if (e !== null) { + e.stopPropagation(); + } + const { totalResultData } = this.state; + const { dispatch } = this.props; + const updatedResult = totalResultData.filter(item => !rows.includes(item.result)); + dispatch({ type: 'dashboard/updateResults', payload: updatedResult }) + .then(() => { + this.setState({ totalResultData: updatedResult }); + }) + .then(() => { + this.handleModalToggle(e, []); + }) + .then(() => { + this.setState({ actionMessage: 'data succesfully deleted' }); + setTimeout(() => { + this.setState({ actionMessage: '' }); + }, 3000); + }); + }; + + removeResultFromSeen = row => { + const { dispatch } = this.props; + dispatch({ + type: 'user/removeResultFromSeen', + payload: row, + }); + }; + + showDropdown = (e, id) => { + // Stop propagation from going to the next page + e.stopPropagation(); + + const dropdownElem = document.getElementById(id); + if (dropdownElem.style.display === 'none') { + dropdownElem.style.display = 'block'; + } else { + dropdownElem.style.display = 'none'; + } + }; + + retrieveResults = row => { + const { dispatch } = this.props; + this.markResultSeen(row); + dispatch({ + type: 'global/updateSelectedResults', + payload: [row], + }).then(() => { + dispatch( + routerRedux.push({ + pathname: '/result', + }) + ); + }); + }; + + markResultSeen = row => { + const { dispatch } = this.props; + dispatch({ + type: 'user/markResultSeen', + payload: row.result, + }); + }; + + handleModalToggle(e, rows) { + // Stop propagation from going to the next page + if (e !== null) { + e.stopPropagation(); + } + this.setState(({ isModalOpen }) => ({ isModalOpen: !isModalOpen, toBeDeletedData: rows })); + } + + render() { + const { favoriteResults, seenResults } = this.props; + const { totalResultData, toBeDeletedData, isModalOpen, actionMessage } = this.state; + const seenDataColumns = [ + { + Header: 'Result', + accessor: 'result', + Cell: cell => { + const row = cell.row.original; + return seenResults.includes(row.result) ? ( +
+ +
+ + {row.controller} + +
+ ) : ( +
+ +
+ + {row.controller} + +
+ ); + }, + }, + { + Header: 'End Time', + accessor: 'end', + Cell: cell => ( + + {formatDate('with time', cell.value)} + + ), + }, + { + Header: 'Scheduled for deletion on', + accessor: 'deletion', + Cell: cell => { + const value = cell.row.original.serverMetadata['server.deletion']; + const remainingDays = getDiffDays(value); + return ( +
+ + {getDiffDate(value)} + + +
+ ); + }, + }, + { Header: 'Status', accessor: 'status' }, + { + Header: '', + accessor: 'fav', + Cell: cell => + favoriteResults.includes(cell.row.original.result) ? ( + { + e.stopPropagation(); + this.unfavoriteResult(cell.row.original.result); + }} + /> + ) : ( + { + e.stopPropagation(); + this.favoriteResult(cell.row.original.result); + }} + /> + ), + }, + { + Header: '', + accessor: 'action', + Cell: cell => { + const row = cell.row.original; + return ( +
+ this.showDropdown(e, `newrun${row.result}`)} + className="dropbtn" + /> +
+
+
this.removeResultFromSeen(e, [row.result])} + > + Mark unread +
+
this.handleModalToggle(e, [row.result])} + > + Delete +
+
+
+
+ ); + }, + }, + ]; + return ( + + {actionMessage !== '' ? ( + + + + ) : ( + <> + )} + +
+ + All runs + + + + +
+ { + this.retrieveResults(record); + }} + /> + + + + + + this.handleModalToggle(e, [])} + actions={[ + , + , + ]} + > + + Overview + Are you sure you want to delete the following runs? + {toBeDeletedData.map(item => ( + + + + {item} + + + ))} + + + + ); + } +} + +export default ExpiringResults; diff --git a/src/pages/ExpiringResults/index.less b/src/pages/ExpiringResults/index.less new file mode 100644 index 000000000..a8ee02e5f --- /dev/null +++ b/src/pages/ExpiringResults/index.less @@ -0,0 +1,31 @@ +.paddingBig { + margin: 16px; +} + +.dropdownContent { + position: absolute; + z-index: 1; + min-width: fit-content; + margin-left: -60%; + overflow: auto; + background-color: #f1f1f1; + box-shadow: 0 8px 16px 0 rgba(0, 0, 0, 0.2); +} + +.dropdownLink { + display: block; + padding: 12px 16px; + color: black; + text-decoration: none; +} + +.dropdownLink:hover { + background-color: #ddd; +} + +.label { + margin: 3px; + padding: 2px 8px; + background-color: rgb(220 239 255); + border-radius: 25px; +} diff --git a/src/pages/LoginHandler/index.js b/src/pages/LoginHandler/index.js index a4cab0fa2..6e45ba65e 100644 --- a/src/pages/LoginHandler/index.js +++ b/src/pages/LoginHandler/index.js @@ -1,125 +1,11 @@ -import React, { useState, useEffect } from 'react'; -import { - Form, - FormGroup, - TextInput, - Checkbox, - ActionGroup, - Button, - Title, -} from '@patternfly/react-core'; +import React from 'react'; import AuthLayout from '@/components/AuthLayout'; -import { connect } from 'dva'; -import styles from './index.less'; -import { validateEmail } from '@/utils/validator'; +import LoginForm from '@/components/LoginForm'; -const mapStateToProps = state => { - const { auth } = state; - return { auth }; -}; - -const LoginHandler = props => { - const [username, setUsername] = useState(''); - const [password, setPassword] = useState(''); - const [errors, setErrors] = useState({ - email: '', - }); - const [btnDisabled, setBtnDisabled] = useState(true); - - const handleUsernameChange = val => { - setUsername(val); - const validEmail = validateEmail(val); - setErrors({ - ...errors, - ...validEmail, - }); - }; - - const handleLoginSubmit = () => { - const { dispatch } = props; - dispatch({ - type: 'auth/loginUser', - payload: { - username, - password, - }, - }); - }; - - /* eslint-disable no-restricted-syntax */ - const validateForm = () => { - if (username.trim() === '' || password.trim() === '') { - return false; - } - for (const dep of Object.entries(errors)) { - if (dep[1].length > 0) { - return false; - } - } - // if we reach here, it means - // we have covered all of the edge cases. - return true; - }; - - useEffect( - () => { - if (validateForm()) { - setBtnDisabled(false); - } else setBtnDisabled(true); - }, - [username, password] - ); - - const form = ( - - - -

{errors.email}

-
- - setPassword(val)} - /> - - - - - - - - +function LoginHandler() { + return ( + } heading="Log into your Pbench Account" backOpt="true" /> ); - return ; -}; +} -export default connect(mapStateToProps)(LoginHandler); +export default LoginHandler; diff --git a/src/pages/LoginHandler/index.test.js b/src/pages/LoginHandler/index.test.js index 8d4063c86..3bd19c5c9 100644 --- a/src/pages/LoginHandler/index.test.js +++ b/src/pages/LoginHandler/index.test.js @@ -4,9 +4,8 @@ import Adapter from 'enzyme-adapter-react-16'; import AuthLayout from '@/components/AuthLayout'; import LoginHandler from './index'; -const mockDispatch = jest.fn(); configure({ adapter: new Adapter() }); -const wrapper = shallow(, { +const wrapper = shallow(, { disableLifecycleMethods: true, }); diff --git a/src/pages/Overview/index.js b/src/pages/Overview/index.js index fbb303c5c..e89e1d199 100644 --- a/src/pages/Overview/index.js +++ b/src/pages/Overview/index.js @@ -1,16 +1,825 @@ -import React, { Component } from 'react'; -// import { routerRedux } from 'dva/router'; +import React from 'react'; +import { routerRedux } from 'dva/router'; +import { + Grid, + GridItem, + Card, + TextContent, + Text, + TextVariants, + Button, + Progress, + ProgressSize, + ProgressMeasureLocation, + ProgressVariant, + Tooltip, + Accordion, + AccordionItem, + AccordionContent, + AccordionToggle, + Label, + Modal, + Alert, + AlertGroup, +} from '@patternfly/react-core'; +import { connect } from 'dva'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faStar, faStopwatch, faTrash } from '@fortawesome/free-solid-svg-icons'; +import { OutlinedClockIcon, UndoAltIcon, EllipsisVIcon } from '@patternfly/react-icons'; +import { formatDate, getDiffDays, getDiffDate } from '../../utils/moment_constants'; +import Table from '@/components/Table'; +import styles from './index.less'; -class Overview extends Component { +const expiringLimit = 15; + +@connect(({ global, user, loading, dashboard }) => ({ + selectedDateRange: global.selectedDateRange, + selectedControllers: global.selectedControllers, + user: user.user, + favoriteResults: user.favoriteResults, + seenResults: user.seenResults, + results: dashboard.results, + loading: loading.effects['dashboard/fetchResults'], +})) +class Overview extends React.Component { constructor(props) { super(props); - this.state = {}; + this.state = { + totalResultData: [], + newData: [], + savedData: [], + expiringData: [], + expanded: true, + isModalOpen: false, + toBeDeletedData: [], + actionMessage: '', + }; + } + + componentDidMount() { + this.fetchRunResult(); + } + + onToggle() { + const { expanded } = this.state; + if (expanded) { + this.setState({ expanded: false }); + } else { + this.setState({ expanded: true }); + } + } + + getRunResult() { + const { results, selectedControllers } = this.props; + const data = results[selectedControllers[0] || 'mock-controller']; + this.setState( + { + totalResultData: data, + }, + () => this.getSeparatedResults() + ); + } + + getSeparatedResults() { + const { totalResultData } = this.state; + const savedData = totalResultData.filter(x => x.serverMetadata.dashboard.saved === true); + const newData = totalResultData.filter(x => x.serverMetadata.dashboard.saved !== true); + const expiringData = totalResultData.filter( + x => getDiffDays(x.serverMetadata['server.deletion']) < expiringLimit + ); + this.setState({ newData, savedData, expiringData }); + } + + compareResults = selectedRows => { + const { dispatch } = this.props; + + dispatch({ + type: 'global/updateSelectedResults', + payload: selectedRows.map(row => row.original.result), + }); + + dispatch( + routerRedux.push({ + pathname: '/comparison-select', + }) + ); + }; + + favoriteResult = result => { + const { dispatch } = this.props; + // dispatch an action to favorite controller + dispatch({ + type: 'user/favoriteResult', + payload: result, + }); + }; + + unfavoriteResult = result => { + const { dispatch } = this.props; + // dispatch an action to unfavorite controller + dispatch({ + type: 'user/removeResultFromFavorites', + payload: result, + }); + }; + + markResultSeen = row => { + const { dispatch } = this.props; + dispatch({ + type: 'user/markResultSeen', + payload: row.result, + }); + }; + + removeResultFromSeen = (e, row) => { + // Stop propagation from going to the next page + if (e !== null) { + e.stopPropagation(); + } + const { dispatch } = this.props; + dispatch({ + type: 'user/removeResultFromSeen', + payload: row, + }); + }; + + showDropdown = (e, id) => { + // Stop propagation from going to the next page + e.stopPropagation(); + + const dropdownElem = document.getElementById(id); + if (dropdownElem.style.display === 'none') { + dropdownElem.style.display = 'block'; + } else { + dropdownElem.style.display = 'none'; + } + }; + + saveRuns = (e, rows) => { + // Stop propagation from going to the next page + if (e !== null) { + e.stopPropagation(); + } + const { totalResultData } = this.state; + const { dispatch } = this.props; + const keys = rows.map(({ original }) => original.result); + keys.forEach(key => { + totalResultData.filter(item => item.result === key)[0].serverMetadata.dashboard.saved = true; + }); + dispatch({ + type: 'dashboard/updateResults', + payload: totalResultData, + }).then(() => { + this.setState({ + totalResultData, + }); + this.getSeparatedResults(); + }); + }; + + deleteResult = (e, rows) => { + // Stop propagation from going to the next page + if (e !== null) { + e.stopPropagation(); + } + const { totalResultData } = this.state; + const { dispatch } = this.props; + const updatedResult = totalResultData.filter(item => !rows.includes(item.result)); + dispatch({ type: 'dashboard/updateResults', payload: updatedResult }) + .then(() => { + this.setState({ totalResultData: updatedResult }); + this.getSeparatedResults(); + }) + .then(() => { + this.handleModalToggle(e, []); + }) + .then(() => { + this.setState({ actionMessage: 'data succesfully deleted' }); + setTimeout(() => { + this.setState({ actionMessage: '' }); + }, 3000); + }); + }; + + retrieveResults = row => { + const { dispatch } = this.props; + this.markResultSeen(row); + dispatch({ + type: 'global/updateSelectedResults', + payload: [row], + }).then(() => { + dispatch( + routerRedux.push({ + pathname: '/result', + }) + ); + }); + }; + + navigateToExpiringResult = () => { + const { dispatch } = this.props; + dispatch( + routerRedux.push({ + pathname: '/expiring-results', + }) + ); + }; + + fetchRunResult() { + const { dispatch, results, selectedDateRange, selectedControllers } = this.props; + if (Object.keys(results).length === 0) { + dispatch({ + type: 'dashboard/fetchResults', + payload: { + selectedDateRange, + controller: selectedControllers, + }, + }).then(() => this.getRunResult()); + } else { + this.getRunResult(); + } + } + + handleModalToggle(e, rows) { + // Stop propagation from going to the next page + if (e !== null) { + e.stopPropagation(); + } + this.setState(({ isModalOpen }) => ({ isModalOpen: !isModalOpen, toBeDeletedData: rows })); } render() { + const { + newData, + savedData, + expiringData, + toBeDeletedData, + expanded, + isModalOpen, + actionMessage, + } = this.state; + const { favoriteResults, seenResults } = this.props; + const newDataColumns = [ + { + Header: 'Result', + accessor: 'result', + Cell: cell => { + const row = cell.row.original; + return seenResults.includes(row.result) ? ( +
+ +
+ + {row.controller} + +
+ ) : ( +
+ +
+ + {row.controller} + +
+ ); + }, + }, + { + Header: 'End Time', + accessor: 'end', + Cell: cell => ( + + {formatDate('without time', cell.value)} + + ), + }, + { + Header: 'Scheduled for deletion on', + accessor: 'deletion', + Cell: cell => { + const value = cell.row.original.serverMetadata['server.deletion']; + const remainingDays = getDiffDays(value); + return ( +
+ + {getDiffDate(value)} + + +
+ ); + }, + }, + { + Header: '', + accessor: 'fav', + Cell: cell => + favoriteResults.includes(cell.row.original.result) ? ( + { + e.stopPropagation(); + this.unfavoriteResult(cell.row.original.result); + }} + /> + ) : ( + { + e.stopPropagation(); + this.favoriteResult(cell.row.original.result); + }} + /> + ), + }, + { + Header: '', + accessor: 'action', + Cell: cell => { + const row = cell.row.original; + return ( +
+ this.showDropdown(e, `newrun${row.result}`)} + className="dropbtn" + /> +
+
+
this.saveRuns(e, [cell.row])}> + Save Runs +
+
this.removeResultFromSeen(e, [row.result])} + > + Mark unread +
+
this.handleModalToggle(e, [row.result])} + > + Delete +
+
+
+
+ ); + }, + }, + ]; + + const savedDataColumns = [ + { + Header: 'Result', + accessor: 'result', + Cell: cell => { + const row = cell.row.original; + return seenResults.includes(row.result) ? ( +
+ +
+ + {row.controller} + +
+ ) : ( +
+ +
+ + {row.controller} + +
+ ); + }, + }, + { + Header: 'End Time', + accessor: 'end', + Cell: cell => ( + + {formatDate('without time', cell.value)} + + ), + }, + { + Header: 'Scheduled for deletion on', + accessor: 'deletion', + Cell: cell => { + const value = cell.row.original.serverMetadata['server.deletion']; + const remainingDays = getDiffDays(value); + return ( +
+ + {getDiffDate(value)} + + +
+ ); + }, + }, + { + Header: 'Status', + accessor: 'status', + Cell: cell => cell.row.original.serverMetadata['dataset.access'], + }, + { + Header: '', + accessor: 'fav', + Cell: cell => + favoriteResults.includes(cell.row.original.result) ? ( + { + e.stopPropagation(); + this.unfavoriteResult(cell.row.original.result); + }} + /> + ) : ( + { + e.stopPropagation(); + this.favoriteResult(cell.row.original.result); + }} + /> + ), + }, + { + Header: '', + accessor: 'action', + Cell: cell => { + const row = cell.row.original; + return ( +
+ this.showDropdown(e, `newrun${row.result}`)} + className="dropbtn" + /> +
+
+
this.removeResultFromSeen(e, [row.result])} + > + Mark unread +
+
this.handleModalToggle(e, [row.result])} + > + Delete +
+
+
+
+ ); + }, + }, + ]; + + const expiringSoonTableColumn = [ + { + Header: 'Result', + accessor: 'result', + Cell: cell => { + const row = cell.row.original; + let isSeen = false; + if (seenResults !== []) { + seenResults.forEach(item => { + if (item.key === row.key) { + isSeen = true; + } + }); + } + if (isSeen) { + return ( +
+ + {cell.value} + + + + + + + {formatDate('utc', row.deletion)} + + + + +
+ ); + } + return ( +
+ +
+ + + + {formatDate('with time', row.deletion)} + + +
+ ); + }, + }, + ]; + return ( -
-

Overview Page

+
+ {actionMessage !== '' && ( + + + + )} + + + + + Overview + + + + + + + +
+ 0 ? styles.expiringCard : styles.noExpiringRunCard + } + > + + this.onToggle()} + isExpanded={expanded} + style={{ '--pf-c-accordion__toggle--before--BackgroundColor': 'white' }} + > +
+ + + + Expiring Soon + + {' '} + + + + +
+
+ +
+ {expiringData.length > 0 ? ( +
+
+ + + These runs will be automatically deleted from the sysem if left + unacknowledged. + + + +
+
+
+ + + ) : ( +
+ + + + {' '} + You have no runs expiring soon + + + Runs that have expiration date within next 10days will appear here. + These runs will be autumatically removed from the system if left + unacknowledged. Learn More. + + +
+ )} + +
+ +
+ + + + + +
+ + + {' '} + Saved Runs + + {' '} + + + + + + +
+
+
+ {savedData.length > 0 ? ( +
this.compareResults(selectedRowIds)} + columns={savedDataColumns} + onRowClick={record => { + this.retrieveResults(record); + }} + data={savedData} + saveRuns={selectedRowIds => this.saveRuns(null, selectedRowIds)} + removeResultFromSeen={(e, selectedRowIds) => + this.removeResultFromSeen(e, selectedRowIds) + } + favoriteResult={selectedRowIds => { + this.favoriteResult(selectedRowIds); + }} + deleteResult={selectedRowIds => + this.handleModalToggle(null, selectedRowIds) + } + isCheckable + /> + ) : ( +
+ + + You have no saved runs + + Runs that you have saved will appear here. These runs will be + autumatically removed from the system if left unacknowledged.{' '} + Learn More. + + +
+ )} + + + + + + +
+ + + {' '} + New and unmanaged Runs + + {' '} + + + + + + +
+
+
+
this.compareResults(selectedRowIds)} + columns={newDataColumns} + onRowClick={record => { + this.retrieveResults(record); + }} + data={newData} + saveRuns={selectedRowIds => this.saveRuns(null, selectedRowIds)} + removeResultFromSeen={selectedRowIds => + this.removeResultFromSeen(null, selectedRowIds) + } + favoriteResult={selectedRowIds => { + this.favoriteResult(selectedRowIds); + }} + deleteResult={selectedRowIds => this.handleModalToggle(null, selectedRowIds)} + isCheckable + /> + + + + + + + this.handleModalToggle(e, [])} + actions={[ + , + , + ]} + > + + Overview + Are you sure you want to delete the following runs? + {toBeDeletedData.map(item => ( + + + + {item} + + + ))} + + ); } diff --git a/src/pages/Overview/index.less b/src/pages/Overview/index.less new file mode 100644 index 000000000..341352028 --- /dev/null +++ b/src/pages/Overview/index.less @@ -0,0 +1,180 @@ +*::-webkit-scrollbar { + width: 1em; +} +*::-webkit-scrollbar-thumb { + height: 1em; + background-color: rgba(0, 0, 0, 0.15); + background-clip: padding-box; + border: 0.5em solid rgba(0, 0, 0, 0); + -webkit-border-radius: 1em; + -webkit-box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.025); +} +*::-webkit-scrollbar-button { + display: none; + width: 0; + height: 0; +} +*::-webkit-scrollbar-corner { + background-color: transparent; +} + +div.pf-c-backdrop { + background-color: None !important; +} + +li { + list-style-type: none; +} + +ul { + margin-left: 0 !important; + padding-left: 0 !important; + list-style: none !important; +} + +a { + color: none !important; + text-decoration: none !important; +} + +.displayFlex { + display: flex; + width: 100%; +} + +.flexEnd { + float: right; + width: 50%; +} + +.flexItem { + align-self: flex-end; + width: 50%; +} + +.centertext { + text-align: center; +} + +.paddingSmall { + margin: 8px; + margin-left: 16px; +} + +.paddingLeft { + padding-left: 4px; +} + +.paddingRight { + padding-right: 4px; +} + +.marginLeft { + margin-left: 4px; +} + +.marginRight { + margin-right: 4px; +} + +.savedRunCard { + min-height: 188vh; + max-height: 188vh; + overflow: scroll; +} + +.savedRunCardExpanded { + max-height: 147.5vh; + min-height: 147.5vh; +} + +.newRunCard { + height: 200vh; + overflow: scroll; +} + +.expiringCard { + max-height: 50vh; + overflow: scroll; +} + +.newRunTable { + overflow: scroll; +} + +.expiringValues { + overflow: scroll; +} + +.paddingBig { + margin: 16px; +} + +.marginBottom { + margin-bottom: 3vh; +} + +.subCard { + border-top: solid 1px #cfcfcf; +} + +.subText { + color: #4c4c4c; + font-size: smaller; +} + +.icons { + margin-right: 8px; +} + +.label { + margin: 3px; + padding: 2px 8px; + background-color: rgb(220 239 255); + border-radius: 25px; +} + +.dropdownContent { + position: absolute; + z-index: 1; + min-width: fit-content; + margin-left: -60%; + overflow: auto; + background-color: #f1f1f1; + box-shadow: 0 8px 16px 0 rgba(0, 0, 0, 0.2); +} + +.dropdownLink { + display: block; + padding: 12px 16px; + color: black; + text-decoration: none; +} + +.dropdownLink:hover { + background-color: #ddd; +} + +.stopwatchIcon { + font-size: 5rem; +} + +button.pf-c-accordion__toggle.pf-m-expanded { + --pf-c-accordion__toggle--before--BackgroundColor: white !important; +} + +.expiringCard, +.newRunCard, +.savedRunCard { + border: 1px solid #bec1c3; +} + +.noExpiringRunCard { + border-top: solid 3px red; + max-height: 50vh; + overflow: scroll; +} + +.expiringTable { + background-color: red !important; +} diff --git a/src/pages/Result/index.js b/src/pages/Result/index.js new file mode 100644 index 000000000..95fc9f345 --- /dev/null +++ b/src/pages/Result/index.js @@ -0,0 +1,66 @@ +import React, { Component } from 'react'; +import { connect } from 'dva'; +import { + TextContent, + Text, + TextVariants, + Hint, + HintBody, + Flex, + FlexItem, +} from '@patternfly/react-core'; +import { ExclamationTriangleIcon } from '@patternfly/react-icons'; +import styles from './index.less'; +import Summary from '../Summary'; + +@connect(({ global, loading }) => ({ + selectedResults: global.selectedResults, + loading: loading.effects['dashboard/fetchResults'], +})) +class Result extends Component { + render() { + const { selectedResults } = this.props; + const acceptanceStatus = selectedResults[0].saved; + const unAcceptedHint = ( + + + + + + + + + You haven't managed the run yet + + + + Accept this run + + + Delete run + + + + + + + ); + + return ( + +
+ + {selectedResults[0].result} + {selectedResults[0].controller} + + {acceptanceStatus === true ? '' : unAcceptedHint} +
+
+ +
+
+ ); + } +} + +export default Result; diff --git a/src/pages/Result/index.less b/src/pages/Result/index.less new file mode 100644 index 000000000..f18e8e015 --- /dev/null +++ b/src/pages/Result/index.less @@ -0,0 +1,38 @@ +.paddingBig { + margin: 16px; +} + +.paddingSmall { + margin-bottom: 8px; +} + +.info { + color: #2c9af3; +} + +.warning { + color: #faad14; +} + +.actionBtn { + margin-right: 12px; + font-size: smaller; +} + +.customAccepteddHint { + border: 0; + border-top: 2px solid #2c9af3; +} + +.customUnAccepteddHint { + background-color: #faad141a; + border: 0; + border-top: 2px solid #faad14; +} + +.label { + margin: 3px; + padding: 2px 8px; + background-color: rgb(220 239 255); + border-radius: 25px; +} diff --git a/src/utils/moment_constants.js b/src/utils/moment_constants.js index af60106f5..1d882e572 100644 --- a/src/utils/moment_constants.js +++ b/src/utils/moment_constants.js @@ -37,6 +37,25 @@ export function getAllMonthsWithinRange(endpoints, index, selectedDateRange) { return queryString; } +export const formatDate = (format, givenDate) => { + switch (format) { + case 'utc': + return moment(givenDate).format(); + case 'without time': + return moment(givenDate).format('Do MMMM YYYY'); + case 'with time': + return moment(givenDate).format('Do MMMM YYYY, h:mm:ss a'); + default: + return givenDate; + } +}; + export const getDiffDate = givenDate => { return moment(givenDate).fromNow(); }; + +export const getDiffDays = givenDate => { + const futureDate = moment(givenDate); + const currDate = moment(new Date()); + return futureDate.diff(currDate, 'days'); +};