Skip to content
This repository has been archived by the owner on Oct 8, 2022. It is now read-only.

Commit

Permalink
feat: Add capability to add a new card (#209)
Browse files Browse the repository at this point in the history
  • Loading branch information
lourenci authored Mar 5, 2020
1 parent 387e5a2 commit d3b0328
Show file tree
Hide file tree
Showing 12 changed files with 743 additions and 8 deletions.
34 changes: 33 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,9 @@ setBoard(newBoard)
| [`onColumnRename`](#oncolumnrename) (required if `allowRenameColumn` or when [`renameColumn`](#rendercolumnheader) is called) | Callback that will be called when a column is renamed |||
| [`allowRemoveCard`](#allowremovecard) | Allow to remove a card in default card template |||
| [`onCardRemove`](#oncardremove) (required if `allowRemoveCard`) | Callback that will be called when a card is removed |||
| [`onCardNew`](#oncardnew) (required if [`addCard`](#rendercolumnheader) is called) | Callback that will be called when a new card is added | 🚫 ||
| [`allowAddCard`](#allowaddcard) | Allow to add a card. Expect an object with the position to add the card in the column. | 🚫 ||
| [`onCardNew`](#oncardnew) (required if `allowAddCard` or when [`addCard`](#rendercolumnheader) is called) | Callback that will be called when a new card is added through the default card adder template | 🚫 ||
| [`onNewCardConfirm`](#onnewcardconfirm) (required if `allowAddCard`) | Callback that will be called when a new card is confirmed by the user through the default card adder template | 🚫 ||

#### `children`

Expand Down Expand Up @@ -456,6 +458,36 @@ When the user removes a card, this callback will be called passing these paramet
| `card` | Card to be added |
| `{ on: 'bottom|top' }` | Whether the card will be added on top or bottom of the column (`bottom` is default) |

#### `allowAddCard`

Allow the user to add a card in the column directly by the board. By default, it adds the card on the bottom of the column, but you can specify whether you want to add at the top or at the bottom of the board by passing an object with 'on' prop.

E.g.:
<Board allowAddCard /> // at the bottom by default
<Board allowAddCard={{ on: 'bottom' }} /> // in the bottom of the column
<Board allowAddCard={{ on: 'top' }} /> // at the top of the column

#### `onCardNew`

When the user adds a new card through the default card adder template, this callback will be called passing the updated board and the new card.

#### `onNewCardConfirm`

When the user confirms a new card through the default card adder template, this callback will be called with a draft of a card with the title and the description typed by the user.

You **must** return the new card with its new id in this callback.

Ex.:

```js
function onCardNew (newCard) {
const newCard = { id: ${required-new-unique-cardId}, ...newCard }
return newCard
}

<Board initialBoard={board} allowAddCard onNewCardConfirm={onCardNew} onCardNew={console.log} />
```

#### `removeCard`

| Arg | Description |
Expand Down
3 changes: 2 additions & 1 deletion jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ module.exports = {
testMatch: ['<rootDir>/src/**/*.spec.js'],
setupFilesAfterEnv: ['@testing-library/jest-dom/extend-expect'],
moduleNameMapper: {
'^@services/(.*)$': '<rootDir>/src/services/$1'
'^@services/(.*)$': '<rootDir>/src/services/$1',
'^@components/(.*)$': '<rootDir>/src/components/$1'
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import React, { useRef } from 'react'
import styled from 'styled-components'
import { when } from '@services/utils'
import CardSkeleton from '@components/Board/components/CardSkeleton'

const DefaultCard = styled(CardSkeleton)`
border-radius: 3px;
background-color: #fff;
padding: 10px;
margin-bottom: 7px;
input {
border: 0px;
font-family: inherit;
font-size: inherit;
}
`

const CardTitle = styled.input`
font-weight: bold;
border-bottom: 1px solid #eee;
padding-bottom: 5px;
font-weight: bold;
display: flex;
justify-content: space-between;
width: 100%;
padding: 0px;
`

const CardDescription = styled.input`
input {
width: 100%;
}
margin-top: 10px;
`
const StyledFormButtons = styled.div`
display: flex;
justify-content: space-between;
margin-top: 5px;
`

const StyledButton = styled.button`
background-color: #eee;
border: none;
padding: 5px;
width: 45%;
margin-top: 5px;
border-radius: 3px;
&:hover {
transition: 0.3s;
cursor: pointer;
background-color: #ccc;
}
`

function CardForm({ onConfirm, onCancel }) {
const inputCardTitle = useRef()
const inputCardDescription = useRef()

function addCard(event) {
event.preventDefault()
when(inputCardTitle.current.value)(value => {
onConfirm({ title: value, description: inputCardDescription.current.value })
})
}

return (
<DefaultCard>
<form onSubmit={addCard}>
<CardTitle name='title' autoFocus defaultValue='Title' ref={inputCardTitle} />
<CardDescription name='description' defaultValue='Description' ref={inputCardDescription} />
<StyledFormButtons>
<StyledButton type='submit'>Add</StyledButton>
<StyledButton type='button' onClick={onCancel}>
Cancel
</StyledButton>
</StyledFormButtons>
</form>
</DefaultCard>
)
}

export default CardForm
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import React from 'react'
import { render, fireEvent } from '@testing-library/react'
import CardForm from './'

describe('<CardForm />', () => {
let subject, onConfirm, onCancel

function mount() {
onConfirm = jest.fn()
onCancel = jest.fn()

subject = render(<CardForm onConfirm={onConfirm} onCancel={onCancel} />)
}

beforeEach(mount)
afterEach(() => {
subject = onConfirm = onCancel = undefined
})

it('renders the card inputs', () => {
expect(subject.container.querySelector('input[name="title"]')).toBeInTheDocument()
expect(subject.container.querySelector('input[name="description"]')).toBeInTheDocument()
})

it('focuses on the title input', () => {
expect(subject.container.querySelector('input[name="title"]')).toHaveFocus()
})

describe('when the user clicks confirm the input', () => {
describe('when the user has informed a valid card', () => {
beforeEach(() => {
fireEvent.change(subject.container.querySelector('input[name="title"]'), { target: { value: 'Card title' } })
fireEvent.change(subject.container.querySelector('input[name="description"]'), {
target: { value: 'Description' }
})
fireEvent.click(subject.queryByText('Add'))
})

it('calls the onConfirm prop passing the values', () => {
expect(onConfirm).toHaveBeenCalledTimes(1)
expect(onConfirm).toHaveBeenCalledWith({ title: 'Card title', description: 'Description' })
})

it('does not call the onCancel prop', () => {
expect(onCancel).not.toHaveBeenCalled()
})
})

describe('when the user has not typed a card title', () => {
beforeEach(() => {
fireEvent.change(subject.container.querySelector('input[name="title"]'), { target: { value: '' } })
fireEvent.click(subject.queryByText('Add'))
})

it('does not call the onConfirm prop', () => {
expect(onConfirm).not.toHaveBeenCalled()
})

it('does not call the onCancel prop', () => {
expect(onCancel).not.toHaveBeenCalled()
})
})
})

describe('when the user cancels the input', () => {
beforeEach(() => {
fireEvent.click(subject.queryByText('Cancel'))
})

it('calls the onCancel prop', () => {
expect(onCancel).toHaveBeenCalledTimes(1)
})

it('does not call the onConfirm prop', () => {
expect(onConfirm).not.toHaveBeenCalled()
})
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import React, { useState } from 'react'
import styled from 'styled-components'
import CardForm from './components/CardForm'

const AddCardButton = styled.button`
width: 100%;
margin-top: 5px;
background-color: transparent;
cursor: pointer;
border: 1px solid #ccc;
transition: 0.3s;
:hover {
background-color: #ccc;
}
border-radius: 3px;
font-size: 20px;
margin-bottom: 10px;
font-weight: bold;
`

export default function CardAdder({ column, onConfirm }) {
function confirmCard(card) {
onConfirm(column, card)
setAddingCard(false)
}

const [addingCard, setAddingCard] = useState(false)

return (
<>
{addingCard ? (
<CardForm onConfirm={confirmCard} onCancel={() => setAddingCard(false)} />
) : (
<AddCardButton onClick={() => setAddingCard(!addingCard)}>+</AddCardButton>
)}
</>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import React from 'react'
import { render, fireEvent } from '@testing-library/react'
import CardAdder from './'

describe('<CardAdder />', () => {
let subject, onConfirm
const column = { id: 1 }
function mount() {
onConfirm = jest.fn()

subject = render(<CardAdder column={column} onConfirm={onConfirm} />)
}

beforeEach(mount)
afterEach(() => {
subject = onConfirm = undefined
})

it('renders the button to add a new card', () => {
expect(subject.queryByText('+')).toBeInTheDocument()
})

describe('when the user clicks to add a new card', () => {
beforeEach(() => fireEvent.click(subject.queryByText('+')))

it('hides the card placeholder', () => {
expect(subject.queryByText('+')).not.toBeInTheDocument()
})

it('renders the card inputs', () => {
expect(subject.container.querySelector('input[name="title"]')).toBeInTheDocument()
expect(subject.container.querySelector('input[name="description"]')).toBeInTheDocument()
})

describe('when the user confirms the new card', () => {
beforeEach(() => {
fireEvent.change(subject.container.querySelector('input[name="title"]'), {
target: { value: 'Card Added by user' }
})
fireEvent.change(subject.container.querySelector('input[name="description"]'), {
target: { value: 'Description' }
})
fireEvent.click(subject.queryByText('Add'))
})

it('calls the "onConfirm" prop passing the new card and the column', () => {
expect(onConfirm).toHaveBeenCalledTimes(1)
expect(onConfirm).toHaveBeenCalledWith(column, { title: 'Card Added by user', description: 'Description' })
})

it('hides the input', () => {
expect(subject.container.querySelector('input')).not.toBeInTheDocument()
})

it('renders the placeholder to add a new card', () => {
expect(subject.queryByText('+')).toBeInTheDocument()
})
})

describe('when the user cancels the new card', () => {
beforeEach(() => {
fireEvent.click(subject.queryByText('Cancel'))
})

it('does not call the "onConfirm" prop', () => {
expect(onConfirm).not.toHaveBeenCalled()
})

it('hides the input', () => {
expect(subject.container.querySelector('input')).not.toBeInTheDocument()
})

it('renders the placeholder to add a new card', () => {
expect(subject.queryByText('+')).toBeInTheDocument()
})
})
})
})
13 changes: 12 additions & 1 deletion src/components/Board/components/Column/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Draggable } from 'react-beautiful-dnd'
import Card from './components/Card'
import CardSkeleton from '../CardSkeleton'
import withDroppable from '../../../withDroppable'
import CardAdder from './components/CardAdder'

export const StyledColumn = styled.div`
height: 100%;
Expand All @@ -19,14 +20,24 @@ const DroppableColumn = withDroppable(styled.div`
min-height: 28px;
`)

function Column({ children, index: columnIndex, renderCard, renderColumnHeader, disableColumnDrag, disableCardDrag }) {
function Column({
children,
index: columnIndex,
renderCard,
renderColumnHeader,
disableColumnDrag,
disableCardDrag,
onCardNew,
allowAddCard
}) {
return (
<Draggable draggableId={`column-draggable-${children.id}`} index={columnIndex} isDragDisabled={disableColumnDrag}>
{columnProvided => (
<StyledColumn ref={columnProvided.innerRef} {...columnProvided.draggableProps} data-testid='column'>
<div {...columnProvided.dragHandleProps} data-testid='column-header'>
{renderColumnHeader(children)}
</div>
{allowAddCard && <CardAdder column={children} onConfirm={onCardNew} />}
<DroppableColumn droppableId={String(children.id)}>
{children.cards.length ? (
children.cards.map((card, index) => (
Expand Down
Loading

0 comments on commit d3b0328

Please sign in to comment.