Skip to content

Commit

Permalink
Merge pull request #3963 from digabi/feature/image-upload
Browse files Browse the repository at this point in the history
feature/image upload
  • Loading branch information
Chrysalis-B authored Oct 2, 2024
2 parents a5acbca + 311c5f3 commit 0ed943f
Show file tree
Hide file tree
Showing 19 changed files with 365 additions and 128 deletions.
9 changes: 2 additions & 7 deletions packages/core/__tests__/EditableGradingInstruction.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { fireEvent, act as testAct, RenderResult } from '@testing-library/react'
import { act as testAct, RenderResult } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { renderGradingInstruction } from './utils/renderEditableGradingInstruction'
import { insertText } from './utils/util'

const act = testAct as (func: () => Promise<void>) => Promise<void>

Expand Down Expand Up @@ -154,12 +155,6 @@ async function focusOnTablesCell(result: RenderResult, text: string) {
insertText(await result.findByText(text), text)
}

function insertText(element: HTMLElement, text: string) {
fireEvent.input(element, {
target: { innerText: text, innerHTML: text }
})
}

function mockCreateRange() {
const originalCreateRange = global.window.document.createRange
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import React from 'react'
import '@testing-library/jest-dom'
import { render, cleanup, act as testAct, within } from '@testing-library/react'
import ProseMirrorWrapper from './utils/ProseMirrorWrapper'
import { mockCreateRange, promisifiedFireEventInput } from './utils/prosemirror'
import FormatButton from '../src/components/grading-instructions/editor/FormatButton'
import ProseMirrorWrapper from '../utils/ProseMirrorWrapper'
import { mockCreateRange } from '../utils/prosemirror'
import FormatButton from '../../src/components/grading-instructions/editor/FormatButton'
import userEvent from '@testing-library/user-event'
import { insertText } from '../utils/util'

const act = testAct as (func: () => Promise<void>) => Promise<void>

Expand Down Expand Up @@ -39,10 +40,10 @@ describe('FormatButton', () => {

it('formats text', async () => {
const props = { markName: 'em', displayName: 'Italic' }
const { button, paragraph } = renderEditorWithFormatButton(props)
const { button, paragraph, container } = renderEditorWithFormatButton(props)

await userEvent.click(button)
await act(async () => await promisifiedFireEventInput(paragraph, { target: { innerHTML: 'hello' } }))
await act(async () => insertText(await container.findByRole('paragraph'), 'hello'))
expect(within(paragraph).getByRole('emphasis')).toHaveTextContent('hello')
})

Expand Down Expand Up @@ -82,10 +83,12 @@ describe('FormatButton', () => {
it('formats text', async () => {
const props = { markName: 'strong', displayName: 'Bold' }

const { button, paragraph } = renderEditorWithFormatButton(props)
const { button, container, paragraph } = renderEditorWithFormatButton(props)

await userEvent.click(button)
await act(async () => await promisifiedFireEventInput(paragraph, { target: { innerHTML: 'hello' } }))
await act(async () => {
insertText(await container.findByRole('paragraph'), 'hello')
})
expect(within(paragraph).getByRole('strong')).toHaveTextContent('hello')
})

Expand All @@ -104,11 +107,11 @@ describe('FormatButton', () => {

function renderEditorWithFormatButton(props: { markName: string; displayName: string; innerHtml?: string }) {
const { markName, displayName, innerHtml = '' } = props
const { getByRole } = render(
const container = render(
<ProseMirrorWrapper innerHtml={innerHtml}>
<FormatButton markName={markName} displayName={displayName} />
</ProseMirrorWrapper>
)
return { button: getByRole('button'), paragraph: getByRole('paragraph') }
return { button: container.getByRole('button'), paragraph: container.getByRole('paragraph'), container }
}
})
9 changes: 2 additions & 7 deletions packages/core/__tests__/editor/Formula.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { fireEvent, act as testAct } from '@testing-library/react'
import { act as testAct } from '@testing-library/react'
import { mockCreateRange } from '../utils/prosemirror'
import { renderGradingInstruction } from '../utils/renderEditableGradingInstruction'
import { insertText } from '../utils/util'

const act = testAct as (func: () => Promise<void>) => Promise<void>

Expand Down Expand Up @@ -41,9 +42,3 @@ describe('Editor - Formula', () => {
expect(onContentChangeMock).toHaveBeenCalledWith(expectedOutput, '')
})
})

function insertText(element: HTMLElement, text: string) {
fireEvent.input(element, {
target: { innerText: text, innerHTML: text }
})
}
88 changes: 88 additions & 0 deletions packages/core/__tests__/editor/ImageUploadButton.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import '@testing-library/jest-dom'
import { cleanup, act as testAct, waitFor } from '@testing-library/react'
import { mockCreateRange } from '../utils/prosemirror'
import {
renderGradingInstruction,
mockedPermanentUrl,
mockedResolvedPath
} from '../utils/renderEditableGradingInstruction'
import userEvent from '@testing-library/user-event'
import { insertText } from '../utils/util'
import { EditorView } from 'prosemirror-view'

jest.spyOn(EditorView.prototype, 'focus').mockImplementation(() => {})

const act = testAct as (func: () => Promise<void>) => Promise<void>

describe('ImageUploadButton', () => {
let onContentChangeMock: jest.Mock
const mockedTempUrl = 'mocked-temp-url'

beforeEach(() => {
onContentChangeMock = jest.fn()
})

beforeAll(() => {
mockCreateRange()
global.URL.createObjectURL = jest.fn(() => mockedTempUrl)
File.prototype.arrayBuffer = jest.fn().mockResolvedValue(new ArrayBuffer(8))
})

afterEach(() => {
cleanup()
})

describe('Renders Image', () => {
const htmlAttrs = {
width: '504',
height: '504',
class: 'foo',
lang: 'fi-FI',
src: `/${mockedResolvedPath}/foo.bar`
}

const xmlAttrs = {
lang: htmlAttrs.lang,
class: htmlAttrs.class,
src: 'foo.bar'
}

it('e:image tags are rendered in and rendered out with correct attributes and tags', async () => {
const container = renderGradingInstruction(
`<div><p>hello</p><e:image data-editor-id="e-image" width=${htmlAttrs.width} height=${htmlAttrs.height} lang=${htmlAttrs.lang} class=${htmlAttrs.class} src=${xmlAttrs.src}></e:image></div>`,
onContentChangeMock
)
const image = container.getByRole('img')
for (const [key, value] of Object.entries(htmlAttrs)) {
expect(image).toHaveAttribute(key, value)
}
await act(async () => {
insertText(await container.findByText('hello'), 'hello world')
})

expect(onContentChangeMock).toHaveBeenCalledTimes(1)
expect(onContentChangeMock).toHaveBeenCalledWith(
`<p>hello world</p><p><e:image lang="${xmlAttrs.lang}" class="${xmlAttrs.class}" src="${xmlAttrs.src}"></e:image></p>`,
''
)
})

it('image can be uploaded', async () => {
const container = renderGradingInstruction('', onContentChangeMock)
const file = new File(['hello'], 'hello.png', { type: 'image/png' })
const input = container.queryByTestId('image-upload-button')
await userEvent.upload(input!, file)
await waitFor(() => {
expect(onContentChangeMock).toHaveBeenCalledTimes(2)
expect(onContentChangeMock).toHaveBeenNthCalledWith(1, `<p><e:image src="${mockedTempUrl}"></e:image></p>`, '')
expect(onContentChangeMock).toHaveBeenNthCalledWith(
2,
`<p><e:image src="${mockedPermanentUrl}"></e:image></p>`,
''
)
const image = container.getByRole('img')
expect(image).toHaveAttribute('src', `/${mockedResolvedPath}/${mockedPermanentUrl}`)
})
})
})
})
9 changes: 2 additions & 7 deletions packages/core/__tests__/editor/br.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { fireEvent, act as testAct } from '@testing-library/react'
import { act as testAct } from '@testing-library/react'
import { mockCreateRange } from '../utils/prosemirror'
import { renderGradingInstruction } from '../utils/renderEditableGradingInstruction'
import { insertText } from '../utils/util'

const act = testAct as (func: () => Promise<void>) => Promise<void>

Expand Down Expand Up @@ -39,9 +40,3 @@ describe('Editor - BR', () => {
expect(onContentChangeMock).toHaveBeenCalledWith(expectedOutput, '')
})
})

function insertText(element: HTMLElement, text: string) {
fireEvent.input(element, {
target: { innerText: text, innerHTML: text }
})
}
9 changes: 2 additions & 7 deletions packages/core/__tests__/editor/hr.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { fireEvent, act as testAct } from '@testing-library/react'
import { act as testAct } from '@testing-library/react'
import { mockCreateRange } from '../utils/prosemirror'
import { renderGradingInstruction } from '../utils/renderEditableGradingInstruction'
import { insertText } from '../utils/util'

const act = testAct as (func: () => Promise<void>) => Promise<void>

Expand Down Expand Up @@ -39,9 +40,3 @@ describe('Editor - HR', () => {
expect(onContentChangeMock).toHaveBeenCalledWith(expectedOutput, '')
})
})

