Skip to content
107 changes: 107 additions & 0 deletions client/common/Table/TableBase.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import classNames from 'classnames';
import { orderBy } from 'lodash';
import PropTypes from 'prop-types';
import React, { useMemo } from 'react';
import Loader from '../../modules/App/components/loader';
import { DIRECTION } from '../../modules/IDE/actions/sorting';
import { TableEmpty } from './TableElements';
import TableHeaderCell, { StyledHeaderCell } from './TableHeaderCell';

const toAscDesc = (direction) => (direction === DIRECTION.ASC ? 'asc' : 'desc');

/**
* Renders the headers, loading spinner, empty message.
* Sorts the array of items based on the `sortBy` prop.
* Expects a `renderRow` prop to render each row.
*/
function TableBase({
sortBy,
onChangeSort,
columns,
items = [],
isLoading,
emptyMessage,
caption,
addDropdownColumn,
renderRow,
className
}) {
const sortedItems = useMemo(
() => orderBy(items, sortBy.field, toAscDesc(sortBy.direction)),
[sortBy.field, sortBy.direction, items]
);

if (isLoading) {
return <Loader />;
}

if (items.length === 0) {
return <TableEmpty>{emptyMessage}</TableEmpty>;
}

return (
<table
className={classNames('sketches-table', className)}
// TODO: summary is deprecated. Use a hidden <caption>.
summary={caption}
>
<thead>
<tr>
{columns.map((column) => (
<TableHeaderCell
key={column.field}
sorting={sortBy}
onSort={onChangeSort}
field={column.field}
defaultOrder={column.defaultOrder}
title={column.title}
/>
))}
{addDropdownColumn && <StyledHeaderCell scope="col" />}
</tr>
</thead>
<tbody>{sortedItems.map((item) => renderRow(item))}</tbody>
</table>
);
}

TableBase.propTypes = {
sortBy: PropTypes.shape({
field: PropTypes.string.isRequired,
direction: PropTypes.string.isRequired
}).isRequired,
/**
* Function that gets called with the new sort order ({ field, direction })
*/
onChangeSort: PropTypes.func.isRequired,
columns: PropTypes.arrayOf(
PropTypes.shape({
field: PropTypes.string.isRequired,
title: PropTypes.string.isRequired,
defaultOrder: PropTypes.oneOf([DIRECTION.ASC, DIRECTION.DESC]),
formatValue: PropTypes.func
})
).isRequired,
items: PropTypes.arrayOf(
PropTypes.shape({
id: PropTypes.string.isRequired
// Will have other properties, depending on the type.
})
),
renderRow: PropTypes.func.isRequired,
addDropdownColumn: PropTypes.bool,
isLoading: PropTypes.bool,
emptyMessage: PropTypes.string.isRequired,
caption: PropTypes.string,
className: PropTypes.string
};

TableBase.defaultProps = {
items: [],
isLoading: false,
caption: '',
addDropdownColumn: false,
className: ''
};

export default TableBase;
64 changes: 64 additions & 0 deletions client/common/Table/TableBase.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import React from 'react';
import { DIRECTION } from '../../modules/IDE/actions/sorting';
import { render, screen } from '../../test-utils';
import TableBase from './TableBase';

describe('<TableBase/>', () => {
const items = [
{ id: '1', name: 'abc', count: 3 },
{ id: '2', name: 'def', count: 10 }
];

const props = {
items,
sortBy: { field: 'count', direction: DIRECTION.DESC },
emptyMessage: 'No items found',
renderRow: (item) => <tr key={item.id} />,
columns: [],
onChangeSort: jest.fn()
};

const subject = (overrideProps) =>
render(<TableBase {...props} {...overrideProps} />);

jest.spyOn(props, 'renderRow');

afterEach(() => {
jest.clearAllMocks();
});

it('shows a spinner when loading', () => {
subject({ isLoading: true });

expect(document.querySelector('.loader')).toBeInTheDocument();
});

it('show the `emptyMessage` when there are no items', () => {
subject({ items: [] });

expect(screen.getByText(props.emptyMessage)).toBeVisible();
});

it('calls `renderRow` function for each row', () => {
subject();

expect(props.renderRow).toHaveBeenCalledTimes(2);
});

it('sorts the items', () => {
subject();

expect(props.renderRow).toHaveBeenNthCalledWith(1, items[1]);
expect(props.renderRow).toHaveBeenNthCalledWith(2, items[0]);
});

it('does not add an extra header if `addDropdownColumn` is false', () => {
subject({ addDropdownColumn: false });
expect(screen.queryByRole('columnheader')).not.toBeInTheDocument();
});

it('adds an extra header if `addDropdownColumn` is true', () => {
subject({ addDropdownColumn: true });
expect(screen.getByRole('columnheader')).toBeInTheDocument();
});
});
9 changes: 9 additions & 0 deletions client/common/Table/TableElements.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import styled from 'styled-components';
import { remSize } from '../../theme';

// eslint-disable-next-line import/prefer-default-export
export const TableEmpty = styled.p`
text-align: center;
font-size: ${remSize(16)};
padding: ${remSize(42)} 0;
`;
100 changes: 100 additions & 0 deletions client/common/Table/TableHeaderCell.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import PropTypes from 'prop-types';
import React from 'react';
import { useTranslation } from 'react-i18next';
import styled from 'styled-components';
import { DIRECTION } from '../../modules/IDE/actions/sorting';
import { prop, remSize } from '../../theme';
import { SortArrowDownIcon, SortArrowUpIcon } from '../icons';

const opposite = (direction) =>
direction === DIRECTION.ASC ? DIRECTION.DESC : DIRECTION.ASC;

