Skip to content

Commit

Permalink
Merge pull request #510 from nickgros/SWC-6548a
Browse files Browse the repository at this point in the history
  • Loading branch information
nickgros authored Oct 19, 2023
2 parents f550ddb + a9840a5 commit 8a8b9a1
Show file tree
Hide file tree
Showing 10 changed files with 470 additions and 18 deletions.
2 changes: 2 additions & 0 deletions packages/synapse-react-client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@
"markdown-it-synapse-math": "^3.0.5",
"markdown-it-synapse-table": "^1.0.6",
"mui-one-time-password-input": "1.1.0",
"papaparse": "^5.4.1",
"plotly.js-basic-dist": "^2.24.3",
"pluralize": "^8.0.0",
"prop-types": "^15.8.1",
Expand Down Expand Up @@ -165,6 +166,7 @@
"@types/lodash-es": "4.17.7",
"@types/markdown-it": "^12.2.3",
"@types/node": "^18.16.19",
"@types/papaparse": "^5.3.10",
"@types/plotly.js": "^2.12.22",
"@types/plotly.js-basic-dist": "^1.54.1",
"@types/pluralize": "^0.0.29",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { Meta, StoryObj } from '@storybook/react'
import JSONArrayEditorModal, {
JSONArrayEditorModalProps,
} from './JSONArrayEditorModal'

const meta = {
title: 'UI/JSONArrayEditor',
component: JSONArrayEditorModal,
} satisfies Meta<Omit<JSONArrayEditorModalProps, 'icon'>>
export default meta
type Story = StoryObj<typeof meta>

