Repository: uber-web/baseui
Pull request #904 (2019-02-19 22:23:01)

feat(table): enables sorting
#### Description

Enables sorting on column label click. Still working with design to figure out optimal visuals, but functionality here should be good to go.

#### Scope

- [x] Minor: New Feature



Patch:
diff --git a/documentation-site/pages/components/table.mdx b/documentation-site/pages/components/table.mdx
index fa4f10400..c247c7e99 100644
--- a/documentation-site/pages/components/table.mdx
+++ b/documentation-site/pages/components/table.mdx
@@ -14,6 +14,7 @@ import Cells from 'examples/table/cells.js';
 import FixedWidthColumn from 'examples/table/fixed-width-column.js';
 import VerticalScroll from 'examples/table/vertical-scroll.js';
 import HorizontalScroll from 'examples/table/horizontal-scroll.js';
+import Sortable from 'examples/table/sortable.js';
 import Filter from 'examples/table/filter.js';
 import Graph from 'examples/table/graph.js';
 import Virtual from 'examples/table/virtual.js';
@@ -35,6 +36,10 @@ Display larger amounts of information in a scannable UI.
   <Cells />
 </Example>
 
+<Example title="Table with sorting by a column" path="examples/table/sortable.js">
+  <Sortable />
+</Example>
+
 <Example
   title="Table with fixed width column"
   path="examples/table/fixed-width-column.js"
diff --git a/documentation-site/static/examples/table/sortable.js b/documentation-site/static/examples/table/sortable.js
new file mode 100644
index 000000000..6c26be333
--- /dev/null
+++ b/documentation-site/static/examples/table/sortable.js
@@ -0,0 +1,119 @@
+import React from 'react';
+
+import {
+  StyledTable,
+  StyledHead,
+  StyledBody,
+  StyledRow,
+  StyledCell,
+  SortableHeadCell,
+} from 'baseui/table';
+
+const DATA = [
+  ['Marlyn', 10],
+  ['Luther', 15],
+  ['Kiera', 13],
+  ['Edna', 20],
+  ['Soraya', 18],
+  ['Dorris', 32],
+  ['Astrid', 26],
+  ['Wendie', 17],
+  ['Marna', 11],
+  ['Malka', 14],
+  ['Jospeh', 10],
+  ['Roselee', 12],
+  ['Justine', 35],
+  ['Marlon', 30],
+  ['Mellissa', 32],
+  ['Fausto', 21],
+  ['Alfredia', 22],
+  ['Abel', 18],
+  ['Winford', 19],
+  ['Neil', 27],
+];
+
+export default class SortableTable extends React.Component {
+  state = {nameDirection: null, ageDirection: null};
+
+  handleSort = (title, prevDirection) => {
+    let nextDirection = null;
+    if (prevDirection === 'ASC') {
+      nextDirection = 'DESC';
+    }
+    if (prevDirection === 'DESC') {
+      nextDirection = null;
+    }
+    if (prevDirection === null) {
+      nextDirection = 'ASC';
+    }
+
+    if (title === 'name') {
+      this.setState({nameDirection: nextDirection, ageDirection: null});
+      return;
+    }
+
+    if (title === 'age') {
+      this.setState({nameDirection: null, ageDirection: nextDirection});
+      return;
+    }
+  };
+
+  getSortedData = () => {
+    if (this.state.nameDirection) {
+      const sorted = DATA.slice(0).sort((a, b) => a[0].localeCompare(b[0]));
+
+      if (this.state.nameDirection === 'ASC') {
+        return sorted;
+      }
+
+      if (this.state.nameDirection === 'DESC') {
+        return sorted.reverse();
+      }
+    }
+
+    if (this.state.ageDirection) {
+      const sorted = DATA.slice(0).sort((a, b) => a[1] - b[1]);
+      if (this.state.ageDirection === 'ASC') {
+        return sorted;
+      }
+
+      if (this.state.ageDirection === 'DESC') {
+        return sorted.reverse();
+      }
+    }
+
+    return DATA;
+  };
+
+  render() {
+    return (
+      <div style={{height: '500px', width: '400px'}}>
+        <StyledTable>
+          <StyledHead>
+            <SortableHeadCell
+              title="Name"
+              direction={this.state.nameDirection}
+              onSort={() => this.handleSort('name', this.state.nameDirection)}
+            >
+              <button onClick={() => console.log('filter')}>f</button>
+            </SortableHeadCell>
+            <SortableHeadCell
+              title="Age"
+              direction={this.state.ageDirection}
+              onSort={() => this.handleSort('age', this.state.ageDirection)}
+            />
+          </StyledHead>
+          <StyledBody>
+            {this.getSortedData().map((row, index) => (
+              <StyledRow key={index}>
+                {row.map((cell, cellIndex) => (
+                  <StyledCell key={cellIndex}>{cell}</StyledCell>
+                ))}
+              </StyledRow>
+            ))}
+          </StyledBody>
+        </StyledTable>
+      </div>
+    );
+  }
+}
diff --git a/src/table/index.js b/src/table/index.js
index 0d2e454d9..f0657bcdb 100644
--- a/src/table/index.js
+++ b/src/table/index.js
@@ -5,6 +5,7 @@ This source code is licensed under the MIT license found in the
 LICENSE file in the root directory of this source tree.
 */
 // @flow