function insertText(element: HTMLElement, text: string) {
fireEvent.input(element, {
target: { innerText: text, innerHTML: text }
})
}
9 changes: 2 additions & 7 deletions packages/core/__tests__/editor/nbsp.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { fireEvent, act as testAct } from '@testing-library/react'
import { act as testAct } from '@testing-library/react'
import { mockCreateRange } from '../utils/prosemirror'
import { renderGradingInstruction } from '../utils/renderEditableGradingInstruction'
import userEvent from '@testing-library/user-event'
import { insertText } from '../utils/util'

const act = testAct as (func: () => Promise<void>) => Promise<void>

Expand Down Expand Up @@ -53,9 +54,3 @@ describe('Editor - NBSP', () => {
expect(onContentChangeMock).toHaveBeenLastCalledWith(expectedOutput, '')
})
})

function insertText(element: HTMLElement, text: string) {
fireEvent.input(element, {
target: { innerText: text, innerHTML: text }
})
}
9 changes: 0 additions & 9 deletions packages/core/__tests__/utils/prosemirror.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import { fireEvent } from '@testing-library/react'

export function mockCreateRange() {
const originalCreateRange = global.window.document.createRange
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
Expand Down Expand Up @@ -28,10 +26,3 @@ export function mockCreateRange() {
}
return () => (global.window.document.createRange = originalCreateRange)
}

