cspace-ui
Version:
CollectionSpace user interface for browsers
503 lines (414 loc) • 13.9 kB
JSX
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import Immutable from 'immutable';
import { defineMessages, intlShape, FormattedMessage } from 'react-intl';
import { Link } from 'react-router-dom';
import get from 'lodash/get';
import { Table } from 'cspace-layout';
import dimensions from '../../../styles/dimensions.css';
import styles from '../../../styles/cspace-ui/SearchResultTable.css';
import emptyResultStyles from '../../../styles/cspace-ui/SearchResultEmpty.css';
const rowHeight = parseInt(dimensions.inputHeight, 10);
const messages = defineMessages({
searchPending: {
id: 'searchResultTable.searchPending',
defaultMessage: '⋯',
},
rowLabel: {
id: 'searchResultTable.rowLabel',
description: 'The aria-label for a row',
defaultMessage: '{primary} selected row {index} of {total}',
},
});
/**
* Determines if a column is sortable for a given search. A column is sortable if sortBy is truthy,
* and the search is not constrained by a related record, or if it is, the field to sort by is not
* complex. This is here to deal with CSPACE-5366 (searches with related record constraints are
* done using CMIS, which can't see into complex fields). If that bug is ever fixed, then it will
* suffice just to check sortBy.
*/
const isSortable = (column, searchDescriptor) => {
const { sortBy } = column;
return (sortBy && (!searchDescriptor.getIn(['searchQuery', 'rel']) || sortBy.indexOf('/0/') === -1));
};
const rowRenderer = (params, location, ariaLabel) => {
// This is a fork of react-virtualized's default row renderer:
// https://github.com/bvaughn/react-virtualized/blob/master/source/Table/defaultRowRenderer.js
const {
className,
columns,
index,
key,
onRowClick,
// onRowDoubleClick,
// onRowMouseOut,
// onRowMouseOver,
// onRowRightClick,
rowData,
style,
} = params;
const a11yProps = {};
if (
onRowClick // ||
// onRowDoubleClick ||
// onRowMouseOut ||
// onRowMouseOver ||
// onRowRightClick
) {
a11yProps['aria-label'] = ariaLabel;
a11yProps.tabIndex = 0;
if (onRowClick) {
a11yProps.onClick = (event) => onRowClick({ event, index, rowData });
}
// if (onRowDoubleClick) {
// a11yProps.onDoubleClick = event =>
// onRowDoubleClick({ event, index, rowData });
// }
// if (onRowMouseOut) {
// a11yProps.onMouseOut = event => onRowMouseOut({ event, index, rowData });
// }
// if (onRowMouseOver) {
// a11yProps.onMouseOver = event => onRowMouseOver({ event, index, rowData });
// }
// if (onRowRightClick) {
// a11yProps.onContextMenu = event =>
// onRowRightClick({ event, index, rowData });
// }
}
if (location) {
return (
<Link
{...a11yProps}
className={className}
key={key}
role="row"
style={style}
to={location}
>
{columns}
</Link>
);
}
return (
<div
{...a11yProps}
className={className}
data-index={index}
key={key}
role="row"
style={style}
>
{columns}
</div>
);
};
const propTypes = {
columnSetName: PropTypes.string,
config: PropTypes.shape({
listTypes: PropTypes.object,
recordTypes: PropTypes.object,
subresources: PropTypes.object,
}).isRequired,
formatCellData: PropTypes.func,
formatColumnLabel: PropTypes.func,
intl: intlShape,
isSearchPending: PropTypes.bool,
linkItems: PropTypes.bool,
// eslint-disable-next-line react/forbid-prop-types
linkState: PropTypes.object,
listType: PropTypes.string,
perms: PropTypes.instanceOf(Immutable.Map),
searchDescriptor: PropTypes.instanceOf(Immutable.Map),
searchError: PropTypes.instanceOf(Immutable.Map),
searchResult: PropTypes.instanceOf(Immutable.Map),
showCheckboxColumn: PropTypes.bool,
renderCheckbox: PropTypes.func,
renderHeader: PropTypes.func,
renderFooter: PropTypes.func,
renderSelectBar: PropTypes.func,
getItemLocation: PropTypes.func,
onItemClick: PropTypes.func,
onSortChange: PropTypes.func,
};
const defaultProps = {
columnSetName: 'default',
formatCellData: (column, data) => data,
formatColumnLabel: (column) => get(column, ['messages', 'label', 'defaultMessage']),
linkItems: true,
listType: 'common',
renderHeader: () => null,
renderFooter: () => null,
renderSelectBar: () => null,
};
export default class SearchResultTable extends Component {
constructor() {
super();
this.getColumnConfig = this.getColumnConfig.bind(this);
this.getItemLocation = this.getItemLocation.bind(this);
this.handleKeyDown = this.handleKeyDown.bind(this);
this.handleRowClick = this.handleRowClick.bind(this);
this.renderNoItems = this.renderNoItems.bind(this);
this.renderRow = this.renderRow.bind(this);
this.renderRowLabel = this.renderRowLabel.bind(this);
this.sort = this.sort.bind(this);
}
handleKeyDown(event) {
if (event.key === 'Enter') {
const index = get(event, ['target', 'dataset', 'index']);
if (typeof index !== 'undefined') {
this.handleRowClick(event.target.dataset.index);
}
}
}
handleRowClick(index) {
const {
config,
listType,
searchResult,
onItemClick,
} = this.props;
if (onItemClick) {
const listTypeConfig = config.listTypes[listType];
const { listNodeName, itemNodeName } = listTypeConfig;
const items = searchResult.getIn([listNodeName, itemNodeName]);
const item = Immutable.List.isList(items) ? items.get(index) : items;
onItemClick(item, index);
}
}
getColumnConfig() {
const {
columnSetName,
config,
searchDescriptor,
} = this.props;
const recordType = searchDescriptor.get('recordType');
const subresource = searchDescriptor.get('subresource');
const columnConfigurer = subresource
? config.subresources[subresource]
: config.recordTypes[recordType];
let columnConfig = get(columnConfigurer, ['columns', columnSetName]);
if (!columnConfig && columnSetName !== defaultProps.columnSetName) {
// Fall back to the default column set if the named one doesn't exist.
columnConfig = get(columnConfigurer, ['columns', defaultProps.columnSetName]);
}
if (!columnConfig) {
columnConfig = [];
}
return columnConfig;
}
getItemLocation(item) {
const {
config,
linkState,
listType,
perms,
searchDescriptor,
} = this.props;
const getItemLocationPath = get(config, ['listTypes', listType, 'getItemLocationPath']);
if (!getItemLocationPath) {
return undefined;
}
const itemContext = { config, perms, searchDescriptor };
const itemLocationPath = getItemLocationPath(item, itemContext);
if (!itemLocationPath) {
return undefined;
}
// Create a location with the item location path, along with enough state to reproduce this
// search. The search descriptor is converted to an object in order to reliably store it in
// location state. Also merge in any object that was passed in via the linkState prop.
const state = {
searchDescriptor: searchDescriptor.toJS(),
// The search traverser on records will always link to the search result page, so use
// its search name.
searchName: 'searchResultPage',
...linkState,
};
return {
state,
pathname: itemLocationPath,
};
}
sort({ sortBy, sortDirection }) {
const {
onSortChange,
} = this.props;
if (onSortChange) {
onSortChange(sortBy + (sortDirection === Table.SortDirection.DESC ? ' desc' : ''));
}
}
renderNoItems() {
const {
isSearchPending,
} = this.props;
const message = isSearchPending ? <FormattedMessage {...messages.searchPending} /> : null;
return <div className={emptyResultStyles.common}>{message}</div>;
}
renderRowLabel(params, totalItems) {
const {
intl,
} = this.props;
const {
index,
rowData,
} = params;
const columnConfig = this.getColumnConfig();
const primaryCol = Object.keys(columnConfig)
.filter((col) => col !== 'workflowState')
.at(0);
const primaryData = rowData.get(primaryCol);
const label = primaryData
? intl.formatMessage(messages.rowLabel,
{ primary: primaryData, index: index + 1, total: totalItems })
: 'row';
return label;
}
renderRow(params, totalItems) {
const {
getItemLocation,
linkItems,
} = this.props;
const {
rowData,
} = params;
let location;
if (linkItems) {
const locationGetter = getItemLocation || this.getItemLocation;
location = locationGetter(rowData);
}
const ariaLabel = this.renderRowLabel(params, totalItems);
return rowRenderer(params, location, ariaLabel);
}
renderTable() {
const {
config,
formatCellData,
formatColumnLabel,
listType,
searchDescriptor,
searchResult,
showCheckboxColumn,
renderCheckbox,
} = this.props;
if (searchResult) {
const searchQuery = searchDescriptor.get('searchQuery');
const listTypeConfig = config.listTypes[listType];
const { listNodeName, itemNodeName } = listTypeConfig;
let sortColumnName = null;
let sortDir = null;
const sortSpec = searchQuery.get('sort');
if (sortSpec) {
[sortColumnName, sortDir] = sortSpec.split(' ');
}
const list = searchResult.get(listNodeName);
const pageSize = parseInt(list.get('pageSize'), 10);
const totalItems = parseInt(list.get('totalItems'), 10);
const itemsInPage = parseInt(list.get('itemsInPage'), 10);
let items = list.get(itemNodeName);
if (!items) {
items = Immutable.List();
}
if (!Immutable.List.isList(items)) {
// If there's only one result, it won't be returned as a list.
items = Immutable.List.of(items);
}
const columnConfig = this.getColumnConfig();
const columns = Object.keys(columnConfig)
.filter((name) => !columnConfig[name].disabled)
.sort((nameA, nameB) => {
const orderA = columnConfig[nameA].order;
const orderB = columnConfig[nameB].order;
return orderA - orderB;
})
.map((name) => {
const column = columnConfig[name];
return {
cellDataGetter: ({ dataKey, rowData }) => {
let data = null;
if (rowData) {
const keys = dataKey.split('|');
for (let i = 0; i < keys.length; i += 1) {
const candidateValue = rowData.get(keys[i]);
if (candidateValue) {
data = candidateValue;
break;
}
}
}
return formatCellData(column, data, rowData);
},
disableSort: !isSortable(column, searchDescriptor),
flexGrow: column.flexGrow,
flexShrink: column.flexShrink,
label: formatColumnLabel(column),
dataKey: column.dataKey || name,
width: column.width,
};
});
let heightBasis;
if (Number.isNaN(totalItems)) {
// We don't yet know how many items are found by the search. Set the height to one item, so
// an ellipsis (or other calculating indicator) can be shown.
heightBasis = 1;
} else {
// If all of the search results fit on one page, shrink the table to fit the number of
// results. Otherwise, size the table to fit the desired page size, even if there aren't
// that many results on this page. This keeps the pager from jumping up on the last page
// and while page/sorting changes are in progress.
heightBasis = (totalItems <= pageSize && !Number.isNaN(itemsInPage))
? itemsInPage
: pageSize;
if (heightBasis === 0) {
// If there are no items, set the height to one, because it looks weird when the footer
// is mashed up against the header. This also leaves room to display a "no records found"
// message if desired.
heightBasis = 1;
}
}
const height = (heightBasis * rowHeight) + rowHeight;
const renderRowWithTotal = (params) => this.renderRow(params, totalItems);
return (
<div style={{ height }}>
<Table
columns={columns}
rowCount={items.size}
rowGetter={({ index }) => items.get(index)}
showCheckboxColumn={showCheckboxColumn}
onRowClick={this.handleRowClick}
renderCheckbox={renderCheckbox}
sort={this.sort}
sortBy={sortColumnName}
sortDirection={sortDir === 'desc' ? Table.SortDirection.DESC : Table.SortDirection.ASC}
noRowsRenderer={this.renderNoItems}
rowRenderer={renderRowWithTotal}
/>
</div>
);
}
return null;
}
render() {
const {
isSearchPending,
searchError,
searchResult,
renderHeader,
renderSelectBar,
renderFooter,
} = this.props;
return (
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
<div
className={styles.common}
role="presentation"
onKeyDown={this.handleKeyDown}
>
{renderHeader({ isSearchPending, searchError, searchResult })}
{renderSelectBar()}
{this.renderTable()}
{renderFooter({ isSearchPending, searchError, searchResult })}
</div>
);
}
}
SearchResultTable.propTypes = propTypes;
SearchResultTable.defaultProps = defaultProps;