+export {default as SortableHeadCell} from './sortable-head-cell.js';
 export {default as Table} from './table.js';
 export {default as Filter} from './filter.js';
 // Styled elements
diff --git a/src/table/sortable-head-cell.js b/src/table/sortable-head-cell.js
new file mode 100644
index 000000000..5e5e0973c
--- /dev/null
+++ b/src/table/sortable-head-cell.js
@@ -0,0 +1,58 @@
+/*
+Copyright (c) 2018 Uber Technologies, Inc.
+
+This source code is licensed under the MIT license found in the
+LICENSE file in the root directory of this source tree.
+*/
+// @flow
+
+import * as React from 'react';
+
+import {getOverrides} from '../helpers/overrides.js';
+import TriangleDown from '../icon/triangle-down.js';
+import TriangleUp from '../icon/triangle-up.js';
+
+import {StyledHeadCell, StyledSortableLabel} from './styled-components.js';
+import type {HeadCellPropsT} from './types.js';
+
+function Direction(props: {direction: 'ASC' | 'DESC' | null}) {
+  if (props.direction === 'ASC') {
+    return <TriangleDown />;
+  }
+
+  if (props.direction === 'DESC') {
+    return <TriangleUp />;
+  }
+
+  return null;
+}
+
+export default function SortableHeadCell(props: HeadCellPropsT) {
+  const {overrides = {}} = props;
+
+  const [HeadCell, headCellProps] = getOverrides(
+    overrides.HeadCell,
+    StyledHeadCell,
+  );
+  const [SortableLabel, sortableLabelProps] = getOverrides(
+    overrides.SortableLabel,
+    StyledSortableLabel,
+  );
+
+  return (
+    <HeadCell role="columnheader" {...headCellProps}>
+      <SortableLabel
+        aria-label={`sorts table by ${props.title} column`}
+        disabled={props.disabled}
+        onClick={() => {
+          props.onSort && props.onSort();
+        }}
+        {...sortableLabelProps}
+      >
+        <Direction direction={props.direction} />
+        {props.title}
+      </SortableLabel>
+      {props.children}
+    </HeadCell>
+  );
+}
diff --git a/src/table/styled-components.js b/src/table/styled-components.js
index 36ad901e2..4b209a150 100644
--- a/src/table/styled-components.js
+++ b/src/table/styled-components.js
@@ -58,6 +58,22 @@ export const StyledHeadCell = styled('div', ({$theme}: SharedStylePropsT) => {
   };
 });
 
