Skip to content

Commit

Permalink
feat: create reusable plugin (#3706)
Browse files Browse the repository at this point in the history
  • Loading branch information
sashuk authored and TomasSlama committed Jul 14, 2023
1 parent be41e57 commit c0db0f6
Show file tree
Hide file tree
Showing 21 changed files with 581 additions and 175 deletions.
2 changes: 1 addition & 1 deletion cypress.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export default defineConfig({

return config
},
specPattern: 'cypress/component/*.spec.tsx',
specPattern: 'cypress/component/**/*.spec.tsx',
devServer: {
framework: 'react',
bundler: 'webpack',
Expand Down
File renamed without changes.
159 changes: 159 additions & 0 deletions cypress/component/RichTextEditor/ImagePlugin.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import React, { useState } from 'react'
import type {
RichTextEditorProps,
UploadedImage,
} from '@toptal/picasso-rich-text-editor'
import { ImagePlugin, RichTextEditor } from '@toptal/picasso-rich-text-editor'
import { Container } from '@toptal/picasso'

const editorTestId = 'editor'
const imageUploadButtonTestId = 'image-upload-button'
const resultContainerTestId = 'result-container'

const defaultProps = {
id: 'foo',
onChange: () => {},
placeholder: 'placeholder',
testIds: {
editor: editorTestId,
imageUploadButton: imageUploadButtonTestId,
},
}

const editorSelector = `#${defaultProps.id}`

const Editor = (props: RichTextEditorProps) => {
const [value, setValue] = useState('')

return (
<Container style={{ maxWidth: '600px' }} padded='small'>
<RichTextEditor {...props} onChange={value => setValue(value)} />
<Container padded='small' data-testid={resultContainerTestId}>
{value}
</Container>
</Container>
)
}

const component = 'RichTextEditor'

const setAliases = () => {
cy.get(editorSelector).as('editor')
cy.getByTestId(imageUploadButtonTestId).as('imageUploadButton')
cy.getByTestId(resultContainerTestId).as('resultContainer')
cy.contains('placeholder').as('placeholder')
}

const getSubmitButton = () =>
cy.get('button').contains('Confirm').closest('button')

describe('ImagePlugin', () => {
describe('when image upload is successful', () => {
it('inserts image into rich text editor', () => {
const uploadedFileName = 'uploaded-image.png'
const uploadedFileContent =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACgAAAAoCAYAAACM/rhtAAAANklEQVR42u3OMQ0AAAgDsM2/aFBBwtEqaJOZPFZBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQcEbC6LIT9nCVOrVAAAAAElFTkSuQmCC'
const uploadedFileAltText = 'alt text'

cy.mount(
<Editor
{...{
...defaultProps,
plugins: [
<ImagePlugin
data-testid={imageUploadButtonTestId}
onUpload={(file: UploadedImage) =>
new Promise<UploadedImage>(resolve => {
setTimeout(
() =>
resolve({
...file,
url: uploadedFileContent,
}),
200
)
})
}
/>,
],
}}
/>
)
setAliases()

cy.get('@editor').click()
cy.get('@imageUploadButton').click()

cy.getByRole('dialog').contains('No file chosen')

getSubmitButton().should('be.disabled')

cy.get('input[type=file]').selectFile(
{
contents: Cypress.Buffer.from(''),
fileName: uploadedFileName,
},
{ force: true }
)

cy.getByRole('dialog').contains('Uploading ' + uploadedFileName)
cy.getByRole('dialog').contains(uploadedFileName)
cy.get('[placeholder="An Image Description"]').type(uploadedFileAltText)

getSubmitButton().should('not.be.disabled')
getSubmitButton().click()

cy.get('@resultContainer').contains(
`<p><img src="${uploadedFileContent}" alt="${uploadedFileAltText}"></p>`
)

cy.get('body').happoScreenshot({
component,
variant: 'image-plugin/successful-upload',
})
})
})

describe('when image upload fails', () => {
it('shows error', () => {
const fileUploadErrorMessage = 'Upload failed'

cy.mount(
<Editor
{...{
...defaultProps,
plugins: [
<ImagePlugin
data-testid={imageUploadButtonTestId}
onUpload={() =>
new Promise<UploadedImage>((resolve, reject) => {
setTimeout(() => reject(fileUploadErrorMessage), 200)
})
}
/>,
],
}}
/>
)
setAliases()

cy.get('@editor').click()
cy.get('@imageUploadButton').click()

cy.get('input[type=file]').selectFile(
{
contents: Cypress.Buffer.from('file contents'),
fileName: 'test.png',
},
{ force: true }
)

cy.get('p').contains(fileUploadErrorMessage)

cy.get('body').happoScreenshot({
component,
variant: 'image-plugin/failed-upload',
})
})
})
})
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import React, { useState } from 'react'
import { Grid } from '@toptal/picasso'
import { RichText, RichTextEditor } from '@toptal/picasso-rich-text-editor'
import {
ImagePlugin,
RichText,
RichTextEditor,
} from '@toptal/picasso-rich-text-editor'
import { htmlToHast } from '@toptal/picasso-rich-text-editor/utils'
import type { CustomEmojiGroup } from '@toptal/picasso-rich-text-editor/RichTextEditor'

Expand All @@ -16,7 +20,13 @@ const Example = () => {
defaultValue={defaultValue}
onChange={setHtml}
id='editor'
plugins={['link', 'emoji', 'image']}
plugins={[
'link',
'emoji',
<ImagePlugin
onUpload={() => new Promise(resolve => setTimeout(resolve, 2000))}
/>,
]}
customEmojis={customEmojis}
/>
</Grid.Item>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import React, { useState } from 'react'
import { Container, Radio } from '@toptal/picasso'
import type { UploadedImage } from '@toptal/picasso-rich-text-editor'
import { ImagePlugin, RichTextEditor } from '@toptal/picasso-rich-text-editor'

import type { RichTextEditorChangeHandler } from '../types'

// Imitate file upload function that sets image URL
const onUploadSucceeded = (uploadedImage: UploadedImage) =>
new Promise<UploadedImage>(resolve => {
setTimeout(() => {
const fileUrl = `./jacqueline/128x128.jpg?originalFileName=${encodeURIComponent(
uploadedImage.file.name
)}`

resolve({ ...uploadedImage, url: fileUrl })
}, 2000)
})

// Imitate failure during upload
const onUploadFailed = () =>
new Promise<UploadedImage>((resolve, reject) => {
setTimeout(() => {
reject('Upload failed')
}, 2000)
})

const Example = () => {
const [value, setValue] = useState<string | undefined>()
const [useSuccessfulUpload, setUseSuccessfullUpload] = useState('true')

const handleChange: RichTextEditorChangeHandler = newValue =>
setValue(newValue)

return (
<>
<Container bottom='medium'>
<Radio.Group
name='onUploadCase'
onChange={(event: React.ChangeEvent<{ value: string }>) => {
setUseSuccessfullUpload(event.target.value)
}}
value={useSuccessfulUpload}
>
<Radio label='Simulate successful upload' value='true' />
<Radio label='Simulate failing upload' value='false' />
</Radio.Group>
</Container>
<RichTextEditor
id='editor'
onChange={handleChange}
placeholder='Write some cool rich text'
plugins={[
<ImagePlugin
onUpload={
useSuccessfulUpload === 'true'
? onUploadSucceeded
: onUploadFailed
}
/>,
]}
/>
<Container
padded='small'
top='large'
style={{
fontFamily: "Consolas, 'Courier New', monospace",
background: 'lightyellow',
}}
>
{value}
</Container>
</>
)
}

export default Example
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,4 @@ page
title: 'Emojis',
takeScreenshot: false,
})
.addExample('RichTextEditor/story/ImageUpload.example.tsx', 'Image upload')
2 changes: 2 additions & 0 deletions packages/picasso-rich-text-editor/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,5 @@ export type {
} from './RichTextEditor'
export { default as RichText } from './RichText'
export type { RichTextProps, ASTType } from './RichText'
export { ImagePlugin } from './plugins'
export type { UploadedImage } from './plugins'
Original file line number Diff line number Diff line change
@@ -1,55 +1,43 @@
import React, { useEffect } from 'react'
import { useModal } from '@toptal/picasso/utils'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import {
$createParagraphNode,
$insertNodes,
$isRootOrShadowRoot,
COMMAND_PRIORITY_EDITOR,
} from 'lexical'
import { $wrapNodeInElement } from '@lexical/utils'
import React from 'react'

import type { RTEPlugin } from '../api'
import { RTEPluginMeta, Toolbar } from '../api'
import ImagePluginButton from './ImagePluginButton'
import ImagePluginModal from './ImagePluginModal'
import { INSERT_IMAGE_COMMAND } from './commands'
import type { ImageNodePayload } from './nodes/ImageNode'
import { $createImageNode, ImageNode } from './nodes/ImageNode'
import { ImagePluginButton, ImagePluginModal } from './components'
import { ImageNode } from './nodes/ImageNode'
import type { OnUploadCallback } from './types'
import type { ImagePluginModalProps } from './components/ImagePluginModal'
import { useImagePlugin } from './hooks'

const PLUGIN_NAME = 'image'

export type Props = {
accept: ImagePluginModalProps['accept']
maxSize: ImagePluginModalProps['maxSize']
onUpload: OnUploadCallback
'data-testid'?: string
}

const ImagePlugin: RTEPlugin = ({ 'data-testid': testId }: Props) => {
const { isOpen, hideModal, showModal } = useModal()
const [editor] = useLexicalComposerContext()

useEffect(() => {
return editor.registerCommand(
INSERT_IMAGE_COMMAND,
(imagePayload: ImageNodePayload) => {
const imageNode = $createImageNode(imagePayload)

$insertNodes([imageNode])
if ($isRootOrShadowRoot(imageNode.getParentOrThrow())) {
$wrapNodeInElement(imageNode, $createParagraphNode).selectEnd()
}

return true
},
COMMAND_PRIORITY_EDITOR
)
}, [editor])
const ImagePlugin: RTEPlugin = ({

Check failure on line 20 in packages/picasso-rich-text-editor/src/plugins/ImagePlugin/ImagePlugin.tsx

View workflow job for this annotation

GitHub Actions / PR Checks

Generic type 'RTEPlugin<P>' requires 1 type argument(s).
accept,
maxSize,
onUpload,
'data-testid': testId,
}: Props) => {
const { modalIsOpen, hideModal, showModal, onSubmit } = useImagePlugin()

return (
<>
<Toolbar keyName={PLUGIN_NAME}>
<ImagePluginButton onClick={showModal} data-testid={testId} />
</Toolbar>
<ImagePluginModal isOpen={isOpen} onClose={hideModal} />
<ImagePluginModal
accept={accept}
maxSize={maxSize}
isOpen={modalIsOpen}
onUpload={onUpload}
onSubmit={onSubmit}
onClose={hideModal}
/>
</>
)
}
Expand Down
Loading

0 comments on commit c0db0f6

Please sign in to comment.