From a0773a49a476dcdb6c98b4bc4fb7971a5bcb5da7 Mon Sep 17 00:00:00 2001 From: Christina Jenks <christina.jenks@ess.eu> Date: Mon, 14 Aug 2023 09:55:29 +0000 Subject: [PATCH] CE-1942: convert HostTable to common table --- cypress/support/commands.js | 7 +- package-lock.json | 184 ++++++++++++++++++++++++++ package.json | 1 + src/components/common/Helper.js | 9 +- src/components/host/HostTable.js | 68 +++++----- src/components/host/HostTable.spec.js | 13 +- src/hooks/pagination.js | 57 ++++++++ src/views/host/HostListView.js | 112 ++++++++++------ 8 files changed, 360 insertions(+), 91 deletions(-) create mode 100644 src/hooks/pagination.js diff --git a/cypress/support/commands.js b/cypress/support/commands.js index 9142c316..1d4efa7f 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -23,4 +23,9 @@ // // -- This will overwrite an existing command -- // Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) -Cypress.Commands.add("login", () => {cy.setCookie("ce-deploy-auth", "DEADBEEF")}); \ No newline at end of file + +// Import React Testing Library commands (select by a11y role etc) +import "@testing-library/cypress/add-commands"; + +// import custom auth commands +Cypress.Commands.add("login", () => {cy.setCookie("ce-deploy-auth", "DEADBEEF")}); diff --git a/package-lock.json b/package-lock.json index 48f09c3f..6a1b8073 100644 --- a/package-lock.json +++ b/package-lock.json @@ -48,6 +48,7 @@ "@storybook/react": "^6.4.22", "@storybook/testing-library": "^0.0.11", "@storybook/testing-react": "^1.2.4", + "@testing-library/cypress": "^9.0.0", "core-js": "^3.22.3", "cypress": "^12.8.0", "cypress-cucumber-preprocessor": "^4.1.0", @@ -7582,6 +7583,112 @@ "url": "https://github.com/sponsors/gregberge" } }, + "node_modules/@testing-library/cypress": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@testing-library/cypress/-/cypress-9.0.0.tgz", + "integrity": "sha512-c1XiCGeHGGTWn0LAU12sFUfoX3qfId5gcSE2yHode+vsyHDWraxDPALjVnHd4/Fa3j4KBcc5k++Ccy6A9qnkMA==", + "dev": true, + "dependencies": { + "@babel/runtime": "^7.14.6", + "@testing-library/dom": "^8.1.0" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "cypress": "^12.0.0" + } + }, + "node_modules/@testing-library/cypress/node_modules/@testing-library/dom": { + "version": "8.20.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-8.20.1.tgz", + "integrity": "sha512-/DiOQ5xBxgdYRC8LNk7U+RWat0S3qRLeIw3ZIkMQ9kkVlRmwD/Eg8k8CqIpD6GW7u20JIUOfMKbxtiLutpjQ4g==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.1.3", + "chalk": "^4.1.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@testing-library/cypress/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@testing-library/cypress/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@testing-library/cypress/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/@testing-library/cypress/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/@testing-library/cypress/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@testing-library/cypress/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/@testing-library/dom": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-9.0.1.tgz", @@ -45125,6 +45232,83 @@ "loader-utils": "^2.0.0" } }, + "@testing-library/cypress": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@testing-library/cypress/-/cypress-9.0.0.tgz", + "integrity": "sha512-c1XiCGeHGGTWn0LAU12sFUfoX3qfId5gcSE2yHode+vsyHDWraxDPALjVnHd4/Fa3j4KBcc5k++Ccy6A9qnkMA==", + "dev": true, + "requires": { + "@babel/runtime": "^7.14.6", + "@testing-library/dom": "^8.1.0" + }, + "dependencies": { + "@testing-library/dom": { + "version": "8.20.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-8.20.1.tgz", + "integrity": "sha512-/DiOQ5xBxgdYRC8LNk7U+RWat0S3qRLeIw3ZIkMQ9kkVlRmwD/Eg8k8CqIpD6GW7u20JIUOfMKbxtiLutpjQ4g==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.1.3", + "chalk": "^4.1.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "pretty-format": "^27.0.2" + } + }, + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "requires": { + "color-convert": "^2.0.1" + } + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + } + } + }, "@testing-library/dom": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-9.0.1.tgz", diff --git a/package.json b/package.json index 6a4142db..d2a88d0b 100644 --- a/package.json +++ b/package.json @@ -74,6 +74,7 @@ "@storybook/react": "^6.4.22", "@storybook/testing-library": "^0.0.11", "@storybook/testing-react": "^1.2.4", + "@testing-library/cypress": "^9.0.0", "core-js": "^3.22.3", "cypress": "^12.8.0", "cypress-cucumber-preprocessor": "^4.1.0", diff --git a/src/components/common/Helper.js b/src/components/common/Helper.js index 0b74973a..f8e079ff 100644 --- a/src/components/common/Helper.js +++ b/src/components/common/Helper.js @@ -157,14 +157,7 @@ export function transformHostQuery(query) { } export function noWrapText(text) { - return ( - <Typography - noWrap - sx={{ maxWidth: "15ch" }} - > - {text} - </Typography> - ); + return <Typography noWrap>{text}</Typography>; } export function extractMainNetwork(interfaces, defaultReturn = "") { diff --git a/src/components/host/HostTable.js b/src/components/host/HostTable.js index bdfc8033..5236a2e7 100644 --- a/src/components/host/HostTable.js +++ b/src/components/host/HostTable.js @@ -1,20 +1,32 @@ import React from "react"; -import { Grid, Tooltip } from "@mui/material"; -import { CustomTable } from "../common/table/CustomTable"; +import { Box, Tooltip } from "@mui/material"; +import { Table } from "@ess-ics/ce-ui-common"; import { HostStatusIcon } from "./HostIcons"; import { noWrapText } from "../common/Helper"; import { useNavigate } from "react-router-dom"; const columns = [ - { id: "bulb", label: "Status", width: "4ch", textAlign: "center" }, - { id: "host", label: "Host", width: "8ch" }, - { id: "description", label: "Description", width: "15ch" }, - { id: "network", label: "Network", width: "10ch" } + { + field: "bulb", + headerName: "Status", + flex: 0, + headerAlign: "center", + align: "center" + }, + { field: "hostName", headerName: "Host" }, + { field: "description", headerName: "Description" }, + { field: "network", headerName: "Network" } ]; export function rowDescription(description) { return ( - <div className={"shortenLongDataLines"}> + <Box + sx={{ + textOverflow: "ellipsis", + whiteSpace: "nowrap", + overflow: "hidden" + }} + > <Tooltip title={description} arrow @@ -22,7 +34,7 @@ export function rowDescription(description) { > <label>{noWrapText(description)}</label> </Tooltip> - </div> + </Box> ); } @@ -30,17 +42,9 @@ export function createRow(hostContainer) { const host = hostContainer.csEntryHost; return { id: host.id, - bulb: ( - <Grid - container - direction="column" - justifyContent="center" - alignItems="center" - > - <HostStatusIcon host={hostContainer} /> - </Grid> - ), - host: noWrapText(host.name), + bulb: <HostStatusIcon host={hostContainer} />, + hostName: noWrapText(host.name), + host: host, description: rowDescription(host.description), network: noWrapText(host.network), discrepancy: hostContainer.alertSeverity === "WARNING", @@ -50,32 +54,20 @@ export function createRow(hostContainer) { }; } -export function HostTable({ - hosts, - totalCount, - lazyParams, - setLazyParams, - rowsPerPage, - loading -}) { +export function HostTable({ hosts, pagination, onPage, loading }) { const navigate = useNavigate(); - const itemLink = (item) => `/hosts/${item.id}`; - - const onRowClicked = (id, item) => { - navigate(itemLink(item)); + const onRowClick = (params) => { + navigate(`/hosts/${params.row?.host?.id}`); }; return ( - <CustomTable + <Table columns={columns} rows={hosts.map((host) => createRow(host))} - handleRowClick={onRowClicked} - itemLink={itemLink} - totalCount={totalCount} - lazyParams={lazyParams} - setLazyParams={setLazyParams} - rowsPerPage={rowsPerPage} + onRowClick={onRowClick} + pagination={pagination} + onPage={onPage} loading={loading} /> ); diff --git a/src/components/host/HostTable.spec.js b/src/components/host/HostTable.spec.js index 9c683501..01b7dd12 100644 --- a/src/components/host/HostTable.spec.js +++ b/src/components/host/HostTable.spec.js @@ -19,27 +19,26 @@ describe("HostTable", () => { }); it("Has the correct columns", () => { - cy.get(".p-column-title").each(($el, index) => { + cy.findAllByRole("columnheader").each(($el, index) => { console.debug(index, columns[index]); cy.wrap($el).contains(columns[index], { matchCase: false }); }); }); it("Truncates all text content", () => { - cy.get("tbody > tr") - .first() + cy.findAllByRole("row") + .eq(1) // first row is headers, so get next index .find(".MuiTypography-noWrap") .should("have.length", textColumns.length); }); it("Displays correct content in first row", () => { - cy.get("tbody > tr") - .first() - .find("td") + cy.findAllByRole("row") + .eq(1) // first row is headers, so get next index .each(($el, index) => { if (index === 0) { const iconTitle = firstRowData[index]; - cy.wrap($el).find(`svg[aria-label=${iconTitle}]`); + cy.wrap($el).findByLabelText(iconTitle).should("exist"); } else { cy.wrap($el) .find("p") diff --git a/src/hooks/pagination.js b/src/hooks/pagination.js new file mode 100644 index 00000000..3c37e95b --- /dev/null +++ b/src/hooks/pagination.js @@ -0,0 +1,57 @@ +import { useCallback, useMemo, useState } from "react"; + +const isZeroOrPositiveInt = (val) => { + return Number.isInteger(val) && val >= 0; +}; + +/** + * Manages pagination state e.g. outside a table or url. + * + * Provides getting/setting for a single pagination object, but internally + * manages changes for each attribute independently so that e.g. if there are + * no changes then the object is unchanged. + */ +export const usePagination = ({ + initPage, + initLimit, + initTotalCount, + rowsPerPageOptions +}) => { + const [page, setPage] = useState(initPage ?? 0); + const [_rowsPerPageOptions] = useState(rowsPerPageOptions ?? [20, 50]); + const [limit, setLimit] = useState(initLimit ?? _rowsPerPageOptions[0]); + const [totalCount, setTotalCount] = useState(initTotalCount ?? 0); + + /** + * Returns current set of pagination values + */ + const pagination = useMemo(() => { + const val = { + page, + limit, + rows: limit, + totalCount, + totalRecords: totalCount, + rowsPerPageOptions: _rowsPerPageOptions + }; + return val; + }, [page, limit, totalCount, _rowsPerPageOptions]); + + /** + * Sets pagination. Ignores negative or nonzero values. + */ + const setPagination = useCallback( + ({ page, limit, totalCount, first, rows, totalRecords }) => { + isZeroOrPositiveInt(page) && setPage(page); + isZeroOrPositiveInt(limit || rows) && setLimit(limit || rows); + isZeroOrPositiveInt(totalCount || totalRecords) && + setTotalCount(totalCount || totalRecords); + }, + [] + ); + + return { + pagination, + setPagination + }; +}; diff --git a/src/views/host/HostListView.js b/src/views/host/HostListView.js index 5571dd31..5ee17904 100644 --- a/src/views/host/HostListView.js +++ b/src/views/host/HostListView.js @@ -1,4 +1,10 @@ -import React, { useState, useEffect, useCallback, useContext } from "react"; +import React, { + useState, + useEffect, + useCallback, + useContext, + useMemo +} from "react"; import { styled } from "@mui/material/styles"; import { Container, @@ -28,6 +34,7 @@ import { serialize, deserialize } from "../../components/common/URLState/URLState"; +import { usePagination } from "../../hooks/pagination"; const PREFIX = "HostListView"; @@ -48,7 +55,7 @@ export function HostListView() { useEffect(() => setTitle(applicationTitle("IOC hosts")), [setTitle]); const [hosts, getHosts /* reset*/, , loading] = useCSEntrySearch(); - const [state, setState] = useUrlState( + const [urlState, setUrlState] = useUrlState( { tab: "0", own: "false", @@ -58,14 +65,31 @@ export function HostListView() { }, { navigateMode: "replace" } ); - const [hostFilter, setHostFilter] = useState("ALL"); - const hideOwnSlider = deserialize(state.tab) === 2; + const urlPagination = useMemo( + () => ({ + rows: deserialize(urlState.rows), + page: deserialize(urlState.page) + }), + [urlState.rows, urlState.page] + ); + + const setUrlPagination = useCallback( + ({ rows, page }) => { + setUrlState({ + rows: serialize(rows), + page: serialize(page) + }); + }, + [setUrlState] + ); + + const hideOwnSlider = deserialize(urlState.tab) === 2; const handleTabChange = useCallback( (event, tab) => { - setState((s) => + setUrlState((s) => serialize(s.tab) === serialize(tab) ? { tab: serialize(tab) } : { tab: serialize(tab), page: "0" } @@ -73,7 +97,7 @@ export function HostListView() { changeTab(tab); }, - [setState] + [setUrlState] ); const changeTab = (tab) => { @@ -87,59 +111,75 @@ export function HostListView() { }; useEffect(() => { - state.tab && changeTab(deserialize(state.tab)); - }, [state]); + urlState.tab && changeTab(deserialize(urlState.tab)); + }, [urlState]); const handleChangeOwn = useCallback( (event) => { const own = event.target.checked; - setState({ own: serialize(own), page: "0" }); + setUrlState({ own: serialize(own), page: "0" }); }, - [setState] + [setUrlState] ); - const lazyParams = useCallback(() => { - return { - rows: deserialize(state.rows), - page: deserialize(state.page) - }; - }, [state]); + const rowsPerPage = [20, 50]; - const setLazyParams = useCallback( - (params) => { - setState({ rows: serialize(params.rows), page: serialize(params.page) }); - }, - [setState] - ); + const { pagination, setPagination } = usePagination({ + rowsPerPageOptions: rowsPerPage, + initLimit: urlPagination.rows ?? rowsPerPage[0], + initPage: urlPagination.page ?? 0 + }); - const rowsPerPage = [20, 50]; + // update pagination whenever search result total pages change + useEffect(() => { + setPagination({ totalCount: hosts.totalCount ?? 0 }); + }, [setPagination, hosts.totalCount]); + // whenever url state changes, update pagination + useEffect(() => { + setPagination({ ...urlPagination }); + }, [setPagination, urlPagination]); + + // whenever table pagination internally changes (user clicks next page etc) + // update the row params + useEffect(() => { + setUrlPagination(pagination); + }, [pagination, setUrlPagination]); + + // Request new search results whenever search or pagination changes useEffect(() => { let requestParams = initRequestParams( - lazyParams(), - transformHostQuery(deserialize(state.query)) + pagination, + transformHostQuery(deserialize(urlState.query)) ); requestParams.filter = - hostFilter !== "NO_IOCS" && deserialize(state.own) ? "OWN" : hostFilter; - + hostFilter !== "NO_IOCS" && deserialize(urlState.own) + ? "OWN" + : hostFilter; getHosts(requestParams); - }, [getHosts, lazyParams, hostFilter, state]); + }, [getHosts, hostFilter, urlState.query, urlState.own, pagination]); + // Callback for searchbar, called whenever user updates search const setSearch = useCallback( (query) => { - setState({ query: serialize(query) }); + setUrlState({ query: serialize(query) }); }, - [setState] + [setUrlState] ); const smUp = useMediaQuery((theme) => theme.breakpoints.up("sm")); const smDown = useMediaQuery((theme) => theme.breakpoints.down("sm")); + // Invoked by Table on change to pagination + const onPage = (params) => { + setPagination(params); + }; + const content = ( <> <SearchBar search={setSearch} - query={deserialize(state.query)} + query={deserialize(urlState.query)} loading={loading} > {smDown ? <HostList hosts={hosts.hostList} /> : null} @@ -147,10 +187,8 @@ export function HostListView() { <HostTable hosts={hosts.hostList} loading={loading} - totalCount={hosts.totalCount} - lazyParams={lazyParams()} - setLazyParams={setLazyParams} - rowsPerPage={rowsPerPage} + pagination={pagination} + onPage={onPage} /> ) : null} </SearchBar> @@ -174,7 +212,7 @@ export function HostListView() { > <Grid item> <Tabs - value={deserialize(state.tab)} + value={deserialize(urlState.tab)} onChange={handleTabChange} > <Tab label={<Typography variant="h5">All</Typography>} /> @@ -206,7 +244,7 @@ export function HostListView() { className={classes.formControl} control={ <Switch - checked={deserialize(state.own)} + checked={deserialize(urlState.own)} onChange={handleChangeOwn} /> } -- GitLab