@hackplan/polaris
Version:
Shopify’s product component library
502 lines (500 loc) • 23.5 kB
JavaScript
import React from 'react';
import isEqual from 'lodash/isEqual';
import debounce from 'lodash/debounce';
import range from 'lodash/range';
import { DragDropContext, Droppable, Draggable, } from 'react-beautiful-dnd';
import Spinner from '../Spinner';
import EmptySearchResult from '../EmptySearchResult';
import Checkbox from '../Checkbox';
import { classNames } from '../../utilities/css';
import { headerCell } from '../shared';
import { withAppProvider } from '../AppProvider';
import EventListener from '../EventListener';
import { Cell, Navigation, BulkActions, CheckableButton, } from './components';
import { measureColumn, getPrevAndCurrentColumns } from './utilities';
import styles from './ResourceTable.scss';
const IsDraggingContext = React.createContext(false);
export class ResourceTable extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
selectMode: false,
collapsed: false,
columnVisibilityData: [],
heights: [],
preservedScrollPosition: {},
isScrolledFarthestLeft: true,
isScrolledFarthestRight: false,
isDragging: false,
rowIds: [],
};
this.resourceTable = React.createRef();
this.scrollContainer = React.createRef();
this.table = React.createRef();
this.handleResize = debounce(() => {
const { footerContent, truncate } = this.props;
const { table: { current: table }, scrollContainer: { current: scrollContainer }, } = this;
let collapsed = false;
if (table && scrollContainer) {
collapsed = table.scrollWidth > scrollContainer.clientWidth;
scrollContainer.scrollLeft = 0;
}
this.setState(Object.assign({ collapsed, heights: [] }, this.calculateColumnVisibilityData(collapsed)), () => {
if (footerContent || !truncate) {
this.setHeightsAndScrollPosition();
}
});
});
this.onBeforeDragStart = () => {
this.setState({
isDragging: true,
});
};
this.onDragEnd = (result, provided) => {
this.setState({
isDragging: false,
});
this.props.onDragEnd && this.props.onDragEnd(result, provided);
};
this.handleSelectMode = (selectMode) => {
this.setState({ selectMode });
};
this.handleToggleAll = () => {
const { onSelection, rows, selectedIndexes = [] } = this.props;
const shouldSelectAll = selectedIndexes.length !== rows.length;
const newSelectedIndexes = shouldSelectAll ? range(rows.length) : [];
if (newSelectedIndexes.length === 0) {
this.handleSelectMode(false);
}
else if (newSelectedIndexes.length > 0) {
this.handleSelectMode(true);
}
if (onSelection) {
onSelection(newSelectedIndexes);
}
};
this.tallestCellHeights = () => {
const { footerContent, truncate } = this.props;
const { table: { current: table }, } = this;
let { heights } = this.state;
if (table) {
const rows = Array.from(table.getElementsByTagName('tr'));
if (!truncate) {
return (heights = rows.map((row) => {
const fixedCell = row.childNodes[0];
return Math.max(row.clientHeight, fixedCell.clientHeight);
}));
}
if (footerContent) {
const footerCellHeight = rows[rows.length - 1]
.childNodes[0].clientHeight;
heights = [footerCellHeight];
}
}
return heights;
};
this.resetScrollPosition = () => {
const { scrollContainer: { current: scrollContainer }, } = this;
if (scrollContainer) {
const { preservedScrollPosition: { left, top }, } = this.state;
if (left) {
scrollContainer.scrollLeft = left;
}
if (top) {
window.scrollTo(0, top);
}
}
};
this.setHeightsAndScrollPosition = () => {
this.setState({ heights: this.tallestCellHeights() }, this.resetScrollPosition);
};
this.calculateColumnVisibilityData = (collapsed) => {
const { table: { current: table }, scrollContainer: { current: scrollContainer }, resourceTable: { current: resourceTable }, } = this;
if (collapsed && table && scrollContainer && resourceTable) {
const headerCells = table.querySelectorAll(headerCell.selector);
const collapsedHeaderCells = Array.from(headerCells);
const firstVisibleColumnIndex = collapsedHeaderCells.length - 1;
const tableLeftVisibleEdge = scrollContainer.scrollLeft;
const tableRightVisibleEdge = scrollContainer.scrollLeft + resourceTable.offsetWidth;
const tableData = {
firstVisibleColumnIndex,
tableLeftVisibleEdge,
tableRightVisibleEdge,
};
const columnVisibilityData = collapsedHeaderCells.map(measureColumn(tableData));
const lastColumn = columnVisibilityData[columnVisibilityData.length - 1];
return Object.assign({ columnVisibilityData }, getPrevAndCurrentColumns(tableData, columnVisibilityData), { isScrolledFarthestLeft: tableLeftVisibleEdge === 0, isScrolledFarthestRight: lastColumn.rightEdge <= tableRightVisibleEdge });
}
return {
columnVisibilityData: [],
previousColumn: undefined,
currentColumn: undefined,
};
};
this.scrollListener = () => {
this.setState((prevState) => (Object.assign({}, this.calculateColumnVisibilityData(prevState.collapsed))));
};
this.navigateTable = (direction) => {
const { currentColumn, previousColumn } = this.state;
const { scrollContainer: { current: scrollContainer }, } = this;
const handleScroll = () => {
if (!currentColumn || !previousColumn) {
return;
}
if (scrollContainer) {
scrollContainer.scrollLeft =
direction === 'right'
? currentColumn.rightEdge
: previousColumn.leftEdge;
requestAnimationFrame(() => {
this.setState((prevState) => (Object.assign({}, this.calculateColumnVisibilityData(prevState.collapsed))));
});
}
};
return handleScroll;
};
this.renderTotals = (total, index) => {
const id = `totals-cell-${index}`;
const { heights } = this.state;
const { truncate = false } = this.props;
let content;
let contentType;
if (index === 0) {
content = this.totalsRowHeading;
}
if (total !== '' && index > 0) {
contentType = 'numeric';
content = total;
}
return (<Cell total testID={id} key={id} height={heights[1]} content={content} contentType={contentType} truncate={truncate}/>);
};
this.defaultRenderRow = (row, index) => {
const { totals, footerContent, truncate = false, onRowClicked, selectedIndexes = [], onDragEnd, } = this.props;
const { heights } = this.state;
const bodyCellHeights = totals ? heights.slice(2) : heights.slice(1);
const className = classNames(styles.TableRow);
const tableRowClickableClassName = onRowClicked
? classNames(styles.TableRowClickable)
: '';
const tableRowSelectableClassName = selectedIndexes.includes(index)
? classNames(styles.TableRowSelected)
: '';
if (footerContent) {
bodyCellHeights.pop();
}
const draggableId = this.state.rowIds[index] || Math.random().toString();
return (<Draggable isDragDisabled={!onDragEnd} draggableId={draggableId} index={index} key={draggableId}>
{(provided, snapshot) => {
const draggableStyle = provided.draggableProps.style;
const transform = draggableStyle ? draggableStyle.transform : null;
return (<tr ref={provided.innerRef} key={`row-${index}`} className={classNames(className, tableRowClickableClassName, tableRowSelectableClassName, snapshot.isDragging ? styles.IsDragging : '')} {...provided.draggableProps} {...provided.dragHandleProps} style={Object.assign({}, provided.draggableProps.style, (transform && {
transform: `translate(0, ${transform.substring(transform.indexOf(',') + 1, transform.indexOf(')'))})`,
}))}>
<IsDraggingContext.Consumer>
{(isDragging) => {
return row.map((content, cellIndex) => {
const id = `cell-${cellIndex}-row-${index}`;
return (<Cell key={id} testID={id} height={bodyCellHeights[index]} content={content} contentType={this.injectColumnContentTypes[cellIndex]} truncate={truncate} onClick={() => {
if (this.props.selectable && cellIndex === 0) {
return;
}
onRowClicked && onRowClicked(index);
}} isDragOccurring={isDragging}/>);
});
}}
</IsDraggingContext.Consumer>
</tr>);
}}
</Draggable>);
};
this.renderFooter = () => {
const { heights } = this.state;
const footerCellHeight = heights[heights.length - 1];
return (<Cell footer testID="footer-cell" height={footerCellHeight} content={this.props.footerContent} truncate={this.props.truncate}/>);
};
this.defaultOnSort = (headingIndex) => {
const { onSort, truncate, defaultSortDirection = 'ascending', initialSortColumnIndex, } = this.props;
const { sortDirection = defaultSortDirection, sortedColumnIndex = initialSortColumnIndex, } = this.state;
let newSortDirection = defaultSortDirection;
if (sortedColumnIndex === headingIndex) {
newSortDirection =
sortDirection === 'ascending' ? 'descending' : 'ascending';
}
const handleSort = () => {
this.setState({
sortDirection: newSortDirection,
sortedColumnIndex: headingIndex,
}, () => {
if (onSort) {
onSort(headingIndex, newSortDirection);
if (!truncate && this.scrollContainer.current) {
const preservedScrollPosition = {
left: this.scrollContainer.current.scrollLeft,
top: window.scrollY,
};
this.setState({ preservedScrollPosition });
this.handleResize();
}
}
});
};
return handleSort;
};
const { translate } = props.polaris.intl;
this.totalsRowHeading = translate('Polaris.DataTable.totalsRowHeading');
}
componentDidMount() {
// We need to defer the calculation in development so the styles have time to be injected.
if (process.env.NODE_ENV === 'development') {
setTimeout(() => {
this.handleResize();
}, 10);
}
else {
this.handleResize();
}
this.generateRowIds();
}
componentDidUpdate(prevProps) {
if (isEqual(prevProps, this.props)) {
return;
}
this.handleResize();
this.generateRowIds();
}
generateRowIds() {
this.setState({
rowIds: this.props.rows.map(() => Math.random().toString()),
});
}
get injectColumnContentTypes() {
const { selectable, columnContentTypes } = this.props;
return [
...(selectable ? ['text'] : []),
...columnContentTypes,
];
}
get injectTotals() {
const { selectable, totals } = this.props;
if (!totals)
return undefined;
return [...(selectable ? [''] : []), ...totals];
}
get injectHeadings() {
const { headings, selectable } = this.props;
return [...(selectable ? [<div key={Date.now()}/>] : []), ...headings];
}
get injectRows() {
const { rows, selectable, selectedIndexes = [], onSelection } = this.props;
const MemoCheckbox = ({ rowIndex }) => {
const handleOnChange = () => {
const selectedIndexesSet = new Set(selectedIndexes);
if (selectedIndexesSet.has(rowIndex)) {
selectedIndexesSet.delete(rowIndex);
}
else {
selectedIndexesSet.add(rowIndex);
}
const newSelectedIndexes = [...selectedIndexesSet].sort();
if (onSelection) {
onSelection(newSelectedIndexes);
}
if (newSelectedIndexes.length === 0) {
this.handleSelectMode(false);
}
else if (newSelectedIndexes.length > 0) {
this.handleSelectMode(true);
}
};
return (<Checkbox label="" checked={selectedIndexes && selectedIndexes.includes(rowIndex)} onChange={handleOnChange}/>);
};
return rows.map((cells, rowIndex) => {
return [
...(selectable
? [<MemoCheckbox key={Date.now()} rowIndex={rowIndex}/>]
: []),
...cells,
];
});
}
get resourceName() {
return (this.props.resourceName || {
singular: this.props.polaris.intl.translate('Polaris.ResourceList.defaultItemSingular'),
plural: this.props.polaris.intl.translate('Polaris.ResourceList.defaultItemPlural'),
});
}
get bulkActionsLabel() {
const { selectedIndexes = [], polaris: { intl }, } = this.props;
const selectedCount = selectedIndexes.length;
return intl.translate('Polaris.ResourceList.selected', {
selectedItemsCount: selectedCount,
});
}
get bulkActionsAccessibilityLabel() {
const { rows, selectedIndexes = [], polaris: { intl }, } = this.props;
const selectedCount = selectedIndexes.length;
const totalItemsCount = rows.length;
const allSelected = selectedCount === totalItemsCount;
if (totalItemsCount === 1 && allSelected) {
return intl.translate('Polaris.ResourceList.a11yCheckboxDeselectAllSingle', { resourceNameSingular: this.resourceName.singular });
}
else if (totalItemsCount === 1) {
return intl.translate('Polaris.ResourceList.a11yCheckboxSelectAllSingle', {
resourceNameSingular: this.resourceName.singular,
});
}
else if (allSelected) {
return intl.translate('Polaris.ResourceList.a11yCheckboxDeselectAllMultiple', {
itemsLength: rows.length,
resourceNamePlural: this.resourceName.plural,
});
}
else {
return intl.translate('Polaris.ResourceList.a11yCheckboxSelectAllMultiple', {
itemsLength: rows.length,
resourceNamePlural: this.resourceName.plural,
});
}
}
get bulkSelectState() {
const { rows, selectedIndexes = [] } = this.props;
let selectState = 'indeterminate';
if (!selectedIndexes ||
(Array.isArray(selectedIndexes) && selectedIndexes.length === 0)) {
selectState = false;
}
else if (selectedIndexes.length === rows.length) {
selectState = true;
}
return selectState;
}
get headerTitle() {
const { rows, polaris: { intl }, loading, } = this.props;
const resourceName = this.resourceName;
const rowsCount = rows.length;
const resource = rowsCount === 1 && !loading ? resourceName.singular : resourceName.plural;
const headerTitleMarkup = loading
? intl.translate('Polaris.ResourceList.loading', { resource })
: intl.translate('Polaris.ResourceList.showing', {
itemsCount: rowsCount,
resource,
});
return headerTitleMarkup;
}
render() {
const { totals, truncate, footerContent, sortable, defaultSortDirection = 'ascending', initialSortColumnIndex = 0, loading, bulkActions, rows, headerNode, } = this.props;
const { polaris: { intl }, } = this.props;
const { collapsed, columnVisibilityData, heights, sortedColumnIndex = initialSortColumnIndex, sortDirection = defaultSortDirection, isScrolledFarthestLeft, isScrolledFarthestRight, selectMode, } = this.state;
const className = classNames(styles.ResourceTable, collapsed && styles.collapsed, footerContent && styles.hasFooter);
const wrapperClassName = classNames(styles.TableWrapper, collapsed && styles.collapsed);
const footerClassName = classNames(footerContent && styles.TableFoot);
const footerMarkup = footerContent ? (<tfoot className={footerClassName}>
<tr>{this.renderFooter()}</tr>
</tfoot>) : null;
const totalsMarkup = totals ? (<tr>{totals.map(this.renderTotals)}</tr>) : null;
const headingMarkup = (<tr>
{this.injectHeadings.map((heading, headingIndex) => {
let sortableHeadingProps;
const id = `heading-cell-${headingIndex}`;
if (sortable) {
const isSortable = sortable[headingIndex];
const isSorted = sortedColumnIndex === headingIndex;
const direction = isSorted ? sortDirection : 'none';
sortableHeadingProps = {
defaultSortDirection,
sorted: isSorted,
sortable: isSortable,
sortDirection: direction,
onSort: this.defaultOnSort(headingIndex),
};
}
const height = !truncate ? heights[0] : undefined;
return (<Cell header key={id} testID={id} height={height} content={heading} contentType={this.injectColumnContentTypes[headingIndex]} truncate={truncate} {...sortableHeadingProps}/>);
})}
</tr>);
const bodyMarkup = this.injectRows.map(this.defaultRenderRow);
const style = footerContent
? { marginBottom: `${heights[heights.length - 1]}px` }
: undefined;
const loadingMarkup = (<div style={{
position: 'absolute',
top: 0,
bottom: 0,
left: 0,
right: 0,
display: 'flex',
alignContent: 'center',
justifyContent: 'center',
alignItems: 'center',
}}>
<div style={{
position: 'absolute',
top: 0,
bottom: 0,
left: 0,
right: 0,
display: 'flex',
justifyContent: 'center',
backgroundColor: 'rgba(255, 255, 255, 0.5)',
}}/>
<Spinner size="large" color="teal"/>
</div>);
const emptyResultMarkup = (<div style={{ paddingTop: 60, paddingBottom: 60 }}>
<EmptySearchResult title={intl.translate('Polaris.ResourceList.emptySearchResultTitle', {
resourceNamePlural: this.resourceName.plural,
})} description={intl.translate('Polaris.ResourceList.emptySearchResultDescription')} withIllustration/>
</div>);
const checkableButtonMarkup = (<div className={selectMode ? styles.HideCheckableButtonWrapper : ''}>
<CheckableButton accessibilityLabel={this.bulkActionsAccessibilityLabel} label={this.headerTitle} onToggleAll={this.handleToggleAll} plain disabled={loading}/>
</div>);
return (<div className={wrapperClassName}>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}>
<div style={{ marginLeft: 8 }}>
{this.props.selectable && checkableButtonMarkup}
<BulkActions label={this.bulkActionsLabel} accessibilityLabel={this.bulkActionsAccessibilityLabel} selected={this.bulkSelectState} onToggleAll={this.handleToggleAll} selectMode={selectMode} onSelectModeToggle={this.handleSelectMode} actions={bulkActions} disabled={loading}/>
<EventListener event="resize" handler={this.handleResize}/>
</div>
{this.props.selectable && <div style={{ height: 62 }}/>}
<div style={{
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
}}>
{this.props.selectable && (<div style={{ marginRight: 16 }}>{headerNode}</div>)}
<Navigation columnVisibilityData={columnVisibilityData} isScrolledFarthestLeft={isScrolledFarthestLeft} isScrolledFarthestRight={isScrolledFarthestRight} navigateTableLeft={this.navigateTable('left')} navigateTableRight={this.navigateTable('right')}/>
</div>
</div>
<div className={className} ref={this.resourceTable}>
<div className={styles.ScrollContainer} ref={this.scrollContainer} style={style}>
<EventListener event="resize" handler={this.handleResize}/>
<EventListener capture event="scroll" handler={this.scrollListener}/>
<IsDraggingContext.Provider value={this.state.isDragging}>
<DragDropContext onBeforeDragStart={this.onBeforeDragStart} onDragEnd={this.onDragEnd}>
<table className={styles.Table} ref={this.table}>
<thead>
{headingMarkup}
{totalsMarkup}
</thead>
<Droppable droppableId="droppable">
{(provided) => (<tbody {...provided.droppableProps} ref={provided.innerRef}>
{bodyMarkup}
{provided.placeholder}
</tbody>)}
</Droppable>
{footerMarkup}
</table>
</DragDropContext>
</IsDraggingContext.Provider>
{!loading && rows.length === 0 && emptyResultMarkup}
{loading && rows.length === 0 && <div style={{ height: 380 }}/>}
{loading && loadingMarkup}
</div>
</div>
</div>);
}
}
export default withAppProvider()(ResourceTable);