diff --git a/README.md b/README.md index e2f637e9..5506ad10 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ Yet another Kanban/Trello board lib for React. * 👊 Reliable: 100% tested on CI; 100% coverage; 100% SemVer. * 🎮 Having fun: Play with Hooks 🎣 and Styled Components 💅🏻. - * ♿️ Acessible: Keyboard and mobile friendly. + * ♿️ Accessible: Keyboard and mobile friendly. ## 🛠 Install and usage @@ -73,12 +73,14 @@ const board = { | `onLaneDragEnd` | Callback that will be called when the lane move ends | | `renderCard` | A card to be rendered instead of the default card | | `renderLaneHeader` | A lane header to be rendered instead of the default lane header | -| `allowAddLane` (required if `onNewLane`) | Allow a new lane be added by the user | +| `allowAddLane` | Allow a new lane be added by the user | | `onNewLane` (required if `allowAddLane`) | Callback that will be called when a new lane is added | | `disableLaneDrag` | Disable the lane move | | `disableCardDrag` | Disable the card move | | `allowRemoveLane` | Allow to remove a lane in default lane header | -| `onLaneRemove` | Callback that will be called when a lane is removed | +| `onLaneRemove` (required if `allowRemoveLane` | Callback that will be called when a lane is removed | +| `allowRenameLane` | Allow to rename a lane in default lane header | +| `onLaneRename` (required if `allowRenameLane` | Callback that will be called when a lane is renamed | #### `children` ```js @@ -169,7 +171,13 @@ The function will receive these parameters: | Arg | Description | |--------------|------------------------------------------------------- | | `lane` | The lane props | -| `removeLane` | A function to remove the lane | +| `laneBag` | A bag with some helper functions to work with the lane | + +##### `laneBag` +| function | Description | +|--------------|------------------------------------------------------- | +| `removeLane` | Call this function to remove the lane from the board | +| `renameLane` | Call this function with a title to rename the lane | Ex.: ```js @@ -187,9 +195,11 @@ const board = { } ( + renderLaneHeader={({ title }, { removeLane, renameLane }) => ( - {title} - + {title} + + @@ -198,7 +208,7 @@ const board = { ``` #### `allowAddLane` -Allow the user to add a new lane directly by the board. Use this together with `onNewLane` prop. +Allow the user to add a new lane directly by the board. #### `onNewLane` When the user adds a new lane, this callback will be called passing the lane title typed by the user. @@ -221,6 +231,9 @@ Disallow the user from move a lane. #### `disableCardDrag` Disallow the user from move a card. +### `AllowRemoveLane` +When using the default header template, when you don't pass a template through the `renderLaneHeader`, it will allow the user to remove a lane. + #### `onLaneRemove` When the user removes a lane, this callback will be called passing these parameters: @@ -229,6 +242,17 @@ When the user removes a lane, this callback will be called passing these paramet | `board` | The board without the removed lane | | `lane` | The removed lane | +### `AllowRenameLane` +When using the default header template, when you don't pass a template through the `renderLaneHeader`, it will allow the user to rename a lane. + +#### `onLaneRename` +When the user renames a lane, this callback will be called passing these parameters: + +| Arg | Description | +|--------------|------------------------------------------------------- | +| `board` | The board with the renamed lane | +| `lane` | The renamed lane | + ## 🚴‍♀️ Roadmap You can view the next features [here](https://github.com/lourenci/react-kanban/milestone/1). diff --git a/assets/index.js b/assets/index.js index cf238bc5..e2d8fc72 100644 --- a/assets/index.js +++ b/assets/index.js @@ -66,4 +66,13 @@ const board = { ] } -render({board}, document.getElementById('app')) +render( + + {board} + , + document.getElementById('app') +) diff --git a/src/components/Board/components/DefaultLaneHeader/index.js b/src/components/Board/components/DefaultLaneHeader/index.js new file mode 100644 index 00000000..ecd35149 --- /dev/null +++ b/src/components/Board/components/DefaultLaneHeader/index.js @@ -0,0 +1,83 @@ +import React, { Fragment, useState } from 'react' +import styled from 'styled-components' + +const LaneHeaderSkeleton = styled.div` + padding-bottom: 10px; + font-weight: bold; + display: flex; + justify-content: space-between; + + span:nth-child(2) { + cursor: pointer; + } +` + +const CursorPointer = styled.span` + cursor: pointer; +` + +const DefaultButton = styled.button` + color: #333333; + background-color: #FFFFFF; + border-color: #CCCCCC; + + :hover, :focus, :active { + background-color: #E6E6E6; + } +` + +const Input = styled.input` + :focus { + outline: none; + } +` + +function useRenameMode (state) { + const [renameMode, setRenameMode] = useState(state) + + function toggleRenameMode () { + setRenameMode(!renameMode) + } + + return [renameMode, toggleRenameMode] +} + +export default function ({ children: lane, allowRemoveLane, onLaneRemove, allowRenameLane, onLaneRename }) { + const [renameMode, toggleRenameMode] = useRenameMode(false) + const [title, setTitle] = useState(lane.title) + const [titleInput, setTitleInput] = useState('') + + function handleRenameLane (title) { + onLaneRename(lane.id, title) + setTitle(title) + toggleRenameMode() + } + + function handleRenameMode () { + setTitleInput(title) + toggleRenameMode() + } + + return ( + + {allowRenameLane && renameMode ? ( + + + setTitleInput(value)} autoFocus /> + + + handleRenameLane(titleInput)}>Rename + Cancel + + + ) : ( + + + {title} + + {allowRemoveLane && onLaneRemove(lane)}>×} + + )} + + ) +} diff --git a/src/components/Board/components/DefaultLaneHeader/index.spec.js b/src/components/Board/components/DefaultLaneHeader/index.spec.js new file mode 100644 index 00000000..ff791534 --- /dev/null +++ b/src/components/Board/components/DefaultLaneHeader/index.spec.js @@ -0,0 +1,148 @@ +import React from 'react' +import { render, fireEvent } from '@testing-library/react' +import DefaultLaneHeader from './' + +describe('', () => { + let subject + + const onLaneRemove = jest.fn() + const onLaneRename = jest.fn() + + const lane = { id: 1, title: 'Lane title' } + + function reset () { + subject = undefined + onLaneRemove.mockClear() + onLaneRename.mockClear() + } + + function mount (props) { + subject = render( + + {lane} + + ) + return subject + } + + beforeEach(reset) + + it('renders a lane header with the title', () => { + expect(mount().queryByText('Lane title')).toBeInTheDocument() + }) + + describe('about the remove lane button', () => { + describe('when the component does not receive the allowRemoveLane prop', () => { + beforeEach(() => mount({ onLaneRemove })) + + it('does not show the remove button', () => { + expect(subject.queryByText('×')).not.toBeInTheDocument() + }) + + it('does not call the "onLaneRemove" callback', () => { + expect(onLaneRemove).not.toHaveBeenCalled() + }) + }) + + describe('when the component receives the "allowRemoveLane" prop', () => { + beforeEach(() => mount({ allowRemoveLane: true })) + + it('shows the remove button', () => { + expect(subject.queryByText('×')).toBeInTheDocument() + }) + + it('does not call the "onLaneRemove" callback', () => { + expect(onLaneRemove).not.toHaveBeenCalled() + }) + + describe('when the user clicks on the remove button', () => { + beforeEach(() => fireEvent.click(subject.queryByText('×'))) + + it('calls the "onLaneRemove" callback passing the lane', () => { + expect(onLaneRemove).toHaveBeenCalledTimes(1) + expect(onLaneRemove).toHaveBeenCalledWith(lane) + }) + }) + }) + }) + + describe('about the lane title renaming', () => { + describe('when the component does not receive the "allowRenameLane" prop', () => { + describe('when the user clicks on the lane title', () => { + beforeEach(() => { + mount({ onLaneRename }) + fireEvent.click(subject.queryByText('Lane title')) + }) + + it('does not allow the user to rename the lane', () => { + expect(subject.queryByText('Lane title')).toBeInTheDocument() + expect(subject.container.querySelector('input')).not.toBeInTheDocument() + }) + + it('does not call the "onLaneRename" callback', () => { + expect(onLaneRename).not.toHaveBeenCalled() + }) + }) + }) + + describe('when the component receives the "allowRenameLane" prop', () => { + describe('when the user clicks on the lane title', () => { + beforeEach(() => { + mount({ allowRenameLane: true, onLaneRename }) + fireEvent.click(subject.queryByText('Lane title')) + }) + + it('does not call the "onLaneRename" callback', () => { + expect(onLaneRename).not.toHaveBeenCalled() + }) + + it('toggles the title for an input for typing a new title', () => { + expect(subject.queryByText('Lane title')).not.toBeInTheDocument() + expect(subject.container.querySelector('input')).toBeInTheDocument() + expect(subject.queryByText('Rename', { selector: 'button' })).toBeInTheDocument() + expect(subject.queryByText('Cancel', { selector: 'button' })).toBeInTheDocument() + }) + + it('focuses on the input', () => { + expect(subject.container.querySelector('input')).toHaveFocus() + }) + + it('fills the input with the lane title', () => { + expect(subject.container.querySelector('input')).toHaveValue('Lane title') + }) + + describe('when the user types a new name and confirms it', () => { + beforeEach(() => { + fireEvent.change(subject.container.querySelector('input'), { target: { value: 'New title' } }) + fireEvent.click(subject.queryByText('Rename', { selector: 'button' })) + }) + + it('toggles the input for the new lane title', () => { + expect(subject.queryByText('New title')).toBeInTheDocument() + expect(subject.container.querySelector('input')).not.toBeInTheDocument() + }) + + it('calls the "onLaneRename" callback passing the lane id with the new title', () => { + expect(onLaneRename).toHaveBeenCalledTimes(1) + expect(onLaneRename).toHaveBeenCalledWith(1, 'New title') + }) + }) + + describe('when the user cancels the renaming', () => { + beforeEach(() => { + fireEvent.click(subject.queryByText('Cancel', { selector: 'button' })) + }) + + it('cancels the renaming', () => { + expect(subject.queryByText('Lane title')).toBeInTheDocument() + expect(subject.container.querySelector('input')).not.toBeInTheDocument() + }) + + it('does call the "onLaneRename" callback', () => { + expect(onLaneRename).not.toHaveBeenCalled() + }) + }) + }) + }) + }) +}) diff --git a/src/components/Board/index.js b/src/components/Board/index.js index 0efb5713..a3921e84 100644 --- a/src/components/Board/index.js +++ b/src/components/Board/index.js @@ -6,6 +6,7 @@ import LaneAdder from './components/LaneAdder' import reorderBoard from './services/reorderBoard' import withDroppable from '../withDroppable' import { addInArrayAtPosition, when } from '@services/utils' +import DefaultLaneHeader from './components/DefaultLaneHeader' const StyledBoard = styled.div` padding: 5px; @@ -18,18 +19,6 @@ const Lanes = styled.div` white-space: nowrap; ` -const DefaultLaneHeader = styled.div` - padding-left: 10px; - padding-bottom: 10px; - font-weight: bold; - display: flex; - justify-content: space-between; - - span:nth-child(2) { - cursor: pointer; - } -` - const DroppableBoard = withDroppable(Lanes) function Board ({ @@ -43,7 +32,9 @@ function Board ({ disableLaneDrag, disableCardDrag, allowRemoveLane, - onLaneRemove + onLaneRemove, + allowRenameLane, + onLaneRename }) { const [board, setBoard] = useState(children) @@ -70,16 +61,21 @@ function Board ({ setBoard({ ...board, lanes }) } - function removeLane (laneId) { - const filteredLanes = board.lanes.filter(lane => lane.id !== laneId) + function removeLane (lane) { + const filteredLanes = board.lanes.filter(({ id }) => id !== lane.id) const filteredBoard = { ...board, lanes: filteredLanes } - if (onLaneRemove) { - const removedLane = board.lanes.find(lane => lane.id === laneId) - onLaneRemove(filteredBoard, removedLane) - } + onLaneRemove(filteredBoard, lane) setBoard(filteredBoard) } + function renameLane (laneId, title) { + const renamedLane = board.lanes.find(lane => lane.id === laneId) + const renamedLanes = board.lanes.map(lane => lane.id === laneId ? { ...lane, title } : lane) + const boardWithRenamedLane = { ...board, lanes: renamedLanes } + onLaneRename(boardWithRenamedLane, { ...renamedLane, title }) + setBoard(boardWithRenamedLane) + } + return ( - {lane.title}{allowRemoveLane && removeLane(lane.id)}>×} - - )} + renderLaneHeader={renderLaneHeader + ? ( + renderLaneHeader(lane, { + removeLane: removeLane.bind(null, lane), + renameLane: renameLane.bind(null, lane.id) + }) + ) : ( + + {lane} + + )} disableLaneDrag={disableLaneDrag} disableCardDrag={disableCardDrag} > diff --git a/src/components/Board/index.spec.js b/src/components/Board/index.spec.js index 9d87d0f4..643e40d4 100644 --- a/src/components/Board/index.spec.js +++ b/src/components/Board/index.spec.js @@ -4,7 +4,7 @@ import Board from './' import { callbacks } from 'react-beautiful-dnd' describe('', () => { - let subject, onCardDragEnd, onLaneDragEnd, onLaneRemove + let subject, onCardDragEnd, onLaneDragEnd, onLaneRemove, onLaneRename const board = { lanes: [ { @@ -270,14 +270,14 @@ describe('', () => { expect(subject.queryByTestId('lane-header')).toHaveTextContent(/^Lane Backlog \(1\)$/) }) - it('passes both the lane content and the removeLane function to the renderLaneHeader prop', () => { + it('passes the lane content, the "removeLane" and the "renameLane" to the "renderLaneHeader" prop', () => { expect(renderLaneHeader).toHaveBeenCalledTimes(1) expect(renderLaneHeader).toHaveBeenCalledWith({ id: 1, title: 'Lane Backlog', wip: 1, cards: [{ id: 2, title: 'Card title', content: 'Card content' }] - }, expect.any(Function)) + }, { removeLane: expect.any(Function), renameLane: expect.any(Function) }) }) }) @@ -354,8 +354,8 @@ describe('', () => { }) describe('about the lane removing', () => { - describe('when the component receives the "allowRemoveProp" prop', () => { - describe('when the component receives the "onLaneRemove" prop', () => { + describe('when the component uses the default header template', () => { + describe('when the component receives the "allowRemoveLane" prop', () => { beforeEach(() => { onLaneRemove = jest.fn() mount({ allowRemoveLane: true, onLaneRemove }) @@ -377,7 +377,7 @@ describe('', () => { expect(lane[0]).toHaveTextContent('Lane Doing') }) - it('calls the "onLaneRemove callback passing both the updated board and the removed lane', () => { + it('calls the "onLaneRemove" callback passing both the updated board and the removed lane', () => { expect(onLaneRemove).toHaveBeenCalledTimes(1) expect(onLaneRemove).toHaveBeenCalledWith( { lanes: [expect.objectContaining({ id: 2 })] }, @@ -386,13 +386,130 @@ describe('', () => { }) }) }) + + describe('when the component does not receive the "allowRemoveLane" prop', () => { + beforeEach(() => { + onLaneRemove = jest.fn() + mount({ onLaneRemove }) + }) + + it('does not call the "onLaneRemove" callback', () => { + expect(onLaneRemove).toHaveBeenCalledTimes(0) + }) + + it('does not show the button on lane header to remove the lane', () => { + expect(subject.queryAllByTestId('lane')[0].querySelector('button')).not.toBeInTheDocument() + }) + }) + }) + + describe('when the component receives a custom header lane template', () => { + beforeEach(() => { + const renderLaneHeader = ({ title }, { removeLane }) =>
{title}
+ onLaneRemove = jest.fn() + mount({ renderLaneHeader, onLaneRemove }) + }) + + describe('when the "removeLane" callback is called', () => { + beforeEach(() => fireEvent.click(within(subject.queryAllByTestId('lane')[0]).queryByText('Lane Backlog'))) + + it('removes the lane', () => { + const lane = subject.queryAllByTestId('lane') + expect(lane).toHaveLength(1) + expect(lane[0]).toHaveTextContent('Lane Doing') + }) + + it('calls the "onLaneRemove" callback passing both the updated board and the removed lane', () => { + expect(onLaneRemove).toHaveBeenCalledTimes(1) + expect(onLaneRemove).toHaveBeenCalledWith( + { lanes: [expect.objectContaining({ id: 2 })] }, + expect.objectContaining({ id: 1 }) + ) + }) + }) + }) + }) + + describe('about the lane renaming', () => { + describe('when the component use the default header template', () => { + describe('when the component receives the "allowRenameLane" prop', () => { + beforeEach(() => { + onLaneRename = jest.fn() + mount({ allowRenameLane: true, onLaneRename }) + }) + + it('does not call the "onLaneRename" callback', () => { + expect(onLaneRename).toHaveBeenCalledTimes(0) + }) + + describe('when the user renames a lane', () => { + beforeEach(() => { + fireEvent.click(within(subject.queryAllByTestId('lane')[0]).queryByText('Lane Backlog')) + fireEvent.change(subject.container.querySelector('input'), { target: { value: 'New title' } }) + fireEvent.click(subject.container.querySelector('button')) + }) + + it('renames the lane', () => { + expect(subject.queryAllByTestId('lane')[0]).toHaveTextContent('New title') + }) + + it('calls the "onLaneRename" callback passing both the updated board and the renamed lane lane', () => { + expect(onLaneRename).toHaveBeenCalledTimes(1) + expect(onLaneRename).toHaveBeenCalledWith( + { + lanes: [ + expect.objectContaining({ id: 1, title: 'New title' }), + expect.objectContaining({ id: 2, title: 'Lane Doing' }) + ] + }, + expect.objectContaining({ id: 1, title: 'New title' }) + ) + }) + }) + }) + + describe('when the component does not receive the "allowRenameLane" prop', () => { + beforeEach(() => { + onLaneRename = jest.fn() + mount({ onLaneRename }) + }) + + it('does not call the "onLaneRename" callback', () => { + expect(onLaneRename).toHaveBeenCalledTimes(0) + }) + + it('does not show the button on lane header to remove the lane', () => { + expect(subject.queryAllByTestId('lane')[0].querySelector('button')).not.toBeInTheDocument() + }) + }) }) - describe('when the component does not receive the "allowRemoveProp" prop', () => { - beforeEach(() => mount()) + describe('when the component receives a custom header lane template', () => { + beforeEach(() => { + const renderLaneHeader = ({ title }, { renameLane }) =>
renameLane('New title')}>{title}
+ onLaneRename = jest.fn() + mount({ renderLaneHeader, onLaneRename }) + }) + + describe('when the "renameLane" callback is called', () => { + beforeEach(() => fireEvent.click(within(subject.queryAllByTestId('lane')[0]).queryByText('Lane Backlog'))) + + it('renames the lane', () => { + expect(subject.queryAllByTestId('lane')[0]).toHaveTextContent('New title') + }) - it('does not show the button on lane header to remove the lane', () => { - expect(subject.queryAllByTestId('lane')[0].querySelector('button')).not.toBeInTheDocument() + it('calls the "onLaneRemove" callback passing both the updated board and the removed lane', () => { + expect(onLaneRename).toHaveBeenCalledTimes(1) + expect(onLaneRename).toHaveBeenCalledWith( + { + lanes: [ + expect.objectContaining({ id: 1, title: 'New title' }), + expect.objectContaining({ id: 2, title: 'Lane Doing' }) + ] + }, + expect.objectContaining({ id: 1, title: 'New title' }) + ) + }) }) }) })