Skip to content

Commit

Permalink
chore(tables): unify the job statistics tables
Browse files Browse the repository at this point in the history
  • Loading branch information
vitshev committed Jun 5, 2024
1 parent 85cd745 commit f98ebed
Show file tree
Hide file tree
Showing 20 changed files with 533 additions and 871 deletions.
2 changes: 1 addition & 1 deletion packages/ui/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

267 changes: 267 additions & 0 deletions packages/ui/src/ui/components/StatisticTable/StatisticTable.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,267 @@
import * as React from 'react';
import {useSelector} from 'react-redux';
import cn from 'bem-cn-lite';

import Icon from '../Icon/Icon';
import hammer from '../../common/hammer';
import ErrorBoundary from '../ErrorBoundary/ErrorBoundary';
import ElementsTableRaw from '../ElementsTable/ElementsTable';
import {getFontFamilies} from '../../store/selectors/settings-ts';

import Toolbar from './Toolbar';
import {getMinWidth} from './get-min-width';
import {filterStatisticTree, prepareStatisticTs} from './prepare-statistic.ts';
import {Statistic, StatisticTree} from './types';

import './Statistics.scss';

const block = cn('job-statistics');

export const LEVEL_OFFSET = 40;

interface TreeItem {
name: string;
level: number;
attributes: {
name: string;
path: string;
prefix: string;
value?: Statistic;
};
isLeafNode?: boolean;
}

interface AvgProps {
item: TreeItem;
}

function Avg({item}: AvgProps) {
const statistic: Statistic = item.attributes.value as Statistic;

if (statistic && statistic.count && statistic.sum) {
const result: number = statistic.sum / statistic.count;

if (result < 1) {
return hammer.format['Number'](result, {significantDigits: 6});
} else {
return hammer.format.Number(result);
}
}

return hammer.format.NO_VALUE;
}

interface StatisticProps {
item: TreeItem;
aggregation: 'avg' | 'min' | 'max' | 'sum' | 'count' | 'last';
}

function StatisticTableStaticCell({item, aggregation}: StatisticProps) {
if (item.isLeafNode && Boolean(item.attributes.value)) {
if (aggregation === 'avg') {
return <Avg item={item} />;
} else {
return hammer.format['Number'](item.attributes?.value?.[aggregation]);
}
}

return hammer.format.NO_VALUE;
}

interface ItemState {
empty: boolean;
collapsed: boolean;
visible: boolean;
}

interface MetricProps {
item: TreeItem;
itemState: ItemState;
toggleItemState: Function;
renderValue: (item: TreeItem) => React.ReactChild;
minWidth?: number;
}

export function ExpandedCell({
item,
itemState,
toggleItemState,
minWidth = undefined,
renderValue,
}: MetricProps) {
const offsetStyle = React.useMemo(() => {
return {minWidth, paddingLeft: (item?.level || 0) * LEVEL_OFFSET};
}, [item.level, minWidth]);

const toggleItemAndTreeState = React.useCallback(() => {
if (!itemState.empty) {
toggleItemState();
}
}, [itemState, toggleItemState]);

if (item.isLeafNode) {
return (
<span className={block('metric')} style={offsetStyle}>
<Icon awesome="chart-line" className={block('metric-icon')} />

<span>{renderValue(item)}</span>
</span>
);
} else {
return (
<span className={block('group')} style={offsetStyle} onClick={toggleItemAndTreeState}>
<Icon
awesome={itemState.collapsed || itemState.empty ? 'angle-down' : 'angle-up'}
className={block('group-icon-toggler')}
/>
<Icon
awesome={itemState.collapsed || itemState.empty ? 'folder' : 'folder-open'}
className={block('group-icon')}
/>
<span>{renderValue(item)}</span>
</span>
);
}
}

type StatisticTableTemplate<Item extends Partial<TreeItem>> = {
[name: string]: (
item: Item,
colName: string,
toggleItemState: Function,
itemState: ItemState,
) => React.ReactChild | null;
};

type ColumnName = 'avg' | 'min' | 'max' | 'sum' | 'count' | 'last';
type VisibleColumns = Array<ColumnName>;

const prepareTableProps = ({visibleColumns}: {visibleColumns: VisibleColumns}) => {
const columns = visibleColumns.reduce(
(ret, col) => {
ret[col] = {
sort: false,
align: 'right',
};

return ret;
},
{
name: {
sort: false,
align: 'left',
},
} as Record<ColumnName | 'name', {sort: boolean; align: 'left' | 'right'}>,
);

return {
theme: 'light',
size: 's',
striped: false,
computeKey(item: TreeItem) {
return item.name;
},
tree: true,
columns: {
sets: {
default: {
items: Object.keys(columns),
},
},
items: columns,
mode: 'default',
},
};
};

const useJobStatisticTable = ({
statistic,
fontFamilies,
}: {
statistic: StatisticTree;
fontFamilies: {regular: string; monospace: string};
}) => {
const [filter, setFilter] = React.useState('');
const [treeState, setTreeState] = React.useState('expanded');

const tree = React.useMemo(() => prepareStatisticTs(statistic), [statistic]);
const items = React.useMemo(() => filterStatisticTree(tree, filter), [tree, filter]);
const minWidth = React.useMemo(() => getMinWidth(items, fontFamilies), [fontFamilies, items]);
const onFilterChange = (value: string) => setFilter(value);

return {
minWidth,
items,
treeState,
setTreeState,
onFilterChange,
};
};

