summaryrefslogtreecommitdiff
path: root/opendc-web/opendc-web-ui/src/components
diff options
context:
space:
mode:
Diffstat (limited to 'opendc-web/opendc-web-ui/src/components')
-rw-r--r--opendc-web/opendc-web-ui/src/components/AppHeader.js60
-rw-r--r--opendc-web/opendc-web-ui/src/components/AppHeader.module.scss (renamed from opendc-web/opendc-web-ui/src/components/AppLogo.module.scss)15
-rw-r--r--opendc-web/opendc-web-ui/src/components/AppHeaderTools.js96
-rw-r--r--opendc-web/opendc-web-ui/src/components/AppHeaderUser.js81
-rw-r--r--opendc-web/opendc-web-ui/src/components/AppLogo.js46
-rw-r--r--opendc-web/opendc-web-ui/src/components/AppNavigation.js47
-rw-r--r--opendc-web/opendc-web-ui/src/components/AppPage.js2
-rw-r--r--opendc-web/opendc-web-ui/src/components/context/ContextSelector.js19
-rw-r--r--opendc-web/opendc-web-ui/src/components/context/ContextSelector.module.scss3
-rw-r--r--opendc-web/opendc-web-ui/src/components/context/PortfolioSelector.js3
-rw-r--r--opendc-web/opendc-web-ui/src/components/context/ProjectSelector.js12
-rw-r--r--opendc-web/opendc-web-ui/src/components/context/TopologySelector.js3
-rw-r--r--opendc-web/opendc-web-ui/src/components/portfolios/PortfolioResults.js136
-rw-r--r--opendc-web/opendc-web-ui/src/components/portfolios/ScenarioTable.js109
-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
-rw-r--r--opendc-web/opendc-web-ui/src/components/topologies/RoomTable.js98
-rw-r--r--opendc-web/opendc-web-ui/src/components/topologies/TopologyMap.js4
-rw-r--r--opendc-web/opendc-web-ui/src/components/topologies/map/MapStage.js2
-rw-r--r--opendc-web/opendc-web-ui/src/components/topologies/map/MapStage.module.scss2
-rw-r--r--opendc-web/opendc-web-ui/src/components/topologies/map/RackEnergyFillContainer.js34
-rw-r--r--opendc-web/opendc-web-ui/src/components/topologies/map/RackSpaceFillContainer.js11
-rw-r--r--opendc-web/opendc-web-ui/src/components/topologies/map/RoomContainer.js25
-rw-r--r--opendc-web/opendc-web-ui/src/components/topologies/map/TileContainer.js6
-rw-r--r--opendc-web/opendc-web-ui/src/components/topologies/map/WallContainer.js2
-rw-r--r--opendc-web/opendc-web-ui/src/components/topologies/map/groups/RackGroup.js4
-rw-r--r--opendc-web/opendc-web-ui/src/components/topologies/sidebar/rack/RackSidebar.module.scss10
-rw-r--r--opendc-web/opendc-web-ui/src/components/util/NavItemLink.js12
34 files changed, 692 insertions, 633 deletions
diff --git a/opendc-web/opendc-web-ui/src/components/AppHeader.js b/opendc-web/opendc-web-ui/src/components/AppHeader.js
index fd54b3ad..f9ef00aa 100644
--- a/opendc-web/opendc-web-ui/src/components/AppHeader.js
+++ b/opendc-web/opendc-web-ui/src/components/AppHeader.js
@@ -20,25 +20,57 @@
* SOFTWARE.
*/
-import { PageHeader } from '@patternfly/react-core'
+import Image from 'next/image'
+import PropTypes from 'prop-types'
import React from 'react'
+import {
+ Masthead,
+ MastheadMain,
+ MastheadBrand,
+ MastheadContent,
+ Toolbar,
+ ToolbarContent,
+ ToolbarItem,
+} from '@patternfly/react-core'
+import Link from 'next/link'
import AppHeaderTools from './AppHeaderTools'
-import { AppNavigation } from './AppNavigation'
-import AppLogo from './AppLogo'
+import AppHeaderUser from './AppHeaderUser'
+import ProjectSelector from './context/ProjectSelector'
-export function AppHeader() {
- // eslint-disable-next-line @next/next/no-img-element
- const logo = <img src="/img/logo.png" width={30} height={30} alt="OpenDC" />
+import styles from './AppHeader.module.scss'
+export default function AppHeader({ nav }) {
return (
- <PageHeader
- logo={logo}
- logoProps={{ href: '/' }}
- logoComponent={AppLogo}
- headerTools={<AppHeaderTools />}
- topNav={<AppNavigation />}
- />
+ <Masthead id="app-header" className={styles.header}>
+ <MastheadMain>
+ <MastheadBrand
+ className={styles.logo}
+ component={(props) => (
+ <Link href="/projects">
+ <a {...props} />
+ </Link>
+ )}
+ >
+ <Image src="/img/logo.svg" alt="OpenDC logo" width={25} height={25} />
+ <span>OpenDC</span>
+ </MastheadBrand>
+ </MastheadMain>
+ <MastheadContent>
+ <Toolbar id="toolbar" isFullHeight isStatic>
+ <ToolbarContent>
+ <ToolbarItem>
+ <ProjectSelector />
+ </ToolbarItem>
+ {nav && <ToolbarItem>{nav}</ToolbarItem>}
+ <AppHeaderTools />
+ <AppHeaderUser />
+ </ToolbarContent>
+ </Toolbar>
+ </MastheadContent>
+ </Masthead>
)
}
-AppHeader.propTypes = {}
+AppHeader.propTypes = {
+ nav: PropTypes.node,
+}
diff --git a/opendc-web/opendc-web-ui/src/components/AppLogo.module.scss b/opendc-web/opendc-web-ui/src/components/AppHeader.module.scss
index 3d228cb6..73ef553c 100644
--- a/opendc-web/opendc-web-ui/src/components/AppLogo.module.scss
+++ b/opendc-web/opendc-web-ui/src/components/AppHeader.module.scss
@@ -20,10 +20,21 @@
* SOFTWARE.
*/
-.appLogo {
+.header.header {
+ /* Increase precedence */
+ --pf-c-masthead--m-display-inline__content--MinHeight: 3rem;
+ --pf-c-masthead--m-display-inline__main--MinHeight: 3rem;
+
+ --pf-c-masthead--c-context-selector--Width: 200px;
+}
+
+.logo {
span {
- margin-left: 4px;
+ margin-left: 8px;
color: #fff;
+ align-self: center;
+ font-weight: 600;
+ font-size: 0.9rem;
}
&:hover,
diff --git a/opendc-web/opendc-web-ui/src/components/AppHeaderTools.js b/opendc-web/opendc-web-ui/src/components/AppHeaderTools.js
index 3e58b209..499bceef 100644
--- a/opendc-web/opendc-web-ui/src/components/AppHeaderTools.js
+++ b/opendc-web/opendc-web-ui/src/components/AppHeaderTools.js
@@ -21,29 +21,19 @@
*/
import {
- Avatar,
Button,
ButtonVariant,
Dropdown,
- DropdownGroup,
DropdownItem,
- DropdownToggle,
KebabToggle,
- PageHeaderTools,
- PageHeaderToolsGroup,
- PageHeaderToolsItem,
- Skeleton,
+ ToolbarGroup,
+ ToolbarItem,
} from '@patternfly/react-core'
-import { useState } from 'react'
-import { useAuth } from '../auth'
+import { useReducer } from 'react'
import { GithubIcon, HelpIcon } from '@patternfly/react-icons'
function AppHeaderTools() {
- const { logout, user, isAuthenticated, isLoading } = useAuth()
- const username = isAuthenticated || isLoading ? user?.name : 'Anonymous'
- const avatar = isAuthenticated || isLoading ? user?.picture : '/img/avatar.svg'
-
- const [isKebabDropdownOpen, setKebabDropdownOpen] = useState(false)
+ const [isKebabDropdownOpen, toggleKebabDropdown] = useReducer((t) => !t, false)
const kebabDropdownItems = [
<DropdownItem
key={0}
@@ -55,23 +45,14 @@ function AppHeaderTools() {
/>,
]
- const [isDropdownOpen, setDropdownOpen] = useState(false)
- const userDropdownItems = [
- <DropdownGroup key="group 2">
- <DropdownItem
- key="group 2 logout"
- isDisabled={!isAuthenticated}
- onClick={() => logout({ returnTo: window.location.origin })}
- >
- Logout
- </DropdownItem>
- </DropdownGroup>,
- ]
-
return (
- <PageHeaderTools>
- <PageHeaderToolsGroup visibility={{ default: 'hidden', lg: 'visible' }}>
- <PageHeaderToolsItem>
+ <ToolbarGroup
+ variant="icon-button-group"
+ alignment={{ default: 'alignRight' }}
+ spacer={{ default: 'spacerNone', md: 'spacerMd' }}
+ >
+ <ToolbarGroup variant="icon-button-group" visibility={{ default: 'hidden', lg: 'visible' }}>
+ <ToolbarItem>
<Button
component="a"
href="https://github.com/atlarge-research/opendc"
@@ -81,8 +62,8 @@ function AppHeaderTools() {
>
<GithubIcon />
</Button>
- </PageHeaderToolsItem>
- <PageHeaderToolsItem>
+ </ToolbarItem>
+ <ToolbarItem>
<Button
component="a"
href="https://opendc.org/"
@@ -92,45 +73,18 @@ function AppHeaderTools() {
>
<HelpIcon />
</Button>
- </PageHeaderToolsItem>
- </PageHeaderToolsGroup>
- <PageHeaderToolsGroup>
- <PageHeaderToolsItem visibility={{ lg: 'hidden' }}>
- <Dropdown
- isPlain
- position="right"
- toggle={<KebabToggle onToggle={() => setKebabDropdownOpen(!isKebabDropdownOpen)} />}
- isOpen={isKebabDropdownOpen}
- dropdownItems={kebabDropdownItems}
- />
- </PageHeaderToolsItem>
- <PageHeaderToolsItem visibility={{ default: 'hidden', md: 'visible' }}>
- <Dropdown
- isPlain
- position="right"
- isOpen={isDropdownOpen}
- toggle={
- <DropdownToggle onToggle={() => setDropdownOpen(!isDropdownOpen)}>
- {username ?? (
- <Skeleton
- fontSize="xs"
- width="150px"
- className="pf-u-display-inline-flex"
- screenreaderText="Loading username"
- />
- )}
- </DropdownToggle>
- }
- dropdownItems={userDropdownItems}
- />
- </PageHeaderToolsItem>
- </PageHeaderToolsGroup>
- {avatar ? (
- <Avatar src={avatar} alt="Avatar image" />
- ) : (
- <Skeleton className="pf-c-avatar" shape="circle" width="2.25rem" screenreaderText="Loading avatar" />
- )}
- </PageHeaderTools>
+ </ToolbarItem>
+ </ToolbarGroup>
+ <ToolbarItem visibility={{ lg: 'hidden' }}>
+ <Dropdown
+ isPlain
+ position="right"
+ toggle={<KebabToggle onToggle={toggleKebabDropdown} />}
+ isOpen={isKebabDropdownOpen}
+ dropdownItems={kebabDropdownItems}
+ />
+ </ToolbarItem>
+ </ToolbarGroup>
)
}
diff --git a/opendc-web/opendc-web-ui/src/components/AppHeaderUser.js b/opendc-web/opendc-web-ui/src/components/AppHeaderUser.js
new file mode 100644
index 00000000..e271accb
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/components/AppHeaderUser.js
@@ -0,0 +1,81 @@
+/*
+ * Copyright (c) 2022 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.
+ */
+
+import {
+ Dropdown,
+ DropdownToggle,
+ Skeleton,
+ ToolbarItem,
+ DropdownItem,
+ DropdownGroup,
+ Avatar,
+} from '@patternfly/react-core'
+import { useReducer } from 'react'
+import { useAuth } from '../auth'
+
+export default function AppHeaderUser() {
+ const { logout, user, isAuthenticated, isLoading } = useAuth()
+ const username = isAuthenticated || isLoading ? user?.name : 'Anonymous'
+ const avatar = isAuthenticated || isLoading ? user?.picture : '/img/avatar.svg'
+
+ const [isDropdownOpen, toggleDropdown] = useReducer((t) => !t, false)
+ const userDropdownItems = [
+ <DropdownGroup key="group 2">
+ <DropdownItem
+ key="group 2 logout"
+ isDisabled={!isAuthenticated}
+ onClick={() => logout({ returnTo: window.location.origin })}
+ >
+ Logout
+ </DropdownItem>
+ </DropdownGroup>,
+ ]
+
+ const avatarComponent = avatar ? (
+ <Avatar src={avatar} alt="Avatar image" size="sm" />
+ ) : (
+ <Skeleton className="pf-c-avatar" shape="circle" width="24px" screenreaderText="Loading avatar" />
+ )
+
+ return (
+ <ToolbarItem visibility={{ default: 'hidden', md: 'visible' }}>
+ <Dropdown
+ isFullHeight
+ position="right"
+ isOpen={isDropdownOpen}
+ toggle={
+ <DropdownToggle onToggle={toggleDropdown} icon={avatarComponent}>
+ {username ?? (
+ <Skeleton
+ fontSize="xs"
+ width="150px"
+ className="pf-u-display-inline-flex"
+ screenreaderText="Loading username"
+ />
+ )}
+ </DropdownToggle>
+ }
+ dropdownItems={userDropdownItems}
+ />
+ </ToolbarItem>
+ )
+}
diff --git a/opendc-web/opendc-web-ui/src/components/AppLogo.js b/opendc-web/opendc-web-ui/src/components/AppLogo.js
deleted file mode 100644
index 92663295..00000000
--- a/opendc-web/opendc-web-ui/src/components/AppLogo.js
+++ /dev/null
@@ -1,46 +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.
- */
-
-import PropTypes from 'prop-types'
-import Link from 'next/link'
-import { appLogo } from './AppLogo.module.scss'
-
-function AppLogo({ href, children, className, ...props }) {
- return (
- <>
- <Link href={href}>
- <a {...props} className={`${className ?? ''} ${appLogo}`}>
- {children}
- <span>OpenDC</span>
- </a>
- </Link>
- </>
- )
-}
-
-AppLogo.propTypes = {
- href: PropTypes.string.isRequired,
- children: PropTypes.node,
- className: PropTypes.string,
-}
-
-export default AppLogo
diff --git a/opendc-web/opendc-web-ui/src/components/AppNavigation.js b/opendc-web/opendc-web-ui/src/components/AppNavigation.js
deleted file mode 100644
index 77c683a2..00000000
--- a/opendc-web/opendc-web-ui/src/components/AppNavigation.js
+++ /dev/null
@@ -1,47 +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.
- */
-
-import { Nav, NavItem, NavList } from '@patternfly/react-core'
-import { useRouter } from 'next/router'
-import NavItemLink from './util/NavItemLink'
-
-export function AppNavigation() {
- const { pathname } = useRouter()
-
- return (
- <Nav variant="horizontal">
- <NavList>
- <NavItem
- id="projects"
- to="/projects"
- itemId={0}
- component={NavItemLink}
- isActive={pathname === '/projects' || pathname === '/projects/[project]'}
- >
- Projects
- </NavItem>
- </NavList>
- </Nav>
- )
-}
-
-AppNavigation.propTypes = {}
diff --git a/opendc-web/opendc-web-ui/src/components/AppPage.js b/opendc-web/opendc-web-ui/src/components/AppPage.js
index 25afaf9a..2893146e 100644
--- a/opendc-web/opendc-web-ui/src/components/AppPage.js
+++ b/opendc-web/opendc-web-ui/src/components/AppPage.js
@@ -21,7 +21,7 @@
*/
import PropTypes from 'prop-types'
-import { AppHeader } from './AppHeader'
+import AppHeader from './AppHeader'
import React from 'react'
import { Page, PageGroup, PageBreadcrumb } from '@patternfly/react-core'
diff --git a/opendc-web/opendc-web-ui/src/components/context/ContextSelector.js b/opendc-web/opendc-web-ui/src/components/context/ContextSelector.js
index a99b60c0..059cfea8 100644
--- a/opendc-web/opendc-web-ui/src/components/context/ContextSelector.js
+++ b/opendc-web/opendc-web-ui/src/components/context/ContextSelector.js
@@ -23,9 +23,10 @@
import PropTypes from 'prop-types'
import { ContextSelector as PFContextSelector, ContextSelectorItem } from '@patternfly/react-core'
import { useMemo, useState } from 'react'
-import { contextSelector } from './ContextSelector.module.scss'
-function ContextSelector({ activeItem, items, onSelect, onToggle, isOpen, label }) {
+import styles from './ContextSelector.module.scss'
+
+function ContextSelector({ id, type = 'page', toggleText, items, onSelect, onToggle, isOpen, isFullHeight }) {
const [searchValue, setSearchValue] = useState('')
const filteredItems = useMemo(
() => items.filter(({ name }) => name.toLowerCase().indexOf(searchValue.toLowerCase()) !== -1) || items,
@@ -34,11 +35,11 @@ function ContextSelector({ activeItem, items, onSelect, onToggle, isOpen, label
return (
<PFContextSelector
- className={contextSelector}
- toggleText={activeItem ? `${label}: ${activeItem.name}` : label}
+ id={id}
+ className={type === 'page' && styles.pageSelector}
+ toggleText={toggleText}
onSearchInputChange={(value) => setSearchValue(value)}
searchInputValue={searchValue}
- isOpen={isOpen}
onToggle={(_, isOpen) => onToggle(isOpen)}
onSelect={(event) => {
const targetId = +event.target.value
@@ -47,6 +48,8 @@ function ContextSelector({ activeItem, items, onSelect, onToggle, isOpen, label
onSelect(target)
onToggle(!isOpen)
}}
+ isOpen={isOpen}
+ isFullHeight={isFullHeight}
>
{filteredItems.map((item) => (
<ContextSelectorItem key={item.id} value={item.id}>
@@ -63,12 +66,14 @@ const Item = PropTypes.shape({
})
ContextSelector.propTypes = {
- activeItem: Item,
+ id: PropTypes.string,
+ type: PropTypes.oneOf(['app', 'page']),
items: PropTypes.arrayOf(Item).isRequired,
+ toggleText: PropTypes.string,
onSelect: PropTypes.func.isRequired,
onToggle: PropTypes.func.isRequired,
isOpen: PropTypes.bool,
- label: PropTypes.string,
+ isFullHeight: PropTypes.bool,
}
export default ContextSelector
diff --git a/opendc-web/opendc-web-ui/src/components/context/ContextSelector.module.scss b/opendc-web/opendc-web-ui/src/components/context/ContextSelector.module.scss
index 07b7b1d0..4f86ac64 100644
--- a/opendc-web/opendc-web-ui/src/components/context/ContextSelector.module.scss
+++ b/opendc-web/opendc-web-ui/src/components/context/ContextSelector.module.scss
@@ -20,10 +20,11 @@
* SOFTWARE.
*/
-.contextSelector.contextSelector {
+.pageSelector.pageSelector {
// Ensure this selector has precedence over the default one
margin-right: 20px;
+ --pf-c-context-selector__menu--ZIndex: var(--pf-global--ZIndex--lg);
--pf-c-context-selector__toggle--PaddingTop: var(--pf-global--spacer--sm);
--pf-c-context-selector__toggle--PaddingRight: 0;
--pf-c-context-selector__toggle--PaddingBottom: var(--pf-global--spacer--sm);
diff --git a/opendc-web/opendc-web-ui/src/components/context/PortfolioSelector.js b/opendc-web/opendc-web-ui/src/components/context/PortfolioSelector.js
index c4f2d50e..e401e6fc 100644
--- a/opendc-web/opendc-web-ui/src/components/context/PortfolioSelector.js
+++ b/opendc-web/opendc-web-ui/src/components/context/PortfolioSelector.js
@@ -34,7 +34,8 @@ function PortfolioSelector({ activePortfolio }) {
return (
<ContextSelector
- label="Portfolio"
+ id="portfolio"
+ toggleText={activePortfolio ? `Portfolio: ${activePortfolio.name}` : 'Select portfolio'}
activeItem={activePortfolio}
items={portfolios}
onSelect={(portfolio) => router.push(`/projects/${portfolio.project.id}/portfolios/${portfolio.number}`)}
diff --git a/opendc-web/opendc-web-ui/src/components/context/ProjectSelector.js b/opendc-web/opendc-web-ui/src/components/context/ProjectSelector.js
index 7721e04c..f2791b38 100644
--- a/opendc-web/opendc-web-ui/src/components/context/ProjectSelector.js
+++ b/opendc-web/opendc-web-ui/src/components/context/ProjectSelector.js
@@ -22,24 +22,28 @@
import { useRouter } from 'next/router'
import { useState } from 'react'
-import { useProjects } from '../../data/project'
+import { useProjects, useProject } from '../../data/project'
import { Project } from '../../shapes'
import ContextSelector from './ContextSelector'
-function ProjectSelector({ activeProject }) {
+function ProjectSelector() {
const router = useRouter()
+ const projectId = +router.query['project']
const [isOpen, setOpen] = useState(false)
+ const { data: activeProject } = useProject(+projectId)
const { data: projects = [] } = useProjects({ enabled: isOpen })
return (
<ContextSelector
- label="Project"
- activeItem={activeProject}
+ id="project"
+ type="app"
+ toggleText={activeProject ? activeProject.name : 'Select project'}
items={projects}
onSelect={(project) => router.push(`/projects/${project.id}`)}
onToggle={setOpen}
isOpen={isOpen}
+ isFullHeight
/>
)
}
diff --git a/opendc-web/opendc-web-ui/src/components/context/TopologySelector.js b/opendc-web/opendc-web-ui/src/components/context/TopologySelector.js
index 9cae4cbf..355d9f4b 100644
--- a/opendc-web/opendc-web-ui/src/components/context/TopologySelector.js
+++ b/opendc-web/opendc-web-ui/src/components/context/TopologySelector.js
@@ -34,7 +34,8 @@ function TopologySelector({ activeTopology }) {
return (
<ContextSelector
- label="Topology"
+ id="topology"
+ toggleText={activeTopology ? `Topology: ${activeTopology.name}` : 'Select topology'}
activeItem={activeTopology}
items={topologies}
onSelect={(topology) => router.push(`/projects/${topology.project.id}/topologies/${topology.number}`)}
diff --git a/opendc-web/opendc-web-ui/src/components/portfolios/PortfolioResults.js b/opendc-web/opendc-web-ui/src/components/portfolios/PortfolioResults.js
index f63f0c7f..f50105ed 100644
--- a/opendc-web/opendc-web-ui/src/components/portfolios/PortfolioResults.js
+++ b/opendc-web/opendc-web-ui/src/components/portfolios/PortfolioResults.js
@@ -20,12 +20,11 @@
* SOFTWARE.
*/
-import React from 'react'
-import PropTypes from 'prop-types'
-import { Bar, CartesianGrid, ComposedChart, ErrorBar, ResponsiveContainer, Scatter, XAxis, YAxis } from 'recharts'
-import { AVAILABLE_METRICS, METRIC_NAMES, METRIC_UNITS } from '../../util/available-metrics'
import { mean, std } from 'mathjs'
-import approx from 'approximate-number'
+import React, { useMemo } from 'react'
+import PropTypes from 'prop-types'
+import { VictoryErrorBar } from 'victory-errorbar'
+import { METRIC_NAMES, METRIC_UNITS, AVAILABLE_METRICS } from '../../util/available-metrics'
import {
Bullseye,
Card,
@@ -41,15 +40,36 @@ import {
Spinner,
Title,
} from '@patternfly/react-core'
+import { Chart, ChartAxis, ChartBar, ChartTooltip } from '@patternfly/react-charts'
import { ErrorCircleOIcon, CubesIcon } from '@patternfly/react-icons'
import { usePortfolio } from '../../data/project'
import PortfolioResultInfo from './PortfolioResultInfo'
import NewScenario from './NewScenario'
-const PortfolioResults = ({ projectId, portfolioId }) => {
- const { status, data: scenarios = [] } = usePortfolio(projectId, portfolioId, {
- select: (portfolio) => portfolio.scenarios,
- })
+function PortfolioResults({ projectId, portfolioId }) {
+ const { status, data: portfolio } = usePortfolio(projectId, portfolioId)
+ const scenarios = useMemo(() => portfolio?.scenarios ?? [], [portfolio])
+
+ const label = ({ datum }) =>
+ `${datum.x}: ${datum.y.toLocaleString()} ± ${datum.errorY.toLocaleString()} ${METRIC_UNITS[datum.metric]}`
+ const selectedMetrics = new Set(portfolio?.targets?.metrics ?? [])
+ const dataPerMetric = useMemo(() => {
+ const dataPerMetric = {}
+ AVAILABLE_METRICS.forEach((metric) => {
+ dataPerMetric[metric] = scenarios
+ .filter((scenario) => scenario.job?.results)
+ .map((scenario) => ({
+ metric,
+ x: scenario.name,
+ y: mean(scenario.job.results[metric]),
+ errorY: std(scenario.job.results[metric]),
+ label,
+ }))
+ })
+ return dataPerMetric
+ }, [scenarios])
+
+ const categories = useMemo(() => ({ x: scenarios.map((s) => s.name).reverse() }), [scenarios])
if (status === 'loading') {
return (
@@ -94,59 +114,57 @@ const PortfolioResults = ({ projectId, portfolioId }) => {
)
}
- const dataPerMetric = {}
-
- AVAILABLE_METRICS.forEach((metric) => {
- dataPerMetric[metric] = scenarios
- .filter((scenario) => scenario.job?.results)
- .map((scenario) => ({
- name: scenario.name,
- value: mean(scenario.job.results[metric]),
- errorX: std(scenario.job.results[metric]),
- }))
- })
-
return (
<Grid hasGutter>
- {AVAILABLE_METRICS.map((metric) => (
- <GridItem xl={6} lg={12} key={metric}>
- <Card>
- <CardHeader>
- <CardActions>
- <PortfolioResultInfo metric={metric} />
- </CardActions>
- <CardTitle>{METRIC_NAMES[metric]}</CardTitle>
- </CardHeader>
- <CardBody>
- <ResponsiveContainer aspect={16 / 9} width="100%">
- <ComposedChart
- data={dataPerMetric[metric]}
- margin={{ left: 35, bottom: 15 }}
- layout="vertical"
- >
- <CartesianGrid strokeDasharray="3 3" />
- <XAxis
- tickFormatter={(tick) => approx(tick)}
- label={{ value: METRIC_UNITS[metric], position: 'bottom', offset: 0 }}
- type="number"
- />
- <YAxis dataKey="name" type="category" />
- <Bar dataKey="value" fill="#3399FF" isAnimationActive={false} />
- <Scatter dataKey="value" opacity={0} isAnimationActive={false}>
- <ErrorBar
- dataKey="errorX"
- width={10}
- strokeWidth={3}
- stroke="#FF6600"
- direction="x"
+ {AVAILABLE_METRICS.map(
+ (metric) =>
+ selectedMetrics.has(metric) && (
+ <GridItem xl={6} lg={12} key={metric}>
+ <Card>
+ <CardHeader>
+ <CardActions>
+ <PortfolioResultInfo metric={metric} />
+ </CardActions>
+ <CardTitle>{METRIC_NAMES[metric]}</CardTitle>
+ </CardHeader>
+ <CardBody>
+ <Chart
+ width={650}
+ height={250}
+ padding={{
+ top: 10,
+ bottom: 60,
+ left: 130,
+ }}
+ domainPadding={25}
+ >
+ <ChartAxis />
+ <ChartAxis
+ dependentAxis
+ showGrid
+ label={METRIC_UNITS[metric]}
+ fixLabelOverlap
+ />
+ <ChartBar
+ categories={categories}
+ data={dataPerMetric[metric]}
+ labelComponent={<ChartTooltip constrainToVisibleArea />}
+ barWidth={25}
+ horizontal
+ />
+ <VictoryErrorBar
+ categories={categories}
+ data={dataPerMetric[metric]}
+ errorY={(d) => d.errorY}
+ labelComponent={<></>}
+ horizontal
/>
- </Scatter>
- </ComposedChart>
- </ResponsiveContainer>
- </CardBody>
- </Card>
- </GridItem>
- ))}
+ </Chart>
+ </CardBody>
+ </Card>
+ </GridItem>
+ )
+ )}
</Grid>
)
}
diff --git a/opendc-web/opendc-web-ui/src/components/portfolios/ScenarioTable.js b/opendc-web/opendc-web-ui/src/components/portfolios/ScenarioTable.js
index 68647957..8dc52f7a 100644
--- a/opendc-web/opendc-web-ui/src/components/portfolios/ScenarioTable.js
+++ b/opendc-web/opendc-web-ui/src/components/portfolios/ScenarioTable.js
@@ -20,8 +20,9 @@
* SOFTWARE.
*/
+import { Bullseye } from '@patternfly/react-core'
import Link from 'next/link'
-import { Table, TableBody, TableHeader } from '@patternfly/react-table'
+import { TableComposable, Thead, Tr, Th, Tbody, Td, ActionsColumn } from '@patternfly/react-table'
import React from 'react'
import { Portfolio, Status } from '../../shapes'
import TableEmptyState from '../util/TableEmptyState'
@@ -33,65 +34,65 @@ function ScenarioTable({ portfolio, status }) {
const projectId = portfolio?.project?.id
const scenarios = portfolio?.scenarios ?? []
- const columns = ['Name', 'Topology', 'Trace', 'State']
- const rows =
- scenarios.length > 0
- ? scenarios.map((scenario) => {
- const topology = scenario.topology
-
- return [
- scenario.name,
- {
- title: topology ? (
- <Link href={`/projects/${projectId}/topologies/${topology.number}`}>
- <a>{topology.name}</a>
- </Link>
- ) : (
- 'Unknown Topology'
- ),
- },
- `${scenario.workload.trace.name} (${scenario.workload.samplingFraction * 100}%)`,
- { title: <ScenarioState state={scenario.job.state} /> },
- ]
- })
- : [
- {
- heightAuto: true,
- cells: [
- {
- props: { colSpan: 4 },
- title: (
- <TableEmptyState
- status={status}
- loadingTitle="Loading Scenarios"
- emptyTitle="No scenarios"
- emptyText="You have not created any scenario for this portfolio yet. Click the New Scenario button to create one."
- />
- ),
- },
- ],
- },
- ]
-
- const actionResolver = (_, { rowIndex }) => [
+ const actions = ({ number }) => [
{
title: 'Delete Scenario',
- onClick: (_, rowId) => deleteScenario({ projectId: projectId, number: scenarios[rowId].number }),
- isDisabled: rowIndex === 0,
+ onClick: () => deleteScenario({ projectId: projectId, number }),
+ isDisabled: number === 0,
},
]
return (
- <Table
- aria-label="Scenario List"
- variant="compact"
- cells={columns}
- rows={rows}
- actionResolver={scenarios.length > 0 ? actionResolver : undefined}
- >
- <TableHeader />
- <TableBody />
- </Table>
+ <TableComposable aria-label="Scenario List" variant="compact">
+ <Thead>
+ <Tr>
+ <Th>Name</Th>
+ <Th>Topology</Th>
+ <Th>Trace</Th>
+ <Th>State</Th>
+ </Tr>
+ </Thead>
+ <Tbody>
+ {scenarios.map((scenario) => (
+ <Tr key={scenario.id}>
+ <Td dataLabel="Name">{scenario.name}</Td>
+ <Td dataLabel="Topology">
+ {scenario.topology ? (
+ <Link href={`/projects/${projectId}/topologies/${scenario.topology.number}`}>
+ <a>{scenario.topology.name}</a>
+ </Link>
+ ) : (
+ 'Unknown Topology'
+ )}
+ ,
+ </Td>
+ <Td dataLabel="Workload">{`${scenario.workload.trace.name} (${
+ scenario.workload.samplingFraction * 100
+ }%)`}</Td>
+ <Td dataLabel="State">
+ <ScenarioState state={scenario.job.state} />
+ </Td>
+ <Td isActionCell>
+ <ActionsColumn items={actions(scenario)} />
+ </Td>
+ </Tr>
+ ))}
+ {scenarios.length === 0 && (
+ <Tr>
+ <Td colSpan={4}>
+ <Bullseye>
+ <TableEmptyState
+ status={status}
+ loadingTitle="Loading Scenarios"
+ emptyTitle="No scenarios"
+ emptyText="You have not created any scenario for this portfolio yet. Click the New Scenario button to create one."
+ />
+ </Bullseye>
+ </Td>
+ </Tr>
+ )}
+ </Tbody>
+ </TableComposable>
)
}
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>
)
}
diff --git a/opendc-web/opendc-web-ui/src/components/topologies/RoomTable.js b/opendc-web/opendc-web-ui/src/components/topologies/RoomTable.js
index 49e5f095..7f7b4171 100644
--- a/opendc-web/opendc-web-ui/src/components/topologies/RoomTable.js
+++ b/opendc-web/opendc-web-ui/src/components/topologies/RoomTable.js
@@ -1,63 +1,67 @@
-import { Button } from '@patternfly/react-core'
+import { Button, Bullseye } from '@patternfly/react-core'
import PropTypes from 'prop-types'
import React from 'react'
import { useDispatch } from 'react-redux'
import { useTopology } from '../../data/topology'
-import { Table, TableBody, TableHeader } from '@patternfly/react-table'
+import { Tr, Th, Thead, TableComposable, Td, ActionsColumn, Tbody } from '@patternfly/react-table'
import { deleteRoom } from '../../redux/actions/topology/room'
import TableEmptyState from '../util/TableEmptyState'
function RoomTable({ projectId, topologyId, onSelect }) {
const dispatch = useDispatch()
const { status, data: topology } = useTopology(projectId, topologyId)
-
const onDelete = (room) => dispatch(deleteRoom(room.id))
-
- const columns = ['Name', 'Tiles', 'Racks']
- const rows =
- topology?.rooms.length > 0
- ? topology.rooms.map((room) => {
- const tileCount = room.tiles.length
- const rackCount = room.tiles.filter((tile) => tile.rack).length
- return [
- {
- title: (
- <Button variant="link" isInline onClick={() => onSelect(room)}>
- {room.name}
- </Button>
- ),
- },
- tileCount === 1 ? '1 tile' : `${tileCount} tiles`,
- rackCount === 1 ? '1 rack' : `${rackCount} racks`,
- ]
- })
- : [
- {
- heightAuto: true,
- cells: [
- {
- props: { colSpan: 3 },
- title: <TableEmptyState status={status} loadingTitle="Loading Rooms" />,
- },
- ],
- },
- ]
-
- const actions =
- topology?.rooms.length > 0
- ? [
- {
- title: 'Delete room',
- onClick: (_, rowId) => onDelete(topology.rooms[rowId]),
- },
- ]
- : []
+ const actions = (room) => [
+ {
+ title: 'Delete room',
+ onClick: () => onDelete(room),
+ },
+ ]
return (
- <Table aria-label="Room list" variant="compact" cells={columns} rows={rows} actions={actions}>
- <TableHeader />
- <TableBody />
- </Table>
+ <TableComposable aria-label="Room list" variant="compact">
+ <Thead>
+ <Tr>
+ <Th>Name</Th>
+ <Th>Tiles</Th>
+ <Th>Racks</Th>
+ </Tr>
+ </Thead>
+ <Tbody>
+ {topology?.rooms.map((room) => {
+ const tileCount = room.tiles.length
+ const rackCount = room.tiles.filter((tile) => tile.rack).length
+ return (
+ <Tr key={room.id}>
+ <Td dataLabel="Name">
+ <Button variant="link" isInline onClick={() => onSelect(room)}>
+ {room.name}
+ </Button>
+ </Td>
+ <Td dataLabel="Tiles">{tileCount === 1 ? '1 tile' : `${tileCount} tiles`}</Td>
+ <Td dataLabel="Racks">{rackCount === 1 ? '1 rack' : `${rackCount} racks`}</Td>
+ <Td isActionCell>
+ <ActionsColumn items={actions(room)} />
+ </Td>
+ </Tr>
+ )
+ })}
+ {topology?.rooms.length === 0 && (
+ <Tr>
+ <Td colSpan={4}>
+ <Bullseye>
+ <TableEmptyState
+ status={status}
+ loadingTitle="Loading Rooms"
+ emptyTitle="No rooms"
+ emptyText="There are currently no rooms in this topology. Open the Floor Plan to create a room"
+ />
+ </Bullseye>
+ </Td>
+ </Tr>
+ )}
+ </Tbody>
+ </TableComposable>
)
}
diff --git a/opendc-web/opendc-web-ui/src/components/topologies/TopologyMap.js b/opendc-web/opendc-web-ui/src/components/topologies/TopologyMap.js
index 47235c7e..ff583750 100644
--- a/opendc-web/opendc-web-ui/src/components/topologies/TopologyMap.js
+++ b/opendc-web/opendc-web-ui/src/components/topologies/TopologyMap.js
@@ -55,9 +55,9 @@ function TopologyMap() {
</EmptyState>
</Bullseye>
) : (
- <Drawer isExpanded={isExpanded} className="full-height">
+ <Drawer isExpanded={isExpanded}>
<DrawerContent panelContent={panelContent}>
- <DrawerContentBody>
+ <DrawerContentBody style={{ position: 'relative' }}>
<MapStage hotkeysRef={hotkeysRef} />
<Collapse onClick={() => setExpanded(true)} />
</DrawerContentBody>
diff --git a/opendc-web/opendc-web-ui/src/components/topologies/map/MapStage.js b/opendc-web/opendc-web-ui/src/components/topologies/map/MapStage.js
index 7b96f548..8bf529b2 100644
--- a/opendc-web/opendc-web-ui/src/components/topologies/map/MapStage.js
+++ b/opendc-web/opendc-web-ui/src/components/topologies/map/MapStage.js
@@ -15,7 +15,7 @@ import Toolbar from './controls/Toolbar'
function MapStage({ hotkeysRef }) {
const reduxContext = useContext(ReactReduxContext)
const stageRef = useRef(null)
- const { width = 100, height = 100 } = useResizeObserver({ ref: stageRef.current?.attrs?.container })
+ const { width = 500, height = 500 } = useResizeObserver({ ref: stageRef.current?.attrs?.container })
const [[x, y], setPos] = useState([0, 0])
const [scale, setScale] = useState(1)
diff --git a/opendc-web/opendc-web-ui/src/components/topologies/map/MapStage.module.scss b/opendc-web/opendc-web-ui/src/components/topologies/map/MapStage.module.scss
index d879b4c8..47c3dde2 100644
--- a/opendc-web/opendc-web-ui/src/components/topologies/map/MapStage.module.scss
+++ b/opendc-web/opendc-web-ui/src/components/topologies/map/MapStage.module.scss
@@ -24,8 +24,6 @@
background-color: var(--pf-global--Color--light-200);
position: relative;
display: flex;
- justify-content: center;
- align-items: center;
width: 100%;
height: 100%;
}
diff --git a/opendc-web/opendc-web-ui/src/components/topologies/map/RackEnergyFillContainer.js b/opendc-web/opendc-web-ui/src/components/topologies/map/RackEnergyFillContainer.js
index be1f3e45..a1ca7426 100644
--- a/opendc-web/opendc-web-ui/src/components/topologies/map/RackEnergyFillContainer.js
+++ b/opendc-web/opendc-web-ui/src/components/topologies/map/RackEnergyFillContainer.js
@@ -3,24 +3,26 @@ import PropTypes from 'prop-types'
import { useSelector } from 'react-redux'
import RackFillBar from './elements/RackFillBar'
-function RackSpaceFillContainer({ tileId, ...props }) {
+function RackSpaceFillContainer({ rackId, ...props }) {
const fillFraction = useSelector((state) => {
+ const rack = state.topology.racks[rackId]
+ if (!rack) {
+ return 0
+ }
+
+ const { machines, cpus, gpus, memories, storages } = state.topology
let energyConsumptionTotal = 0
- const rack = state.topology.racks[state.topology.tiles[tileId].rack]
- const machineIds = rack.machines
- machineIds.forEach((machineId) => {
- if (machineId !== null) {
- const machine = state.topology.machines[machineId]
- machine.cpus.forEach((id) => (energyConsumptionTotal += state.topology.cpus[id].energyConsumptionW))
- machine.gpus.forEach((id) => (energyConsumptionTotal += state.topology.gpus[id].energyConsumptionW))
- machine.memories.forEach(
- (id) => (energyConsumptionTotal += state.topology.memories[id].energyConsumptionW)
- )
- machine.storages.forEach(
- (id) => (energyConsumptionTotal += state.topology.storages[id].energyConsumptionW)
- )
+
+ for (const machineId of rack.machines) {
+ if (!machineId) {
+ continue
}
- })
+ const machine = machines[machineId]
+ machine.cpus.forEach((id) => (energyConsumptionTotal += cpus[id].energyConsumptionW))
+ machine.gpus.forEach((id) => (energyConsumptionTotal += gpus[id].energyConsumptionW))
+ machine.memories.forEach((id) => (energyConsumptionTotal += memories[id].energyConsumptionW))
+ machine.storages.forEach((id) => (energyConsumptionTotal += storages[id].energyConsumptionW))
+ }
return Math.min(1, energyConsumptionTotal / rack.powerCapacityW)
})
@@ -28,7 +30,7 @@ function RackSpaceFillContainer({ tileId, ...props }) {
}
RackSpaceFillContainer.propTypes = {
- tileId: PropTypes.string.isRequired,
+ rackId: PropTypes.string.isRequired,
}
export default RackSpaceFillContainer
diff --git a/opendc-web/opendc-web-ui/src/components/topologies/map/RackSpaceFillContainer.js b/opendc-web/opendc-web-ui/src/components/topologies/map/RackSpaceFillContainer.js
index 0c15d54b..2039a9d3 100644
--- a/opendc-web/opendc-web-ui/src/components/topologies/map/RackSpaceFillContainer.js
+++ b/opendc-web/opendc-web-ui/src/components/topologies/map/RackSpaceFillContainer.js
@@ -25,13 +25,18 @@ import PropTypes from 'prop-types'
import { useSelector } from 'react-redux'
import RackFillBar from './elements/RackFillBar'
-function RackSpaceFillContainer({ tileId, ...props }) {
- const rack = useSelector((state) => state.topology.racks[state.topology.tiles[tileId].rack])
+function RackSpaceFillContainer({ rackId, ...props }) {
+ const rack = useSelector((state) => state.topology.racks[rackId])
+
+ if (!rack) {
+ return null
+ }
+
return <RackFillBar {...props} type="space" fillFraction={rack.machines.length / rack.capacity} />
}
RackSpaceFillContainer.propTypes = {
- tileId: PropTypes.string.isRequired,
+ rackId: PropTypes.string.isRequired,
}
export default RackSpaceFillContainer
diff --git a/opendc-web/opendc-web-ui/src/components/topologies/map/RoomContainer.js b/opendc-web/opendc-web-ui/src/components/topologies/map/RoomContainer.js
index 65189891..76785bea 100644
--- a/opendc-web/opendc-web-ui/src/components/topologies/map/RoomContainer.js
+++ b/opendc-web/opendc-web-ui/src/components/topologies/map/RoomContainer.js
@@ -27,15 +27,24 @@ import { goFromBuildingToRoom } from '../../../redux/actions/interaction-level'
import RoomGroup from './groups/RoomGroup'
function RoomContainer({ roomId, ...props }) {
- const state = useSelector((state) => {
- return {
- interactionLevel: state.interactionLevel,
- currentRoomInConstruction: state.construction.currentRoomInConstruction,
- room: state.topology.rooms[roomId],
- }
- })
+ const interactionLevel = useSelector((state) => state.interactionLevel)
+ const currentRoomInConstruction = useSelector((state) => state.construction.currentRoomInConstruction)
+ const room = useSelector((state) => state.topology.rooms[roomId])
const dispatch = useDispatch()
- return <RoomGroup {...props} {...state} onClick={() => dispatch(goFromBuildingToRoom(roomId))} />
+
+ if (!room) {
+ return null
+ }
+
+ return (
+ <RoomGroup
+ {...props}
+ interactionLevel={interactionLevel}
+ currentRoomInConstruction={currentRoomInConstruction}
+ room={room}
+ onClick={() => dispatch(goFromBuildingToRoom(roomId))}
+ />
+ )
}
RoomContainer.propTypes = {
diff --git a/opendc-web/opendc-web-ui/src/components/topologies/map/TileContainer.js b/opendc-web/opendc-web-ui/src/components/topologies/map/TileContainer.js
index 21be3c79..0788b894 100644
--- a/opendc-web/opendc-web-ui/src/components/topologies/map/TileContainer.js
+++ b/opendc-web/opendc-web-ui/src/components/topologies/map/TileContainer.js
@@ -28,9 +28,13 @@ import TileGroup from './groups/TileGroup'
function TileContainer({ tileId, ...props }) {
const interactionLevel = useSelector((state) => state.interactionLevel)
+ const dispatch = useDispatch()
const tile = useSelector((state) => state.topology.tiles[tileId])
- const dispatch = useDispatch()
+ if (!tile) {
+ return null
+ }
+
const onClick = (tile) => {
if (tile.rack) {
dispatch(goFromRoomToRack(tile.id))
diff --git a/opendc-web/opendc-web-ui/src/components/topologies/map/WallContainer.js b/opendc-web/opendc-web-ui/src/components/topologies/map/WallContainer.js
index 143f70c2..106d8d3d 100644
--- a/opendc-web/opendc-web-ui/src/components/topologies/map/WallContainer.js
+++ b/opendc-web/opendc-web-ui/src/components/topologies/map/WallContainer.js
@@ -27,7 +27,7 @@ import WallGroup from './groups/WallGroup'
function WallContainer({ roomId, ...props }) {
const tiles = useSelector((state) => {
- return state.topology.rooms[roomId].tiles.map((tileId) => state.topology.tiles[tileId])
+ return state.topology.rooms[roomId]?.tiles.map((tileId) => state.topology.tiles[tileId]) ?? []
})
return <WallGroup {...props} tiles={tiles} />
}
diff --git a/opendc-web/opendc-web-ui/src/components/topologies/map/groups/RackGroup.js b/opendc-web/opendc-web-ui/src/components/topologies/map/groups/RackGroup.js
index dad2d62d..ed942661 100644
--- a/opendc-web/opendc-web-ui/src/components/topologies/map/groups/RackGroup.js
+++ b/opendc-web/opendc-web-ui/src/components/topologies/map/groups/RackGroup.js
@@ -11,8 +11,8 @@ function RackGroup({ tile }) {
<Group>
<TileObject positionX={tile.positionX} positionY={tile.positionY} color={RACK_BACKGROUND_COLOR} />
<Group>
- <RackSpaceFillContainer tileId={tile.id} positionX={tile.positionX} positionY={tile.positionY} />
- <RackEnergyFillContainer tileId={tile.id} positionX={tile.positionX} positionY={tile.positionY} />
+ <RackSpaceFillContainer rackId={tile.rack} positionX={tile.positionX} positionY={tile.positionY} />
+ <RackEnergyFillContainer rackId={tile.rack} positionX={tile.positionX} positionY={tile.positionY} />
</Group>
</Group>
)
diff --git a/opendc-web/opendc-web-ui/src/components/topologies/sidebar/rack/RackSidebar.module.scss b/opendc-web/opendc-web-ui/src/components/topologies/sidebar/rack/RackSidebar.module.scss
index 6f258aec..f4c8829f 100644
--- a/opendc-web/opendc-web-ui/src/components/topologies/sidebar/rack/RackSidebar.module.scss
+++ b/opendc-web/opendc-web-ui/src/components/topologies/sidebar/rack/RackSidebar.module.scss
@@ -1,12 +1,14 @@
.sidebarContainer {
display: flex;
- height: 100%;
- max-height: 100%;
flex-direction: column;
+
+ height: 100%;
}
.machineListContainer {
- flex: 1;
- overflow-y: scroll;
+ overflow-y: auto;
+
+ flex: 1 0 300px;
+
margin-top: 10px;
}
diff --git a/opendc-web/opendc-web-ui/src/components/util/NavItemLink.js b/opendc-web/opendc-web-ui/src/components/util/NavItemLink.js
index c0d109bd..83301361 100644
--- a/opendc-web/opendc-web-ui/src/components/util/NavItemLink.js
+++ b/opendc-web/opendc-web-ui/src/components/util/NavItemLink.js
@@ -23,11 +23,13 @@
import Link from 'next/link'
import PropTypes from 'prop-types'
-const NavItemLink = ({ children, href, ...props }) => (
- <Link href={href}>
- <a {...props}>{children}</a>
- </Link>
-)
+function NavItemLink({ children, href, ...props }) {
+ return (
+ <Link href={href}>
+ <a {...props}>{children}</a>
+ </Link>
+ )
+}
NavItemLink.propTypes = {
children: PropTypes.node,