summaryrefslogtreecommitdiff
path: root/opendc-web/opendc-web-ui/src/components/projects
diff options
context:
space:
mode:
authorFabian Mastenbroek <mail.fabianm@gmail.com>2022-09-20 22:10:01 +0200
committerGitHub <noreply@github.com>2022-09-20 22:10:01 +0200
commitf7ba5cd9bbf1f4d145c3d3d171c2632d44b5f94a (patch)
tree855256f27ded3cf0ec662119dbf26c3b138a8f5b /opendc-web/opendc-web-ui/src/components/projects
parent48d43a83f675db8f5f13755081e56b3cde1a7207 (diff)
parent86bc9e74630374853d11bc1c8f7ba5ffafbaa868 (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')
-rw-r--r--opendc-web/opendc-web-ui/src/components/projects/FilterPanel.js2
-rw-r--r--opendc-web/opendc-web-ui/src/components/projects/NewProject.js39
-rw-r--r--opendc-web/opendc-web-ui/src/components/projects/NewProject.module.scss26
-rw-r--r--opendc-web/opendc-web-ui/src/components/projects/NewTopologyModal.js12
-rw-r--r--opendc-web/opendc-web-ui/src/components/projects/PortfolioTable.js105
-rw-r--r--opendc-web/opendc-web-ui/src/components/projects/ProjectCollection.js137
-rw-r--r--opendc-web/opendc-web-ui/src/components/projects/ProjectTable.js73
-rw-r--r--opendc-web/opendc-web-ui/src/components/projects/TopologyTable.js89
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>
)
}