export function StatisticTable({
virtual,
visibleColumns,
fixedHeader,
statistic,
}: {
virtual?: boolean;
fixedHeader?: boolean;
statistic: StatisticTree;
visibleColumns: Array<'avg' | 'min' | 'max' | 'sum' | 'count' | 'last'>;
}) {
const fontFamilies = useSelector(getFontFamilies);
const {items, minWidth, treeState, setTreeState, onFilterChange} = useJobStatisticTable({
statistic,
fontFamilies,
});

const templates = React.useMemo(
() =>
({
name(item, _, toggleItemState, itemState) {
return (
<ExpandedCell
item={item}
minWidth={minWidth}
toggleItemState={toggleItemState}
itemState={itemState}
renderValue={(item) => item?.attributes?.name}
/>
);
},
__default__(item, columnName: ColumnName) {
if (item.isLeafNode) {
return <StatisticTableStaticCell item={item} aggregation={columnName} />;
}

return null;
},
} as StatisticTableTemplate<TreeItem>),
[minWidth],
);
const tableProps = React.useMemo(() => {
return prepareTableProps({
visibleColumns,
});
}, [...visibleColumns]);

return (
<ErrorBoundary>
<div className={block()}>
<Toolbar onFilterChange={onFilterChange} onTreeStateChange={setTreeState} />

<div className={block('table-container')}>
<ElementsTableRaw
{...tableProps}
virtual={virtual}
treeState={treeState}
templates={templates}
items={items}
css={block()}
headerClassName={block('header', {fixed: fixedHeader})}
/>
</div>
</div>
</ErrorBoundary>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,12 @@
display: inline-block;
min-width: 550px;
}

&__header_fixed {
width: 100%;
position: sticky;
top: 0;
z-index: 1;
background-color: rgba(256, 256, 256, 1);
}
}
Original file line number Diff line number Diff line change
@@ -1,58 +1,58 @@
import React, {useCallback} from 'react';
import {useDispatch, useSelector} from 'react-redux';
import {useDispatch} from 'react-redux';
import cn from 'bem-cn-lite';

import Filter from '../../../../components/Filter/Filter';
import Button from '../../../../components/Button/Button';
import YTIcon from '../../../../components/Icon/Icon';
import HelpLink from '../../../../components/HelpLink/HelpLink';
import Filter from '../Filter/Filter';
import Button from '../Button/Button';
import YTIcon from '../Icon/Icon';
import HelpLink from '../HelpLink/HelpLink';

import {RootState} from '../../../../store/reducers';
import {changeFilter, collapseTable, expandTable} from '../../../../store/actions/job/statistics';
import {isDocsAllowed} from '../../../../config';
import UIFactory from '../../../../UIFactory';
import {isDocsAllowed} from '../../config';
import UIFactory from '../../UIFactory';

const toolbarBlock = cn('elements-toolbar');
const Icon: any = YTIcon;

const block = cn('job-statistics');

export default function Toolbar() {
interface StoreParams {
filter: string;
}
export default function Toolbar(props: {
onFilterChange: (value: string) => void;
onTreeStateChange: (state: 'expanded' | 'mixed' | 'collapsed') => void;
}) {
const collapseTable = React.useCallback(
() => props.onTreeStateChange('collapsed'),
[props.onTreeStateChange],
);
const expandTable = React.useCallback(
() => props.onTreeStateChange('expanded'),
[props.onTreeStateChange],
);

const dispatch = useDispatch();
const {filter}: StoreParams = useSelector((state: RootState) => state.job.statistics);

const handleExpand = useCallback(() => dispatch(expandTable()), [dispatch]);
const handleCollapse = useCallback(() => dispatch(collapseTable()), [dispatch]);
const handleFilterChange = useCallback(
(val: string) => dispatch(changeFilter(val)),
(val: string) => dispatch(props.onFilterChange(val)),
[dispatch],
);

return (
<div className={toolbarBlock(null, block('toolbar'))}>
<div className={toolbarBlock('container')}>
<div className={toolbarBlock('component', block('filter'))}>
<Filter size="m" debounce={500} value={filter} onChange={handleFilterChange} />
<Filter size="m" debounce={500} value={''} onChange={handleFilterChange} />
</div>

<div className={toolbarBlock('component', block('expand-collapse'))}>
<span className={block('expand-metrics')}>
<Button size="m" title="Expand All" onClick={handleExpand}>
<Icon awesome="arrow-to-bottom" />
<Button size="m" title="Expand All" onClick={expandTable}>
<YTIcon awesome="arrow-to-bottom" />
</Button>
</span>

<span className={block('collapse-metrics')}>
<Button size="m" title="Collapse All" onClick={handleCollapse}>
<Icon awesome="arrow-to-top" />
<Button size="m" title="Collapse All" onClick={collapseTable}>
<YTIcon awesome="arrow-to-top" />
</Button>
</span>
</div>

{isDocsAllowed() && (
<div className={toolbarBlock('component', block('help'))}>
<HelpLink url={UIFactory.docsUrls['problems:jobstatistics']} />
Expand Down
Loading

0 comments on commit f98ebed

Please sign in to comment.