[MM-55993] Create a composable independent table to be used in Admin reporting (#25611)

Этот коммит содержится в:
M-ZubairAhmed
2023-12-18 18:52:58 +00:00
коммит произвёл GitHub
родитель c6b600b186
Коммит 7b49e15824
11 изменённых файлов: 789 добавлений и 8 удалений

Просмотреть файл

@@ -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",

Просмотреть файл

@@ -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 = (
<FormattedMessage
id='admin.system_users.list.memberSince.today'
defaultMessage='Today'
/>
);
} else if (elapsedDays === 1) {
elapsedDaysText = (
<FormattedMessage
id='admin.system_users.list.memberSince.yesterday'
defaultMessage='Yesterday'
/>
);
} else {
elapsedDaysText = (
<FormattedMessage
id='admin.system_users.list.memberSince.days'
defaultMessage='{days} days'
values={{days: elapsedDays}}
/>
);
}
return (
<OverlayTrigger
delayShow={Constants.OVERLAY_TIME_DELAY}
placement='bottom'
overlay={
<Tooltip id='system-users-cell-elapsed-duration-tooltip'>
{exactPassedInDate}
</Tooltip>
}
>
<span>{elapsedDaysText}</span>
</OverlayTrigger>
);
}

Просмотреть файл

@@ -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';

Просмотреть файл

@@ -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;
}
}
}

Просмотреть файл

