Skip to content

Commit

Permalink
feat(component): Add drag and drop support to table component (#484)
Browse files Browse the repository at this point in the history
  • Loading branch information
animesh1987 authored Jan 13, 2021
1 parent b4cceb8 commit c29d8bf
Show file tree
Hide file tree
Showing 12 changed files with 281 additions and 31 deletions.
4 changes: 3 additions & 1 deletion packages/big-design/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,10 @@
"@bigcommerce/big-design-icons": "^0.14.1",
"@bigcommerce/big-design-theme": "^0.11.0",
"@popperjs/core": "^2.2.1",
"@types/react-datepicker": "^2.11.0",
"downshift": "6.0.6",
"focus-trap": "^5.1.0",
"polished": "^3.0.3",
"react-beautiful-dnd": "^13.0.0",
"react-datepicker": "^2.16.0",
"react-popper": "^2.2.3",
"react-uid": "^2.2.0"
Expand Down Expand Up @@ -68,6 +68,8 @@
"@types/node": "^13.1.8",
"@types/react": "^16.8.8",
"@types/react-dom": "^16.8.5",
"@types/react-beautiful-dnd": "^13.0.0",
"@types/react-datepicker": "^2.11.0",
"@types/react-test-renderer": "^16.8.3",
"@types/styled-components": "^4.1.12",
"babel-jest": "^25.4.0",
Expand Down
12 changes: 10 additions & 2 deletions packages/big-design/src/components/Table/Body/Body.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,17 @@
import React, { memo, TableHTMLAttributes } from 'react';
import React, { forwardRef, memo, TableHTMLAttributes } from 'react';

import { StyledTableBody } from './styled';

export interface BodyProps extends TableHTMLAttributes<HTMLTableSectionElement> {
withFirstRowBorder?: boolean;
}

export const Body: React.FC<BodyProps> = memo(({ className, style, ...props }) => <StyledTableBody {...props} />);
interface PrivateProps {
forwardedRef?: React.Ref<HTMLTableSectionElement>;
}

const RawBody: React.FC<BodyProps & PrivateProps> = (props) => <StyledTableBody ref={props.forwardedRef} {...props} />;

export const Body = memo(
forwardRef<HTMLTableSectionElement, BodyProps>((props, ref) => <RawBody {...props} forwardedRef={ref} />),
);
10 changes: 10 additions & 0 deletions packages/big-design/src/components/Table/HeaderCell/HeaderCell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ export interface HeaderCheckboxCellProps {
stickyHeader?: boolean;
}

export interface DragIconCellProps {
actionsRef: RefObject<HTMLDivElement>;
}

const InternalHeaderCell = <T extends TableItem>({
children,
column,
Expand Down Expand Up @@ -78,4 +82,10 @@ export const HeaderCheckboxCell: React.FC<HeaderCheckboxCellProps> = memo(({ sti
return <StyledTableHeaderCheckbox stickyHeader={stickyHeader} stickyHeight={actionsSize.height} />;
});

export const DragIconHeaderCell: React.FC<DragIconCellProps> = memo(({ actionsRef }) => {
const actionsSize = useComponentSize(actionsRef);

return <StyledTableHeaderCell stickyHeight={actionsSize.height} />;
});

export const HeaderCell = typedMemo(InternalHeaderCell);
26 changes: 22 additions & 4 deletions packages/big-design/src/components/Table/Row/Row.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React, { TableHTMLAttributes } from 'react';
import { DragIndicatorIcon } from '@bigcommerce/big-design-icons';
import React, { forwardRef, TableHTMLAttributes } from 'react';

import { typedMemo } from '../../../utils';
import { Checkbox } from '../../Checkbox';
Expand All @@ -8,20 +9,30 @@ import { TableColumn, TableItem } from '../types';
import { StyledTableRow } from './styled';

export interface RowProps<T> extends TableHTMLAttributes<HTMLTableRowElement> {
isDragging?: boolean;
isSelected?: boolean;
isSelectable?: boolean;
item: T;
columns: Array<TableColumn<T>>;
showDragIcon?: boolean;
onItemSelect?(item: T): void;
}

interface PrivateProps {
forwardedRef?: React.Ref<HTMLTableRowElement>;
}

const InternalRow = <T extends TableItem>({
columns,
forwardedRef,
isDragging = false,
isSelectable = false,
isSelected = false,
item,
showDragIcon = false,
onItemSelect,
}: RowProps<T>) => {
...rest
}: RowProps<T> & PrivateProps) => {
const onChange = () => {
if (onItemSelect) {
onItemSelect(item);
Expand All @@ -31,7 +42,12 @@ const InternalRow = <T extends TableItem>({
const label = isSelected ? `Selected` : `Unselected`;

return (
<StyledTableRow isSelected={isSelected}>
<StyledTableRow isSelected={isSelected} ref={forwardedRef} isDragging={isDragging} {...rest}>
{showDragIcon && (
<DataCell>
<DragIndicatorIcon />
</DataCell>
)}
{isSelectable && (
<DataCell key="data-checkbox" isCheckbox={true}>
<Checkbox checked={isSelected} hiddenLabel label={label} onChange={onChange} />
Expand All @@ -50,4 +66,6 @@ const InternalRow = <T extends TableItem>({
);
};

export const Row = typedMemo(InternalRow);
export const Row = typedMemo(
forwardRef<HTMLTableRowElement, RowProps<any>>((props, ref) => <InternalRow {...props} forwardedRef={ref} />),
);
2 changes: 2 additions & 0 deletions packages/big-design/src/components/Table/Row/styled.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@ import styled from 'styled-components';
import { withTransition } from '../../../mixins/transitions';

interface StyledTableRowProps {
isDragging: boolean;
isSelected: boolean;
}

export const StyledTableRow = styled.tr<StyledTableRowProps>`
${withTransition(['background-color'])}
display: ${({ isDragging }) => (isDragging ? 'table' : 'table-row')};
background-color: ${({ isSelected, theme }) => (isSelected ? theme.colors.primary10 : 'transparent')};
Expand Down
110 changes: 89 additions & 21 deletions packages/big-design/src/components/Table/Table.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React, { memo, useCallback, useEffect, useRef, useState } from 'react';
import { DragDropContext, Draggable, Droppable, DropResult } from 'react-beautiful-dnd';

import { useEventCallback, useUniqueId } from '../../hooks';
import { MarginProps } from '../../mixins';
Expand All @@ -8,7 +9,7 @@ import { Actions } from './Actions';
import { Body } from './Body';
import { Head } from './Head';
import { HeaderCell } from './HeaderCell';
import { HeaderCheckboxCell } from './HeaderCell/HeaderCell';
import { DragIconHeaderCell, HeaderCheckboxCell } from './HeaderCell/HeaderCell';
import { Row } from './Row';
import { StyledTable, StyledTableFigure } from './styled';
import { TableColumn, TableItem, TableProps } from './types';
Expand All @@ -24,13 +25,15 @@ const InternalTable = <T extends TableItem>(props: TableProps<T>): React.ReactEl
itemName,
items,
keyField = 'id',
onRowDrop,
pagination,
selectable,
sortable,
stickyHeader,
style,
...rest
} = props;

const actionsRef = useRef<HTMLDivElement>(null);
const uniqueTableId = useUniqueId('table');
const tableIdRef = useRef(id || uniqueTableId);
Expand Down Expand Up @@ -77,6 +80,25 @@ const InternalTable = <T extends TableItem>(props: TableProps<T>): React.ReactEl
[sortable],
);

const onDragEnd = useCallback(
(result: DropResult) => {
const { destination, source } = result;

if (!destination) {
return;
}

if (destination.droppableId === source.droppableId && destination.index === source.index) {
return;
}

if (typeof onRowDrop === 'function') {
onRowDrop(source.index, destination.index);
}
},
[onRowDrop],
);

const shouldRenderActions = () => {
return Boolean(actions) || Boolean(pagination) || Boolean(selectable) || Boolean(itemName);
};
Expand All @@ -92,6 +114,7 @@ const InternalTable = <T extends TableItem>(props: TableProps<T>): React.ReactEl
const renderHeaders = () => (
<Head hidden={headerless}>
<tr>
{typeof onRowDrop === 'function' && <DragIconHeaderCell actionsRef={actionsRef} />}
{isSelectable && <HeaderCheckboxCell stickyHeader={stickyHeader} actionsRef={actionsRef} />}

{columns.map((column, index) => {
Expand All @@ -118,26 +141,62 @@ const InternalTable = <T extends TableItem>(props: TableProps<T>): React.ReactEl
</Head>
);

const renderItems = () => (
<Body withFirstRowBorder={headerless}>
{items.map((item: T, index) => {
const key = getItemKey(item, index);
const isSelected = selectedItems.has(item);

return (
<Row
columns={columns}
isSelectable={isSelectable}
isSelected={isSelected}
item={item}
key={key}
onItemSelect={onItemSelect}
/>
);
})}
</Body>
const renderDroppableItems = () => (
<Droppable droppableId={`${uniqueTableId}-bd-droppable`}>
{(provided) => (
<Body withFirstRowBorder={headerless} ref={provided.innerRef} {...provided.droppableProps}>
{items.map((item: T, index) => {
const key = getItemKey(item, index);
const isSelected = selectedItems.has(item);

return (
<Draggable key={key} draggableId={String(key)} index={index}>
{(provided, snapshot) => (
<Row
isDragging={snapshot.isDragging}
{...provided.dragHandleProps}
{...provided.draggableProps}
ref={provided.innerRef}
columns={columns}
isSelectable={isSelectable}
isSelected={isSelected}
item={item}
onItemSelect={onItemSelect}
showDragIcon={true}
/>
)}
</Draggable>
);
})}
{provided.placeholder}
</Body>
)}
</Droppable>
);

const renderItems = () =>
onRowDrop ? (
renderDroppableItems()
) : (
<Body withFirstRowBorder={headerless}>
{items.map((item: T, index) => {
const key = getItemKey(item, index);
const isSelected = selectedItems.has(item);

return (
<Row
columns={columns}
isSelectable={isSelectable}
isSelected={isSelected}
item={item}
key={key}
onItemSelect={onItemSelect}
/>
);
})}
</Body>
);

const renderEmptyState = () => {
if (items.length === 0 && emptyComponent) {
return emptyComponent;
Expand All @@ -162,8 +221,17 @@ const InternalTable = <T extends TableItem>(props: TableProps<T>): React.ReactEl
/>
)}
<StyledTable {...rest} id={tableIdRef.current}>
{renderHeaders()}
{renderItems()}
{onRowDrop ? (
<DragDropContext onDragEnd={onDragEnd}>
{renderHeaders()}
{renderItems()}
</DragDropContext>
) : (
<>
{renderHeaders()}
{renderItems()}
</>
)}
</StyledTable>

{renderEmptyState()}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -535,6 +535,7 @@ exports[`renders a simple table 1`] = `
transition: all 150ms ease-out;
-webkit-transition-property: background-color;
transition-property: background-color;
display: table-row;
background-color: transparent;
}
Expand Down
44 changes: 44 additions & 0 deletions packages/big-design/src/components/Table/spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -469,3 +469,47 @@ describe('sortable', () => {
expect(screen.queryByText(/no items/i)).not.toBeInTheDocument();
});
});

describe('draggable', () => {
let columns: any;
let items: any;
let onRowDrop: jest.Mock;

beforeEach(() => {
onRowDrop = jest.fn();
items = [
{ sku: 'SM13', name: '[Sample] Smith Journal 13', stock: 25 },
{ sku: 'DPB', name: '[Sample] Dustpan & Brush', stock: 34 },
{ sku: 'OFSUC', name: '[Sample] Utility Caddy', stock: 45 },
{ sku: 'CLC', name: '[Sample] Canvas Laundry Cart', stock: 2 },
{ sku: 'CGLD', name: '[Sample] Laundry Detergent', stock: 29 },
];
columns = [
{ header: 'Sku', hash: 'sku', render: ({ sku }: any) => sku, isSortable: true },
{ header: 'Name', hash: 'name', render: ({ name }: any) => name },
{ header: 'Stock', hash: 'stock', render: ({ stock }: any) => stock },
];
});

test('renders drag and drop icon', () => {
const { container } = render(<Table columns={columns} items={items} onRowDrop={onRowDrop} />);
const dragIcons = container.querySelectorAll('svg');

expect(dragIcons?.length).toBe(items.length);
});

test('onRowDrop called with expected args when a row is dropped', () => {
const spaceKey = { keyCode: 32 };
const downKey = { keyCode: 40 };
const { container } = render(<Table columns={columns} items={items} onRowDrop={onRowDrop} />);
const dragEl = container.querySelector('[data-rbd-draggable-id]') as HTMLElement;
dragEl.focus();
expect(dragEl).toHaveFocus();

fireEvent.keyDown(dragEl, spaceKey);
fireEvent.keyDown(dragEl, downKey);
fireEvent.keyDown(dragEl, spaceKey);

expect(onRowDrop).toHaveBeenCalledWith(0, 1);
});
});
1 change: 1 addition & 0 deletions packages/big-design/src/components/Table/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export interface TableProps<T> extends React.TableHTMLAttributes<HTMLTableElemen
itemName?: string;
items: T[];
keyField?: string;
onRowDrop?(from: number, to: number): void;
pagination?: TablePaginationProps;
selectable?: TableSelectable<T>;
sortable?: TableSortable<T>;
Expand Down
Loading

0 comments on commit c29d8bf

Please sign in to comment.