export function promisifiedFireEventInput(element: Element, options: object) {
return new Promise<void>(resolve => {
fireEvent.input(element, options)
resolve()
})
}
22 changes: 18 additions & 4 deletions packages/core/__tests__/utils/renderEditableGradingInstruction.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,30 @@ import { render } from '@testing-library/react'
import { GradingInstructionProvider } from '../../src/components/grading-instructions/GradingInstructionProvider'
import EditableGradingInstruction from '../../src/components/grading-instructions/EditableGradingInstruction'
import React from 'react'
import { withCommonExamContext } from '../../src/components/context/CommonExamContext'
import { EditableProps } from '../../src/components/context/GradingInstructionContext'
import { CommonExamProps } from '../../src/components/exam/Exam'

export const mockedPermanentUrl = 'mocked-permanent-url'
export const mockedResolvedPath = 'mock-resolved-path'

const WrappedGradingInstructionProvider = withCommonExamContext<EditableProps & CommonExamProps>(
GradingInstructionProvider
)

export function renderGradingInstruction(inputData: string, onContentChangeMock = () => {}) {
const doc = new DOMParser().parseFromString(inputData, 'text/html')
return render(
<GradingInstructionProvider
<WrappedGradingInstructionProvider
editable={true}
onContentChange={onContentChangeMock}
saveScreenshot={() => Promise.resolve('')}
onSaveImage={() => Promise.resolve(mockedPermanentUrl)}
answers={[]}
attachmentsURL=""
resolveAttachment={filename => `/${mockedResolvedPath}/${filename}`}
doc={doc}
>
<EditableGradingInstruction element={doc.documentElement} />
</GradingInstructionProvider>
<EditableGradingInstruction element={doc.documentElement} />x
</WrappedGradingInstructionProvider>
)
}
7 changes: 7 additions & 0 deletions packages/core/__tests__/utils/util.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { fireEvent } from '@testing-library/react'

