-
Notifications
You must be signed in to change notification settings - Fork 23
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #510 from nickgros/SWC-6548a
- Loading branch information
Showing
10 changed files
with
470 additions
and
18 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
17 changes: 17 additions & 0 deletions
17
packages/synapse-react-client/src/components/JSONArrayEditor/JSONArrayEditor.stories.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
}, | ||
} |
139 changes: 139 additions & 0 deletions
139
packages/synapse-react-client/src/components/JSONArrayEditor/JSONArrayEditor.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
210 changes: 210 additions & 0 deletions
210
packages/synapse-react-client/src/components/JSONArrayEditor/JSONArrayEditorModal.test.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() | ||
}) | ||
}) |
Oops, something went wrong.