diff --git a/package-lock.json b/package-lock.json index 4ea38f814b7d809d9fff357f22638c48451e8b7c..d2bc55f33aa424948d6ba13f87b807af622bbfa2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "@testing-library/user-event": "^12.6.0", "ansi-to-html": "^0.7.2", "isomorphic-fetch": "^3.0.0", + "javascript-time-ago": "^2.5.9", "moment": "^2.29.1", "node-request-interceptor": "^0.6.3", "notistack": "^3.0.1", @@ -20567,6 +20568,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/javascript-time-ago": { + "version": "2.5.9", + "resolved": "https://registry.npmjs.org/javascript-time-ago/-/javascript-time-ago-2.5.9.tgz", + "integrity": "sha512-pQ8mNco/9g9TqWXWWjP0EWl6i/lAQScOyEeXy5AB+f7MfLSdgyV9BJhiOD1zrIac/lrxPYOWNbyl/IW8CW5n0A==", + "dependencies": { + "relative-time-format": "^1.1.6" + } + }, "node_modules/jest": { "version": "26.6.3", "resolved": "https://registry.npmjs.org/jest/-/jest-26.6.3.tgz", @@ -31976,6 +31985,11 @@ "node": ">= 0.10" } }, + "node_modules/relative-time-format": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/relative-time-format/-/relative-time-format-1.1.6.tgz", + "integrity": "sha512-aCv3juQw4hT1/P/OrVltKWLlp15eW1GRcwP1XdxHrPdZE9MtgqFpegjnTjLhi2m2WI9MT/hQQtE+tjEWG1hgkQ==" + }, "node_modules/remark-external-links": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/remark-external-links/-/remark-external-links-8.0.0.tgz", @@ -55363,6 +55377,14 @@ "iterate-iterator": "^1.0.1" } }, + "javascript-time-ago": { + "version": "2.5.9", + "resolved": "https://registry.npmjs.org/javascript-time-ago/-/javascript-time-ago-2.5.9.tgz", + "integrity": "sha512-pQ8mNco/9g9TqWXWWjP0EWl6i/lAQScOyEeXy5AB+f7MfLSdgyV9BJhiOD1zrIac/lrxPYOWNbyl/IW8CW5n0A==", + "requires": { + "relative-time-format": "^1.1.6" + } + }, "jest": { "version": "26.6.3", "resolved": "https://registry.npmjs.org/jest/-/jest-26.6.3.tgz", @@ -64049,6 +64071,11 @@ "integrity": "sha512-G08Dxvm4iDN3MLM0EsP62EDV9IuhXPR6blNz6Utcp7zyV3tr4HVNINt6MpaRWbxoOHT3Q7YN2P+jaHX8vUbgog==", "dev": true }, + "relative-time-format": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/relative-time-format/-/relative-time-format-1.1.6.tgz", + "integrity": "sha512-aCv3juQw4hT1/P/OrVltKWLlp15eW1GRcwP1XdxHrPdZE9MtgqFpegjnTjLhi2m2WI9MT/hQQtE+tjEWG1hgkQ==" + }, "remark-external-links": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/remark-external-links/-/remark-external-links-8.0.0.tgz", diff --git a/package.json b/package.json index c3551deb8a8cebd58c3ed98be1a11e49dc7ee32b..1c67920cb4487f177b29d3d74cc55cb601619d7a 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "@testing-library/user-event": "^12.6.0", "ansi-to-html": "^0.7.2", "isomorphic-fetch": "^3.0.0", + "javascript-time-ago": "^2.5.9", "moment": "^2.29.1", "node-request-interceptor": "^0.6.3", "notistack": "^3.0.1", diff --git a/src/components/IOC/GitRefLink/GitRefLink.js b/src/components/IOC/GitRefLink/GitRefLink.js index aed0d253917c23452533c0a93cfc37b84f6503dd..54e07f242e69cc9218757ad18a3e741b06f343d1 100644 --- a/src/components/IOC/GitRefLink/GitRefLink.js +++ b/src/components/IOC/GitRefLink/GitRefLink.js @@ -3,8 +3,9 @@ * Component providing link (and tag) to gitlab */ import React from "react"; -import { Typography, Link as MuiLink } from "@mui/material"; +import { Typography, Link as MuiLink, Stack } from "@mui/material"; import { string } from "prop-types"; +import LaunchIcon from "@mui/icons-material/Launch"; const propTypes = { /** String containing url to gitlab template */ @@ -13,7 +14,25 @@ const propTypes = { revision: string }; -export default function GitRefLink({ url, revision }) { +const defaultRenderLinkContents = (revision) => { + return ( + <Stack + flexDirection="row" + alignItems="center" + gap={0.5} + > + {revision} + <LaunchIcon fontSize="small" /> + </Stack> + ); +}; + +export default function GitRefLink({ + url, + revision, + renderLinkContents = defaultRenderLinkContents, + LinkProps +}) { // if no git reference has been entered if (!url || url.trim === "") { return <></>; @@ -39,8 +58,10 @@ export default function GitRefLink({ url, revision }) { target="_blank" rel="noreferrer" underline="hover" + aria-label={`External Git Link, revision ${revision}`} + {...LinkProps} > - {revision} + {renderLinkContents(revision)} </MuiLink> </Typography> ); diff --git a/src/components/IOC/IOCIcons/IOCIcons.js b/src/components/IOC/IOCIcons/IOCIcons.js index 450b88ee13ed3dac6b5d6e7b2c3fe770c42e4cc4..2cf9d58319aefe12a5dc2e63708e5655f4eff31f 100644 --- a/src/components/IOC/IOCIcons/IOCIcons.js +++ b/src/components/IOC/IOCIcons/IOCIcons.js @@ -1,5 +1,5 @@ import React from "react"; -import { Tooltip, Typography, useTheme } from "@mui/material"; +import { Typography, useTheme } from "@mui/material"; import { Brightness1, Cancel, @@ -12,6 +12,7 @@ import { } from "@mui/icons-material"; import Popover from "../../common/Popover"; import { AlertBannerList } from "@ess-ics/ce-ui-common"; +import LabeledIcon from "../../common/LabeledIcon/LabeledIcon"; function AlertMessagesPopoverContents({ title, alerts }) { // Filter out INFO level alerts @@ -48,27 +49,27 @@ export function IOCStatusIcon({ ioc }) { const iconConfig = { active: { title: "Active", - icon: <Brightness1 title="Active" /> + icon: Brightness1 }, disabled: { title: "Disabled", - icon: <Cancel title="Disabled" /> + icon: Cancel }, alert: { title: "Alert", - icon: <Error title="Alert" /> + icon: Error }, inactive: { title: "Inactive", - icon: <StopCircle title="Inactive" /> + icon: StopCircle }, "inactive alert": { title: "Alert", - icon: <ErrorOutline title="Active" /> + icon: ErrorOutline }, null: { title: "Unknown", - icon: <HelpOutline title="Unknown" /> + icon: HelpOutline } }; @@ -111,7 +112,13 @@ export function IOCStatusIcon({ ioc }) { } const iconTitle = iconConfig[status].title; - const statusIcon = iconConfig[status].icon; + const statusIcon = ( + <LabeledIcon + label={iconTitle} + Icon={iconConfig[status].icon} + labelPosition="none" + /> + ); return ( <div> @@ -148,7 +155,11 @@ export function IOCStatusIcon({ ioc }) { ); } -export function CommandTypeIcon({ type, colorStyle = "colors" }) { +export function CommandTypeIcon({ + type, + colorStyle = "colors", + labelPosition = "tooltip" +}) { const theme = useTheme(); const commandTypeColors = { @@ -169,24 +180,25 @@ export function CommandTypeIcon({ type, colorStyle = "colors" }) { const commandTypeIcons = { start: { title: "Set to active", - icon: <PlayCircleFilled /> + icon: PlayCircleFilled }, stop: { title: "Set to inactive", - icon: <PauseCircleFilled /> + icon: PauseCircleFilled } }; const iconStyle = { fill: colorConfig[colorStyle][type.toLowerCase()] }; const iconTitle = commandTypeIcons[type.toLowerCase()].title; - const statusIcon = commandTypeIcons[type.toLowerCase()].icon; + const StatusIcon = commandTypeIcons[type.toLowerCase()].icon; return ( - <Tooltip - title={iconTitle} - style={iconStyle} - > - {statusIcon} - </Tooltip> + <LabeledIcon + label={iconTitle} + LabelProps={{ nowrap: true }} + labelPosition={labelPosition} + Icon={StatusIcon} + IconProps={{ style: { iconStyle } }} + /> ); } diff --git a/src/components/IOC/IOCTable/IOCTable.spec.js b/src/components/IOC/IOCTable/IOCTable.spec.js index c2606c24e04f4febd24a9ad1bba543bb82332040..bac3b2bb9d0ba8084fb79be4987cfd6909cd8dfb 100644 --- a/src/components/IOC/IOCTable/IOCTable.spec.js +++ b/src/components/IOC/IOCTable/IOCTable.spec.js @@ -39,7 +39,7 @@ describe("HostTable", () => { .each(($el, index) => { if (index === 0) { const iconTitle = firstRowData[index]; - cy.wrap($el).find(`svg[title=${iconTitle}]`); + cy.wrap($el).findByRole("img", { name: iconTitle }); } else { cy.wrap($el) .find("p") diff --git a/src/components/Job/JobDetails.js b/src/components/Job/JobDetails.js index 22c817790daee504b5e7876df4e2d446e4af95bc..7d683b756444a7c1692811b9e5ca5890afb95ee4 100644 --- a/src/components/Job/JobDetails.js +++ b/src/components/Job/JobDetails.js @@ -10,8 +10,7 @@ import { import { KeyValueTable, SimpleAccordion, - AlertBannerList, - Stepper + AlertBannerList } from "@ess-ics/ce-ui-common"; import { JobBadge } from "./JobBadge"; import { DeploymentJobOutput } from "../deployments/DeploymentJobOutput"; @@ -20,21 +19,7 @@ import { formatDate } from "../common/Helper"; import GitRefLink from "../IOC/GitRefLink"; import AccessControl from "../auth/AccessControl"; import { AWXJobDetails } from "../../api/DataTypes"; - -const STATUS = { - queued: { - label: "Queued", - alertType: "info" - }, - running: { - label: "Running", - alertType: "info" - }, - successful: { - label: "Completed", - alertType: "success" - } -}; +import { JobStatusStepper } from "./JobStatus"; function createAlert(operation, job) { const jobDetails = new AWXJobDetails(operation.type, job); @@ -117,17 +102,6 @@ export function JobDetails({ operation, job }) { : "-" }; - const normalizedStatus = job?.status?.toLowerCase(); - - const currentStep = Object.keys(STATUS).indexOf(normalizedStatus); - var activeStep = STATUS[normalizedStatus] ? currentStep + 1 : currentStep; - const jobFailed = job?.status?.toLowerCase() === "failed"; - - // to show the correct failed step for an already finished operation - if (jobFailed) { - activeStep = operation?.startTime ? 1 : 0; - } - return ( <Grid container @@ -165,15 +139,7 @@ export function JobDetails({ operation, job }) { > <Card> <CardContent> - <div> - {job && ( - <Stepper - steps={Object.values(STATUS).map((it) => it.label)} - activeStep={activeStep} - isActiveStepFailed={jobFailed} - /> - )} - </div> + <div>{job && <JobStatusStepper {...{ job, operation }} />}</div> </CardContent> </Card> </Grid> diff --git a/src/components/Job/JobIcons.js b/src/components/Job/JobIcons.js index 20b4b48cb9f41160dfbc27fb74068133a2ab89b5..0f9ece62909646a8d94565f615b73384b2d252b4 100644 --- a/src/components/Job/JobIcons.js +++ b/src/components/Job/JobIcons.js @@ -9,14 +9,18 @@ export function JobStatusIcon({ status }) { return <DeploymentStatusIcon status={status} />; } -export function JobTypeIcon({ type, colorStyle }) { +export function JobTypeIcon({ type, colorStyle, labelPosition }) { const icon = ["START", "STOP"].includes(type) ? ( <CommandTypeIcon type={type} colorStyle={colorStyle} + labelPosition={labelPosition} /> ) : ( - <DeploymentTypeIcon type={type} /> + <DeploymentTypeIcon + type={type} + labelPosition={labelPosition} + /> ); return icon; diff --git a/src/components/Job/JobStatus.js b/src/components/Job/JobStatus.js new file mode 100644 index 0000000000000000000000000000000000000000..eca307f5ec533e05684f12edf422bad2b80571ca --- /dev/null +++ b/src/components/Job/JobStatus.js @@ -0,0 +1,90 @@ +import { Stepper } from "@ess-ics/ce-ui-common"; +import React from "react"; +import { + RotateRightOutlined, + CheckCircleOutline, + ErrorOutline, + HelpOutline +} from "@mui/icons-material"; +import LabeledIcon from "../common/LabeledIcon"; +import { theme } from "../../style/Theme"; + +const STEPPER_STATUS = { + queued: { + label: "Queued", + alertType: "info", + icon: RotateRightOutlined, + color: theme.palette.status.progress + }, + running: { + label: "Running", + alertType: "info", + icon: RotateRightOutlined, + color: theme.palette.status.progress + }, + successful: { + label: "Completed", + alertType: "success", + icon: CheckCircleOutline, + color: theme.palette.status.ok + } +}; + +const STATUS = { + ...STEPPER_STATUS, + failed: { + label: "Error", + alertType: "error", + icon: ErrorOutline, + color: theme.palette.status.fail + }, + unknown: { + label: "Unknown", + alertType: "info", + icon: HelpOutline, + color: theme.palette.disabled + } +}; + +export const JobStatusStepper = ({ job, operation }) => { + const normalizedStatus = job?.status?.toLowerCase(); + + const currentStep = Object.keys(STEPPER_STATUS).indexOf(normalizedStatus); + let activeStep = STEPPER_STATUS[normalizedStatus] + ? currentStep + 1 + : currentStep; + const jobFailed = job?.status?.toLowerCase() === "failed"; + + // to show the correct failed step for an already finished operation + if (jobFailed) { + activeStep = operation?.startTime ? 1 : 0; + } + + return ( + <Stepper + steps={Object.values(STEPPER_STATUS).map((it) => it.label)} + activeStep={activeStep} + isActiveStepFailed={jobFailed} + /> + ); +}; + +export const JobStatusIcon = ({ job }) => { + const activeStep = STATUS[job?.status?.toLowerCase()] ?? STATUS.unknown; + + return ( + <LabeledIcon + label={activeStep.label} + labelPosition="right" + LabelProps={{ + color: activeStep.alertType === "error" ? "status.fail" : "inherit" + }} + Icon={activeStep.icon} + IconProps={{ + style: { + fill: activeStep.color + } + }} + /> + ); +}; diff --git a/src/components/Job/JobTable.js b/src/components/Job/JobTable.js deleted file mode 100644 index 42d3f372fb5946dccfdde903674ba4fcd8abf389..0000000000000000000000000000000000000000 --- a/src/components/Job/JobTable.js +++ /dev/null @@ -1,103 +0,0 @@ -import React from "react"; -import { Table } from "@ess-ics/ce-ui-common"; -import { Box, Grid } from "@mui/material"; -import { EllipsisTextLink, noWrapText } from "../common/Helper"; -import { JobTypeIcon } from "./JobIcons"; -import { formatDate } from "../common/Helper"; - -const jobLogColumns = [ - { - field: "type", - headerName: "Type", - headerAlign: "center", - align: "center", - maxWidth: 60 - }, - { field: "ioc", headerName: "IOC name", minWidth: 120 }, - { field: "version", headerName: "Revision", minWidth: 120 }, - { field: "host", headerName: "Host", minWidth: 120 }, - { field: "user", headerName: "User", minWidth: 100 }, - { - field: "start", - headerName: "Time", - minWidth: 120, - sortable: false, - headerAlign: "right", - align: "right" - } -]; - -const userPageJobLogColumns = [ - ...jobLogColumns.filter((it) => it.field !== "user") -]; - -function createTableRow(job, colorStyle) { - return { - id: job.id, - type: ( - <Grid - container - direction="column" - justifyContent="center" - alignItems="center" - > - <JobTypeIcon - type={job.type} - colorStyle={colorStyle} - /> - </Grid> - ), - ioc: ( - <EllipsisTextLink - text={job.iocName} - href={`/iocs/${job.iocId}`} - /> - ), - version: job.gitReference, - host: job.host?.hostName ? ( - <EllipsisTextLink - text={job.host?.hostName} - href={`/hosts/${job?.host?.externalHostId}`} - /> - ) : null, - user: ( - <EllipsisTextLink - text={job.createdBy} - href={`/user/${job.createdBy}`} - /> - ), - start: noWrapText(formatDate(job.createdAt)) - }; -} - -function createTableRowJobLog(job) { - return createTableRow(job, "black"); -} - -export function JobTable({ - jobs, - rowType = "jobLog", - pagination, - onPage, - loading -}) { - const tableTypeSpecifics = { - jobLog: [jobLogColumns, createTableRowJobLog], - userPageJobLog: [userPageJobLogColumns, createTableRowJobLog] - }; - - const [columns, createRow] = tableTypeSpecifics[rowType]; - - return ( - <Box sx={{ pt: 2 }}> - <Table - columns={columns} - rows={jobs?.map((job) => createRow(job))} - pagination={pagination} - onPage={onPage} - loading={loading} - disableHover - /> - </Box> - ); -} diff --git a/src/components/Job/JobTable/JobGitRefLink.js b/src/components/Job/JobTable/JobGitRefLink.js new file mode 100644 index 0000000000000000000000000000000000000000..5fe2a6a65e36f923d6650ec741c639f1ef7be491 --- /dev/null +++ b/src/components/Job/JobTable/JobGitRefLink.js @@ -0,0 +1,49 @@ +import React, { useContext, useMemo } from "react"; +import GitRefLink from "../../IOC/GitRefLink/GitRefLink"; +import { apiContext } from "../../../api/DeployApi"; +import { useAPIMethod } from "@ess-ics/ce-ui-common"; +import { Skeleton } from "@mui/material"; + +export const JobGitRefLink = ({ job }) => { + const client = useContext(apiContext); + const projectParams = useMemo( + () => ({ + project_id: job?.gitProjectId + }), + [job] + ); + + const { value: project } = useAPIMethod({ + fcn: client.apis.Git.gitProjectDetails, + params: projectParams + }); + + const shortRefParams = useMemo( + () => ({ + project_id: job?.gitProjectId, + reference: job?.gitReference + }), + [job] + ); + const { value: commits } = useAPIMethod({ + fcn: client.apis.Git.listTagsAndCommitIds, + params: shortRefParams + }); + + if (!project || !commits) { + return <Skeleton width={100} />; + } + + const gitRef = commits?.at(0)?.shortReference ?? job.gitReference; + + return ( + <GitRefLink + url={project?.projectUrl} + revision={gitRef} + renderLinkContents={(revision) => revision} + LinkProps={{ + "aria-label": `External Git Link, project ${project.name}, revision ${gitRef}` + }} + /> + ); +}; diff --git a/src/components/Job/JobTable/JobStatusColumn.js b/src/components/Job/JobTable/JobStatusColumn.js new file mode 100644 index 0000000000000000000000000000000000000000..a82aa04e2727efb25f01dc3ee033f26950d9671c --- /dev/null +++ b/src/components/Job/JobTable/JobStatusColumn.js @@ -0,0 +1,68 @@ +import React from "react"; +import EventIcon from "@mui/icons-material/Event"; +import AccessTimeIcon from "@mui/icons-material/AccessTime"; +import { Stack, Tooltip } from "@mui/material"; +import { formatDate, timeAgo } from "../../common/Helper"; +import LabeledIcon from "../../common/LabeledIcon"; +import moment from "moment"; +import { JobStatusIcon } from "../JobStatus"; + +const formattedDuration = ({ startDate, finishDate }) => { + if (startDate && finishDate) { + return moment + .utc(finishDate.getTime() - startDate.getTime()) + .format("HH:mm:ss"); + } else { + return null; + } +}; + +export const JobStatusColumn = ({ job }) => { + const createOrStartDate = new Date(job?.startTime ?? job.createdAt); + const formattedCreateOrStartDate = `${ + job?.startTime ? "Started" : "Created" + } ${timeAgo.format(new Date(createOrStartDate))}`; + + const duration = formattedDuration({ + finishDate: new Date(job.finishedAt), + startDate: new Date(createOrStartDate) + }); + + return ( + <Stack> + <Stack gap={0.5}> + <JobStatusIcon job={job} /> + <Stack> + <Tooltip + title={`${formatDate(createOrStartDate)}`} + aria-label={`${formattedCreateOrStartDate}, on ${formatDate( + createOrStartDate + )}`} + > + <LabeledIcon + label={formattedCreateOrStartDate} + LabelProps={{ variant: "body2" }} + labelPosition="right" + Icon={EventIcon} + IconProps={{ fontSize: "small" }} + /> + </Tooltip> + {job?.finishedAt ? ( + <Tooltip + title={`Finished ${job.finishedAt}`} + aria-label={`Finshed ${job.finishedAt}, after ${duration}`} + > + <LabeledIcon + label={`${duration}`} + LabelProps={{ variant: "body2" }} + labelPosition="right" + Icon={AccessTimeIcon} + IconProps={{ fontSize: "small" }} + /> + </Tooltip> + ) : null} + </Stack> + </Stack> + </Stack> + ); +}; diff --git a/src/components/Job/JobTable/JobSummary.js b/src/components/Job/JobTable/JobSummary.js new file mode 100644 index 0000000000000000000000000000000000000000..3383abea67e7c0bee7bf121fbfa93a3d5aaeca70 --- /dev/null +++ b/src/components/Job/JobTable/JobSummary.js @@ -0,0 +1,40 @@ +import { Chip, Link, Stack } from "@mui/material"; +import React from "react"; +import CommitIcon from "@mui/icons-material/Commit"; +import { JobGitRefLink } from "./JobGitRefLink"; +import { JobTypeIcon } from "../JobIcons"; + +export const JobSummary = ({ job, colorStyle }) => { + return ( + <Stack> + <JobTypeIcon + type={job.type} + colorStyle={colorStyle} + labelPosition="right" + /> + <Link + href={`/iocs/${job.iocId}`} + aria-label={`IOC ${job.iocName}`} + > + {job.iocName} + </Link> + <Stack + flexDirection="row" + gap={0.5} + alignItems="center" + > + <Link + href={`/jobs/${job?.id}`} + aria-label={`Job ID ${job.id}`} + >{`#${job.id}`}</Link> + {job.type === "DEPLOY" ? ( + <Chip + size="small" + icon={<CommitIcon />} + label={<JobGitRefLink job={job} />} + /> + ) : null} + </Stack> + </Stack> + ); +}; diff --git a/src/components/Job/JobTable/JobTable.js b/src/components/Job/JobTable/JobTable.js new file mode 100644 index 0000000000000000000000000000000000000000..13e3dfdedd1cfda970078d5f01c0947d6cb77b0f --- /dev/null +++ b/src/components/Job/JobTable/JobTable.js @@ -0,0 +1,78 @@ +import React from "react"; +import { Table } from "@ess-ics/ce-ui-common"; +import { JobStatusColumn } from "./JobStatusColumn"; +import { JobSummary } from "./JobSummary"; +import { UserAvatar } from "../../common/User/UserAvatar"; +import { Link } from "@mui/material"; + +const jobLogColumns = [ + { + field: "status", + headerName: "Status" + }, + { + field: "job", + headerName: "Job" + }, + { + field: "host", + headerName: "Host" + }, + { + field: "user", + headerName: "User", + align: "center", + headerAlign: "center" + } +]; + +const userPageJobLogColumns = [ + ...jobLogColumns.filter((it) => it.field !== "user") +]; + +function createTableRow(job, colorStyle) { + return { + id: job.id, + status: <JobStatusColumn {...{ job }} />, + job: <JobSummary {...{ job, colorStyle }} />, + host: job.host?.hostName ? ( + <Link + href={`/hosts/${job?.host?.externalHostId}`} + aria-label={`Host ${job.host?.hostName}`} + > + {job.host?.hostName} + </Link> + ) : null, + user: <UserAvatar username={job.createdBy} /> + }; +} + +function createTableRowJobLog(job) { + return createTableRow(job, "black"); +} + +export function JobTable({ + jobs, + rowType = "jobLog", + pagination, + onPage, + loading +}) { + const tableTypeSpecifics = { + jobLog: [jobLogColumns, createTableRowJobLog], + userPageJobLog: [userPageJobLogColumns, createTableRowJobLog] + }; + + const [columns, createRow] = tableTypeSpecifics[rowType]; + + return ( + <Table + columns={columns} + rows={jobs?.map((job) => createRow(job))} + pagination={pagination} + onPage={onPage} + loading={loading} + rowHeight={90} + /> + ); +} diff --git a/src/components/Job/JobTable/index.js b/src/components/Job/JobTable/index.js new file mode 100644 index 0000000000000000000000000000000000000000..539901071250e3d21ff44159d7dd070a7e96ca0e --- /dev/null +++ b/src/components/Job/JobTable/index.js @@ -0,0 +1,4 @@ +import { JobTable } from "./JobTable"; + +export { JobTable }; +export default JobTable; diff --git a/src/components/common/Helper.js b/src/components/common/Helper.js index d71c31046bc5c2a3b1172e4f113b488d4b043502..0983f74111e37844d4da510bb4ba91b5260007f6 100644 --- a/src/components/common/Helper.js +++ b/src/components/common/Helper.js @@ -2,6 +2,8 @@ import React, { useState, useEffect } from "react"; import moment from "moment"; import { alpha } from "@mui/material/styles"; import { Link, Tooltip, Typography, styled } from "@mui/material"; +import TimeAgo from "javascript-time-ago"; +import en from "javascript-time-ago/locale/en"; export function formatToList(items) { if (!items) return null; @@ -205,3 +207,6 @@ export function pick(obj, keys) { } return ret; } + +TimeAgo.addDefaultLocale(en); +export const timeAgo = new TimeAgo("en-GB"); diff --git a/src/components/common/LabeledIcon/LabeledIcon.js b/src/components/common/LabeledIcon/LabeledIcon.js new file mode 100644 index 0000000000000000000000000000000000000000..d6e83f470fb8e0ece061959b06bc8455526bbbee --- /dev/null +++ b/src/components/common/LabeledIcon/LabeledIcon.js @@ -0,0 +1,76 @@ +import React from "react"; +import { node, oneOfType, string, oneOf, object } from "prop-types"; +import { Stack, Tooltip, Typography } from "@mui/material"; + +/** + * Component that renders an icon with a label. + * + * @param {object} Icon icon reference (provided as e.g. TheIcon, not as JSX e.g. <TheIcon />) + * @param {object} IconProps additional props to pass to the Icon. Accessibility props are already provided. + * @param {string} label text or Component to render as the label for the icon + * @param {object} LabelProps additional props to provide to the label when rendered as a Typography component + * (not applicable when positioned as "tooltip" or "none"). + * @param {string} labelPosition describes position of the label. "tooltip" provides the label as a tooltip, + * "left" and "right" positions the label to the left or right respectively, and "none" provides no visible label + * (but still includes a11y attributes so that it is visible to screen readers) + */ +const LabeledIcon = React.forwardRef((props, ref) => { + const { + className, + Icon, + IconProps, + label, + LabelProps, + labelPosition = "tooltip", + ...rest + } = props; + + if (labelPosition === "tooltip") { + return ( + <Tooltip + title={label} + className={className} + > + <Icon + title={label} + titleAccess={label} + {...IconProps} + /> + </Tooltip> + ); + } + if (labelPosition === "none") { + return ( + <Icon + title={label} + titleAccess={label} + {...IconProps} + /> + ); + } + + const renderedLabel = <Typography {...LabelProps}>{label}</Typography>; + return ( + <Stack + flexDirection="row" + gap={0.5} + alignItems="center" + className={className} + ref={ref} + {...rest} + > + {labelPosition === "left" ? renderedLabel : null} + <Icon {...IconProps} /> + {labelPosition === "right" ? renderedLabel : null} + </Stack> + ); +}); +LabeledIcon.propTypes = { + Icon: node, + IconProps: object, + label: oneOfType([string, node]), + LabelProps: object, + labelPosition: oneOf(["tooltip", "right", "left", "none"]) +}; + +export default LabeledIcon; diff --git a/src/components/common/LabeledIcon/index.js b/src/components/common/LabeledIcon/index.js new file mode 100644 index 0000000000000000000000000000000000000000..484513514a310d79980055d46a2b48dd6bc4d55e --- /dev/null +++ b/src/components/common/LabeledIcon/index.js @@ -0,0 +1,4 @@ +import LabeledIcon from "./LabeledIcon"; + +export { LabeledIcon }; +export default LabeledIcon; diff --git a/src/components/common/User/UserAvatar.js b/src/components/common/User/UserAvatar.js new file mode 100644 index 0000000000000000000000000000000000000000..05b94e06371f9d250c11e0ee93ee884da24d5809 --- /dev/null +++ b/src/components/common/User/UserAvatar.js @@ -0,0 +1,76 @@ +import { useAPIMethod } from "@ess-ics/ce-ui-common/dist/hooks/API"; +import { Avatar, Tooltip, styled } from "@mui/material"; +import React, { useContext, useEffect, useMemo } from "react"; +import { apiContext } from "../../../api/DeployApi"; +import { Link } from "react-router-dom"; +import { userContext } from "@ess-ics/ce-ui-common/dist/contexts/User"; + +const unpacker = (data) => { + if (data) { + return data[0]; + } else { + return null; + } +}; + +export const UserAvatar = styled(({ username, size = 48 }) => { + const { user: userChanged } = useContext(userContext); + + const client = useContext(apiContext); + + const params = useMemo( + () => ({ + user_name: username + }), + [username] + ); + + const { + value: user, + wrapper: getUser, + loading, + error + } = useAPIMethod({ + fcn: client.apis.Git.infoFromUserName, + params, + unpacker + }); + + useEffect(() => { + getUser(); + }, [userChanged, getUser]); + + const renderedAvatar = (() => { + if (error || loading || !user?.avatar) { + return ( + <Avatar + sx={{ width: size, height: size }} + alt={username} + variant="circle" + > + {username.toUpperCase().slice(0, 2)} + </Avatar> + ); + } + return ( + <Avatar + sx={{ width: size, height: size }} + src={user?.avatar} + alt={username} + variant="circle" + /> + ); + })(); + + return ( + <Tooltip title={username}> + <Link + to={`/user/${username}`} + style={{ textDecoration: "none" }} + aria-label={`User Profile ${username}`} + > + {renderedAvatar} + </Link> + </Tooltip> + ); +})({}); diff --git a/src/components/common/User/index.js b/src/components/common/User/index.js index 44989ea76755d9b0c09beb68eba9323e55729505..416c1fc44ff1a708b7b8c28bf8597077ee8f2184 100644 --- a/src/components/common/User/index.js +++ b/src/components/common/User/index.js @@ -1,5 +1,6 @@ import { UserIocList } from "./UserIOCList"; import { UserOperationList } from "./UserOperationList"; import { UserProfile } from "./UserProfile"; +import { UserAvatar } from "./UserAvatar"; -export { UserIocList, UserOperationList, UserProfile }; +export { UserIocList, UserOperationList, UserProfile, UserAvatar }; diff --git a/src/components/deployments/DeploymentIcons.js b/src/components/deployments/DeploymentIcons.js index c591afcb1a31eaff243f048da27897dce4444486..b7794ff07b7bfdd892d79a119fa04c88752d509d 100644 --- a/src/components/deployments/DeploymentIcons.js +++ b/src/components/deployments/DeploymentIcons.js @@ -7,26 +7,26 @@ import { AddCircleOutline, RemoveCircleOutline } from "@mui/icons-material"; -import { Tooltip } from "@mui/material"; import { theme } from "../../style/Theme"; +import { LabeledIcon } from "../common/LabeledIcon"; export function DeploymentStatusIcon({ status }) { const deploymentStatusIcons = { successful: { title: "Successful", - icon: <CheckCircleOutline /> + icon: CheckCircleOutline }, failed: { title: "Failed", - icon: <ErrorOutline /> + icon: ErrorOutline }, running: { title: "Running", - icon: <RotateRightOutlined /> + icon: RotateRightOutlined }, queued: { title: "Queued", - icon: <QueueOutlined /> + icon: QueueOutlined } }; @@ -43,37 +43,38 @@ export function DeploymentStatusIcon({ status }) { const statusIcon = deploymentStatusIcons[state].icon; return ( - <Tooltip - title={iconTitle} - style={iconStyle} - > - {statusIcon} - </Tooltip> + <LabeledIcon + label={iconTitle} + labelPosition="tooltip" + Icon={statusIcon} + IconProps={{ style: iconStyle }} + /> ); } -export function DeploymentTypeIcon({ type }) { +export function DeploymentTypeIcon({ type, labelPosition = "tooltip" }) { const deploymentTypeIcons = { deploy: { title: "Deployment", - icon: <AddCircleOutline /> + icon: AddCircleOutline }, undeploy: { title: "Undeployment", - icon: <RemoveCircleOutline /> + icon: RemoveCircleOutline } }; const iconStyle = { fill: theme.palette.status.icons }; const iconTitle = deploymentTypeIcons[type.toLowerCase()].title; - const statusIcon = deploymentTypeIcons[type.toLowerCase()].icon; + const StatusIcon = deploymentTypeIcons[type.toLowerCase()].icon; return ( - <Tooltip - title={iconTitle} - style={iconStyle} - > - {statusIcon} - </Tooltip> + <LabeledIcon + label={iconTitle} + LabelProps={{ noWrap: true }} + labelPosition={labelPosition} + Icon={StatusIcon} + IconProps={{ style: { iconStyle } }} + /> ); } diff --git a/src/components/host/HostIcons.js b/src/components/host/HostIcons.js index 38721d10317ba7effae147572021412569f03006..c0b66b1cd45d0efb32a928ccb22d242d0e4c053e 100644 --- a/src/components/host/HostIcons.js +++ b/src/components/host/HostIcons.js @@ -1,5 +1,5 @@ import React from "react"; -import { Tooltip, useTheme } from "@mui/material"; +import { useTheme } from "@mui/material"; import { Brightness1, ErrorOutline, @@ -7,6 +7,7 @@ import { StopCircle, HelpOutline } from "@mui/icons-material"; +import LabeledIcon from "../common/LabeledIcon"; export function HostStatusIcon({ host }) { const theme = useTheme(); @@ -16,23 +17,23 @@ export function HostStatusIcon({ host }) { const iconConfig = { available: { title: "Active", - icon: <Brightness1 /> + icon: Brightness1 }, alert: { title: "Alert", - icon: <Error /> + icon: Error }, inactive: { title: "Inactive", - icon: <StopCircle /> + icon: StopCircle }, "inactive alert": { title: "Alert", - icon: <ErrorOutline /> + icon: ErrorOutline }, null: { title: "Unknown", - icon: <HelpOutline /> + icon: HelpOutline } }; @@ -77,11 +78,11 @@ export function HostStatusIcon({ host }) { const statusIcon = iconConfig[state].icon; return ( - <Tooltip - title={iconTitle} - style={iconStyle} - > - {statusIcon} - </Tooltip> + <LabeledIcon + label={iconTitle} + labelPosition="tooltip" + Icon={statusIcon} + IconProps={{ style: iconStyle }} + /> ); } diff --git a/src/components/host/HostTable.spec.js b/src/components/host/HostTable.spec.js index 92e2e74317de4e2087063e391c6df3106d263a7a..6287fad1470bfd9e112428c600c8fea807a3f025 100644 --- a/src/components/host/HostTable.spec.js +++ b/src/components/host/HostTable.spec.js @@ -38,7 +38,7 @@ describe("HostTable", () => { .each(($el, index) => { if (index === 0) { const iconTitle = firstRowData[index]; - cy.wrap($el).findByLabelText(iconTitle).should("exist"); + cy.wrap($el).findByRole("img", { name: iconTitle }); } else { cy.wrap($el) .find("p") diff --git a/src/components/records/RecordIcons.js b/src/components/records/RecordIcons.js index b6922f9f2452a2e06c8c2f18f663888a66608481..e9f81c9a52010f0a951ab8c0cebea0009854b0fb 100644 --- a/src/components/records/RecordIcons.js +++ b/src/components/records/RecordIcons.js @@ -1,5 +1,6 @@ import React from "react"; -import { Tooltip, useTheme } from "@mui/material"; +import { useTheme } from "@mui/material"; +import LabeledIcon from "../common/LabeledIcon"; import { Brightness1, StopCircle, HelpOutline } from "@mui/icons-material"; export function RecordStatusIcon({ record }) { @@ -10,15 +11,15 @@ export function RecordStatusIcon({ record }) { const iconConfig = { active: { title: "Active", - icon: <Brightness1 /> + icon: Brightness1 }, inactive: { title: "Inactive", - icon: <StopCircle /> + icon: StopCircle }, null: { title: "Unknown", - icon: <HelpOutline /> + icon: HelpOutline } }; @@ -28,11 +29,11 @@ export function RecordStatusIcon({ record }) { const statusIcon = iconConfig[state].icon; return ( - <Tooltip - title={iconTitle} - style={iconStyle} - > - {statusIcon} - </Tooltip> + <LabeledIcon + label={iconTitle} + labelPosition="tooltip" + Icon={statusIcon} + IconProps={{ style: iconStyle }} + /> ); } diff --git a/src/stories/components/common/LabeledIcon/LabeledIcon.stories.js b/src/stories/components/common/LabeledIcon/LabeledIcon.stories.js new file mode 100644 index 0000000000000000000000000000000000000000..f4251be32e517cebef9adf29f19d32b6064a7c5d --- /dev/null +++ b/src/stories/components/common/LabeledIcon/LabeledIcon.stories.js @@ -0,0 +1,33 @@ +import React from "react"; +import LabeledIcon from "../../../../components/common/LabeledIcon/LabeledIcon"; +import AccountCircleIcon from "@mui/icons-material/AccountCircle"; +import { RouterHarness } from "../../../../mocks/AppHarness"; + +export default { + title: "common/LabeledIcon", + argTypes: { + labelPosition: { + options: ["none", "right", "left"], + control: { type: "radio" } + } + } +}; + +const Template = (args) => { + return ( + <RouterHarness> + <LabeledIcon + Icon={AccountCircleIcon} + {...args} + /> + </RouterHarness> + ); +}; + +export const Default = (args) => <Template {...args} />; +Default.args = { + label: "Account", + LabelProps: { fontWeight: "bold" }, + labelPosition: "right", + IconProps: { color: "primary" } +}; diff --git a/src/views/jobs/JobListView.js b/src/views/jobs/JobListView.js index 0d6ade23e747eae1f53987dbfe66c48b37a0288a..f57109c9ea62d15efe61b136230e9f813541a42f 100644 --- a/src/views/jobs/JobListView.js +++ b/src/views/jobs/JobListView.js @@ -12,7 +12,7 @@ import { deserialize } from "../../components/common/URLState/URLState"; import { usePagination } from "../../hooks/pagination"; -import { JobTable } from "../../components/Job"; +import { JobTable } from "../../components/Job/JobTable"; import { apiContext } from "../../api/DeployApi"; export function JobListView() {