import React, { useContext, useEffect, useRef, useState } from 'react';
import classnames from 'classnames';
import { v4 } from 'uuid';
import styles from './styles.module.scss';
import { Icons } from '..';
const TABLE_HEADER = 'TABLE_HEADER';
const TABLE_ROW = 'TABLE_ROW';
const ALIGN_TO_JUSTIFY = {
'left': 'flex-start',
'center': 'center',
'right': 'flex-end'
};
const SORT_INDICATORS = {
'asc':
,
'desc':
,
'none': null,
};
export type SortDirection = 'asc' | 'desc' | 'none';
export type SortRule = {
column: string | null
direction: SortDirection
}
type ListViewContextType = {
mode: typeof TABLE_ROW | typeof TABLE_HEADER
height: React.CSSProperties['minHeight']
fontSize: React.CSSProperties['fontSize']
rowHeaderWidth: number
item?: any
sort?: SortRule[]
onClickHeader?: (column: C, value: any) => void
}
// In practice, this should not be used, but a React.Context requires a default value
const DEFAULT_LIST_VIEW_CONTEXT: ListViewContextType = {
mode: 'TABLE_ROW' as const,
height: 0,
fontSize: 0,
rowHeaderWidth: 0,
}
const ListViewContext = React.createContext(DEFAULT_LIST_VIEW_CONTEXT);
export type ListViewProps = {
data: T[]
sort?: SortRule[]
onClickHeader?: (column: C, sortTemplate: (item: T) => SortableValue) => void
onClickRow?: (item: T) => void
keyTemplate?: (item: T) => C
showHeaders?: boolean
fixedWidth?: boolean
padOuterColumns?: boolean
scrollX?: boolean
rowHeight?: React.CSSProperties['minHeight']
headerHeight?: React.CSSProperties['minHeight']
fontSize?: React.CSSProperties['fontSize']
headerFontSize?: React.CSSProperties['fontSize']
// HACK: this is needed because we're reading `props` out of children,
// which isn't defined on all React.ReactNode types (such as string or number)
children: JSX.Element | JSX.Element[]
// children: React.ReactNode
}
interface ObjectWithID {
id: string | number
}
function isAnObjectWithID(thing: unknown): thing is ObjectWithID {
if (thing instanceof Object) return thing.hasOwnProperty('id');
return false;
}
function defaultKeyTemplate(item: T) {
if (isAnObjectWithID(item)) {
return item.id;
} else {
return v4();
}
}
/**
* `T` is the data type of each Row,
* `C` is the column id type (probably a union of string literals)
*/
export default function ListView(props: ListViewProps) {
const {
data = [],
sort = [],
onClickHeader,
onClickRow,
keyTemplate = defaultKeyTemplate,
showHeaders = true,
fixedWidth = true,
padOuterColumns = false,
scrollX = false,
rowHeight,
headerHeight,
fontSize,
headerFontSize,
children,
} = props;
// Handle scrolling with state, refs, and a listener
const containerRef = useRef(null);
const tableRef = useRef(null);
const [tableShadows, setTableShadows] = useState({left: false, right: false});
useEffect(() => {
if (!containerRef.current) return;
function shadowHandler() {
if (!(containerRef.current && tableRef.current)) return;
if (scrollX) {
const container = containerRef.current.getBoundingClientRect();
const table = tableRef.current.getBoundingClientRect();
const leftShadow = table.left < container.left;
const rightShadow = table.right > container.right;
if (tableShadows.left !== leftShadow || tableShadows.right !== rightShadow) {
setTableShadows({left: leftShadow, right: rightShadow});
}
}
}
function cleanup() {
window.removeEventListener('resize', shadowHandler);
if (!containerRef.current) return;
containerRef.current.removeEventListener('scroll', shadowHandler);
}
window.addEventListener('resize', shadowHandler);
containerRef.current.addEventListener('scroll', shadowHandler);
shadowHandler();
return cleanup;
});
// Compute total width of "row headers"
const rowHeaderWidth = React.Children.toArray(children).reduce((a, n) => {
a += (n as React.ReactElement).props.isRowHeader ? (n as React.ReactElement).props.width : 0;
return a;
}, 0) as number;
const headerContextValue: ListViewContextType = {
mode: TABLE_HEADER,
height: headerHeight,
fontSize: headerFontSize,
rowHeaderWidth,
sort,
onClickHeader,
}
return (
{showHeaders ? (
{children}
) : null}
{data.map(item => (
onClickRow(item) : undefined}
>
{children}
))}
);
}
type ListViewColumnProps = {
id: string
title?: React.ReactNode
template?: (item: T) => React.ReactNode
valueTemplate?: (item: T) => SortableValue
onClick?: (item: T) => void
disabled?: (item: T) => boolean
isRowHeader?: boolean
width?: React.CSSProperties['width']
minWidth?: React.CSSProperties['minWidth']
align?: keyof typeof ALIGN_TO_JUSTIFY
}
/**
* `T` is the data type of each Row,
*/
export function ListViewColumn(props: ListViewColumnProps) {
const {
id,
title = null,
template = () => null,
valueTemplate,
onClick,
disabled = () => false,
isRowHeader = false,
width = 'auto',
minWidth = 'auto',
align = 'left'
} = props;
const {
mode,
height,
fontSize,
rowHeaderWidth,
item,
sort = [],
onClickHeader,
} = useContext(ListViewContext);
const headerClickable = Boolean(onClickHeader);
const cellClickable = item && !disabled(item) && Boolean(onClick);
if (mode === TABLE_HEADER) {
const sortRuleIndex = sort.findIndex(x => x.column === id);
const sortRule = sortRuleIndex > -1 ? sort[sortRuleIndex] : null;
const sortIndicator = sortRule && ['asc', 'desc'].indexOf(sortRule.direction) > -1 ?
SORT_INDICATORS[sortRule.direction] : null;
return (
onClickHeader(id, valueTemplate || template) : undefined}
className={classnames(styles.listViewHeader, { [styles.clickable]: headerClickable })}
style={{minHeight: height, fontSize, justifyContent: ALIGN_TO_JUSTIFY[align]}}
>
{title !== null ? title : id}
{sortIndicator}
|
);
} else {
const contents = (
onClick(item) : undefined}
className={classnames(styles.listViewCell, { [styles.clickable]: cellClickable })}
style={{minHeight: height, fontSize, justifyContent: ALIGN_TO_JUSTIFY[align]}}
>
{Boolean(template) && template(item)}
);
return isRowHeader ? (
{contents}
|
) : (
{contents}
|
);
}
}
export const ListViewColumnSpacer: React.FunctionComponent<{id?: string}> = ({id}) => {
return ;
}
export const ListViewClickableLink: React.FunctionComponent<{
onClick?: (evt: React.MouseEvent) => void
}> = ({
onClick,
children,
}) => {
return (
{children}
);
}
export type SortableValue = number | string | boolean | null | undefined;
// Helper functions
export function getDefaultSortFunction(sortTemplate: (item: T) => SortableValue, sortDirection: SortDirection, nullsLast = true) {
return function(a: T, b: T) {
// Short circuit and return initial order if sorting is toggled off
if (sortDirection !== 'asc' && sortDirection !== 'desc') { return 1; }
// Pass each item through the sortTemplate function
const aValue = sortTemplate(a),
bValue = sortTemplate(b);
// Invert sorting if mode is descending
const sortMultiplier = sortDirection === 'desc' ? -1 : 1;
// Use coercion trick to check for either null or undefined
if (aValue == null && bValue == null) {
return 0;
} else if (aValue == null) {
return nullsLast ? -1 : 1;
} else if (bValue == null) {
return nullsLast ? 1 : -1;
} else if (typeof aValue === 'number' && typeof bValue === 'number') {
return (aValue > bValue ? 1 : aValue < bValue ? -1 : 0) * sortMultiplier;
} else {
return String(aValue).localeCompare(String(bValue)) * sortMultiplier;
}
}
}
export function getNextSortDirection(sortDirection: SortDirection, reverse = false) {
const nextMap = {
'asc': 'desc',
'desc': 'none',
'none': 'asc'
};
return reverse ?
nextMap[nextMap[sortDirection]] :
nextMap[sortDirection] as SortDirection;
};