diff --git a/ui/components/DataTable/DataTable.tsx b/ui/components/DataTable/DataTable.tsx index 408f9c1b24..1a676cec58 100644 --- a/ui/components/DataTable/DataTable.tsx +++ b/ui/components/DataTable/DataTable.tsx @@ -1,12 +1,3 @@ -import { - Checkbox, - Table, - TableBody, - TableCell, - TableContainer, - TableHead, - TableRow, -} from "@material-ui/core"; import _ from "lodash"; import qs from "query-string"; import * as React from "react"; @@ -24,31 +15,25 @@ import FilterDialog, { } from "../FilterDialog"; import Flex from "../Flex"; import Icon, { IconType } from "../Icon"; -import InfoModal from "../InfoModal"; import SearchField from "../SearchField"; -import Spacer from "../Spacer"; -import Text from "../Text"; import { filterRows, filterSelectionsToQueryString, filterText, initialFormState, parseFilterStateFromURL, - sortByField, toPairs, } from "./helpers"; -import SortableLabel from "./SortableLabel"; + +import SearchedNamespacesModal from "./TableView/SearchedNamespacesModal"; +import TableView from "./TableView/TableView"; +import { SortField } from "./TableView/types"; import { Field, FilterState } from "./types"; -/** DataTable Properties */ export interface Props { - /** The ID of the table. */ id?: string; - /** CSS MUI Overrides or other styling. */ className?: string; - /** A list of objects with four fields: `label`, which is a string representing the column header, `value`, which can be a string, or a function that extracts the data needed to fill the table cell, and `sortValue`, which customizes your input to the search function */ fields: Field[]; - /** A list of data that will be iterated through to create the columns described in `fields`. */ rows?: any[]; filters?: FilterConfig; dialogOpen?: boolean; @@ -59,12 +44,6 @@ export interface Props { disableSort?: boolean; searchedNamespaces?: SearchedNamespaces; } -//styled components -const EmptyRow = styled(TableRow)<{ colSpan: number }>` - td { - text-align: center; - } -`; const TopBar = styled(Flex)` max-width: 100%; @@ -75,7 +54,6 @@ const IconFlex = styled(Flex)` padding: 0 ${(props) => props.theme.spacing.small}; `; -/** Form DataTable */ function UnstyledDataTable({ id, className, @@ -90,21 +68,31 @@ function UnstyledDataTable({ disableSort, searchedNamespaces, }: Props) { - //URL info const history = useHistory(); const location = useLocation(); const search = location.search; const state = parseFilterStateFromURL(search); - const [filterDialogOpen, setFilterDialogOpen] = React.useState(dialogOpen); - const [searchedNamespacesModalOpen, setSearchedNamespacesModalOpen] = - React.useState(false); + + const [checked, setChecked] = React.useState([]); + const [filterState, setFilterState] = React.useState({ filters: selectionsToFilters(state.initialSelections, filters), formState: initialFormState(filters, state.initialSelections), textFilters: state.textFilters, }); + const [sortedItem, setSortedItem] = React.useState(() => { + const defaultSortField = fields.find((f) => f.defaultSort); + const sortField = defaultSortField + ? { + ...defaultSortField, + reverseSort: false, + } + : null; + return sortField; + }); + const handleFilterChange = (sel: FilterSelections) => { const filterQuery = filterSelectionsToQueryString(sel); history.replace({ ...location, search: filterQuery }); @@ -114,6 +102,20 @@ function UnstyledDataTable({ filtered = filterText(filtered, fields, filterState.textFilters); const chips = toPairs(filterState); + const sortItems = (filtered) => { + let sorted = filtered; + if (sortedItem) { + sorted = _.orderBy( + filtered, + [sortedItem.sortValue || sortedItem.value], + [sortedItem.reverseSort ? "desc" : "asc"] + ); + } + return sorted; + }; + + const items = sortItems(filtered); + const doChange = (formState) => { if (handleFilterChange) { handleFilterChange(formState); @@ -176,95 +178,8 @@ function UnstyledDataTable({ setFilterState({ ...filterState, filters, formState }); }; - const [sortFieldIndex, setSortFieldIndex] = React.useState(() => { - let sortFieldIndex = fields.findIndex((f) => f.defaultSort); - - if (sortFieldIndex === -1) { - sortFieldIndex = 0; - } - - return sortFieldIndex; - }); - - const secondarySortFieldIndex = fields.findIndex((f) => f.secondarySort); - - const [reverseSort, setReverseSort] = React.useState(false); - - let sortFields = [fields[sortFieldIndex]]; - - const useSecondarySort = - secondarySortFieldIndex > -1 && sortFieldIndex != secondarySortFieldIndex; - - if (useSecondarySort) { - sortFields = sortFields.concat(fields[secondarySortFieldIndex]); - sortFields = sortFields.concat( - fields.filter( - (_, index) => - index != sortFieldIndex && index != secondarySortFieldIndex - ) - ); - } else { - sortFields = sortFields.concat( - fields.filter((_, index) => index != sortFieldIndex) - ); - } - - const sorted = sortByField( - filtered, - reverseSort, - sortFields, - useSecondarySort, - disableSort - ); - - const numFields = fields.length + (checkboxes ? 1 : 0); - - const [checked, setChecked] = React.useState([]); - - const r = _.map(sorted, (r, i) => { - return ( - - {checkboxes && ( - - { - if (e.target.checked) setChecked([...checked, r.uid]); - else setChecked(_.without(checked, r.uid)); - }} - color="primary" - /> - - )} - {_.map(fields, (f) => { - const style: React.CSSProperties = { - ...(f.minWidth && { minWidth: f.minWidth }), - ...(f.maxWidth && { maxWidth: f.maxWidth }), - }; - - return ( - 0 ? style : undefined} - key={f.label} - > - - {(typeof f.value === "function" ? f.value(r) : r[f.value]) || - "-"} - - - ); - })} - - ); - }); - return ( - {checkboxes && } {filters && !hideSearchAndFilters && ( @@ -276,18 +191,9 @@ function UnstyledDataTable({ /> {searchedNamespaces && ( - - setSearchedNamespacesModalOpen(!searchedNamespacesModalOpen) - } - variant="text" - > - - + )} - - - - - {checkboxes && ( - - - e.target.checked - ? setChecked(filtered?.map((r) => r.uid)) - : setChecked([]) - } - color="primary" - /> - - )} - {_.map(fields, (f, index) => ( - - {typeof f.labelRenderer === "function" ? ( - f.labelRenderer(r) - ) : ( - { - if (onColumnHeaderClick) { - onColumnHeaderClick(f); - } - - setSortFieldIndex(...args); - }} - setReverseSort={(isReverse) => { - if (onColumnHeaderClick) { - onColumnHeaderClick(f); - } - - setReverseSort(isReverse); - }} - /> - )} - - ))} - - - - {r.length > 0 ? ( - r - ) : ( - - - - - - {emptyMessagePlaceholder || ( - No data - )} - - - - )} - -
-
+ setChecked(checked)} + onSortChange={(field) => { + if (onColumnHeaderClick) onColumnHeaderClick(field); + setSortedItem(field); + }} + /> {!hideSearchAndFilters && ( void; }; -const TableButton = styled(Button)` +export const TableButton = styled(Button)` &.MuiButton-root { margin: 0; text-transform: none; @@ -44,7 +43,7 @@ export default function SortableLabel({ const sort = fields[sortFieldIndex]; return ( - + - {sort.label === field.label ? ( { + const [searchedNamespacesModalOpen, setSearchedNamespacesModalOpen] = + React.useState(false); + return ( + <> + + setSearchedNamespacesModalOpen(!searchedNamespacesModalOpen) + } + variant="text" + > + + + + + ); +}; + +export default SearchedNamespacesModal; diff --git a/ui/components/DataTable/TableView/SortableLabelView.tsx b/ui/components/DataTable/TableView/SortableLabelView.tsx new file mode 100644 index 0000000000..afaf22ba4a --- /dev/null +++ b/ui/components/DataTable/TableView/SortableLabelView.tsx @@ -0,0 +1,52 @@ +import React from "react"; +import Flex from "../../Flex"; +import Icon, { IconType } from "../../Icon"; +import { TableButton } from "../SortableLabel"; +import { SortableLabelViewProps } from "./types"; + +const SortableLabelView = ({ + field, + onSortClick, + setSortedField, + sortedField, +}: SortableLabelViewProps) => { + return ( + + { + setSortedField({ + ...field, + reverseSort: + sortedField?.label === field.label + ? !sortedField.reverseSort + : false, + }); + onSortClick({ + ...field, + reverseSort: + sortedField?.label === field.label + ? !sortedField.reverseSort + : false, + }); + }} + > +

+ {field.label} +

+
+ {sortedField?.label === field.label ? ( + + ) : ( +
+ )} + + ); +}; + +export default SortableLabelView; diff --git a/ui/components/DataTable/TableView/TableBody.tsx b/ui/components/DataTable/TableView/TableBody.tsx new file mode 100644 index 0000000000..f923ca3cb5 --- /dev/null +++ b/ui/components/DataTable/TableView/TableBody.tsx @@ -0,0 +1,87 @@ +import { Checkbox, TableBody, TableCell, TableRow } from "@material-ui/core"; +import React from "react"; +import styled from "styled-components"; +import Flex from "../../Flex"; +import Icon, { IconType } from "../../Icon"; +import Text from "../../Text"; +import { TableBodyViewProps } from "./types"; + +const EmptyRow = styled(TableRow)<{ colSpan: number }>` + td { + text-align: center; + } +`; + +const TableBodyView = ({ + rows, + fields, + hasCheckboxes, + checkedFields, + emptyMessagePlaceholder, + onCheckChange, +}: TableBodyViewProps) => { + if (rows.length === 0) { + const numFields = fields.length + (hasCheckboxes ? 1 : 0); + return ( + + + + + + {emptyMessagePlaceholder || ( + No data + )} + + + + + ); + } + + return ( + + {rows?.map((r, i) => { + return ( + + {hasCheckboxes && ( + + c === r.uid) > -1} + onChange={(e) => { + onCheckChange(e.target.checked, r.uid); + }} + color="primary" + /> + + )} + {fields?.map((f) => { + const style: React.CSSProperties = { + ...(f.minWidth && { minWidth: f.minWidth }), + ...(f.maxWidth && { maxWidth: f.maxWidth }), + }; + + return ( + 0 ? style : undefined} + key={f.label} + > + + {(typeof f.value === "function" + ? f.value(r) + : r[f.value]) || "-"} + + + ); + })} + + ); + })} + + ); +}; + +export default TableBodyView; diff --git a/ui/components/DataTable/TableView/TableHeader.tsx b/ui/components/DataTable/TableView/TableHeader.tsx new file mode 100644 index 0000000000..e8763d9666 --- /dev/null +++ b/ui/components/DataTable/TableView/TableHeader.tsx @@ -0,0 +1,51 @@ +import { Checkbox, TableCell, TableHead, TableRow } from "@material-ui/core"; +import React from "react"; +import SortableLabelView from "./SortableLabelView"; +import { SortField, TableHeaderProps } from "./types"; + +const TableHeader = ({ + fields, + hasCheckboxes, + checked, + defaultSortedField, + onSortChange, + onCheckChange, +}: TableHeaderProps) => { + const [sortedField, setSortedField] = + React.useState(defaultSortedField); + return ( + + + {hasCheckboxes && ( + + { + onCheckChange(e.target.checked); + }} + color="primary" + /> + + )} + {fields.map((f) => ( + + {typeof f.labelRenderer === "function" ? ( + f.labelRenderer(f) + ) : ( + { + onSortChange(f); + }} + /> + )} + + ))} + + + ); +}; + +export default TableHeader; diff --git a/ui/components/DataTable/TableView/TableView.tsx b/ui/components/DataTable/TableView/TableView.tsx new file mode 100644 index 0000000000..2b02a586cc --- /dev/null +++ b/ui/components/DataTable/TableView/TableView.tsx @@ -0,0 +1,57 @@ +import { Table, TableContainer } from "@material-ui/core"; +import React from "react"; +import TableBodyView from "./TableBody"; +import TableHeader from "./TableHeader"; +import { TableViewProps } from "./types"; + +const TableView = ({ + rows, + fields, + id, + checkedFields, + defaultSortedField, + onSortChange, + hasCheckboxes, + onBatchCheck, +}: TableViewProps) => { + const onCheckChange = (checked: boolean, id: string) => { + const selectedFields = [...checkedFields]; + if (checked) { + selectedFields.push(id); + } else { + selectedFields.splice(selectedFields.indexOf(id), 1); + } + onBatchCheck(selectedFields); + }; + const onHeaderCheckChange = (checked: boolean) => { + if (checked) { + checkedFields = rows.map((r) => r.uid); + } else { + checkedFields = []; + } + onBatchCheck(checkedFields); + }; + return ( + + + + +
+
+ ); +}; + +export default TableView; diff --git a/ui/components/DataTable/TableView/types.ts b/ui/components/DataTable/TableView/types.ts new file mode 100644 index 0000000000..6ee7c6e9f4 --- /dev/null +++ b/ui/components/DataTable/TableView/types.ts @@ -0,0 +1,44 @@ +import { Field } from "../types"; + +export interface SortField extends Field { + reverseSort: boolean; +} + +export interface SortableLabelViewProps { + field: Field; + onSortClick?: (field: SortField) => void; + sortedField?: SortField; + setSortedField: React.Dispatch>; +} + +export interface TableBodyViewProps { + rows: any[]; + fields: Field[]; + hasCheckboxes?: boolean; + checkedFields?: string[]; + emptyMessagePlaceholder?: React.ReactNode; + onCheckChange?: (checked: boolean, id: string) => void; +} + +export interface TableHeaderProps { + fields: Field[]; + defaultSortedField?: SortField; + hasCheckboxes?: boolean; + checked?: boolean; + onSortChange?: (field: SortField) => void; + onCheckChange?: (checked: boolean) => void; +} + +export interface TableViewProps { + id?: string; + className?: string; + fields: Field[]; + defaultSortedField?: SortField; + rows?: any[]; + onSortChange?: (field: SortField) => void; + onBatchCheck?: (uids: string[]) => void; + hasCheckboxes?: boolean; + checkedFields?: string[]; + emptyMessagePlaceholder?: React.ReactNode; + disableSort?: boolean; +} diff --git a/ui/components/DataTable/__tests__/DataTableFilters.test.tsx b/ui/components/DataTable/__tests__/DataTableFilters.test.tsx index 799c73be21..6f44692723 100644 --- a/ui/components/DataTable/__tests__/DataTableFilters.test.tsx +++ b/ui/components/DataTable/__tests__/DataTableFilters.test.tsx @@ -237,7 +237,8 @@ describe("DataTableFilters", () => { const tableRows2 = document.querySelectorAll("tbody tr"); expect(tableRows2).toHaveLength(3); - expect(tableRows2[1].innerHTML).toContain("rad"); + + expect(tableRows2[2].innerHTML).toContain("rad"); const chip2 = screen.getByText(`type${filterSeparator}baz`); expect(chip2).toBeTruthy(); diff --git a/ui/components/DataTable/__tests__/__snapshots__/DataTable.test.tsx.snap b/ui/components/DataTable/__tests__/__snapshots__/DataTable.test.tsx.snap index c0682a899e..5114a5e7c1 100644 --- a/ui/components/DataTable/__tests__/__snapshots__/DataTable.test.tsx.snap +++ b/ui/components/DataTable/__tests__/__snapshots__/DataTable.test.tsx.snap @@ -64,13 +64,14 @@ exports[`DataTable snapshots renders 1`] = ` -webkit-box-align: center; -ms-flex-align: center; align-items: center; + gap: 4px; -webkit-box-pack: start; -webkit-justify-content: flex-start; -ms-flex-pack: start; justify-content: flex-start; } -.c8 { +.c7 { display: -webkit-box; display: -webkit-flex; display: -ms-flexbox; @@ -84,7 +85,7 @@ exports[`DataTable snapshots renders 1`] = ` align-items: center; } -.c14 { +.c13 { display: -webkit-box; display: -webkit-flex; display: -ms-flexbox; @@ -102,7 +103,7 @@ exports[`DataTable snapshots renders 1`] = ` justify-content: flex-start; } -.c16 { +.c15 { display: -webkit-box; display: -webkit-flex; display: -ms-flexbox; @@ -121,7 +122,7 @@ exports[`DataTable snapshots renders 1`] = ` justify-content: flex-start; } -.c11 { +.c10 { font-family: 'proxima-nova',Helvetica,Arial,sans-serif; font-size: 14px; font-weight: 400; @@ -129,7 +130,7 @@ exports[`DataTable snapshots renders 1`] = ` font-style: normal; } -.c17 { +.c16 { font-family: 'proxima-nova',Helvetica,Arial,sans-serif; font-size: 20px; font-weight: 400; @@ -138,63 +139,59 @@ exports[`DataTable snapshots renders 1`] = ` color: #737373; } -.c9 svg { +.c8 svg { height: 16px; width: 16px; } -.c9 svg path.path-fill, -.c9 svg line.path-fill, -.c9 svg polygon.path-fill, -.c9 svg rect.path-fill, -.c9 svg circle.path-fill, -.c9 svg polyline.path-fill { +.c8 svg path.path-fill, +.c8 svg line.path-fill, +.c8 svg polygon.path-fill, +.c8 svg rect.path-fill, +.c8 svg circle.path-fill, +.c8 svg polyline.path-fill { fill: !important; -webkit-transition: fill 200ms cubic-bezier(0.4,0,0.2,1) 0ms; transition: fill 200ms cubic-bezier(0.4,0,0.2,1) 0ms; } -.c9 svg path.stroke-fill, -.c9 svg line.stroke-fill, -.c9 svg polygon.stroke-fill, -.c9 svg rect.stroke-fill, -.c9 svg circle.stroke-fill, -.c9 svg polyline.stroke-fill { +.c8 svg path.stroke-fill, +.c8 svg line.stroke-fill, +.c8 svg polygon.stroke-fill, +.c8 svg rect.stroke-fill, +.c8 svg circle.stroke-fill, +.c8 svg polyline.stroke-fill { stroke: !important; -webkit-transition: stroke 200ms cubic-bezier(0.4,0,0.2,1) 0ms; transition: stroke 200ms cubic-bezier(0.4,0,0.2,1) 0ms; } -.c9 svg rect.rect-height { +.c8 svg rect.rect-height { height: 16px; width: 16px; } -.c9.downward { +.c8.downward { -webkit-transform: rotate(180deg); -ms-transform: rotate(180deg); transform: rotate(180deg); } -.c9.upward { +.c8.upward { -webkit-transform: initial; -ms-transform: initial; transform: initial; } -.c9 .c10 { +.c8 .c9 { margin-left: 4px; } -.c9 img { +.c8 img { width: 16px; } -.c7 { - padding: 4px; -} - -.c12 { +.c11 { position: relative; height: 100%; width: 0px; @@ -207,12 +204,12 @@ exports[`DataTable snapshots renders 1`] = ` transition-timing-function: ease-in-out; } -.c12.open { +.c11.open { left: 0px; width: 350px; } -.c13 { +.c12 { background: rbga(255,255,255,0.75); height: 100%; width: 100%; @@ -221,15 +218,15 @@ exports[`DataTable snapshots renders 1`] = ` padding-left: 32px; } -.c15 .MuiListItem-gutters { +.c14 .MuiListItem-gutters { padding-left: 0px; } -.c15 .MuiCheckbox-root { +.c14 .MuiCheckbox-root { padding: 0px; } -.c15 .MuiCheckbox-colorSecondary.Mui-checked { +.c14 .MuiCheckbox-colorSecondary.Mui-checked { color: #00b3ec; } @@ -326,9 +323,6 @@ exports[`DataTable snapshots renders 1`] = `
-
@@ -339,7 +333,6 @@ exports[`DataTable snapshots renders 1`] = ` className="MuiTableContainer-root" > @@ -388,10 +381,7 @@ exports[`DataTable snapshots renders 1`] = `
-
-
-
-
Ready @@ -591,7 +572,7 @@ exports[`DataTable snapshots renders 1`] = ` className="MuiTableCell-root MuiTableCell-body" > 2004-01-02T15:04:05-0700 @@ -602,7 +583,7 @@ exports[`DataTable snapshots renders 1`] = ` className="MuiTableCell-root MuiTableCell-body" > 3000 @@ -618,7 +599,7 @@ exports[`DataTable snapshots renders 1`] = ` className="MuiTableCell-root MuiTableCell-body" > - @@ -644,7 +625,7 @@ exports[`DataTable snapshots renders 1`] = ` className="MuiTableCell-root MuiTableCell-body" > 2006-01-02T15:04:05-0700 @@ -655,7 +636,7 @@ exports[`DataTable snapshots renders 1`] = ` className="MuiTableCell-root MuiTableCell-body" > 2000 @@ -671,7 +652,7 @@ exports[`DataTable snapshots renders 1`] = ` className="MuiTableCell-root MuiTableCell-body" > @@ -695,7 +676,7 @@ exports[`DataTable snapshots renders 1`] = ` className="MuiTableCell-root MuiTableCell-body" > 2005-01-02T15:04:05-0700 @@ -706,7 +687,7 @@ exports[`DataTable snapshots renders 1`] = ` className="MuiTableCell-root MuiTableCell-body" > 1000 @@ -717,20 +698,20 @@ exports[`DataTable snapshots renders 1`] = `
diff --git a/ui/components/__tests__/FluxObjectsTable.test.tsx b/ui/components/__tests__/FluxObjectsTable.test.tsx index fe81af6c81..8d22b9f0a7 100644 --- a/ui/components/__tests__/FluxObjectsTable.test.tsx +++ b/ui/components/__tests__/FluxObjectsTable.test.tsx @@ -61,17 +61,17 @@ describe("FluxObjectsTable", () => { ); const rows = document.querySelectorAll("tbody tr"); - const deploymentName = rows[0].querySelector("td:first-child"); - const link = deploymentName.querySelector("a"); - - expect(link.href).toEqual("http://localhost/some-cool-url"); - // Since our resolver does not specify any behavior for a Service, // this should not have a link. - const serviceName = rows[1].querySelector("td:first-child"); + const serviceName = rows[0].querySelector("td:first-child"); const serviceLink = serviceName.querySelector("a"); expect(serviceLink).toBeFalsy(); + + const deploymentName = rows[1].querySelector("td:first-child"); + const link = deploymentName.querySelector("a"); + + expect(link.href).toEqual("http://localhost/some-cool-url"); }); it("runs the onClick handler", () => { const onClick = jest.fn();