diff --git a/scm-ui/ui-components/src/__snapshots__/storyshots.test.ts.snap b/scm-ui/ui-components/src/__snapshots__/storyshots.test.ts.snap index e8c0f9d3f4..206a2075b4 100644 --- a/scm-ui/ui-components/src/__snapshots__/storyshots.test.ts.snap +++ b/scm-ui/ui-components/src/__snapshots__/storyshots.test.ts.snap @@ -336,7 +336,7 @@ exports[`Storyshots DateFromNow Default 1`] = ` exports[`Storyshots Forms|Checkbox Default 1`] = `
`; + +exports[`Storyshots Table|Table Default 1`] = ` + + + + + + + + + + + + + + + + + + + + +
+ First Name + + Last Name + + + E-Mail +
+

+ Tricia +

+
+ + McMillan + + + + tricia@hitchhiker.com + +
+

+ Arthur +

+
+ + Dent + + + + arthur@hitchhiker.com + +
+`; + +exports[`Storyshots Table|Table TextColumn 1`] = ` + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Id + + + Name + + + Description + +
+ 21 + + Pommes + + Fried potato sticks +
+ 42 + + Quarter-Pounder + + Big burger +
+ -84 + + Icecream + + Cold dessert +
+`; diff --git a/scm-ui/ui-components/src/index.ts b/scm-ui/ui-components/src/index.ts index f5c7f62418..d37a04b2e5 100644 --- a/scm-ui/ui-components/src/index.ts +++ b/scm-ui/ui-components/src/index.ts @@ -63,6 +63,7 @@ export * from "./layout"; export * from "./modals"; export * from "./navigation"; export * from "./repos"; +export * from "./table"; export { File, diff --git a/scm-ui/ui-components/src/table/Column.tsx b/scm-ui/ui-components/src/table/Column.tsx new file mode 100644 index 0000000000..4cd0f5d30d --- /dev/null +++ b/scm-ui/ui-components/src/table/Column.tsx @@ -0,0 +1,18 @@ +import React, { FC, ReactNode } from "react"; +import { ColumnProps } from "./table"; + +type Props = ColumnProps & { + children: (row: any, columnIndex: number) => ReactNode; +}; + +const Column: FC = ({ row, columnIndex, children }) => { + if (row === undefined) { + throw new Error("missing row, use column only as child of Table"); + } + if (columnIndex === undefined) { + throw new Error("missing row, use column only as child of Table"); + } + return <>{children(row, columnIndex)}; +}; + +export default Column; diff --git a/scm-ui/ui-components/src/table/SortIcon.tsx b/scm-ui/ui-components/src/table/SortIcon.tsx new file mode 100644 index 0000000000..0d553041c6 --- /dev/null +++ b/scm-ui/ui-components/src/table/SortIcon.tsx @@ -0,0 +1,19 @@ +import React, { FC } from "react"; +import styled from "styled-components"; +import Icon from "../Icon"; + +type Props = { + name: string; + isVisible: boolean; +}; + +const IconWithMarginLeft = styled(Icon)` + visibility: ${(props: Props) => (props.isVisible ? "visible" : "hidden")}; + margin-left: 0.25em; +`; + +const SortIcon: FC = (props: Props) => { + return ; +}; + +export default SortIcon; diff --git a/scm-ui/ui-components/src/table/Table.stories.tsx b/scm-ui/ui-components/src/table/Table.stories.tsx new file mode 100644 index 0000000000..b4df2a1cb8 --- /dev/null +++ b/scm-ui/ui-components/src/table/Table.stories.tsx @@ -0,0 +1,53 @@ +import React from "react"; +import { storiesOf } from "@storybook/react"; +import Table from "./Table"; +import Column from "./Column"; +import TextColumn from "./TextColumn"; + +storiesOf("Table|Table", module) + .add("Default", () => ( + + {(row: any) =>

{row.firstname}

}
+ { + return (a: any, b: any) => { + if (a.lastname > b.lastname) { + return -1; + } else if (a.lastname < b.lastname) { + return 1; + } else { + return 0; + } + }; + }} + > + {(row: any) => {row.lastname}} + + {(row: any) => {row.email}} +
+ )) + .add("TextColumn", () => ( + + + + +
+ )) + .add("Empty", () => ( + + + +
+ )); diff --git a/scm-ui/ui-components/src/table/Table.tsx b/scm-ui/ui-components/src/table/Table.tsx new file mode 100644 index 0000000000..ffcd38238f --- /dev/null +++ b/scm-ui/ui-components/src/table/Table.tsx @@ -0,0 +1,117 @@ +import React, { FC, ReactElement, useState } from "react"; +import styled from "styled-components"; +import { Comparator } from "./table"; +import SortIcon from "./SortIcon"; +import Notification from "../Notification"; + +const StyledTable = styled.table.attrs(() => ({ + className: "table content is-hoverable" +}))``; + +type Props = { + data: any[]; + sortable?: boolean; + emptyMessage?: string; + children: Array; +}; + +const Table: FC = ({ data, sortable, children, emptyMessage }) => { + const [tableData, setTableData] = useState(data); + const [ascending, setAscending] = useState(false); + const [lastSortBy, setlastSortBy] = useState(); + const [hoveredColumnIndex, setHoveredColumnIndex] = useState(); + + const isSortable = (child: ReactElement) => { + return sortable && child.props.createComparator; + }; + + const sortFunctions: Comparator | undefined[] = []; + React.Children.forEach(children, (child, index) => { + if (child && isSortable(child)) { + sortFunctions.push(child.props.createComparator(child.props, index)); + } else { + sortFunctions.push(undefined); + } + }); + + const mapDataToColumns = (row: any) => { + return ( + + {React.Children.map(children, (child, columnIndex) => { + return {React.cloneElement(child, { ...child.props, columnIndex, row })}; + })} + + ); + }; + + const sortDescending = (sortAscending: (a: any, b: any) => number) => { + return (a: any, b: any) => { + return sortAscending(a, b) * -1; + }; + }; + + const tableSort = (index: number) => { + const sortFn = sortFunctions[index]; + if (!sortFn) { + throw new Error(`column with index ${index} is not sortable`); + } + const sortableData = [...tableData]; + let sortOrder = ascending; + if (lastSortBy !== index) { + setAscending(true); + sortOrder = true; + } + const sortFunction = sortOrder ? sortFn : sortDescending(sortFn); + sortableData.sort(sortFunction); + setTableData(sortableData); + setAscending(!sortOrder); + setlastSortBy(index); + }; + + const shouldShowIcon = (index: number) => { + return index === lastSortBy || index === hoveredColumnIndex; + }; + + if (!tableData || tableData.length <= 0) { + if (emptyMessage) { + return {emptyMessage}; + } else { + return null; + } + } + + return ( + + + + {React.Children.map(children, (child, index) => ( + tableSort(index) : undefined} + onMouseEnter={() => setHoveredColumnIndex(index)} + onMouseLeave={() => setHoveredColumnIndex(undefined)} + > + {child.props.header} + {isSortable(child) && renderSortIcon(child, ascending, shouldShowIcon(index))} + + ))} + + + {tableData.map(mapDataToColumns)} + + ); +}; + +Table.defaultProps = { + sortable: true +}; + +const renderSortIcon = (child: ReactElement, ascending: boolean, showIcon: boolean) => { + if (child.props.ascendingIcon && child.props.descendingIcon) { + return ; + } else { + return ; + } +}; + +export default Table; diff --git a/scm-ui/ui-components/src/table/TextColumn.tsx b/scm-ui/ui-components/src/table/TextColumn.tsx new file mode 100644 index 0000000000..a170b02192 --- /dev/null +++ b/scm-ui/ui-components/src/table/TextColumn.tsx @@ -0,0 +1,28 @@ +import React, { FC } from "react"; +import { ColumnProps } from "./table"; + +type Props = ColumnProps & { + dataKey: string; +}; + +const TextColumn: FC = ({ row, dataKey }) => { + return row[dataKey]; +}; + +TextColumn.defaultProps = { + createComparator: (props: Props) => { + return (a: any, b: any) => { + if (a[props.dataKey] < b[props.dataKey]) { + return -1; + } else if (a[props.dataKey] > b[props.dataKey]) { + return 1; + } else { + return 0; + } + }; + }, + ascendingIcon: "sort-alpha-down-alt", + descendingIcon: "sort-alpha-down" +}; + +export default TextColumn; diff --git a/scm-ui/ui-components/src/table/index.ts b/scm-ui/ui-components/src/table/index.ts new file mode 100644 index 0000000000..c99a4e64c1 --- /dev/null +++ b/scm-ui/ui-components/src/table/index.ts @@ -0,0 +1,4 @@ +export { default as Table } from "./Table"; +export { default as Column } from "./Column"; +export { default as TextColumn } from "./TextColumn"; +export { default as SortIcon } from "./SortIcon"; diff --git a/scm-ui/ui-components/src/table/table.ts b/scm-ui/ui-components/src/table/table.ts new file mode 100644 index 0000000000..9af3e872cb --- /dev/null +++ b/scm-ui/ui-components/src/table/table.ts @@ -0,0 +1,12 @@ +import { ReactNode } from "react"; + +export type Comparator = (a: any, b: any) => number; + +export type ColumnProps = { + header: ReactNode; + row?: any; + columnIndex?: number; + createComparator?: (props: any, columnIndex: number) => Comparator; + ascendingIcon?: string; + descendingIcon?: string; +};