diff --git a/packages/big-design/package.json b/packages/big-design/package.json index bf0278115..0d3294fe8 100644 --- a/packages/big-design/package.json +++ b/packages/big-design/package.json @@ -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" @@ -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", diff --git a/packages/big-design/src/components/Table/Body/Body.tsx b/packages/big-design/src/components/Table/Body/Body.tsx index 79afa63a7..c3c671d82 100644 --- a/packages/big-design/src/components/Table/Body/Body.tsx +++ b/packages/big-design/src/components/Table/Body/Body.tsx @@ -1,4 +1,4 @@ -import React, { memo, TableHTMLAttributes } from 'react'; +import React, { forwardRef, memo, TableHTMLAttributes } from 'react'; import { StyledTableBody } from './styled'; @@ -6,4 +6,12 @@ export interface BodyProps extends TableHTMLAttributes withFirstRowBorder?: boolean; } -export const Body: React.FC = memo(({ className, style, ...props }) => ); +interface PrivateProps { + forwardedRef?: React.Ref; +} + +const RawBody: React.FC = (props) => ; + +export const Body = memo( + forwardRef((props, ref) => ), +); diff --git a/packages/big-design/src/components/Table/HeaderCell/HeaderCell.tsx b/packages/big-design/src/components/Table/HeaderCell/HeaderCell.tsx index a322cee11..bee68303a 100644 --- a/packages/big-design/src/components/Table/HeaderCell/HeaderCell.tsx +++ b/packages/big-design/src/components/Table/HeaderCell/HeaderCell.tsx @@ -22,6 +22,10 @@ export interface HeaderCheckboxCellProps { stickyHeader?: boolean; } +export interface DragIconCellProps { + actionsRef: RefObject; +} + const InternalHeaderCell = ({ children, column, @@ -78,4 +82,10 @@ export const HeaderCheckboxCell: React.FC = memo(({ sti return ; }); +export const DragIconHeaderCell: React.FC = memo(({ actionsRef }) => { + const actionsSize = useComponentSize(actionsRef); + + return ; +}); + export const HeaderCell = typedMemo(InternalHeaderCell); diff --git a/packages/big-design/src/components/Table/Row/Row.tsx b/packages/big-design/src/components/Table/Row/Row.tsx index 64a01f4d4..7e9150c7c 100644 --- a/packages/big-design/src/components/Table/Row/Row.tsx +++ b/packages/big-design/src/components/Table/Row/Row.tsx @@ -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'; @@ -8,20 +9,30 @@ import { TableColumn, TableItem } from '../types'; import { StyledTableRow } from './styled'; export interface RowProps extends TableHTMLAttributes { + isDragging?: boolean; isSelected?: boolean; isSelectable?: boolean; item: T; columns: Array>; + showDragIcon?: boolean; onItemSelect?(item: T): void; } +interface PrivateProps { + forwardedRef?: React.Ref; +} + const InternalRow = ({ columns, + forwardedRef, + isDragging = false, isSelectable = false, isSelected = false, item, + showDragIcon = false, onItemSelect, -}: RowProps) => { + ...rest +}: RowProps & PrivateProps) => { const onChange = () => { if (onItemSelect) { onItemSelect(item); @@ -31,7 +42,12 @@ const InternalRow = ({ const label = isSelected ? `Selected` : `Unselected`; return ( - + + {showDragIcon && ( + + + + )} {isSelectable && ( @@ -50,4 +66,6 @@ const InternalRow = ({ ); }; -export const Row = typedMemo(InternalRow); +export const Row = typedMemo( + forwardRef>((props, ref) => ), +); diff --git a/packages/big-design/src/components/Table/Row/styled.tsx b/packages/big-design/src/components/Table/Row/styled.tsx index 6138ff1ba..52ca8b57c 100644 --- a/packages/big-design/src/components/Table/Row/styled.tsx +++ b/packages/big-design/src/components/Table/Row/styled.tsx @@ -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` ${withTransition(['background-color'])} + display: ${({ isDragging }) => (isDragging ? 'table' : 'table-row')}; background-color: ${({ isSelected, theme }) => (isSelected ? theme.colors.primary10 : 'transparent')}; diff --git a/packages/big-design/src/components/Table/Table.tsx b/packages/big-design/src/components/Table/Table.tsx index 761a88e57..dddf6b5b8 100644 --- a/packages/big-design/src/components/Table/Table.tsx +++ b/packages/big-design/src/components/Table/Table.tsx @@ -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'; @@ -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'; @@ -24,6 +25,7 @@ const InternalTable = (props: TableProps): React.ReactEl itemName, items, keyField = 'id', + onRowDrop, pagination, selectable, sortable, @@ -31,6 +33,7 @@ const InternalTable = (props: TableProps): React.ReactEl style, ...rest } = props; + const actionsRef = useRef(null); const uniqueTableId = useUniqueId('table'); const tableIdRef = useRef(id || uniqueTableId); @@ -77,6 +80,25 @@ const InternalTable = (props: TableProps): 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); }; @@ -92,6 +114,7 @@ const InternalTable = (props: TableProps): React.ReactEl const renderHeaders = () => ( + {typeof onRowDrop === 'function' && } {isSelectable && } {columns.map((column, index) => { @@ -118,26 +141,62 @@ const InternalTable = (props: TableProps): React.ReactEl ); - const renderItems = () => ( - - {items.map((item: T, index) => { - const key = getItemKey(item, index); - const isSelected = selectedItems.has(item); - - return ( - - ); - })} - + const renderDroppableItems = () => ( + + {(provided) => ( + + {items.map((item: T, index) => { + const key = getItemKey(item, index); + const isSelected = selectedItems.has(item); + + return ( + + {(provided, snapshot) => ( + + )} + + ); + })} + {provided.placeholder} + + )} + ); + const renderItems = () => + onRowDrop ? ( + renderDroppableItems() + ) : ( + + {items.map((item: T, index) => { + const key = getItemKey(item, index); + const isSelected = selectedItems.has(item); + + return ( + + ); + })} + + ); + const renderEmptyState = () => { if (items.length === 0 && emptyComponent) { return emptyComponent; @@ -162,8 +221,17 @@ const InternalTable = (props: TableProps): React.ReactEl /> )} - {renderHeaders()} - {renderItems()} + {onRowDrop ? ( + + {renderHeaders()} + {renderItems()} + + ) : ( + <> + {renderHeaders()} + {renderItems()} + + )} {renderEmptyState()} diff --git a/packages/big-design/src/components/Table/__snapshots__/spec.tsx.snap b/packages/big-design/src/components/Table/__snapshots__/spec.tsx.snap index 4618c8cef..c2ab05f44 100644 --- a/packages/big-design/src/components/Table/__snapshots__/spec.tsx.snap +++ b/packages/big-design/src/components/Table/__snapshots__/spec.tsx.snap @@ -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; } diff --git a/packages/big-design/src/components/Table/spec.tsx b/packages/big-design/src/components/Table/spec.tsx index 9829e2581..7142032d4 100644 --- a/packages/big-design/src/components/Table/spec.tsx +++ b/packages/big-design/src/components/Table/spec.tsx @@ -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(); + 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(
); + 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); + }); +}); diff --git a/packages/big-design/src/components/Table/types.ts b/packages/big-design/src/components/Table/types.ts index 58452e201..4d49c9e4f 100644 --- a/packages/big-design/src/components/Table/types.ts +++ b/packages/big-design/src/components/Table/types.ts @@ -43,6 +43,7 @@ export interface TableProps extends React.TableHTMLAttributes; sortable?: TableSortable; diff --git a/packages/docs/pages/Table/TablePage.tsx b/packages/docs/pages/Table/TablePage.tsx index 3ef67f830..a1aeba389 100644 --- a/packages/docs/pages/Table/TablePage.tsx +++ b/packages/docs/pages/Table/TablePage.tsx @@ -39,6 +39,13 @@ const sort = (items, columnHash, direction) => { ); }; +const dragEnd = (items, from, to) => { + const item = items.splice(from, 1); + items.splice(to, 0, ...item); + + return items; +}; + const TablePage = () => { return ( <> @@ -247,6 +254,32 @@ const TablePage = () => { /> {/* jsx-to-string:end */} + +

Usage with drag and drop

+ + + {/* jsx-to-string:start */} + {function Example() { + const [items, setItems] = useState(data); + + const onDragEnd = (from: number, to: number) => setItems((currentItems) => dragEnd(currentItems, from, to)); + + return ( +
sku, isSortable: true }, + { header: 'Name', hash: 'name', render: ({ name }) => name, isSortable: true }, + { header: 'Stock', hash: 'stock', render: ({ stock }) => stock, isSortable: true }, + ]} + items={items} + itemName="Products" + onRowDrop={onDragEnd} + /> + ); + }} + {/* jsx-to-string:end */} + ); }; diff --git a/packages/docs/pages/_document.tsx b/packages/docs/pages/_document.tsx index 8b6c27872..ca9536121 100644 --- a/packages/docs/pages/_document.tsx +++ b/packages/docs/pages/_document.tsx @@ -1,5 +1,6 @@ import Document, { DocumentContext, Head, Html, Main, NextScript } from 'next/document'; import React from 'react'; +import { resetServerContext } from 'react-beautiful-dnd'; import { ServerStyleSheet } from 'styled-components'; import { GTM_ID, GTM_URL } from '../utils/analytics/gtm'; @@ -8,6 +9,7 @@ const isProd = process.env.NODE_ENV === 'production'; export default class AppDocument extends Document { static async getInitialProps(ctx: DocumentContext) { + resetServerContext(); const sheet = new ServerStyleSheet(); const originalRenderPage = ctx.renderPage; diff --git a/yarn.lock b/yarn.lock index 44128558a..7167b73d4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3633,6 +3633,13 @@ resolved "https://registry.yarnpkg.com/@types/q/-/q-1.5.4.tgz#15925414e0ad2cd765bfef58842f7e26a7accb24" integrity sha512-1HcDas8SEj4z1Wc696tH56G8OlRaH/sqZOynNNB+HF0WOeXPaxTtbYzJY2oEfiUxjSKjhCKr+MvR7dCHcEelug== +"@types/react-beautiful-dnd@^13.0.0": + version "13.0.0" + resolved "https://registry.yarnpkg.com/@types/react-beautiful-dnd/-/react-beautiful-dnd-13.0.0.tgz#e60d3d965312fcf1516894af92dc3e9249587db4" + integrity sha512-by80tJ8aTTDXT256Gl+RfLRtFjYbUWOnZuEigJgNsJrSEGxvFe5eY6k3g4VIvf0M/6+xoLgfYWoWonlOo6Wqdg== + dependencies: + "@types/react" "*" + "@types/react-datepicker@^2.11.0": version "2.11.1" resolved "https://registry.yarnpkg.com/@types/react-datepicker/-/react-datepicker-2.11.1.tgz#f7ad70cf935c75ced3d769347cc2212c6234fcd9" @@ -5784,6 +5791,13 @@ crypto-browserify@3.12.0, crypto-browserify@^3.11.0: randombytes "^2.0.0" randomfill "^1.0.3" +css-box-model@^1.2.0: + version "1.2.1" + resolved "https://registry.yarnpkg.com/css-box-model/-/css-box-model-1.2.1.tgz#59951d3b81fd6b2074a62d49444415b0d2b4d7c1" + integrity sha512-a7Vr4Q/kd/aw96bnJG332W9V9LkJO69JRcaCYDUqjp6/z0w6VcZjgAcTbgFxEPfBgdnAwlh3iwu+hLopa+flJw== + dependencies: + tiny-invariant "^1.0.6" + css-color-keywords@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/css-color-keywords/-/css-color-keywords-1.0.0.tgz#fea2616dc676b2962686b3af8dbdbe180b244e05" @@ -7960,7 +7974,7 @@ hmac-drbg@^1.0.0: minimalistic-assert "^1.0.0" minimalistic-crypto-utils "^1.0.1" -hoist-non-react-statics@^3.3.0: +hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.2: version "3.3.2" resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45" integrity sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw== @@ -9895,7 +9909,7 @@ mdn-data@2.0.6: resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-2.0.6.tgz#852dc60fcaa5daa2e8cf6c9189c440ed3e042978" integrity sha512-rQvjv71olwNHgiTbfPZFkJtjNMciWgswYeciZhtvWLO8bmX3TnhyA62I6sTWOyZssWHJJjY6/KiWwqQsWWsqOA== -memoize-one@^5.0.0: +memoize-one@^5.0.0, memoize-one@^5.1.1: version "5.1.1" resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.1.1.tgz#047b6e3199b508eaec03504de71229b8eb1d75c0" integrity sha512-HKeeBpWvqiVJD57ZUAsJNm71eHTykffzcLZVYWiVfQeI1rJtuEaS7hQiEpWfVVk18donPwJEcFKIkCmPJNOhHA== @@ -12011,6 +12025,11 @@ quote-stream@^1.0.1, quote-stream@~1.0.2: minimist "^1.1.3" through2 "^2.0.0" +raf-schd@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/raf-schd/-/raf-schd-4.0.2.tgz#bd44c708188f2e84c810bf55fcea9231bcaed8a0" + integrity sha512-VhlMZmGy6A6hrkJWHLNTGl5gtgMUm+xfGza6wbwnE914yeQ5Ybm18vgM734RZhMgfw4tacUrWseGZlpUrrakEQ== + randombytes@^2.0.0, randombytes@^2.0.1, randombytes@^2.0.5, randombytes@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a" @@ -12031,6 +12050,19 @@ range-parser@~1.2.1: resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031" integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg== +react-beautiful-dnd@^13.0.0: + version "13.0.0" + resolved "https://registry.yarnpkg.com/react-beautiful-dnd/-/react-beautiful-dnd-13.0.0.tgz#f70cc8ff82b84bc718f8af157c9f95757a6c3b40" + integrity sha512-87It8sN0ineoC3nBW0SbQuTFXM6bUqM62uJGY4BtTf0yzPl8/3+bHMWkgIe0Z6m8e+gJgjWxefGRVfpE3VcdEg== + dependencies: + "@babel/runtime" "^7.8.4" + css-box-model "^1.2.0" + memoize-one "^5.1.1" + raf-schd "^4.0.2" + react-redux "^7.1.1" + redux "^4.0.4" + use-memo-one "^1.1.1" + react-datepicker@^2.16.0: version "2.16.0" resolved "https://registry.yarnpkg.com/react-datepicker/-/react-datepicker-2.16.0.tgz#6bd68de94f5fb38c8f6a4370f3c612837c700e4e" @@ -12107,6 +12139,17 @@ react-popper@^2.2.3: react-fast-compare "^3.0.1" warning "^4.0.2" +react-redux@^7.1.1: + version "7.2.2" + resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-7.2.2.tgz#03862e803a30b6b9ef8582dadcc810947f74b736" + integrity sha512-8+CQ1EvIVFkYL/vu6Olo7JFLWop1qRUeb46sGtIMDCSpgwPQq8fPLpirIB0iTqFe9XYEFPHssdX8/UwN6pAkEA== + dependencies: + "@babel/runtime" "^7.12.1" + hoist-non-react-statics "^3.3.2" + loose-envify "^1.4.0" + prop-types "^15.7.2" + react-is "^16.13.1" + react-refresh@0.8.3: version "0.8.3" resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.8.3.tgz#721d4657672d400c5e3c75d063c4a85fb2d5d68f" @@ -12361,6 +12404,14 @@ redent@^3.0.0: indent-string "^4.0.0" strip-indent "^3.0.0" +redux@^4.0.4: + version "4.0.5" + resolved "https://registry.yarnpkg.com/redux/-/redux-4.0.5.tgz#4db5de5816e17891de8a80c424232d06f051d93f" + integrity sha512-VSz1uMAH24DM6MF72vcojpYPtrTUu3ByVWfPL1nPfVRb5mZVTve5GnNCUV53QM/BZ66xfWrm0CTWoM+Xlz8V1w== + dependencies: + loose-envify "^1.4.0" + symbol-observable "^1.2.0" + regenerate-unicode-properties@^8.1.0, regenerate-unicode-properties@^8.2.0: version "8.2.0" resolved "https://registry.yarnpkg.com/regenerate-unicode-properties/-/regenerate-unicode-properties-8.2.0.tgz#e5de7111d655e7ba60c057dbe9ff37c87e65cdec" @@ -13758,7 +13809,7 @@ svgo@^1.0.0, svgo@^1.2.2, svgo@^1.3.2: unquote "~1.1.1" util.promisify "~1.0.0" -symbol-observable@^1.1.0: +symbol-observable@^1.1.0, symbol-observable@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-1.2.0.tgz#c22688aed4eab3cdc2dfeacbb561660560a00804" integrity sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ== @@ -13961,6 +14012,11 @@ tiny-inflate@^1.0.0: resolved "https://registry.yarnpkg.com/tiny-inflate/-/tiny-inflate-1.0.2.tgz#93d9decffc8805bd57eae4310f0b745e9b6fb3a7" integrity sha1-k9nez/yIBb1X6uQxDwt0Xptvs6c= +tiny-invariant@^1.0.6: + version "1.1.0" + resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.1.0.tgz#634c5f8efdc27714b7f386c35e6760991d230875" + integrity sha512-ytxQvrb1cPc9WBEI/HSeYYoGD0kWnGEOR8RY6KomWLBVhqz0RgTwVO9dLrGz7dC+nN9llyI7OKAgRq8Vq4ZBSw== + tiny-warning@^1.0.2: version "1.0.3" resolved "https://registry.yarnpkg.com/tiny-warning/-/tiny-warning-1.0.3.tgz#94a30db453df4c643d0fd566060d60a875d84754" @@ -14389,6 +14445,11 @@ url@^0.11.0: punycode "1.3.2" querystring "0.2.0" +use-memo-one@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/use-memo-one/-/use-memo-one-1.1.1.tgz#39e6f08fe27e422a7d7b234b5f9056af313bd22c" + integrity sha512-oFfsyun+bP7RX8X2AskHNTxu+R3QdE/RC5IefMbqptmACAA/gfol1KDD5KRzPsGMa62sWxGZw+Ui43u6x4ddoQ== + use-subscription@1.4.1: version "1.4.1" resolved "https://registry.yarnpkg.com/use-subscription/-/use-subscription-1.4.1.tgz#edcbcc220f1adb2dd4fa0b2f61b6cc308e620069"