From 7b49e15824224f4981bab8b9b6adbfc192bd1c0e Mon Sep 17 00:00:00 2001 From: M-ZubairAhmed Date: Mon, 18 Dec 2023 18:52:58 +0000 Subject: [PATCH] [MM-55993] Create a composable independent table to be used in Admin reporting (#25611) --- webapp/channels/package.json | 1 + .../list_table/elapsed_duration_cell.tsx | 82 +++++ .../admin_console/list_table/index.ts | 10 + .../admin_console/list_table/list_table.scss | 263 ++++++++++++++ .../admin_console/list_table/list_table.tsx | 326 ++++++++++++++++++ .../admin_console/list_table/pagination.tsx | 53 +++ webapp/channels/src/components/menu/menu.tsx | 2 + webapp/channels/src/i18n/en.json | 14 + .../src/sass/routes/_admin-console.scss | 11 +- webapp/channels/src/sass/utils/_mixins.scss | 3 - webapp/package-lock.json | 32 ++ 11 files changed, 789 insertions(+), 8 deletions(-) create mode 100644 webapp/channels/src/components/admin_console/list_table/elapsed_duration_cell.tsx create mode 100644 webapp/channels/src/components/admin_console/list_table/index.ts create mode 100644 webapp/channels/src/components/admin_console/list_table/list_table.scss create mode 100644 webapp/channels/src/components/admin_console/list_table/list_table.tsx create mode 100644 webapp/channels/src/components/admin_console/list_table/pagination.tsx diff --git a/webapp/channels/package.json b/webapp/channels/package.json index ea4e7bdc52..0839a216b2 100644 --- a/webapp/channels/package.json +++ b/webapp/channels/package.json @@ -21,6 +21,7 @@ "@mui/styled-engine-sc": "5.11.11", "@stripe/react-stripe-js": "1.13.0", "@stripe/stripe-js": "1.41.0", + "@tanstack/react-table": "8.10.7", "@tippyjs/react": "4.2.6", "@types/color-hash": "1.0.2", "@types/turndown": "5.0.1", diff --git a/webapp/channels/src/components/admin_console/list_table/elapsed_duration_cell.tsx b/webapp/channels/src/components/admin_console/list_table/elapsed_duration_cell.tsx new file mode 100644 index 0000000000..a216cc5a21 --- /dev/null +++ b/webapp/channels/src/components/admin_console/list_table/elapsed_duration_cell.tsx @@ -0,0 +1,82 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import moment from 'moment'; +import React, {useMemo} from 'react'; +import {FormattedMessage, useIntl} from 'react-intl'; + +import OverlayTrigger from 'components/overlay_trigger'; +import Tooltip from 'components/tooltip'; + +import Constants from 'utils/constants'; + +interface Props { + date?: number; +} + +export function ElapsedDurationCell(props: Props) { + const {formatMessage} = useIntl(); + + const todaysDate = moment().startOf('day').valueOf(); + + const {elapsedDays, exactPassedInDate} = useMemo(() => { + const startOfTodayMoment = moment().startOf('day'); + const passedInDateMoment = moment(props.date); + const exactPassedInDate = passedInDateMoment.format( + `MMMM DD, Y [${formatMessage({id: 'adminConsole.list.table.exactTime.at', defaultMessage: 'at'})}] hh:mm:ss A`, + ); + + const startOfPassedInDateMoment = passedInDateMoment.startOf('day'); + + // TODO: Use Timestamp component here + const elapsedDays = startOfTodayMoment.diff(startOfPassedInDateMoment, 'days'); + + return { + elapsedDays, + exactPassedInDate, + }; + }, [props.date, todaysDate]); + + if (!props.date) { + return null; + } + + let elapsedDaysText = null; + if (elapsedDays < 1) { + elapsedDaysText = ( + + ); + } else if (elapsedDays === 1) { + elapsedDaysText = ( + + ); + } else { + elapsedDaysText = ( + + ); + } + + return ( + + {exactPassedInDate} + + } + > + {elapsedDaysText} + + ); +} diff --git a/webapp/channels/src/components/admin_console/list_table/index.ts b/webapp/channels/src/components/admin_console/list_table/index.ts new file mode 100644 index 0000000000..f2354e4170 --- /dev/null +++ b/webapp/channels/src/components/admin_console/list_table/index.ts @@ -0,0 +1,10 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +export {useReactTable, getCoreRowModel, getSortedRowModel, createColumnHelper} from '@tanstack/react-table'; +export type {CellContext, PaginationState, SortingState, OnChangeFn, ColumnDef} from '@tanstack/react-table'; + +export {ListTable as AdminConsoleListTable, PAGE_SIZES} from './list_table'; +export type {TableMeta, PageSizeOption} from './list_table'; + +export {ElapsedDurationCell} from './elapsed_duration_cell'; diff --git a/webapp/channels/src/components/admin_console/list_table/list_table.scss b/webapp/channels/src/components/admin_console/list_table/list_table.scss new file mode 100644 index 0000000000..4fa0ae9fb8 --- /dev/null +++ b/webapp/channels/src/components/admin_console/list_table/list_table.scss @@ -0,0 +1,263 @@ +table.adminConsoleListTable { + // This is rgba(var(--sys-center-channel-color-rgb), 0.08) but since we cannot use opacity + // due to sticky row which shows the background thus a defined color is used + $sysCenterChannelColorWith8Alpha: #f0f0f0; + + width: 100%; + + thead { + border-top: 1px solid rgba(var(--center-channel-color-rgb), 0.08); + + tr { + color: rgba(var(--sys-center-channel-color-rgb), 0.64); + font-size: 12px; + font-weight: 600; + line-height: 16px; + text-wrap: nowrap; + white-space: nowrap; + + th { + max-width: 50rem; + background-color: var(--sys-center-channel-bg); + padding-block-end: 12px; + padding-block-start: 12px; + + &:first-child { + padding-inline-end: 12px; + padding-inline-start: 24px; + } + + &:not(:first-child):not(:last-child) { + padding-inline-end: 12px; + padding-inline-start: 12px; + } + + &:last-child { + padding-inline-end: 24px; + padding-inline-start: 12px; + } + + &.pinned { + position: sticky; + z-index: 1; + right: 0; + left: 0; + min-width: 20rem; + background-color: var(--sys-center-channel-bg); + + &:first-child { + left: 0; + + &::after { + position: absolute; + top: 0; + right: 0; + bottom: 0; + width: 1px; + background-color: rgba(var(--center-channel-color-rgb), 0.08); + content: ""; + } + } + + &:last-child { + right: 0; + + &::before { + position: absolute; + top: 0; + bottom: 0; + left: 0; + width: 1px; + background-color: rgba(var(--center-channel-color-rgb), 0.08); + content: ""; + } + } + + & > span { + font-size: 14px; + font-weight: 400; + line-height: 14px; + } + } + + &.sortable { + vertical-align: middle; + + &:hover { + background-color: $sysCenterChannelColorWith8Alpha; + cursor: pointer; + + & > span.hoverSortingIcon { + visibility: visible; + } + } + + & > span { + font-size: 14px; + font-weight: 400; + line-height: 14px; + + &.hoverSortingIcon { + color: rgba(var(--sys-center-channel-color-rgb), 0.32); + visibility: hidden; + } + } + } + + &.sortable.pinned { + display: flex; + align-items: center; + justify-content: space-between; + + & > span { + &:first-child { + flex-grow: 1; + justify-content: flex-start; + } + } + } + + &.pinned:not(.sortable) { + display: flex; + align-items: center; + justify-content: space-between; + } + } + } + } + + tbody { + tr { + border-top: 1px solid rgba(var(--center-channel-color-rgb), 0.08); + background-color: var(--sys-center-channel-bg); + color: var(--sys-center-channel-color); + font-size: 14px; + font-weight: 400; + line-height: 20px; + + &:hover { + background-color: $sysCenterChannelColorWith8Alpha; + cursor: pointer; + + // Since the sticky element creates a new stacking context and doesnt automatically inherit the background color + // set the background color of the sticky element to the same as the on hover + td.pinned { + background-color: $sysCenterChannelColorWith8Alpha; + } + } + + td { + overflow: hidden; + padding-block-end: 16px; + padding-block-start: 16px; + text-overflow: ellipsis; + white-space: nowrap; + + &:first-child { + padding-inline-end: 12px; + padding-inline-start: 24px; + } + + &:not(:first-child):not(:last-child) { + padding-inline-end: 12px; + padding-inline-start: 12px; + } + + &:last-child { + padding-inline-end: 24px; + padding-inline-start: 12px; + } + + // Pinning of columns can only be done on the first and/or last column + &.pinned { + position: sticky; + z-index: 1; + overflow: visible; + background-color: var(--sys-center-channel-bg); + + &:first-child { + left: 0; + + &::after { + position: absolute; + top: 0; + right: 0; + bottom: 0; + width: 1px; + background-color: rgba(var(--center-channel-color-rgb), 0.08); + content: ""; + } + } + + &:last-child { + right: 0; + + &::before { + position: absolute; + top: 0; + bottom: 0; + left: 0; + width: 1px; + background-color: rgba(var(--center-channel-color-rgb), 0.08); + content: ""; + } + } + } + } + } + } + + tfoot { + border-top: 1px solid rgba(var(--center-channel-color-rgb), 0.08); + } +} + +.adminConsoleListTabletOptionalFoot, +.adminConsoleListTabletOptionalHead { + position: sticky; + z-index: 1; + left: 0; + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + color: rgba(var(--sys-center-channel-color-rgb), 0.6); + font-size: 14px; + font-weight: 400; + line-height: normal; + padding-block-end: 12px; + padding-block-start: 12px; + padding-inline-end: 24px; + padding-inline-start: 24px; + + .adminConsoleListTablePageSize { + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + + & > .react-select { + min-width: 64px; + margin: 0 6px; + + .react-select__value-container { + color: rgba(var(--sys-center-channel-color-rgb), 1); + } + + .react-select__menu-list { + color: rgba(var(--sys-center-channel-color-rgb), 1); + } + } + } + + .paginationButtons { + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + + & > span { + padding-inline-end: 12px; + } + } +} diff --git a/webapp/channels/src/components/admin_console/list_table/list_table.tsx b/webapp/channels/src/components/admin_console/list_table/list_table.tsx new file mode 100644 index 0000000000..39cd1818cb --- /dev/null +++ b/webapp/channels/src/components/admin_console/list_table/list_table.tsx @@ -0,0 +1,326 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {SortDirection, Table} from '@tanstack/react-table'; +import {flexRender} from '@tanstack/react-table'; +import classNames from 'classnames'; +import React, {useMemo} from 'react'; +import type {AriaAttributes, MouseEvent, ReactNode} from 'react'; +import {FormattedMessage, defineMessages, useIntl} from 'react-intl'; +import ReactSelect, {components} from 'react-select'; +import type {IndicatorContainerProps, ValueType} from 'react-select'; + +import OverlayTrigger from 'components/overlay_trigger'; +import Tooltip from 'components/tooltip'; + +import Constants from 'utils/constants'; + +import {Pagination} from './pagination'; + +import './list_table.scss'; + +const SORTABLE_CLASS = 'sortable'; +const PINNED_CLASS = 'pinned'; + +export const PAGE_SIZES = [10, 20, 50, 100]; +const PageSizes = defineMessages({ + 10: { + id: 'adminConsole.list.table.rowsCount.10', + defaultMessage: '10', + }, + 20: { + id: 'adminConsole.list.table.rowsCount.20', + defaultMessage: '20', + }, + 50: { + id: 'adminConsole.list.table.rowsCount.50', + defaultMessage: '50', + }, + 100: { + id: 'adminConsole.list.table.rowsCount.100', + defaultMessage: '100', + }, +}); + +export type PageSizeOption = { + label: string; + value: number; +}; + +export type TableMeta = { + tableId: string; + tableCaption?: string; + isLoading?: boolean; + onRowClick?: (row: string) => void; + disablePrevPage?: boolean; + disableNextPage?: boolean; + onPreviousPageClick?: () => void; + onNextPageClick?: () => void; + paginationInfo?: ReactNode; + totalRowInfo?: ReactNode; + hasAdditionalPaginationAtTop?: boolean; +}; + +interface TableMandatoryTypes { + id: string; +} + +type Props = { + table: Table; +}; + +/** + * A wrapper around the react-table component that provides a consistent look and feel for the admin console list tables. + * It also provides a default pagination component. This table is not meant to be used outside of the admin console since it relies on the admin console styles. + * + * @param {Table} table - See https://tanstack.com/table/v8/docs/api/core/table/ for more details + */ +export function ListTable( + props: Props, +) { + const {formatMessage} = useIntl(); + + const tableMeta = props.table.options.meta as TableMeta; + const headerIdPrefix = `${tableMeta.tableId}-header-`; + const rowIdPrefix = `${tableMeta.tableId}-row-`; + const cellIdPrefix = `${tableMeta.tableId}-cell-`; + + const pageSizeOptions = useMemo(() => { + return PAGE_SIZES.map((size) => { + return { + label: formatMessage(PageSizes[size]), + value: size, + }; + }); + }, []); + + const selectedPageSize = pageSizeOptions.find((option) => option.value === props.table.getState().pagination.pageSize) || pageSizeOptions[0]; + + function handlePageSizeChange(selectedOption: ValueType) { + const {value} = selectedOption as PageSizeOption; + props.table.setPageSize(Number(value)); + } + + function handleRowClick(event: MouseEvent) { + const {currentTarget: {id = ''}} = event; + const rowOriginalId = id.replace(rowIdPrefix, ''); + + if (tableMeta.onRowClick && rowOriginalId.length > 0) { + event.preventDefault(); + tableMeta.onRowClick(rowOriginalId); + } + } + + return ( + <> +
+ {tableMeta.totalRowInfo} + {tableMeta.hasAdditionalPaginationAtTop && ( + + )} +
+ + + + {props.table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + ))} + + ))} + + + {props.table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + ))} + + ))} + + + {props.table.getFooterGroups().map((footerGroup) => ( + + {footerGroup.headers.map((footer) => ( + + ))} + + ))} + +
{tableMeta.tableCaption}
+ {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} + + {/* Sort Icons */} + {header.column.getIsSorted() === 'asc' && ( +
+ {cell.getIsPlaceholder() ? null : flexRender(cell.column.columnDef.cell, cell.getContext())} +
+ {footer.isPlaceholder ? null : flexRender(footer.column.columnDef.footer, footer.getContext())} +
+
+ {handlePageSizeChange && ( +
+ + + +
+ )} + +
+ + ); +} + +function IndicatorsContainer(props: IndicatorContainerProps) { + return ( + + + + ); +} + +function getAriaSortForTableHeader( + canSort: boolean, + sortDirection: boolean | SortDirection, +): AriaAttributes['aria-sort'] { + if (!canSort) { + return 'none'; + } + + if (sortDirection === 'asc') { + return 'ascending'; + } + + if (sortDirection === 'desc') { + return 'descending'; + } + + return 'none'; +} diff --git a/webapp/channels/src/components/admin_console/list_table/pagination.tsx b/webapp/channels/src/components/admin_console/list_table/pagination.tsx new file mode 100644 index 0000000000..e851bf86b4 --- /dev/null +++ b/webapp/channels/src/components/admin_console/list_table/pagination.tsx @@ -0,0 +1,53 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {useIntl} from 'react-intl'; + +import type {TableMeta} from './list_table'; + +type Props = Pick< +TableMeta, +| 'onPreviousPageClick' +| 'onNextPageClick' +| 'disablePrevPage' +| 'disableNextPage' +| 'isLoading' +| 'paginationInfo' +>; + +export function Pagination(props: Props) { + const {formatMessage} = useIntl(); + + return ( +
+ {props.paginationInfo} + {props.onPreviousPageClick && ( +