[GH-1467] Automatically create new checkbox when enter is pressed (#1505)
* Initial implementation for creation of new checkbox when enter is pressed. * Card detail context added: - used for adding new content element - tracks id of new block that was added - used in checkbox element to set focus * Deleting of last added empty checkbox supported. * Rename addNewElement/addNewBlock to addElement/addBlock. * New component CardDetailProvider for card detail context introduced. * Delete only automatically added checkboxes. * Fix existing unit tests: add `CardDetailProvider` when needed. * Unit tests for `CheckboxElement` updated: - use mocked mutator - test for focus of last added checkbox added - test for adding new checkbox on pressing enter added - test for deleting automatically added checkbox on pressing esc/enter added
This commit is contained in:
parent
e02a03290d
commit
07cbc522fd
13 changed files with 376 additions and 155 deletions
|
@ -1,7 +1,7 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react'
|
||||
import React, {ReactElement, ReactNode} from 'react'
|
||||
import {render, screen, waitFor} from '@testing-library/react'
|
||||
|
||||
import '@testing-library/jest-dom'
|
||||
|
@ -22,9 +22,17 @@ import './content/textElement'
|
|||
import './content/imageElement'
|
||||
import './content/dividerElement'
|
||||
import './content/checkboxElement'
|
||||
import {CardDetailProvider} from './cardDetail/cardDetailContext'
|
||||
|
||||
const board = TestBlockFactory.createBoard()
|
||||
const card = TestBlockFactory.createCard(board)
|
||||
const wrap = (child: ReactNode): ReactElement => (
|
||||
wrapIntl(
|
||||
<CardDetailProvider card={card} >
|
||||
{child}
|
||||
</CardDetailProvider>,
|
||||
)
|
||||
)
|
||||
|
||||
jest.mock('../mutator')
|
||||
const mockedMutator = mocked(mutator, true)
|
||||
|
@ -35,7 +43,7 @@ describe('components/addContentMenuItem', () => {
|
|||
})
|
||||
test('return an image menu item', () => {
|
||||
const {container} = render(
|
||||
wrapIntl(
|
||||
wrap(
|
||||
<AddContentMenuItem
|
||||
type={'image'}
|
||||
card={card}
|
||||
|
@ -48,7 +56,7 @@ describe('components/addContentMenuItem', () => {
|
|||
|
||||
test('return a text menu item', async () => {
|
||||
const {container} = render(
|
||||
wrapIntl(
|
||||
wrap(
|
||||
<AddContentMenuItem
|
||||
type={'text'}
|
||||
card={card}
|
||||
|
@ -64,7 +72,7 @@ describe('components/addContentMenuItem', () => {
|
|||
|
||||
test('return a checkbox menu item', async () => {
|
||||
const {container} = render(
|
||||
wrapIntl(
|
||||
wrap(
|
||||
<AddContentMenuItem
|
||||
type={'checkbox'}
|
||||
card={card}
|
||||
|
@ -80,7 +88,7 @@ describe('components/addContentMenuItem', () => {
|
|||
|
||||
test('return a divider menu item', async () => {
|
||||
const {container} = render(
|
||||
wrapIntl(
|
||||
wrap(
|
||||
<AddContentMenuItem
|
||||
type={'divider'}
|
||||
card={card}
|
||||
|
@ -96,7 +104,7 @@ describe('components/addContentMenuItem', () => {
|
|||
|
||||
test('return an error and empty element from unknow type', () => {
|
||||
const {container} = render(
|
||||
wrapIntl(
|
||||
wrap(
|
||||
<AddContentMenuItem
|
||||
type={'unknown'}
|
||||
card={card}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
import React, {useState, useRef, useEffect, useCallback} from 'react'
|
||||
import React, {useCallback, useEffect, useRef, useState} from 'react'
|
||||
import {FormattedMessage} from 'react-intl'
|
||||
|
||||
import {BlockIcons} from '../../blockIcons'
|
||||
|
@ -14,11 +14,12 @@ import Button from '../../widgets/buttons/button'
|
|||
import {Focusable} from '../../widgets/editable'
|
||||
import EditableArea from '../../widgets/editableArea'
|
||||
import EmojiIcon from '../../widgets/icons/emoji'
|
||||
import TelemetryClient, {TelemetryCategory, TelemetryActions} from '../../telemetry/telemetryClient'
|
||||
import TelemetryClient, {TelemetryActions, TelemetryCategory} from '../../telemetry/telemetryClient'
|
||||
|
||||
import BlockIconSelector from '../blockIconSelector'
|
||||
|
||||
import CommentsList from './commentsList'
|
||||
import {CardDetailProvider} from './cardDetailContext'
|
||||
import CardDetailContents from './cardDetailContents'
|
||||
import CardDetailContentsMenu from './cardDetailContentsMenu'
|
||||
import CardDetailProperties from './cardDetailProperties'
|
||||
|
@ -143,14 +144,14 @@ const CardDetail = (props: Props): JSX.Element|null => {
|
|||
{/* Content blocks */}
|
||||
|
||||
<div className='CardDetail content fullwidth content-blocks'>
|
||||
<CardDetailContents
|
||||
card={props.card}
|
||||
contents={props.contents}
|
||||
readonly={props.readonly}
|
||||
/>
|
||||
{!props.readonly &&
|
||||
<CardDetailContentsMenu card={props.card}/>
|
||||
}
|
||||
<CardDetailProvider card={card}>
|
||||
<CardDetailContents
|
||||
card={props.card}
|
||||
contents={props.contents}
|
||||
readonly={props.readonly}
|
||||
/>
|
||||
{!props.readonly && <CardDetailContentsMenu/>}
|
||||
</CardDetailProvider>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
import React from 'react'
|
||||
import React, {ReactElement, ReactNode} from 'react'
|
||||
|
||||
import {fireEvent, render} from '@testing-library/react'
|
||||
|
||||
|
@ -11,6 +11,7 @@ import {TestBlockFactory} from '../../test/testBlockFactory'
|
|||
import {mockDOM, wrapDNDIntl} from '../../testUtils'
|
||||
|
||||
import CardDetailContents from './cardDetailContents'
|
||||
import {CardDetailProvider} from './cardDetailContext'
|
||||
|
||||
global.fetch = jest.fn()
|
||||
|
||||
|
@ -47,8 +48,16 @@ describe('components/cardDetail/cardDetailContents', () => {
|
|||
|
||||
const card = TestBlockFactory.createCard(board)
|
||||
|
||||
const wrap = (child: ReactNode): ReactElement => (
|
||||
wrapDNDIntl(
|
||||
<CardDetailProvider card={card}>
|
||||
{child}
|
||||
</CardDetailProvider>,
|
||||
)
|
||||
)
|
||||
|
||||
test('should match snapshot', async () => {
|
||||
const component = wrapDNDIntl((
|
||||
const component = wrap((
|
||||
<CardDetailContents
|
||||
id='test-id'
|
||||
card={card}
|
||||
|
@ -67,7 +76,7 @@ describe('components/cardDetail/cardDetailContents', () => {
|
|||
|
||||
test('should match snapshot with contents array', async () => {
|
||||
const contents = [TestBlockFactory.createDivider(card)]
|
||||
const component = wrapDNDIntl((
|
||||
const component = wrap((
|
||||
<CardDetailContents
|
||||
id='test-id'
|
||||
card={card}
|
||||
|
@ -85,7 +94,7 @@ describe('components/cardDetail/cardDetailContents', () => {
|
|||
})
|
||||
|
||||
test('should match snapshot after onBlur triggers', async () => {
|
||||
const component = wrapDNDIntl((
|
||||
const component = wrap((
|
||||
<CardDetailContents
|
||||
id='test-id'
|
||||
card={card}
|
||||
|
@ -132,7 +141,7 @@ describe('components/cardDetail/cardDetailContents', () => {
|
|||
|
||||
test('should match snapshot with contents array that has array inside it', async () => {
|
||||
const contents = [TestBlockFactory.createDivider(card), [TestBlockFactory.createDivider(card), TestBlockFactory.createDivider(card)]]
|
||||
const component = wrapDNDIntl((
|
||||
const component = wrap((
|
||||
<CardDetailContents
|
||||
id='test-id'
|
||||
card={card}
|
||||
|
@ -152,7 +161,7 @@ describe('components/cardDetail/cardDetailContents', () => {
|
|||
test('should match snapshot after drag and drop event', async () => {
|
||||
const contents = [TestBlockFactory.createDivider(card), [TestBlockFactory.createDivider(card), TestBlockFactory.createDivider(card)]]
|
||||
card.fields.contentOrder = contents.map((content) => (Array.isArray(content) ? content.map((c) => c.id) : (content as any).id))
|
||||
const component = wrapDNDIntl((
|
||||
const component = wrap((
|
||||
<CardDetailContents
|
||||
id='test-id'
|
||||
card={card}
|
||||
|
@ -181,7 +190,7 @@ describe('components/cardDetail/cardDetailContents', () => {
|
|||
test('should match snapshot after drag and drop event 2', async () => {
|
||||
const contents = [TestBlockFactory.createDivider(card), TestBlockFactory.createDivider(card)]
|
||||
card.fields.contentOrder = contents.map((content) => (Array.isArray(content) ? content.map((c) => c.id) : (content as any).id))
|
||||
const component = wrapDNDIntl((
|
||||
const component = wrap((
|
||||
<CardDetailContents
|
||||
id='test-id'
|
||||
card={card}
|
||||
|
@ -210,7 +219,7 @@ describe('components/cardDetail/cardDetailContents', () => {
|
|||
test('should match snapshot after drag and drop event 3', async () => {
|
||||
const contents = [TestBlockFactory.createDivider(card), TestBlockFactory.createDivider(card)]
|
||||
card.fields.contentOrder = contents.map((content) => (Array.isArray(content) ? content.map((c) => c.id) : (content as any).id))
|
||||
const component = wrapDNDIntl((
|
||||
const component = wrap((
|
||||
<CardDetailContents
|
||||
id='test-id'
|
||||
card={card}
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
import {render, screen} from '@testing-library/react'
|
||||
import {act, render, screen} from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import React from 'react'
|
||||
import React, {ReactElement, ReactNode} from 'react'
|
||||
import {Provider as ReduxProvider} from 'react-redux'
|
||||
|
||||
import {wrapIntl, mockStateStore} from '../../testUtils'
|
||||
|
@ -16,6 +16,7 @@ import '../content/textElement'
|
|||
import '../content/imageElement'
|
||||
import '../content/dividerElement'
|
||||
import '../content/checkboxElement'
|
||||
import {CardDetailProvider} from './cardDetailContext'
|
||||
|
||||
jest.mock('../../mutator')
|
||||
|
||||
|
@ -23,31 +24,34 @@ const board = TestBlockFactory.createBoard()
|
|||
const card = TestBlockFactory.createCard(board)
|
||||
describe('components/cardDetail/cardDetailContentsMenu', () => {
|
||||
const store = mockStateStore([], {})
|
||||
const wrap = (child: ReactNode): ReactElement => (
|
||||
wrapIntl(
|
||||
<ReduxProvider store={store}>
|
||||
<CardDetailProvider card={card}>
|
||||
{child}
|
||||
</CardDetailProvider>
|
||||
</ReduxProvider>,
|
||||
)
|
||||
)
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
test('return cardDetailContentsMenu', () => {
|
||||
const {container} = render(wrapIntl(
|
||||
<ReduxProvider store={store}>
|
||||
<CardDetailContentsMenu card={card}/>
|
||||
</ReduxProvider>,
|
||||
))
|
||||
const {container} = render(wrap(<CardDetailContentsMenu/>))
|
||||
const buttonElement = screen.getByRole('button', {name: 'menuwrapper'})
|
||||
userEvent.click(buttonElement)
|
||||
expect(container).toMatchSnapshot()
|
||||
})
|
||||
|
||||
test('return cardDetailContentsMenu and add Text content', async () => {
|
||||
const {container} = render(wrapIntl(
|
||||
<ReduxProvider store={store}>
|
||||
<CardDetailContentsMenu card={card}/>
|
||||
</ReduxProvider>,
|
||||
))
|
||||
const {container} = render(wrap(<CardDetailContentsMenu/>))
|
||||
const buttonElement = screen.getByRole('button', {name: 'menuwrapper'})
|
||||
userEvent.click(buttonElement)
|
||||
expect(container).toMatchSnapshot()
|
||||
const buttonAddText = screen.getByRole('button', {name: 'text'})
|
||||
userEvent.click(buttonAddText)
|
||||
await act(async () => {
|
||||
const buttonAddText = screen.getByRole('button', {name: 'text'})
|
||||
userEvent.click(buttonAddText)
|
||||
})
|
||||
expect(container).toMatchSnapshot()
|
||||
})
|
||||
})
|
||||
|
|
|
@ -1,24 +1,30 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
import React from 'react'
|
||||
import {FormattedMessage, useIntl, IntlShape} from 'react-intl'
|
||||
import React, {useCallback} from 'react'
|
||||
import {FormattedMessage, IntlShape, useIntl} from 'react-intl'
|
||||
|
||||
import {BlockTypes} from '../../blocks/block'
|
||||
import mutator from '../../mutator'
|
||||
import {Utils} from '../../utils'
|
||||
import {Card} from '../../blocks/card'
|
||||
import Button from '../../widgets/buttons/button'
|
||||
import Menu from '../../widgets/menu'
|
||||
import MenuWrapper from '../../widgets/menuWrapper'
|
||||
|
||||
import {ContentHandler, contentRegistry} from '../content/contentRegistry'
|
||||
import {contentRegistry} from '../content/contentRegistry'
|
||||
|
||||
function addContentMenu(card: Card, intl: IntlShape, type: BlockTypes): JSX.Element {
|
||||
import {useCardDetailContext} from './cardDetailContext'
|
||||
|
||||
function addContentMenu(intl: IntlShape, type: BlockTypes): JSX.Element {
|
||||
const handler = contentRegistry.getHandler(type)
|
||||
if (!handler) {
|
||||
Utils.logError(`addContentMenu, unknown content type: ${type}`)
|
||||
return <></>
|
||||
}
|
||||
const cardDetail = useCardDetailContext()
|
||||
const addElement = useCallback(async () => {
|
||||
const {card} = cardDetail
|
||||
const index = card.fields.contentOrder.length
|
||||
cardDetail.addBlock(handler, index, false)
|
||||
}, [cardDetail, handler])
|
||||
|
||||
return (
|
||||
<Menu.Text
|
||||
|
@ -26,33 +32,12 @@ function addContentMenu(card: Card, intl: IntlShape, type: BlockTypes): JSX.Elem
|
|||
id={type}
|
||||
name={handler.getDisplayText(intl)}
|
||||
icon={handler.getIcon()}
|
||||
onClick={() => {
|
||||
addBlock(card, intl, handler)
|
||||
}}
|
||||
onClick={addElement}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
async function addBlock(card: Card, intl: IntlShape, handler: ContentHandler) {
|
||||
const newBlock = await handler.createBlock(card.rootId)
|
||||
newBlock.parentId = card.id
|
||||
newBlock.rootId = card.rootId
|
||||
|
||||
const contentOrder = card.fields.contentOrder.slice()
|
||||
contentOrder.push(newBlock.id)
|
||||
const typeName = handler.getDisplayText(intl)
|
||||
const description = intl.formatMessage({id: 'ContentBlock.addElement', defaultMessage: 'add {type}'}, {type: typeName})
|
||||
mutator.performAsUndoGroup(async () => {
|
||||
await mutator.insertBlock(newBlock, description)
|
||||
await mutator.changeCardContentOrder(card.id, card.fields.contentOrder, contentOrder, description)
|
||||
})
|
||||
}
|
||||
|
||||
type Props = {
|
||||
card: Card
|
||||
}
|
||||
|
||||
const CardDetailContentsMenu = React.memo((props: Props) => {
|
||||
const CardDetailContentsMenu = React.memo(() => {
|
||||
const intl = useIntl()
|
||||
return (
|
||||
<div className='CardDetailContentsMenu content add-content'>
|
||||
|
@ -64,7 +49,7 @@ const CardDetailContentsMenu = React.memo((props: Props) => {
|
|||
/>
|
||||
</Button>
|
||||
<Menu position='top'>
|
||||
{contentRegistry.contentTypes.map((type) => addContentMenu(props.card, intl, type))}
|
||||
{contentRegistry.contentTypes.map((type) => addContentMenu(intl, type))}
|
||||
</Menu>
|
||||
</MenuWrapper>
|
||||
</div>
|
||||
|
|
81
webapp/src/components/cardDetail/cardDetailContext.tsx
Normal file
81
webapp/src/components/cardDetail/cardDetailContext.tsx
Normal file
|
@ -0,0 +1,81 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
import React, {createContext, ReactElement, ReactNode, useContext, useMemo, useState} from 'react'
|
||||
|
||||
import {useIntl} from 'react-intl'
|
||||
|
||||
import {Block} from '../../blocks/block'
|
||||
import {Card} from '../../blocks/card'
|
||||
import {ContentHandler} from '../content/contentRegistry'
|
||||
import mutator from '../../mutator'
|
||||
|
||||
export type AddedBlock = {
|
||||
id: string
|
||||
autoAdded: boolean
|
||||
}
|
||||
|
||||
export type CardDetailContextType = {
|
||||
card: Card
|
||||
lastAddedBlock: AddedBlock
|
||||
addBlock: (handler: ContentHandler, index: number, auto: boolean) => void
|
||||
deleteBlock: (block: Block, index: number) => void
|
||||
}
|
||||
|
||||
export const CardDetailContext = createContext<CardDetailContextType | null>(null)
|
||||
|
||||
export function useCardDetailContext(): CardDetailContextType {
|
||||
const cardDetailContext = useContext(CardDetailContext)
|
||||
if (!cardDetailContext) {
|
||||
throw new Error('CardDetailContext is not available!')
|
||||
}
|
||||
return cardDetailContext
|
||||
}
|
||||
|
||||
type CardDetailProps = {
|
||||
card: Card
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export const CardDetailProvider = (props: CardDetailProps): ReactElement => {
|
||||
const intl = useIntl()
|
||||
const [lastAddedBlock, setLastAddedBlock] = useState<AddedBlock>({
|
||||
id: '',
|
||||
autoAdded: false,
|
||||
})
|
||||
const {card} = props
|
||||
const contextValue = useMemo(() => ({
|
||||
card,
|
||||
lastAddedBlock,
|
||||
addBlock: async (handler: ContentHandler, index: number, auto: boolean) => {
|
||||
const block = await handler.createBlock(card.rootId)
|
||||
block.parentId = card.id
|
||||
block.rootId = card.rootId
|
||||
const contentOrder = card.fields.contentOrder.slice()
|
||||
contentOrder.splice(index, 0, block.id)
|
||||
setLastAddedBlock({
|
||||
id: block.id,
|
||||
autoAdded: auto,
|
||||
})
|
||||
const typeName = handler.getDisplayText(intl)
|
||||
const description = intl.formatMessage({id: 'ContentBlock.addElement', defaultMessage: 'add {type}'}, {type: typeName})
|
||||
await mutator.performAsUndoGroup(async () => {
|
||||
await mutator.insertBlock(block, description)
|
||||
await mutator.changeCardContentOrder(card.id, card.fields.contentOrder, contentOrder, description)
|
||||
})
|
||||
},
|
||||
deleteBlock: async (block: Block, index: number) => {
|
||||
const contentOrder = card.fields.contentOrder.slice()
|
||||
contentOrder.splice(index, 1)
|
||||
const description = intl.formatMessage({id: 'ContentBlock.DeleteAction', defaultMessage: 'delete'})
|
||||
await mutator.performAsUndoGroup(async () => {
|
||||
await mutator.deleteBlock(block, description)
|
||||
await mutator.changeCardContentOrder(card.id, card.fields.contentOrder, contentOrder, description)
|
||||
})
|
||||
},
|
||||
}), [card, lastAddedBlock, intl])
|
||||
return (
|
||||
<CardDetailContext.Provider value={contextValue}>
|
||||
{props.children}
|
||||
</CardDetailContext.Provider>
|
||||
)
|
||||
}
|
|
@ -1,6 +1,28 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`components/content/CheckboxElement should match snapshot 1`] = `
|
||||
exports[`components/content/checkboxElement should change title 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="CheckboxElement"
|
||||
>
|
||||
<input
|
||||
id="checkbox-test-id"
|
||||
type="checkbox"
|
||||
value="off"
|
||||
/>
|
||||
<input
|
||||
class="Editable undefined"
|
||||
placeholder="Edit text..."
|
||||
spellcheck="true"
|
||||
title="new title"
|
||||
type="text"
|
||||
value="new title"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`components/content/checkboxElement should match snapshot 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="CheckboxElement"
|
||||
|
@ -21,30 +43,7 @@ exports[`components/content/CheckboxElement should match snapshot 1`] = `
|
|||
</div>
|
||||
`;
|
||||
|
||||
exports[`components/content/CheckboxElement should match snapshot on change title 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="CheckboxElement"
|
||||
>
|
||||
<input
|
||||
id="checkbox-test-id"
|
||||
type="checkbox"
|
||||
value="off"
|
||||
/>
|
||||
<input
|
||||
class="Editable undefined"
|
||||
placeholder="Edit text..."
|
||||
spellcheck="true"
|
||||
title="test-title"
|
||||
value="test-title"
|
||||
>
|
||||
changed name
|
||||
</input>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`components/content/CheckboxElement should match snapshot on read only 1`] = `
|
||||
exports[`components/content/checkboxElement should match snapshot when read only 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="CheckboxElement"
|
||||
|
@ -67,7 +66,7 @@ exports[`components/content/CheckboxElement should match snapshot on read only 1
|
|||
</div>
|
||||
`;
|
||||
|
||||
exports[`components/content/CheckboxElement should match snapshot on toggle 1`] = `
|
||||
exports[`components/content/checkboxElement should toggle value 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="CheckboxElement"
|
||||
|
|
|
@ -1,47 +1,66 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react'
|
||||
import {fireEvent, render} from '@testing-library/react'
|
||||
import React, {ReactElement, ReactNode} from 'react'
|
||||
import {fireEvent, render, screen, waitFor} from '@testing-library/react'
|
||||
import '@testing-library/jest-dom'
|
||||
import {mocked} from 'ts-jest/utils'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
|
||||
import {wrapIntl} from '../../testUtils'
|
||||
|
||||
import {ContentBlock} from '../../blocks/contentBlock'
|
||||
import {ContentBlock, createContentBlock} from '../../blocks/contentBlock'
|
||||
import {CardDetailContext, CardDetailContextType, CardDetailProvider} from '../cardDetail/cardDetailContext'
|
||||
import {TestBlockFactory} from '../../test/testBlockFactory'
|
||||
import mutator from '../../mutator'
|
||||
|
||||
import CheckboxElement from './checkboxElement'
|
||||
|
||||
const fetchMock = require('fetch-mock-jest')
|
||||
jest.mock('../../mutator')
|
||||
const mockedMutator = mocked(mutator, true)
|
||||
|
||||
describe('components/content/CheckboxElement', () => {
|
||||
const defaultBlock: ContentBlock = {
|
||||
id: 'test-id',
|
||||
workspaceId: '',
|
||||
parentId: '',
|
||||
rootId: '',
|
||||
modifiedBy: 'test-user-id',
|
||||
schema: 0,
|
||||
type: 'checkbox',
|
||||
title: 'test-title',
|
||||
fields: {},
|
||||
createdBy: 'test-user-id',
|
||||
createAt: 0,
|
||||
updateAt: 0,
|
||||
deleteAt: 0,
|
||||
}
|
||||
const board = TestBlockFactory.createBoard()
|
||||
const card = TestBlockFactory.createCard(board)
|
||||
const checkboxBlock: ContentBlock = {
|
||||
id: 'test-id',
|
||||
workspaceId: '',
|
||||
parentId: card.id,
|
||||
rootId: card.rootId,
|
||||
modifiedBy: 'test-user-id',
|
||||
schema: 1,
|
||||
type: 'checkbox',
|
||||
title: 'test-title',
|
||||
fields: {value: false},
|
||||
createdBy: 'test-user-id',
|
||||
createAt: 0,
|
||||
updateAt: 0,
|
||||
deleteAt: 0,
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
fetchMock.post('*', {})
|
||||
})
|
||||
const cardDetailContextValue = (autoAdded: boolean): CardDetailContextType => ({
|
||||
card,
|
||||
lastAddedBlock: {
|
||||
id: checkboxBlock.id,
|
||||
autoAdded,
|
||||
},
|
||||
deleteBlock: jest.fn(),
|
||||
addBlock: jest.fn(),
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
fetchMock.mockClear()
|
||||
})
|
||||
const wrap = (child: ReactNode): ReactElement => (
|
||||
wrapIntl(
|
||||
<CardDetailProvider card={card}>
|
||||
{child}
|
||||
</CardDetailProvider>,
|
||||
)
|
||||
)
|
||||
|
||||
test('should match snapshot', () => {
|
||||
const component = wrapIntl(
|
||||
describe('components/content/checkboxElement', () => {
|
||||
beforeEach(jest.clearAllMocks)
|
||||
|
||||
it('should match snapshot', () => {
|
||||
const component = wrap(
|
||||
<CheckboxElement
|
||||
block={defaultBlock}
|
||||
block={checkboxBlock}
|
||||
readonly={false}
|
||||
/>,
|
||||
)
|
||||
|
@ -49,10 +68,10 @@ describe('components/content/CheckboxElement', () => {
|
|||
expect(container).toMatchSnapshot()
|
||||
})
|
||||
|
||||
test('should match snapshot on read only', () => {
|
||||
const component = wrapIntl(
|
||||
it('should match snapshot when read only', () => {
|
||||
const component = wrap(
|
||||
<CheckboxElement
|
||||
block={defaultBlock}
|
||||
block={checkboxBlock}
|
||||
readonly={true}
|
||||
/>,
|
||||
)
|
||||
|
@ -60,29 +79,101 @@ describe('components/content/CheckboxElement', () => {
|
|||
expect(container).toMatchSnapshot()
|
||||
})
|
||||
|
||||
test('should match snapshot on change title', () => {
|
||||
const component = wrapIntl(
|
||||
it('should change title', () => {
|
||||
const {container} = render(wrap(
|
||||
<CheckboxElement
|
||||
block={defaultBlock}
|
||||
block={checkboxBlock}
|
||||
readonly={false}
|
||||
/>,
|
||||
)
|
||||
const {container, getByTitle} = render(component)
|
||||
const input = getByTitle(/test-title/i)
|
||||
fireEvent.blur(input, {target: {textContent: 'changed name'}})
|
||||
))
|
||||
const newTitle = 'new title'
|
||||
const input = screen.getByRole('textbox', {name: /test-title/i})
|
||||
userEvent.clear(input)
|
||||
userEvent.type(input, newTitle)
|
||||
fireEvent.blur(input)
|
||||
expect(container).toMatchSnapshot()
|
||||
expect(mockedMutator.updateBlock).toHaveBeenCalledTimes(1)
|
||||
expect(mockedMutator.updateBlock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({title: newTitle}),
|
||||
checkboxBlock,
|
||||
expect.anything())
|
||||
})
|
||||
|
||||
test('should match snapshot on toggle', () => {
|
||||
const component = wrapIntl(
|
||||
it('should toggle value', () => {
|
||||
const {container} = render(wrap(
|
||||
<CheckboxElement
|
||||
block={defaultBlock}
|
||||
block={checkboxBlock}
|
||||
readonly={false}
|
||||
/>,
|
||||
)
|
||||
const {container, getByRole} = render(component)
|
||||
const input = getByRole('checkbox')
|
||||
fireEvent.change(input, {target: {value: 'on'}})
|
||||
))
|
||||
const input = screen.getByRole('checkbox')
|
||||
userEvent.click(input)
|
||||
expect(container).toMatchSnapshot()
|
||||
expect(mockedMutator.updateBlock).toHaveBeenCalledTimes(1)
|
||||
expect(mockedMutator.updateBlock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({fields: {value: true}}),
|
||||
checkboxBlock,
|
||||
expect.anything())
|
||||
})
|
||||
|
||||
it('should have focus when last added', () => {
|
||||
render(wrapIntl(
|
||||
<CardDetailContext.Provider value={cardDetailContextValue(false)}>
|
||||
<CheckboxElement
|
||||
block={checkboxBlock}
|
||||
readonly={false}
|
||||
/>
|
||||
</CardDetailContext.Provider>,
|
||||
))
|
||||
const input = screen.getByRole('textbox', {name: /test-title/i})
|
||||
expect(input).toHaveFocus()
|
||||
})
|
||||
|
||||
it('should add new checkbox when enter pressed', async () => {
|
||||
const addElement = jest.fn()
|
||||
render(wrap(
|
||||
<CheckboxElement
|
||||
block={checkboxBlock}
|
||||
readonly={false}
|
||||
onAddElement={addElement}
|
||||
/>,
|
||||
))
|
||||
const input = screen.getByRole('textbox', {name: /test-title/i})
|
||||
|
||||
// should not add new checkbox when current one has empty title
|
||||
userEvent.clear(input)
|
||||
userEvent.type(input, '{enter}')
|
||||
expect(addElement).toHaveBeenCalledTimes(0)
|
||||
|
||||
// should add new checkbox when current one has non-empty title
|
||||
userEvent.clear(input)
|
||||
userEvent.type(input, 'new-title{enter}')
|
||||
await waitFor(() => expect(addElement).toHaveBeenCalledTimes(1))
|
||||
})
|
||||
|
||||
it('should delete automatically added checkbox with empty title on esc/enter pressed', () => {
|
||||
const addedBlock = createContentBlock(checkboxBlock)
|
||||
addedBlock.title = ''
|
||||
const deleteElement = jest.fn()
|
||||
|
||||
render(wrapIntl(
|
||||
<CardDetailContext.Provider value={cardDetailContextValue(true)}>
|
||||
<CheckboxElement
|
||||
block={addedBlock}
|
||||
readonly={false}
|
||||
onDeleteElement={deleteElement}
|
||||
/>
|
||||
</CardDetailContext.Provider>,
|
||||
))
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
userEvent.type(input, '{esc}')
|
||||
expect(deleteElement).toHaveBeenCalledTimes(1)
|
||||
userEvent.type(input, '{enter}')
|
||||
expect(deleteElement).toHaveBeenCalledTimes(2)
|
||||
|
||||
// should not delete if title is not empty
|
||||
userEvent.type(input, 'new-title{esc}')
|
||||
expect(deleteElement).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
|
|
@ -1,25 +1,39 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
import React, {useState} from 'react'
|
||||
import React, {useEffect, useRef, useState} from 'react'
|
||||
import {useIntl} from 'react-intl'
|
||||
|
||||
import {createCheckboxBlock} from '../../blocks/checkboxBlock'
|
||||
import {ContentBlock} from '../../blocks/contentBlock'
|
||||
import CheckIcon from '../../widgets/icons/check'
|
||||
import mutator from '../../mutator'
|
||||
import Editable from '../../widgets/editable'
|
||||
import Editable, {Focusable} from '../../widgets/editable'
|
||||
import {useCardDetailContext} from '../cardDetail/cardDetailContext'
|
||||
|
||||
import {contentRegistry} from './contentRegistry'
|
||||
|
||||
import './checkboxElement.scss'
|
||||
|
||||
type Props = {
|
||||
block: ContentBlock
|
||||
readonly: boolean
|
||||
onAddElement?: () => void
|
||||
onDeleteElement?: () => void
|
||||
}
|
||||
|
||||
const CheckboxElement = React.memo((props: Props) => {
|
||||
const {block, readonly} = props
|
||||
const intl = useIntl()
|
||||
const titleRef = useRef<Focusable>(null)
|
||||
const cardDetail = useCardDetailContext()
|
||||
const [addedBlockId, setAddedBlockId] = useState(cardDetail.lastAddedBlock.id)
|
||||
|
||||
useEffect(() => {
|
||||
if (block.id === addedBlockId) {
|
||||
titleRef.current?.focus()
|
||||
setAddedBlockId('')
|
||||
}
|
||||
}, [block, addedBlockId, titleRef])
|
||||
|
||||
const [active, setActive] = useState(Boolean(block.fields.value))
|
||||
const [title, setTitle] = useState(block.title)
|
||||
|
@ -42,14 +56,24 @@ const CheckboxElement = React.memo((props: Props) => {
|
|||
}}
|
||||
/>
|
||||
<Editable
|
||||
ref={titleRef}
|
||||
value={title}
|
||||
placeholderText={intl.formatMessage({id: 'ContentBlock.editText', defaultMessage: 'Edit text...'})}
|
||||
onChange={setTitle}
|
||||
onSave={() => {
|
||||
const newBlock = createCheckboxBlock(block)
|
||||
newBlock.title = title
|
||||
newBlock.fields.value = active
|
||||
mutator.updateBlock(newBlock, block, intl.formatMessage({id: 'ContentBlock.editCardCheckboxText', defaultMessage: 'edit card text'}))
|
||||
saveOnEsc={true}
|
||||
onSave={async (saveType) => {
|
||||
const {lastAddedBlock} = cardDetail
|
||||
if (title === '' && block.id === lastAddedBlock.id && lastAddedBlock.autoAdded && props.onDeleteElement) {
|
||||
props.onDeleteElement()
|
||||
} else {
|
||||
const newBlock = createCheckboxBlock(block)
|
||||
newBlock.title = title
|
||||
newBlock.fields.value = active
|
||||
await mutator.updateBlock(newBlock, block, intl.formatMessage({id: 'ContentBlock.editCardCheckboxText', defaultMessage: 'edit card text'}))
|
||||
if (saveType === 'onEnter' && title !== '' && props.onAddElement) {
|
||||
props.onAddElement()
|
||||
}
|
||||
}
|
||||
}}
|
||||
readonly={readonly}
|
||||
spellCheck={true}
|
||||
|
@ -65,11 +89,13 @@ contentRegistry.registerContentType({
|
|||
createBlock: async () => {
|
||||
return createCheckboxBlock()
|
||||
},
|
||||
createComponent: (block, readonly) => {
|
||||
createComponent: (block, readonly, onAddElement, onDeleteElement) => {
|
||||
return (
|
||||
<CheckboxElement
|
||||
block={block}
|
||||
readonly={readonly}
|
||||
onAddElement={onAddElement}
|
||||
onDeleteElement={onDeleteElement}
|
||||
/>
|
||||
)
|
||||
},
|
||||
|
|
|
@ -1,9 +1,13 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {useCallback} from 'react'
|
||||
|
||||
import {ContentBlock} from '../../blocks/contentBlock'
|
||||
import {Utils} from '../../utils'
|
||||
|
||||
import {useCardDetailContext} from '../cardDetail/cardDetailContext'
|
||||
|
||||
import {contentRegistry} from './contentRegistry'
|
||||
|
||||
// Need to require here to prevent webpack from tree-shaking these away
|
||||
|
@ -16,10 +20,12 @@ import './checkboxElement'
|
|||
type Props = {
|
||||
block: ContentBlock
|
||||
readonly: boolean
|
||||
cords: {x: number, y?: number, z?: number}
|
||||
}
|
||||
|
||||
export default function ContentElement(props: Props): JSX.Element|null {
|
||||
const {block, readonly} = props
|
||||
const {block, readonly, cords} = props
|
||||
const cardDetail = useCardDetailContext()
|
||||
|
||||
const handler = contentRegistry.getHandler(block.type)
|
||||
if (!handler) {
|
||||
|
@ -27,5 +33,15 @@ export default function ContentElement(props: Props): JSX.Element|null {
|
|||
return null
|
||||
}
|
||||
|
||||
return handler.createComponent(block, readonly)
|
||||
const addElement = useCallback(() => {
|
||||
const index = cords.x + 1
|
||||
cardDetail.addBlock(handler, index, true)
|
||||
}, [cardDetail, cords, handler])
|
||||
|
||||
const deleteElement = useCallback(() => {
|
||||
const index = cords.x
|
||||
cardDetail.deleteBlock(block, index)
|
||||
}, [block, cords, cardDetail])
|
||||
|
||||
return handler.createComponent(block, readonly, addElement, deleteElement)
|
||||
}
|
||||
|
|
|
@ -7,12 +7,12 @@ import {BlockTypes} from '../../blocks/block'
|
|||
import {ContentBlock} from '../../blocks/contentBlock'
|
||||
import {Utils} from '../../utils'
|
||||
|
||||
type ContentHandler = {
|
||||
export type ContentHandler = {
|
||||
type: BlockTypes,
|
||||
getDisplayText: (intl: IntlShape) => string,
|
||||
getIcon: () => JSX.Element,
|
||||
createBlock: (rootId: string) => Promise<ContentBlock>,
|
||||
createComponent: (block: ContentBlock, readonly: boolean) => JSX.Element,
|
||||
createComponent: (block: ContentBlock, readonly: boolean, onAddElement?: () => void, onDeleteElement?: () => void) => JSX.Element,
|
||||
}
|
||||
|
||||
class ContentRegistry {
|
||||
|
@ -41,6 +41,4 @@ class ContentRegistry {
|
|||
|
||||
const contentRegistry = new ContentRegistry()
|
||||
|
||||
export type {ContentHandler}
|
||||
export {contentRegistry}
|
||||
|
||||
|
|
|
@ -149,6 +149,7 @@ const ContentBlock = React.memo((props: Props): JSX.Element => {
|
|||
<ContentElement
|
||||
block={block}
|
||||
readonly={readonly}
|
||||
cords={cords}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
|
|
|
@ -128,6 +128,7 @@ const GalleryCard = React.memo((props: Props) => {
|
|||
key={b.id}
|
||||
block={b}
|
||||
readonly={true}
|
||||
cords={{x: 0}}
|
||||
/>
|
||||
))
|
||||
}
|
||||
|
@ -137,6 +138,7 @@ const GalleryCard = React.memo((props: Props) => {
|
|||
key={block.id}
|
||||
block={block}
|
||||
readonly={true}
|
||||
cords={{x: 0}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
|
Loading…
Reference in a new issue