export const Modal: Story = {
args: {
isShowingModal: true,
},
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import React, { useCallback, useState } from 'react'
import type RJSFForm from '@rjsf/core'
import Form from '@rjsf/mui'
import validator from '@rjsf/validator-ajv8'
import { JSONSchema7 } from 'json-schema'
import ArrayFieldDescriptionTemplate from '../SchemaDrivenAnnotationEditor/template/ArrayFieldDescriptionTemplate'
import ArrayFieldItemTemplate from '../SchemaDrivenAnnotationEditor/template/ArrayFieldItemTemplate'
import ArrayFieldTemplate from '../SchemaDrivenAnnotationEditor/template/ArrayFieldTemplate'
import ArrayFieldTitleTemplate from '../SchemaDrivenAnnotationEditor/template/ArrayFieldTitleTemplate'
import ButtonTemplate from '../SchemaDrivenAnnotationEditor/template/ButtonTemplate'
import {
Alert,
AlertTitle,
Box,
Button,
Collapse,
TextField,
Typography,
} from '@mui/material'
import { parse, ParseError } from 'papaparse'
import { RJSFSchema } from '@rjsf/utils'
type JSONArrayEditorProps = {
value: string[]
onChange: (newValue: string[]) => void
onSubmit: (formData: string[]) => void
}

const arraySchema: JSONSchema7 = {
$schema: 'http://json-schema.org/draft-07/schema#',
type: 'array',
items: {
type: 'string',
},
}

const JSONArrayEditor = React.forwardRef(function JSONArrayEditor(
props: JSONArrayEditorProps,
ref: React.Ref<RJSFForm<any, RJSFSchema, any>>,
) {
const { value, onChange, onSubmit } = props
const [showPasteNewValuesForm, setShowPasteNewValuesForm] = useState(false)
const [pastedValues, setPastedValues] = useState('')
const [parseErrors, setParseErrors] = useState<ParseError[]>([])
const addPastedValuesToArray = useCallback(() => {
if (pastedValues) {
parse<string[]>(pastedValues, {
complete: result => {
if (result.errors.length > 0) {
setParseErrors(result.errors)
} else {
onChange([...value, ...result.data[0]])
setParseErrors([])
setPastedValues('')
setShowPasteNewValuesForm(false)
}
},
})
} else {
setParseErrors([])
setPastedValues('')
setShowPasteNewValuesForm(false)
}
}, [onChange, pastedValues, value])

return (
<Box
className="JsonSchemaFormContainer"
sx={{
// Hide the label/button to show more info
'.JsonSchemaForm .LabelContainer': {
display: 'none',
visibility: 'hidden',
},
}}
>
<Form
ref={ref}
schema={arraySchema}
className="JsonSchemaForm"
uiSchema={{
'ui:submitButtonOptions': {
norender: true,
},
}}
validator={validator}
formData={value}
onChange={({ formData }) => onChange(formData)}
onSubmit={({ formData }) => onSubmit(formData)}
templates={{
ArrayFieldDescriptionTemplate: ArrayFieldDescriptionTemplate,
ArrayFieldItemTemplate: ArrayFieldItemTemplate,
ArrayFieldTemplate: ArrayFieldTemplate,
ArrayFieldTitleTemplate: ArrayFieldTitleTemplate,
ButtonTemplates: ButtonTemplate,
}}
/>
<Button onClick={() => setShowPasteNewValuesForm(true)}>
Paste new values
</Button>
<Collapse sx={{ mt: 2 }} in={showPasteNewValuesForm}>
<TextField
multiline
InputProps={{ inputProps: { 'aria-label': 'CSV or TSV to Append' } }}
rows={5}
placeholder={'Place comma or tab delimited values here'}
value={pastedValues}
onChange={e => setPastedValues(e.target.value)}
/>
<Box my={1} display={'flex'} justifyContent={'flex-end'}>
<Button onClick={() => setShowPasteNewValuesForm(false)}>
Cancel
</Button>
<Button onClick={addPastedValuesToArray}>Add</Button>
</Box>
{parseErrors && parseErrors.length > 0 && (
<Alert severity={'error'} sx={{ my: 2 }}>
<AlertTitle>Parsing errors encountered:</AlertTitle>
<ul>
{parseErrors.map((error, index) => {
return (
<Typography
component={'li'}
lineHeight={1.5}
key={index}
variant={'smallText1'}
>
At {error.row}: {error.message}
</Typography>
)
})}
</ul>
</Alert>
)}
</Collapse>
</Box>
)
})

export default JSONArrayEditor
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
import React from 'react'
import { render, screen, waitFor } from '@testing-library/react'
import JSONArrayEditorModal, {
JSONArrayEditorModalProps,
} from './JSONArrayEditorModal'
import userEvent from '@testing-library/user-event'
import { createWrapper } from '../../testutils/TestingLibraryUtils'

function renderComponent(props: JSONArrayEditorModalProps) {
return render(<JSONArrayEditorModal {...props} />, {
wrapper: createWrapper(),
})
}

async function setUp(props: JSONArrayEditorModalProps) {
const user = userEvent.setup()
const component = renderComponent(props)
const showPasteValuesButton = await screen.findByRole<HTMLButtonElement>(
'button',
{
name: 'Paste new values',
},
)

const confirmModalButton = await screen.findByRole<HTMLButtonElement>(
'button',
{
name: 'OK',
},
)

const cancelModalButton = await screen.findByRole<HTMLButtonElement>(
'button',
{
name: 'Cancel',
},
)

return {
component,
user,
showPasteValuesButton,
confirmModalButton,
cancelModalButton,
}
}

describe('JSONArrayEditorModal', () => {
const onConfirm = jest.fn()
const onCancel = jest.fn()
beforeEach(() => {
jest.clearAllMocks()
})
it('Can enter values', async () => {
const { user, confirmModalButton } = await setUp({
isShowingModal: true,
onConfirm,
onCancel,
})

const firstInput = await screen.findByRole('textbox')
await user.type(firstInput, 'first value')

const addValueButton = await screen.findByRole('button', {
name: 'Add Item',
})
await user.click(addValueButton)
const secondInput = (await screen.findAllByRole('textbox'))[1]
await user.type(secondInput, 'second value')

await user.click(confirmModalButton)

expect(onConfirm).toHaveBeenCalledWith(['first value', 'second value'])
})
it('Can append by entering CSV', async () => {
const textToPaste = 'second value,third value,fourth value'
const { user, showPasteValuesButton, confirmModalButton } = await setUp({
isShowingModal: true,
onConfirm,
onCancel,
})

const firstInput = await screen.findByRole('textbox')
await user.type(firstInput, 'first value')

await user.click(showPasteValuesButton)

const pasteValuesInput = await screen.findByLabelText(
'CSV or TSV to Append',
)

await user.click(pasteValuesInput)
await user.paste(textToPaste)

const confirmAppendPasteButton = await screen.findByRole('button', {
name: 'Add',
})
await user.click(confirmAppendPasteButton)

await waitFor(() => {
expect(pasteValuesInput).not.toBeVisible()
expect(screen.getAllByRole('textbox')).toHaveLength(4)
})

await user.click(confirmModalButton)
expect(onConfirm).toHaveBeenCalledWith([
'first value',
'second value',
'third value',
'fourth value',
])
})
it('Can append by entering TSV', async () => {
const textToPaste = 'second value\tthird value\tfourth value'
const { user, showPasteValuesButton, confirmModalButton } = await setUp({
isShowingModal: true,
onConfirm,
onCancel,
})

const firstInput = await screen.findByRole('textbox')
await user.type(firstInput, 'first value')

await user.click(showPasteValuesButton)

const pasteValuesInput = await screen.findByLabelText(
'CSV or TSV to Append',
)

await user.click(pasteValuesInput)
await user.paste(textToPaste)

const confirmAppendPasteButton = await screen.findByRole('button', {
name: 'Add',
})
await user.click(confirmAppendPasteButton)

await waitFor(() => {
expect(pasteValuesInput).not.toBeVisible()
expect(screen.getAllByRole('textbox')).toHaveLength(4)
})

await user.click(confirmModalButton)
expect(onConfirm).toHaveBeenCalledWith([
'first value',
'second value',
'third value',
'fourth value',
])
})
it('Does not append pasted values when paste is cancelled', async () => {
const textToPaste = 'second value,third value,fourth value'
const { user, showPasteValuesButton, confirmModalButton } = await setUp({
isShowingModal: true,
onConfirm,
onCancel,
})

const firstInput = await screen.findByRole('textbox')
await user.type(firstInput, 'first value')

await user.click(showPasteValuesButton)

const pasteValuesInput = await screen.findByLabelText(
'CSV or TSV to Append',
)

await user.click(pasteValuesInput)
await user.paste(textToPaste)

// The modal also has a cancel button, so let's grab the first one, which should be for pasting values
const cancelPasteButton = (
await screen.findAllByRole('button', {
name: 'Cancel',
})
)[0]
await user.click(cancelPasteButton)
expect(onCancel).not.toHaveBeenCalled()

await waitFor(() => {
expect(pasteValuesInput).not.toBeVisible()
expect(screen.getAllByRole('textbox')).toHaveLength(1)
})

await user.click(confirmModalButton)
expect(onConfirm).toHaveBeenCalledWith(['first value'])
})
it('Handles cancelling the modal', async () => {
const { user, cancelModalButton } = await setUp({
isShowingModal: true,
onConfirm,
onCancel,
})

const firstInput = await screen.findByRole('textbox')
await user.type(firstInput, 'first value')

const addValueButton = await screen.findByRole('button', {
name: 'Add Item',
})
await user.click(addValueButton)
const secondInput = (await screen.findAllByRole('textbox'))[1]
await user.type(secondInput, 'second value')

await user.click(cancelModalButton)

expect(onCancel).toHaveBeenCalled()
expect(onConfirm).not.toHaveBeenCalled()
})
})
Loading

0 comments on commit 8a8b9a1

Please sign in to comment.