+export const StyledSortableLabel = styled('button', ({$theme}) => {
+  return {
+    ...$theme.typography.font350,
+    alignItems: 'center',
+    border: 'none',
+    display: 'flex',
+    padding: 0,
+    ':hover:enabled': {
+      cursor: 'pointer',
+    },
+    ':disabled': {
+      color: $theme.colors.mono500,
+    },
+  };
+});
+
 export const StyledBody = styled('div', ({$width}: HorizontalStyleProps) => {
   return {
     width: $width ? $width : '100%',
diff --git a/src/table/types.js b/src/table/types.js
index d48db3a5e..1fe6f80df 100644
--- a/src/table/types.js
+++ b/src/table/types.js
@@ -11,6 +11,23 @@ import * as React from 'react';
 import type {OverrideT} from '../helpers/overrides.js';
 import type {ThemeT} from '../styles/types.js';
 
+export type HeadCellPropsT = {|
+  /** Additional content to fill head cell. Most likely used for a filter button. */
+  children?: React.Node,
+  /** Visually indicates sort direction. Displays a chevron next to column title. */
+  direction: 'ASC' | 'DESC' | null,
+  /** Disabled click-to-sort. Renders column title in muted color. */
+  disabled?: boolean,
+  /** Callback for when column title is clicked to trigger sort. */
+  onSort?: () => mixed,
+  overrides?: {
+    HeadCell: OverrideT<*>,
+    SortableLabel: OverrideT<*>,
+  },
+  /** Column title. */
+  title: string,
+|};
+
 export type TablePropsT = {|
   /** Table columns. Data passed to each header cell. */
   columns: Array<string | React.Node>,


Test patch:
diff --git a/src/table/__tests__/sortable-head-cell.test.js b/src/table/__tests__/sortable-head-cell.test.js
new file mode 100644
index 000000000..4e789943d
--- /dev/null
+++ b/src/table/__tests__/sortable-head-cell.test.js
@@ -0,0 +1,48 @@
+/*
+Copyright (c) 2018 Uber Technologies, Inc.
+
+This source code is licensed under the MIT license found in the
+LICENSE file in the root directory of this source tree.
+*/
+// @flow
+
+import React from 'react';
+import {mount} from 'enzyme';
+
+import TriangleDown from '../../icon/triangle-down.js';
+import TriangleUp from '../../icon/triangle-up.js';
+import {SortableHeadCell} from '../index.js';
+
+describe('sortable-head-cell', () => {
+  it('displays triangle down when direction is ASC', () => {
+    const wrapper = mount(<SortableHeadCell direction="ASC" title="test" />);
+    const down = wrapper.find(TriangleDown);
+    expect(down).toHaveLength(1);
+  });
+
+  it('displays triangle up when direction is DESC', () => {
+    const wrapper = mount(<SortableHeadCell direction="DESC" title="test" />);
+    const up = wrapper.find(TriangleUp);
+    expect(up).toHaveLength(1);
+  });
+
+  it('calls provided onSort fn on click', () => {
+    const spy = jest.fn();
+    const wrapper = mount(
+      <SortableHeadCell onSort={spy} direction={null} title="test" />,
+    );
+    const button = wrapper.find('button');
+    button.simulate('click');
+    expect(spy).toHaveBeenCalledTimes(1);
+  });
+
+  it('does not call provided onSort fn on click if disbled', () => {
+    const spy = jest.fn();
+    const wrapper = mount(
+      <SortableHeadCell onSort={spy} direction={null} title="test" disabled />,
+    );
+    const button = wrapper.find('button');
+    button.simulate('click');
+    expect(spy).toHaveBeenCalledTimes(0);
+  });
+});
diff --git a/src/table/__tests__/table-sortable.scenario.js b/src/table/__tests__/table-sortable.scenario.js
new file mode 100644
index 000000000..44f7c23c8
--- /dev/null
+++ b/src/table/__tests__/table-sortable.scenario.js
@@ -0,0 +1,132 @@
+/*
+Copyright (c) 2018 Uber Technologies, Inc.
+
+This source code is licensed under the MIT license found in the
+LICENSE file in the root directory of this source tree.
+*/
+// @flow
+
+import React from 'react';
+
+import {
+  StyledTable,
+  StyledHead,
+  StyledBody,
+  StyledRow,
+  StyledCell,
+  SortableHeadCell,
+} from '../index.js';
+
+export const name = 'table-sortable';
+
+const DATA = [
+  ['Marlyn', 10],
+  ['Luther', 15],
+  ['Kiera', 13],
+  ['Edna', 20],
+  ['Soraya', 18],
+  ['Dorris', 32],
+  ['Astrid', 26],
+  ['Wendie', 17],
+  ['Marna', 11],
+  ['Malka', 14],
+  ['Jospeh', 10],
+  ['Roselee', 12],
+  ['Justine', 35],
+  ['Marlon', 30],
+  ['Mellissa', 32],
+  ['Fausto', 21],
+  ['Alfredia', 22],
+  ['Abel', 18],
+  ['Winford', 19],
+  ['Neil', 27],
+];
+
+// eslint-disable-next-line flowtype/no-weak-types
+class SortableTable extends React.Component<any, any> {
+  state = {nameDirection: null, ageDirection: null};
+
+  handleSort = (title: string, prevDirection: ?string) => {
+    let nextDirection = null;
+    if (prevDirection === 'ASC') {
+      nextDirection = 'DESC';
+    }
+    if (prevDirection === 'DESC') {
+      nextDirection = null;
+    }
+    if (prevDirection === null) {
+      nextDirection = 'ASC';
+    }
+
+    if (title === 'name') {
+      this.setState({nameDirection: nextDirection, ageDirection: null});
+      return;
+    }
+
+    if (title === 'age') {
+      this.setState({nameDirection: null, ageDirection: nextDirection});
+      return;
+    }
+  };
+
+  getSortedData = () => {
+    if (this.state.nameDirection) {
+      // $FlowFixMe
+      const sorted = DATA.slice(0).sort((a, b) => a[0].localeCompare(b[0]));
+
+      if (this.state.nameDirection === 'ASC') {
+        return sorted;
+      }
+
+      if (this.state.nameDirection === 'DESC') {
+        return sorted.reverse();
+      }
+    }
+
+    if (this.state.ageDirection) {
+      const sorted = DATA.slice(0).sort((a, b) => a[1] - b[1]);
+      if (this.state.ageDirection === 'ASC') {
+        return sorted;
+      }
+
+      if (this.state.ageDirection === 'DESC') {
+        return sorted.reverse();
+      }
+    }
+
+    return DATA;
+  };
+
+  render() {
+    return (
+      <div style={{height: '500px', width: '400px'}}>
+        <StyledTable>
+          <StyledHead>
+            <SortableHeadCell
+              title="Name"
+              direction={this.state.nameDirection}
+              onSort={() => this.handleSort('name', this.state.nameDirection)}
+            />
+            <SortableHeadCell
+              disabled
+              title="Age"
+              direction={this.state.ageDirection}
+              onSort={() => this.handleSort('age', this.state.ageDirection)}
+            />
+          </StyledHead>
+          <StyledBody>
+            {this.getSortedData().map((row, index) => (
+              <StyledRow key={index}>
+                {row.map((cell, cellIndex) => (
+                  <StyledCell key={cellIndex}>{cell}</StyledCell>
+                ))}
+              </StyledRow>
+            ))}
+          </StyledBody>
+        </StyledTable>
+      </div>
+    );
+  }
+}
+
+export const component = () => <SortableTable />;