const ariaSort = (direction) =>
direction === DIRECTION.ASC ? 'ascending' : 'descending';

const TableHeaderTitle = styled.span`
border-bottom: 2px dashed transparent;
padding: ${remSize(3)} 0;
color: ${prop('inactiveTextColor')};
${(props) => props.selected && `border-color: ${prop('accentColor')(props)}`}
`;

export const StyledHeaderCell = styled.th`
height: ${remSize(32)};
position: sticky;
top: 0;
z-index: 1;
background-color: ${prop('backgroundColor')};
font-weight: normal;
&:nth-child(1) {
padding-left: ${remSize(12)};
}
button {
display: flex;
align-items: center;
height: ${remSize(35)};
svg {
margin-left: ${remSize(8)};
fill: ${prop('inactiveTextColor')};
}
}
`;

const TableHeaderCell = ({ sorting, field, title, defaultOrder, onSort }) => {
const isSelected = sorting.field === field;
const { direction } = sorting;
const { t } = useTranslation();
const directionWhenClicked = isSelected ? opposite(direction) : defaultOrder;
// TODO: more generic translation properties
const translationKey =
directionWhenClicked === DIRECTION.ASC
? 'SketchList.ButtonLabelAscendingARIA'
: 'SketchList.ButtonLabelDescendingARIA';
const buttonLabel = t(translationKey, {
displayName: title
});

return (
<StyledHeaderCell
scope="col"
aria-sort={isSelected ? ariaSort(direction) : null}
>
<button
onClick={() => onSort({ field, direction: directionWhenClicked })}
aria-label={buttonLabel}
aria-pressed={isSelected}
>
<TableHeaderTitle selected={isSelected}>{title}</TableHeaderTitle>
{/* TODO: show icons on hover of cell */}
{isSelected && direction === DIRECTION.ASC && (
<SortArrowUpIcon
aria-label={t('SketchList.DirectionAscendingARIA')}
/>
)}
{isSelected && direction === DIRECTION.DESC && (
<SortArrowDownIcon
aria-label={t('SketchList.DirectionDescendingARIA')}
/>
)}
</button>
</StyledHeaderCell>
);
};

TableHeaderCell.propTypes = {
sorting: PropTypes.shape({
field: PropTypes.string.isRequired,
direction: PropTypes.string.isRequired
}).isRequired,
field: PropTypes.string.isRequired,
title: PropTypes.string,
defaultOrder: PropTypes.oneOf([DIRECTION.ASC, DIRECTION.DESC]),
onSort: PropTypes.func.isRequired
};

TableHeaderCell.defaultProps = {
title: '',
defaultOrder: DIRECTION.ASC
};

export default TableHeaderCell;
89 changes: 89 additions & 0 deletions client/common/Table/TableHeaderCell.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import React from 'react';
import { DIRECTION } from '../../modules/IDE/actions/sorting';
import { render, fireEvent, screen } from '../../test-utils';
import TableHeaderCell from './TableHeaderCell';

describe('<TableHeaderCell>', () => {
const onSort = jest.fn();

const table = document.createElement('table');
const tr = document.createElement('tr');
table.appendChild(tr);
document.body.appendChild(table);

const subject = (sorting, defaultOrder = DIRECTION.ASC) =>
render(
<TableHeaderCell
onSort={onSort}
field="name"
title="Name"
defaultOrder={defaultOrder}
sorting={sorting}
/>,
{ container: tr }
);

afterEach(() => {
jest.resetAllMocks();
});

describe('indicates the active sort order', () => {
it('shows an up arrow when active ascending', () => {
subject({ field: 'name', direction: DIRECTION.ASC });
expect(screen.getByRole('columnheader')).toHaveAttribute(
'aria-sort',
'ascending'
);
expect(screen.getByLabelText('Ascending')).toBeVisible();
expect(screen.queryByLabelText('Descending')).not.toBeInTheDocument();
});

it('shows a down arrow when active descending', () => {
subject({ field: 'name', direction: DIRECTION.DESC });
expect(screen.getByRole('columnheader')).toHaveAttribute(
'aria-sort',
'descending'
);
expect(screen.queryByLabelText('Ascending')).not.toBeInTheDocument();
expect(screen.getByLabelText('Descending')).toBeVisible();
});

it('has no icon when inactive', () => {
subject({ field: 'other', direction: DIRECTION.ASC });
expect(screen.getByRole('columnheader')).not.toHaveAttribute('aria-sort');
expect(screen.queryByLabelText('Ascending')).not.toBeInTheDocument();
expect(screen.queryByLabelText('Descending')).not.toBeInTheDocument();
});
});

describe('calls onSort when clicked', () => {
const checkSort = (expectedDirection) => {
fireEvent.click(screen.getByText('Name'));

expect(onSort).toHaveBeenCalledWith({
field: 'name',
direction: expectedDirection
});
};

it('uses defaultOrder when inactive, ascending', () => {
subject({ field: 'other', direction: DIRECTION.ASC }, DIRECTION.ASC);
checkSort(DIRECTION.ASC);
});

it('uses defaultOrder when inactive, descending', () => {
subject({ field: 'other', direction: DIRECTION.ASC }, DIRECTION.DESC);
checkSort(DIRECTION.DESC);
});

it('calls with DESC if currently sorted ASC', () => {
subject({ field: 'name', direction: DIRECTION.ASC });
checkSort(DIRECTION.DESC);
});

it('calls with ASC if currently sorted DESC', () => {
subject({ field: 'name', direction: DIRECTION.DESC });
checkSort(DIRECTION.ASC);
});
});
});
Loading