export function insertText(element: HTMLElement, text: string) {
fireEvent.input(element, {
target: { innerText: text, innerHTML: text }
})
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,13 @@ export interface GradingInstructionPropsBase {
export interface EditableProps extends GradingInstructionPropsBase {
editable: true
onContentChange: (answerHTML: string, path: string) => void
saveScreenshot: (type: string, data: Buffer, displayNumber?: string) => Promise<string>
onSaveImage: (file: File, displayNumber?: string) => Promise<string>
}

export interface NotEditableProps extends GradingInstructionPropsBase {
editable?: false
onContentChange?: never
saveScreenshot?: never
onSaveImage?: never
}

export type GradingInstructionProps = EditableProps | NotEditableProps
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,22 +12,17 @@ import { FormulaPopup } from './editor/FormulaPopup'
import FormatButton from './editor/FormatButton'
import { NbspButton, nbspPlugin } from './editor/NBSP'
import { spanWithNowrap } from './editor/spanWithNowrap'

const schema = new Schema({
nodes: baseSchema.spec.nodes.append(formulaSchema).append(tableSchema),
marks: baseSchema.spec.marks.append(spanWithNowrap)
})

const outputSchema = new Schema({
nodes: baseSchema.spec.nodes.append(formulaOutputSchema).append(tableSchema),
marks: baseSchema.spec.marks.append(spanWithNowrap)
})
import { ImageUploadButton } from './editor/ImageUploadButton'
import { imageInputSchema, imageOutputSchema } from './editor/schemas/image-schema'
import { CommonExamContext } from '../context/CommonExamContext'

function Menu(props: { setFormulaState: (values: FormulaEditorState) => void }) {
const { onSaveImage } = useContext(GradingInstructionContext)
return (
<>
<FormatButton markName="strong" displayName="Bold" />
<FormatButton markName="em" displayName="Italic" />
{onSaveImage && <ImageUploadButton saveImage={onSaveImage} />}
<TableMenu />
<FormulaButton setFormulaState={props.setFormulaState} />
<NbspButton />
Expand All @@ -37,12 +32,27 @@ function Menu(props: { setFormulaState: (values: FormulaEditorState) => void })

function EditableGradingInstruction({ element }: { element: Element }) {
const { onContentChange } = useContext(GradingInstructionContext)
const doc = ProseDOMParser.fromSchema(schema).parse(element)
const { resolveAttachment } = useContext(CommonExamContext)

const inputSchema = new Schema({
nodes: baseSchema.spec.nodes
.append(formulaSchema)
.append(tableSchema)
.update('image', imageInputSchema(resolveAttachment)),
marks: baseSchema.spec.marks.append(spanWithNowrap)
})

const outputSchema = new Schema({
nodes: baseSchema.spec.nodes.append(formulaOutputSchema).append(tableSchema).update('image', imageOutputSchema),
marks: baseSchema.spec.marks.append(spanWithNowrap)
})

const doc = ProseDOMParser.fromSchema(inputSchema).parse(element)
const [mount, setMount] = useState<HTMLElement | null>(null)
const [formulaState, setFormulaState] = useState<FormulaEditorState | null>(null)
const formulaPlugin = new FormulaPlugin(setFormulaState)
const [state, setState] = useState(
EditorState.create({ schema, doc, plugins: [keymap(baseKeymap), formulaPlugin, nbspPlugin] })
EditorState.create({ schema: inputSchema, doc, plugins: [keymap(baseKeymap), formulaPlugin, nbspPlugin] })
)

return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ import { GradingInstructionContext, GradingInstructionProps } from '../context/G
export const GradingInstructionProvider = ({
editable,
onContentChange,
saveScreenshot,
onSaveImage,
children
}: GradingInstructionProps) => {
const contextValue = editable ? { editable, onContentChange, saveScreenshot } : { editable }
const contextValue = editable ? { editable, onContentChange, onSaveImage } : { editable }
return <GradingInstructionContext.Provider value={contextValue}>{children}</GradingInstructionContext.Provider>
}
Loading

0 comments on commit 0ed943f

Please sign in to comment.