@@ -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<number>({
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<TableType extends TableMandatoryTypes> = {
table: Table<TableType>;
};
/**
* 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<TableType extends TableMandatoryTypes>(
props: Props<TableType>,
) {
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<PageSizeOption>) {
const {value} = selectedOption as PageSizeOption;
props.table.setPageSize(Number(value));
}
function handleRowClick(event: MouseEvent<HTMLTableRowElement>) {
const {currentTarget: {id = ''}} = event;
const rowOriginalId = id.replace(rowIdPrefix, '');
if (tableMeta.onRowClick && rowOriginalId.length > 0) {
event.preventDefault();
tableMeta.onRowClick(rowOriginalId);
}
}
return (
<>
<div className='adminConsoleListTabletOptionalHead'>
{tableMeta.totalRowInfo}
{tableMeta.hasAdditionalPaginationAtTop && (
<Pagination
disablePrevPage={tableMeta.disablePrevPage}
disableNextPage={tableMeta.disableNextPage}
isLoading={tableMeta.isLoading}
onPreviousPageClick={tableMeta.onPreviousPageClick}
onNextPageClick={tableMeta.onNextPageClick}
paginationInfo={tableMeta.paginationInfo}
/>
)}
</div>
<table
id={tableMeta.tableId}
aria-colcount={props.table.getAllColumns().length}
aria-describedby={`${tableMeta.tableId}-headerId`} // Set this id to the table header so that the title describes the table
className={classNames(
'adminConsoleListTable',
tableMeta.tableId,
)}
>
<caption className='sr-only'>{tableMeta.tableCaption}</caption>
<thead>
{props.table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th
key={header.id}
id={`${headerIdPrefix}${header.id}`}
colSpan={header.colSpan}
scope='col'
role='columnheader'
aria-sort={getAriaSortForTableHeader(header.column.getCanSort(), header.column.getIsSorted())}
className={classNames(`${header.id}`, {
[SORTABLE_CLASS]: header.column.getCanSort(),
[PINNED_CLASS]: header.column.getCanPin(),
})}
onClick={header.column.getToggleSortingHandler()}
>
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
{/* Sort Icons */}
{header.column.getIsSorted() === 'asc' && (
<span
aria-hidden='true'
className='icon icon-arrow-up'
/>
)}
{header.column.getIsSorted() === 'desc' && (
<span
aria-hidden='true'
className='icon icon-arrow-down'
/>
)}
{header.column.getCanSort() &&
header.column.getIsSorted() !== 'asc' &&
header.column.getIsSorted() !== 'desc' && (
<span
aria-hidden='true'
className='icon icon-arrow-up hoverSortingIcon'
/>
)}
{/* Pinned Icon */}
{header.column.getCanPin() && (
<OverlayTrigger
delayShow={Constants.OVERLAY_TIME_DELAY}
placement={
header.index === 0 ? 'bottom' : 'left'
}
overlay={
<Tooltip id='system-users-column-pinned-tooltip'>
<FormattedMessage
id='adminConsole.list.table.pinnedColumn'
defaultMessage='This column is pinned'
/>
</Tooltip>
}
>
<span
aria-hidden='true'
className='icon icon-pin-outline'
/>
</OverlayTrigger>
)}
</th>
))}
</tr>
))}
</thead>
<tbody>
{props.table.getRowModel().rows.map((row) => (
<tr
id={`${rowIdPrefix}${row.original.id}`}
key={row.id}
role='row'
onClick={handleRowClick}
>
{row.getVisibleCells().map((cell) => (
<td
key={cell.id}
id={`${cellIdPrefix}${cell.id}`}
role='cell'
headers={`${headerIdPrefix}${cell.column.id}`}
className={classNames(`${cell.column.id}`, {
[PINNED_CLASS]: cell.column.getCanPin(),
})}
>
{cell.getIsPlaceholder() ? null : flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
<tfoot>
{props.table.getFooterGroups().map((footerGroup) => (
<tr key={footerGroup.id}>
{footerGroup.headers.map((footer) => (
<th
key={footer.id}
colSpan={footer.colSpan}
className={classNames({
[PINNED_CLASS]: footer.column.getCanPin(),
})}
>
{footer.isPlaceholder ? null : flexRender(footer.column.columnDef.footer, footer.getContext())}
</th>
))}
</tr>
))}
</tfoot>
</table>
<div className='adminConsoleListTabletOptionalFoot'>
{handlePageSizeChange && (
<div
className='adminConsoleListTablePageSize'
aria-label={formatMessage(
{
id: 'adminConsole.list.table.rowCount.label',
defaultMessage: 'Show {count} rows per page',
},
{count: selectedPageSize.label},
)}
>
<FormattedMessage
id='adminConsole.list.table.rowsCount.(show)rowsPerPage'
defaultMessage='Show'
/>
<ReactSelect
className='react-select'
classNamePrefix='react-select'
autoFocus={false}
isClearable={false}
isMulti={false}
isSearchable={false}
menuPlacement='top'
options={pageSizeOptions}
value={selectedPageSize}
onChange={handlePageSizeChange}
isDisabled={tableMeta.isLoading}
components={{
IndicatorSeparator: null,
IndicatorsContainer,
}}
/>
<FormattedMessage
id='adminConsole.list.table.rowsCount.show(rowsPerPage)'
defaultMessage='rows per page'
/>
</div>
)}
<Pagination
disablePrevPage={tableMeta.disablePrevPage}
disableNextPage={tableMeta.disableNextPage}
isLoading={tableMeta.isLoading}
onPreviousPageClick={tableMeta.onPreviousPageClick}
onNextPageClick={tableMeta.onNextPageClick}
paginationInfo={tableMeta.paginationInfo}
/>
</div>
</>
);
}
function IndicatorsContainer(props: IndicatorContainerProps<PageSizeOption>) {
return (
<components.IndicatorsContainer {...props}>
<i className='icon icon-chevron-down'/>
</components.IndicatorsContainer>
);
}
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';
}

Просмотреть файл

@@ -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 (
<div className='paginationButtons'>
{props.paginationInfo}
{props.onPreviousPageClick && (
<button
className='btn btn-icon btn-sm'
disabled={props.disablePrevPage || props.isLoading}
onClick={props.onPreviousPageClick}
aria-label={formatMessage({id: 'adminConsole.list.table.pagination.previous', defaultMessage: 'Go to previous page'})}
>
<i
className='icon icon-chevron-left'
aria-hidden='true'
/>
</button>
)}
{props.onNextPageClick && (
<button
className='btn btn-icon btn-sm'
disabled={props.disableNextPage || props.isLoading}
onClick={props.onNextPageClick}
aria-label={formatMessage({id: 'adminConsole.list.table.pagination.next', defaultMessage: 'Go to next page'})}
>
<i
className='icon icon-chevron-right'
aria-hidden='true'
/>
</button>
)}
</div>
);
}

Просмотреть файл

@@ -40,6 +40,7 @@ type MenuButtonProps = {
id: string;
dateTestId?: string;
'aria-label'?: string;
disabled?: boolean;
class?: string;
children: ReactNode;
}
@@ -174,6 +175,7 @@ export function Menu(props: Props) {
aria-controls={props.menu.id}
aria-haspopup={true}
aria-expanded={isMenuOpen}
disabled={props.menuButton?.disabled ?? false}
aria-label={props.menuButton?.['aria-label'] ?? ''}
className={props.menuButton?.class ?? ''}
onClick={handleMenuButtonClick}

Просмотреть файл

@@ -2446,6 +2446,9 @@
"admin.system_users.allUsers": "All Users",
"admin.system_users.guest": "Guest",
"admin.system_users.inactive": "Inactive",
"admin.system_users.list.memberSince.days": "{days} days",
"admin.system_users.list.memberSince.today": "Today",
"admin.system_users.list.memberSince.yesterday": "Yesterday",
"admin.system_users.noTeams": "No Teams",
"admin.system_users.revoke_all_sessions_button": "Revoke All Sessions",
"admin.system_users.revoke_all_sessions_modal_message": "This action revokes all sessions in the system. All users will be logged out from all devices, including your session. Are you sure you want to revoke all sessions?",
@@ -2670,6 +2673,17 @@
"admin.webserverModeTitle": "Webserver Mode:",
"admin.webserverModeUncompressed": "Uncompressed",
"admin.webserverModeUncompressedDescription": "The Mattermost server will serve static files uncompressed.",
"adminConsole.list.table.exactTime.at": "at",
"adminConsole.list.table.pagination.next": "Go to next page",
"adminConsole.list.table.pagination.previous": "Go to previous page",
"adminConsole.list.table.pinnedColumn": "This column is pinned",
"adminConsole.list.table.rowCount.label": "Show {count} rows per page",
"adminConsole.list.table.rowsCount.(show)rowsPerPage": "Show",
"adminConsole.list.table.rowsCount.10": "10",
"adminConsole.list.table.rowsCount.100": "100",
"adminConsole.list.table.rowsCount.20": "20",
"adminConsole.list.table.rowsCount.50": "50",
"adminConsole.list.table.rowsCount.show(rowsPerPage)": "rows per page",
"air_gapped_modal.close": "Close",
"air_gapped_modal.description": "To start your trial, please visit {link} and request a trial key.",
"air_gapped_modal.title": "Request a trial key",

Просмотреть файл

@@ -9,7 +9,6 @@
height: 100%;
color: #333;
grid-area: center;
-webkit-overflow-scrolling: touch;
> div {
width: 100%;
@@ -30,12 +29,8 @@
.admin-console__wrapper {
position: relative;
height: 100%;
-webkit-flex: 1 1 auto;
flex: 1 1 auto;
padding: 20px;
margin-right: 5px;
scrollbar-color: var(--center-channel-color-32) #fff0;
scrollbar-width: thin;
> .wrapper--admin {
display: flex;
@@ -43,6 +38,12 @@
height: 100%;
flex-direction: column;
}
& > .admin-console__container {
overflow: auto;
background-color: var(--sys-center-channel-bg);
box-shadow: 0 2px 3px 0 alpha-color($black, 0.08);
}
}
.admin-console__content {

Просмотреть файл

@@ -74,7 +74,6 @@
@mixin clearfix {
overflow: hidden;
*zoom: 1;
}
@mixin pie-clearfix {
@@ -83,8 +82,6 @@
clear: both;
content: "";
}
*zoom: 1;
}
@mixin tertiary-button {

32
webapp/package-lock.json сгенерированный
Просмотреть файл

@@ -67,6 +67,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",
@@ -4643,6 +4644,37 @@
"postcss-syntax": ">=0.36.2"
}
},
"node_modules/@tanstack/react-table": {
"version": "8.10.7",
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.10.7.tgz",
"integrity": "sha512-bXhjA7xsTcsW8JPTTYlUg/FuBpn8MNjiEPhkNhIGCUR6iRQM2+WEco4OBpvDeVcR9SE+bmWLzdfiY7bCbCSVuA==",
"dependencies": {
"@tanstack/table-core": "8.10.7"
},
"engines": {
"node": ">=12"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": ">=16",
"react-dom": ">=16"
}
},
"node_modules/@tanstack/table-core": {
"version": "8.10.7",
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.10.7.tgz",
"integrity": "sha512-KQk5OMg5OH6rmbHZxuNROvdI+hKDIUxANaHlV+dPlNN7ED3qYQ/WkpY2qlXww1SIdeMlkIhpN/2L00rof0fXFw==",
"engines": {
"node": ">=12"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@testing-library/dom": {
"version": "8.20.1",
"dev": true,