diff options
| author | Fabian Mastenbroek <mail.fabianm@gmail.com> | 2022-09-20 22:10:01 +0200 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2022-09-20 22:10:01 +0200 |
| commit | f7ba5cd9bbf1f4d145c3d3d171c2632d44b5f94a (patch) | |
| tree | 855256f27ded3cf0ec662119dbf26c3b138a8f5b /opendc-web/opendc-web-ui/src/components/projects | |
| parent | 48d43a83f675db8f5f13755081e56b3cde1a7207 (diff) | |
| parent | 86bc9e74630374853d11bc1c8f7ba5ffafbaa868 (diff) | |
merge: Improve web interface (#100)
This pull request addresses several issues with the current web interface.
## Implementation Notes :hammer_and_pick:
* Update dependencies of web UI where possible
* Fix deletion of topology
* Fix duplication of topology
* Only display selected metrics
* Use correct color for login button
* Fix z-index of context selector
* Move project selector into masthead
* Reduce height of application header
* Redesign projects page
* Use PatternFly Charts for plots
* Do not fail on stale Redux state
* Fix overflow of topology sidebar
* Fix deletion of portfolios
* Migrate to composable table
## External Dependencies :four_leaf_clover:
* `classnames` has been replaced by `clsx`
* PatternFly Charts have replaced the use of `recharts`
Diffstat (limited to 'opendc-web/opendc-web-ui/src/components/projects')
8 files changed, 250 insertions, 233 deletions
diff --git a/opendc-web/opendc-web-ui/src/components/projects/FilterPanel.js b/opendc-web/opendc-web-ui/src/components/projects/FilterPanel.js index 285217e9..7c6d129c 100644 --- a/opendc-web/opendc-web-ui/src/components/projects/FilterPanel.js +++ b/opendc-web/opendc-web-ui/src/components/projects/FilterPanel.js @@ -6,7 +6,7 @@ import { filterPanel } from './FilterPanel.module.scss' export const FILTERS = { SHOW_ALL: 'All Projects', SHOW_OWN: 'My Projects', SHOW_SHARED: 'Shared with me' } const FilterPanel = ({ onSelect, activeFilter = 'SHOW_ALL' }) => ( - <ToggleGroup className={`${filterPanel} mb-2`}> + <ToggleGroup className={`${filterPanel} pf-u-mb-sm`}> {Object.keys(FILTERS).map((filter) => ( <ToggleGroupItem key={filter} diff --git a/opendc-web/opendc-web-ui/src/components/projects/NewProject.js b/opendc-web/opendc-web-ui/src/components/projects/NewProject.js deleted file mode 100644 index bfa7c01a..00000000 --- a/opendc-web/opendc-web-ui/src/components/projects/NewProject.js +++ /dev/null @@ -1,39 +0,0 @@ -import React, { useState } from 'react' -import { Button } from '@patternfly/react-core' -import { PlusIcon } from '@patternfly/react-icons' -import { useNewProject } from '../../data/project' -import { buttonContainer } from './NewProject.module.scss' -import TextInputModal from '../util/modals/TextInputModal' - -/** - * A container for creating a new project. - */ -const NewProject = () => { - const [isVisible, setVisible] = useState(false) - const { mutate: addProject } = useNewProject() - - const onSubmit = (name) => { - if (name) { - addProject({ name }) - } - setVisible(false) - } - - return ( - <> - <div className={buttonContainer}> - <Button - icon={<PlusIcon />} - color="primary" - className="pf-u-float-right" - onClick={() => setVisible(true)} - > - New Project - </Button> - </div> - <TextInputModal title="New Project" label="Project name" isOpen={isVisible} callback={onSubmit} /> - </> - ) -} - -export default NewProject diff --git a/opendc-web/opendc-web-ui/src/components/projects/NewProject.module.scss b/opendc-web/opendc-web-ui/src/components/projects/NewProject.module.scss deleted file mode 100644 index 5a0e74fc..00000000 --- a/opendc-web/opendc-web-ui/src/components/projects/NewProject.module.scss +++ /dev/null @@ -1,26 +0,0 @@ -/*! - * Copyright (c) 2021 AtLarge Research - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - */ - -.buttonContainer { - flex: 0 1 auto; - padding: 20px 0; -} diff --git a/opendc-web/opendc-web-ui/src/components/projects/NewTopologyModal.js b/opendc-web/opendc-web-ui/src/components/projects/NewTopologyModal.js index be4256e3..780ec034 100644 --- a/opendc-web/opendc-web-ui/src/components/projects/NewTopologyModal.js +++ b/opendc-web/opendc-web-ui/src/components/projects/NewTopologyModal.js @@ -57,9 +57,10 @@ const NewTopologyModal = ({ projectId, isOpen, onSubmit: onSubmitUpstream, onCan setErrors({ name: true }) return false } else { - const candidate = topologies.find((topology) => topology.id === originTopology) || { projectId, rooms: [] } + const candidate = topologies.find((topology) => topology.id === originTopology) || { rooms: [] } const topology = produce(candidate, (draft) => { - delete draft.id + delete draft.project + draft.projectId = projectId draft.name = name }) onSubmitUpstream(topology) @@ -87,7 +88,12 @@ const NewTopologyModal = ({ projectId, isOpen, onSubmit: onSubmitUpstream, onCan <TextInput id="name" name="name" type="text" isRequired ref={nameInput} /> </FormGroup> <FormGroup label="Topology to duplicate" fieldId="origin" isRequired> - <FormSelect id="origin" name="origin" value={originTopology} onChange={setOriginTopology}> + <FormSelect + id="origin" + name="origin" + value={originTopology} + onChange={(v) => setOriginTopology(+v)} + > <FormSelectOption value={-1} key={-1} label="None - start from scratch" /> {topologies.map((topology) => ( <FormSelectOption value={topology.id} key={topology.id} label={topology.name} /> diff --git a/opendc-web/opendc-web-ui/src/components/projects/PortfolioTable.js b/opendc-web/opendc-web-ui/src/components/projects/PortfolioTable.js index aa679843..0afeaeaf 100644 --- a/opendc-web/opendc-web-ui/src/components/projects/PortfolioTable.js +++ b/opendc-web/opendc-web-ui/src/components/projects/PortfolioTable.js @@ -20,64 +20,75 @@ * SOFTWARE. */ +import { Bullseye } from '@patternfly/react-core' import PropTypes from 'prop-types' import Link from 'next/link' -import { Table, TableBody, TableHeader } from '@patternfly/react-table' +import { TableComposable, Thead, Tbody, Tr, Th, Td, ActionsColumn } from '@patternfly/react-table' import React from 'react' import TableEmptyState from '../util/TableEmptyState' import { usePortfolios, useDeletePortfolio } from '../../data/project' -const PortfolioTable = ({ projectId }) => { +function PortfolioTable({ projectId }) { const { status, data: portfolios = [] } = usePortfolios(projectId) const { mutate: deletePortfolio } = useDeletePortfolio() - const columns = ['Name', 'Scenarios', 'Metrics', 'Repeats'] - const rows = - portfolios.length > 0 - ? portfolios.map((portfolio) => [ - { - title: ( - <Link href={`/projects/${projectId}/portfolios/${portfolio.number}`}>{portfolio.name}</Link> - ), - }, - portfolio.scenarios.length === 1 ? '1 scenario' : `${portfolio.scenarios.length} scenarios`, - portfolio.targets.metrics.length === 1 ? '1 metric' : `${portfolio.targets.metrics.length} metrics`, - portfolio.targets.repeats === 1 ? '1 repeat' : `${portfolio.targets.repeats} repeats`, - ]) - : [ - { - heightAuto: true, - cells: [ - { - props: { colSpan: 4 }, - title: ( - <TableEmptyState - status={status} - loadingTitle="Loading portfolios" - emptyTitle="No portfolios" - emptyText="You have not created any portfolio for this project yet. Click the New Portfolio button to create one." - /> - ), - }, - ], - }, - ] - - const actions = - portfolios.length > 0 - ? [ - { - title: 'Delete Portfolio', - onClick: (_, rowId) => deletePortfolio({ projectId, number: portfolios[rowId].number }), - }, - ] - : [] + const actions = (portfolio) => [ + { + title: 'Delete Portfolio', + onClick: () => deletePortfolio({ projectId, number: portfolio.number }), + }, + ] return ( - <Table aria-label="Portfolio List" variant="compact" cells={columns} rows={rows} actions={actions}> - <TableHeader /> - <TableBody /> - </Table> + <TableComposable aria-label="Portfolio List" variant="compact"> + <Thead> + <Tr> + <Th>Name</Th> + <Th>Scenarios</Th> + <Th>Metrics</Th> + <Th>Repeats</Th> + </Tr> + </Thead> + <Tbody> + {portfolios.map((portfolio) => ( + <Tr key={portfolio.id}> + <Td dataLabel="Name"> + <Link href={`/projects/${projectId}/portfolios/${portfolio.number}`}>{portfolio.name}</Link> + </Td> + <Td dataLabel="Scenarios"> + {portfolio.scenarios.length === 1 + ? '1 scenario' + : `${portfolio.scenarios.length} scenarios`} + </Td> + <Td dataLabel="Metrics"> + {portfolio.targets.metrics.length === 1 + ? '1 metric' + : `${portfolio.targets.metrics.length} metrics`} + </Td> + <Td dataLabel="Repeats"> + {portfolio.targets.repeats === 1 ? '1 repeat' : `${portfolio.targets.repeats} repeats`} + </Td> + <Td isActionCell> + <ActionsColumn items={actions(portfolio)} /> + </Td> + </Tr> + ))} + {portfolios.length === 0 && ( + <Tr> + <Td colSpan={4}> + <Bullseye> + <TableEmptyState + status={status} + loadingTitle="Loading portfolios" + emptyTitle="No portfolios" + emptyText="You have not created any portfolio for this project yet. Click the New Portfolio button to create one." + /> + </Bullseye> + </Td> + </Tr> + )} + </Tbody> + </TableComposable> ) } diff --git a/opendc-web/opendc-web-ui/src/components/projects/ProjectCollection.js b/opendc-web/opendc-web-ui/src/components/projects/ProjectCollection.js new file mode 100644 index 00000000..70f02812 --- /dev/null +++ b/opendc-web/opendc-web-ui/src/components/projects/ProjectCollection.js @@ -0,0 +1,137 @@ +import { + Gallery, + Bullseye, + EmptyState, + EmptyStateIcon, + Card, + CardTitle, + CardActions, + DropdownItem, + CardHeader, + Dropdown, + KebabToggle, + CardBody, + CardHeaderMain, + TextVariants, + Text, + TextContent, + Tooltip, + Button, + Label, +} from '@patternfly/react-core' +import { PlusIcon, FolderIcon, TrashIcon } from '@patternfly/react-icons' +import PropTypes from 'prop-types' +import React, { useReducer, useMemo } from 'react' +import { Project, Status } from '../../shapes' +import { parseAndFormatDateTime } from '../../util/date-time' +import { AUTH_DESCRIPTION_MAP, AUTH_ICON_MAP, AUTH_NAME_MAP } from '../../util/authorizations' +import NavItemLink from '../util/NavItemLink' +import TableEmptyState from '../util/TableEmptyState' + +function ProjectCard({ project, onDelete }) { + const [isKebabOpen, toggleKebab] = useReducer((t) => !t, false) + const { id, role, name, updatedAt } = project + const Icon = AUTH_ICON_MAP[role] + + return ( + <Card + isCompact + isRounded + isFlat + className="pf-u-min-height" + style={{ '--pf-u-min-height--MinHeight': '175px' }} + > + <CardHeader className="pf-u-flex-grow-1"> + <CardHeaderMain className="pf-u-align-self-flex-start"> + <FolderIcon /> + </CardHeaderMain> + <CardActions> + <Tooltip content={AUTH_DESCRIPTION_MAP[role]}> + <Label icon={<Icon />}>{AUTH_NAME_MAP[role]}</Label> + </Tooltip> + <Dropdown + isPlain + position="right" + toggle={<KebabToggle className="pf-u-px-0" onToggle={toggleKebab} />} + isOpen={isKebabOpen} + dropdownItems={[ + <DropdownItem + key="trash" + onClick={() => { + onDelete() + toggleKebab() + }} + position="right" + icon={<TrashIcon />} + > + Delete + </DropdownItem>, + ]} + /> + </CardActions> + </CardHeader> + <CardTitle component={NavItemLink} className="pf-u-pb-0" href={`/projects/${id}`}> + {name} + </CardTitle> + <CardBody isFilled={false}> + <TextContent> + <Text component={TextVariants.small}>Last modified {parseAndFormatDateTime(updatedAt)}</Text> + </TextContent> + </CardBody> + </Card> + ) +} + +function ProjectCollection({ status, projects, onDelete, onCreate, isFiltering }) { + const sortedProjects = useMemo(() => { + const res = [...projects] + res.sort((a, b) => (new Date(a.updatedAt) < new Date(b.updatedAt) ? 1 : -1)) + return res + }, [projects]) + + if (sortedProjects.length === 0) { + return ( + <TableEmptyState + status={status} + isFiltering={isFiltering} + loadingTitle="Loading Projects" + emptyTitle="No projects" + emptyText="You have not created any projects yet. Create a new project to get started quickly." + emptyAction={ + <Button icon={<PlusIcon />} onClick={onCreate}> + Create Project + </Button> + } + /> + ) + } + + return ( + <Gallery hasGutter aria-label="Available projects"> + {sortedProjects.map((project) => ( + <ProjectCard key={project.id} project={project} onDelete={() => onDelete(project)} /> + ))} + <Card isCompact isFlat isRounded style={{ borderStyle: 'dotted' }}> + <Bullseye> + <EmptyState> + <Button isBlock variant="link" onClick={onCreate}> + <EmptyStateIcon icon={PlusIcon} /> + <br /> + Create Project + </Button> + </EmptyState> + </Bullseye> + </Card> + </Gallery> + ) +} + +ProjectCollection.propTypes = { + status: Status.isRequired, + isFiltering: PropTypes.bool, + projects: PropTypes.arrayOf(Project).isRequired, + onDelete: PropTypes.func, + onCreate: PropTypes.func, +} + +export default ProjectCollection diff --git a/opendc-web/opendc-web-ui/src/components/projects/ProjectTable.js b/opendc-web/opendc-web-ui/src/components/projects/ProjectTable.js deleted file mode 100644 index 6921578c..00000000 --- a/opendc-web/opendc-web-ui/src/components/projects/ProjectTable.js +++ /dev/null @@ -1,73 +0,0 @@ -import PropTypes from 'prop-types' -import React from 'react' -import Link from 'next/link' -import { Project, Status } from '../../shapes' -import { Table, TableBody, TableHeader } from '@patternfly/react-table' -import { parseAndFormatDateTime } from '../../util/date-time' -import { AUTH_DESCRIPTION_MAP, AUTH_ICON_MAP } from '../../util/authorizations' -import TableEmptyState from '../util/TableEmptyState' - -const ProjectTable = ({ status, projects, onDelete, isFiltering }) => { - const columns = ['Project name', 'Last edited', 'Access Rights'] - const rows = - projects.length > 0 - ? projects.map((project) => { - const Icon = AUTH_ICON_MAP[project.role] - return [ - { - title: <Link href={`/projects/${project.id}`}>{project.name}</Link>, - }, - parseAndFormatDateTime(project.updatedAt), - { - title: ( - <> - <Icon className="pf-u-mr-md" key="auth" /> {AUTH_DESCRIPTION_MAP[project.role]} - </> - ), - }, - ] - }) - : [ - { - heightAuto: true, - cells: [ - { - props: { colSpan: 3 }, - title: ( - <TableEmptyState - status={status} - loadingTitle="Loading Projects" - isFiltering={isFiltering} - /> - ), - }, - ], - }, - ] - - const actions = - projects.length > 0 - ? [ - { - title: 'Delete Project', - onClick: (_, rowId) => onDelete(projects[rowId]), - }, - ] - : [] - - return ( - <Table aria-label="Project List" variant="compact" cells={columns} rows={rows} actions={actions}> - <TableHeader /> - <TableBody /> - </Table> - ) -} - -ProjectTable.propTypes = { - status: Status.isRequired, - isFiltering: PropTypes.bool, - projects: PropTypes.arrayOf(Project).isRequired, - onDelete: PropTypes.func, -} - -export default ProjectTable diff --git a/opendc-web/opendc-web-ui/src/components/projects/TopologyTable.js b/opendc-web/opendc-web-ui/src/components/projects/TopologyTable.js index ced5304a..62deace0 100644 --- a/opendc-web/opendc-web-ui/src/components/projects/TopologyTable.js +++ b/opendc-web/opendc-web-ui/src/components/projects/TopologyTable.js @@ -20,66 +20,67 @@ * SOFTWARE. */ +import { Bullseye } from '@patternfly/react-core' import PropTypes from 'prop-types' import Link from 'next/link' -import { Table, TableBody, TableHeader } from '@patternfly/react-table' +import { Tr, Th, Thead, Td, ActionsColumn, Tbody, TableComposable } from '@patternfly/react-table' import React from 'react' import TableEmptyState from '../util/TableEmptyState' import { parseAndFormatDateTime } from '../../util/date-time' import { useTopologies, useDeleteTopology } from '../../data/topology' -const TopologyTable = ({ projectId }) => { +function TopologyTable({ projectId }) { const { status, data: topologies = [] } = useTopologies(projectId) const { mutate: deleteTopology } = useDeleteTopology() - const columns = ['Name', 'Rooms', 'Last Edited'] - const rows = - topologies.length > 0 - ? topologies.map((topology) => [ - { - title: <Link href={`/projects/${projectId}/topologies/${topology.number}`}>{topology.name}</Link>, - }, - topology.rooms.length === 1 ? '1 room' : `${topology.rooms.length} rooms`, - parseAndFormatDateTime(topology.updatedAt), - ]) - : [ - { - heightAuto: true, - cells: [ - { - props: { colSpan: 3 }, - title: ( - <TableEmptyState - status={status} - loadingTitle="Loading topologies" - emptyTitle="No topologies" - emptyText="You have not created any topology for this project yet. Click the New Topology button to create one." - /> - ), - }, - ], - }, - ] - - const actionResolver = (_, { rowIndex }) => [ + const actions = ({ number }) => [ { title: 'Delete Topology', - onClick: (_, rowId) => deleteTopology({ projectId, number: topologies[rowId].number }), - isDisabled: rowIndex === 0, + onClick: () => deleteTopology({ projectId, number }), + isDisabled: number === 0, }, ] return ( - <Table - aria-label="Topology List" - variant="compact" - cells={columns} - rows={rows} - actionResolver={topologies.length > 0 ? actionResolver : () => []} - > - <TableHeader /> - <TableBody /> - </Table> + <TableComposable aria-label="Topology List" variant="compact"> + <Thead> + <Tr> + <Th>Name</Th> + <Th>Rooms</Th> + <Th>Last Edited</Th> + </Tr> + </Thead> + <Tbody> + {topologies.map((topology) => ( + <Tr key={topology.id}> + <Td dataLabel="Name"> + <Link href={`/projects/${projectId}/topologies/${topology.number}`}>{topology.name}</Link> + </Td> + <Td dataLabel="Rooms"> + {topology.rooms.length === 1 ? '1 room' : `${topology.rooms.length} rooms`} + </Td> + <Td dataLabel="Last Edited">{parseAndFormatDateTime(topology.updatedAt)}</Td> + <Td isActionCell> + <ActionsColumn items={actions(topology)} /> + </Td> + </Tr> + ))} + {topologies.length === 0 && ( + <Tr> + <Td colSpan={3}> + <Bullseye> + <TableEmptyState + status={status} + loadingTitle="Loading topologies" + emptyTitle="No topologies" + emptyText="You have not created any topology for this project yet. Click the New Topology button to create one." + /> + </Bullseye> + </Td> + </Tr> + )} + </Tbody> + </TableComposable> ) } |
