import React from 'react';
import classNames from 'classnames';
import { connect } from 'react-redux';
import {
find, get, union, sortBy, groupBy, concat, debounce
} from 'lodash';
import { NODE_DETAILS_DATA_ROWS_DEFAULT_LIMIT } from '../../constants/limits';
import { TABLE_ROW_FOCUS_DEBOUNCE_INTERVAL } from '../../constants/timer';
import ShowMore from '../show-more';
import NodeDetailsTableRow from './node-details-table-row';
import NodeDetailsTableHeaders from './node-details-table-headers';
import { ipToPaddedString } from '../../utils/string-utils';
import { moveElement, insertElement } from '../../utils/array-utils';
import {
isIP, isNumeric, defaultSortDesc, getTableColumnsStyles
} from '../../utils/node-details-utils';
function getDefaultSortedBy(columns, nodes) {
// default sorter specified by columns
const defaultSortColumn = find(columns, { defaultSort: true });
if (defaultSortColumn) {
return defaultSortColumn.id;
}
// otherwise choose first metric
const firstNodeWithMetrics = find(nodes, n => get(n, ['metrics', 0]));
if (firstNodeWithMetrics) {
return get(firstNodeWithMetrics, ['metrics', 0, 'id']);
}
return 'label';
}
function maybeToLower(value) {
if (!value || !value.toLowerCase) {
return value;
}
return value.toLowerCase();
}
function getNodeValue(node, header) {
const fieldId = header && header.id;
if (fieldId !== null) {
let field = union(node.metrics, node.metadata).find(f => f.id === fieldId);
if (field) {
if (isIP(header)) {
// Format the IPs so that they are sorted numerically.
return ipToPaddedString(field.value);
} if (isNumeric(header)) {
return parseFloat(field.value);
}
return field.value;
}
if (node.parents) {
field = node.parents.find(f => f.topologyId === fieldId);
if (field) {
return field.label;
}
}
if (node[fieldId] !== undefined && node[fieldId] !== null) {
return node[fieldId];
}
}
return null;
}
function getValueForSortedBy(sortedByHeader) {
return node => maybeToLower(getNodeValue(node, sortedByHeader));
}
function getMetaDataSorters(nodes) {
// returns an array of sorters that will take a node
return get(nodes, [0, 'metadata'], []).map((field, index) => (node) => {
const nodeMetadataField = node.metadata && node.metadata[index];
if (nodeMetadataField) {
if (isNumeric(nodeMetadataField)) {
return parseFloat(nodeMetadataField.value);
}
return nodeMetadataField.value;
}
return null;
});
}
function sortNodes(nodes, getValue, sortedDesc) {
const sortedNodes = sortBy(
nodes,
getValue,
getMetaDataSorters(nodes)
);
if (sortedDesc) {
sortedNodes.reverse();
}
return sortedNodes;
}
function getSortedNodes(nodes, sortedByHeader, sortedDesc) {
const getValue = getValueForSortedBy(sortedByHeader);
const withAndWithoutValues = groupBy(nodes, (n) => {
if (!n || n.valueEmpty) {
return 'withoutValues';
}
const v = getValue(n);
return v !== null && v !== undefined ? 'withValues' : 'withoutValues';
});
const withValues = sortNodes(withAndWithoutValues.withValues, getValue, sortedDesc);
const withoutValues = sortNodes(withAndWithoutValues.withoutValues, getValue, sortedDesc);
return concat(withValues, withoutValues);
}
// By inserting this fake invisible row into the table, with the help of
// some CSS trickery, we make the inner scrollable content of the table
// have a minimal height. That prevents auto-scroll under a focus if the
// number of table rows shrinks.
function minHeightConstraint(height = 0) {
return
;
}
class NodeDetailsTable extends React.Component {
constructor(props, context) {
super(props, context);
this.state = {
limit: props.limit,
sortedBy: this.props.sortedBy,
sortedDesc: this.props.sortedDesc
};
this.focusState = {};
this.updateSorted = this.updateSorted.bind(this);
this.handleLimitClick = this.handleLimitClick.bind(this);
this.onMouseLeaveRow = this.onMouseLeaveRow.bind(this);
this.onMouseEnterRow = this.onMouseEnterRow.bind(this);
this.saveTableContentRef = this.saveTableContentRef.bind(this);
this.saveTableHeadRef = this.saveTableHeadRef.bind(this);
// Use debouncing to prevent event flooding when e.g. crossing fast with mouse cursor
// over the whole table. That would be expensive as each focus causes table to rerender.
this.debouncedFocusRow = debounce(this.focusRow, TABLE_ROW_FOCUS_DEBOUNCE_INTERVAL);
this.debouncedBlurRow = debounce(this.blurRow, TABLE_ROW_FOCUS_DEBOUNCE_INTERVAL);
}
updateSorted(sortedBy, sortedDesc) {
this.setState({ sortedBy, sortedDesc });
this.props.onSortChange(sortedBy, sortedDesc);
}
handleLimitClick() {
this.setState(prevState => ({
limit: prevState.limit ? 0 : this.props.limit
}));
}
focusRow(rowIndex, node) {
// Remember the focused row index, the node that was focused and
// the table content height so that we can keep the node row fixed
// without auto-scrolling happening.
// NOTE: It would be ideal to modify the real component state here,
// but that would cause whole table to rerender, which becomes to
// expensive with the current implementation if the table consists
// of 1000+ nodes.
this.focusState = {
focusedNode: node,
focusedRowIndex: rowIndex,
tableContentMinHeightConstraint: this.tableContentRef && this.tableContentRef.scrollHeight,
};
}
blurRow() {
// Reset the focus state
this.focusState = {};
}
onMouseEnterRow(rowIndex, node) {
this.debouncedBlurRow.cancel();
this.debouncedFocusRow(rowIndex, node);
}
onMouseLeaveRow() {
this.debouncedFocusRow.cancel();
this.debouncedBlurRow();
}
saveTableContentRef(ref) {
this.tableContentRef = ref;
}
saveTableHeadRef(ref) {
this.tableHeadRef = ref;
}
getColumnHeaders() {
const columns = this.props.columns || [];
return [{ id: 'label', label: this.props.label }].concat(columns);
}
componentDidMount() {
const scrollbarWidth = this.tableContentRef.offsetWidth - this.tableContentRef.clientWidth;
this.tableHeadRef.style.paddingRight = `${scrollbarWidth}px`;
}
render() {
const {
nodeIdKey, columns, topologyId, onClickRow,
onMouseEnter, onMouseLeave, timestamp
} = this.props;
const sortedBy = this.state.sortedBy || getDefaultSortedBy(columns, this.props.nodes);
const sortedByHeader = this.getColumnHeaders().find(h => h.id === sortedBy);
const sortedDesc = (this.state.sortedDesc === null)
? defaultSortDesc(sortedByHeader) : this.state.sortedDesc;
let nodes = getSortedNodes(this.props.nodes, sortedByHeader, sortedDesc);
const { focusedNode, focusedRowIndex, tableContentMinHeightConstraint } = this.focusState;
if (Number.isInteger(focusedRowIndex) && focusedRowIndex < nodes.length) {
const nodeRowIndex = nodes.findIndex(node => node.id === focusedNode.id);
if (nodeRowIndex >= 0) {
// If the focused node still exists in the table, we move it
// to the hovered row, keeping the rest of the table sorted.
nodes = moveElement(nodes, nodeRowIndex, focusedRowIndex);
} else {
// Otherwise we insert the dead focused node there, pretending
// it's still alive. That enables the users to read off all the
// info they want and perhaps even open the details panel. Also,
// only if we do this, we can guarantee that mouse hover will
// always freeze the table row until we focus out.
nodes = insertElement(nodes, focusedRowIndex, focusedNode);
}
}
// If we are 1 over the limit, we still show the row. We never display
// "+1" but only "+2" and up.
const limit = this.state.limit > 0 && nodes.length === this.state.limit + 1
? nodes.length
: this.state.limit;
const limited = nodes && limit > 0 && nodes.length > limit;
const expanded = limit === 0;
const notShown = nodes.length - limit;
if (nodes && limited) {
nodes = nodes.slice(0, limit);
}
const className = classNames('node-details-table-wrapper-wrapper', this.props.className);
const headers = this.getColumnHeaders();
const styles = getTableColumnsStyles(headers);
return (
{this.props.nodes && this.props.nodes.length > 0 && (
)}
{nodes && nodes.map((node, index) => (
))}
{minHeightConstraint(tableContentMinHeightConstraint)}
);
}
}
NodeDetailsTable.defaultProps = {
limit: NODE_DETAILS_DATA_ROWS_DEFAULT_LIMIT,
// key to identify a node in a row (used for topology links)
nodeIdKey: 'id',
onSortChange: () => { },
sortedBy: null,
sortedDesc: null,
};
function mapStateToProps(state) {
return {
timestamp: state.get('pausedAt'),
};
}
export default connect(mapStateToProps)(NodeDetailsTable);