Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feature/image upload #3963

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
6440b20
feature(wip): add file upload feature to editor
Chrysalis-B Sep 13, 2024
c4a5d07
feature: render e:image tags from xml in prosemirror editor as img a…
Chrysalis-B Sep 16, 2024
8abe98d
refactor: add custom class already in exam mastering as opposed to p…
Chrysalis-B Sep 20, 2024
bbdd3d5
refactor(wip): alternative way to add custom classes to images
Chrysalis-B Sep 20, 2024
c06f818
refactor: use data-attributes instead of classed for editor element m…
Chrysalis-B Sep 23, 2024
d5b5b86
refactor: use seperate schemas for editor input and output instead of…
Chrysalis-B Sep 23, 2024
4b3756d
fix: only export attributes that conform to xml schema
Chrysalis-B Sep 23, 2024
b976660
feature(wip): send file as buffer to saveScreenshot callback for furt…
Chrysalis-B Sep 23, 2024
31c9296
feature: update image with permanent url
Chrysalis-B Sep 24, 2024
402e7af
test(wip): start adding unit tests for image upload
Chrysalis-B Sep 24, 2024
9844198
chore: update snapshots
Chrysalis-B Sep 25, 2024
de14074
refactor/test: make insertTest util for all tests and get rid of prom…
Chrysalis-B Sep 25, 2024
0a7427a
refactor: change variable name, add test-id,change copy of button
Chrysalis-B Sep 25, 2024
6a71364
test: add more component tests for imageuplodabutton
Chrysalis-B Sep 25, 2024
89707f8
refactor: use helper function to add e-formula data attribute
Chrysalis-B Sep 25, 2024
3908331
refactor: change saveScreenshot to saveImage and modify function sign…
Chrysalis-B Sep 26, 2024
3e98088
chore: remove redundant dependency
Chrysalis-B Sep 26, 2024
e7871d7
fix: do not allow pasting images from clipboard into editor
Chrysalis-B Sep 26, 2024
5907f86
fix: allow same file to be uploaded twice
Chrysalis-B Sep 26, 2024
f17e61f
feature: send question display number to saveImage callback
Chrysalis-B Sep 26, 2024
96b32cb
refactor: use setNodeAttribute instead of setNodeMarkup, only attribu…
Chrysalis-B Sep 27, 2024
4e66273
refactor: throw error if saveImage does not return an image url
Chrysalis-B Sep 27, 2024
4c76d8a
fix: rebase mistake
Chrysalis-B Sep 27, 2024
480fc3e
refactor/fix: rename variables and resolve image path with resolveAtt…
Chrysalis-B Sep 30, 2024
0ccdf17
chore: fix snapshots
Chrysalis-B Sep 30, 2024
156d719
revert: preview path
Chrysalis-B Sep 30, 2024
591c1d1
feat: remove temp image if no permanent path is provided by callback
Chrysalis-B Sep 30, 2024
311c5f3
feat: only allow jpg,png and tiff images
Chrysalis-B Oct 2, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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),
Chrysalis-B marked this conversation as resolved.
Show resolved Hide resolved
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
Loading