Skip to content
This repository has been archived by the owner on Sep 18, 2023. It is now read-only.

Commit

Permalink
feat(components): add dynamic create/view CRUD components (#98)
Browse files Browse the repository at this point in the history
  • Loading branch information
sbolel authored Jul 22, 2023
1 parent ca1e762 commit 246f2d8
Show file tree
Hide file tree
Showing 8 changed files with 627 additions and 0 deletions.
91 changes: 91 additions & 0 deletions src/components/crud/CreateForm.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
// CreateForm.stories.tsx
import { Story, Meta } from '@storybook/react'
import { action } from '@storybook/addon-actions'
import TextField from '@mui/material/TextField'
import CreateForm, { CreateFormProps } from './CreateForm'

export default {
title: 'Components/CRUD/CreateForm',
component: CreateForm,
} as Meta

const defaultArgs = {
onClose: action('closed'),
onSubmit: action('submitted'),
open: true,
title: 'Add a new vendor',
submitLabel: 'Save',
cancelLabel: 'Cancel',
}

const Template: Story<CreateFormProps> = (args) => <CreateForm {...args} />

export const Default = Template.bind({})
Default.args = {
...defaultArgs,
schema: [
{
name: 'name',
label: 'Name',
type: 'text',
required: true,
component: TextField,
},
{
name: 'address',
label: 'Address',
type: 'text',
required: true,
component: TextField,
},
],
}

export const WithPrefilledValues = Template.bind({})
WithPrefilledValues.args = {
...defaultArgs,
schema: [
{
name: 'name',
label: 'Name',
type: 'text',
required: true,
component: TextField,
value: 'Vendor X',
},
{
name: 'address',
label: 'Address',
type: 'text',
required: true,
component: TextField,
value: 'Address X',
},
],
}

export const WithDialogProps = Template.bind({})
WithDialogProps.args = {
...defaultArgs,
DialogProps: {
open: true,
fullWidth: false,
maxWidth: 'sm',
},
schema: [
{
name: 'name',
label: 'Name',
type: 'text',
required: true,
component: TextField,
},
{
name: 'address',
label: 'Address',
type: 'text',
required: true,
component: TextField,
},
],
}
153 changes: 153 additions & 0 deletions src/components/crud/CreateForm.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
// CreateForm.test.tsx
import { useState } from 'react'
import {
act,
fireEvent,
render,
renderHook,
waitFor,
} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import TextField from '@mui/material/TextField'
import CreateForm from '@/components/crud/CreateForm'

describe('CreateForm', () => {
const defaultValues = {
name: 'Vendor 1',
address: 'Address 1',
}

const schema = [
{
id: 'name',
name: 'name',
label: 'Name',
type: 'text',
required: true,
component: TextField,
},
{
id: 'address',
name: 'address',
label: 'Address',
type: 'text',
required: true,
component: TextField,
},
]

let onCloseMock: jest.Mock<unknown>
let onSubmitMock: jest.Mock<unknown>

beforeEach(() => {
jest.clearAllMocks()
onCloseMock = jest.fn()
onSubmitMock = jest.fn()
})

test('renders the form when open is true', async () => {
const { getByLabelText } = render(
<CreateForm
open={true}
onClose={onCloseMock}
onSubmit={onSubmitMock}
schema={schema}
/>
)
const nameField = getByLabelText(/Name/)
const addressField = getByLabelText(/Address/)
expect(nameField).toBeInTheDocument()
expect(addressField).toBeInTheDocument()
})

test('calls onSubmit with form data when form is submitted', async () => {
const { getByLabelText, getByText } = render(
<CreateForm
open={true}
onClose={onCloseMock}
onSubmit={onSubmitMock}
schema={schema}
submitLabel="Submit"
/>
)

const nameField = getByLabelText(/Name/)
const addressField = getByLabelText(/Address/)
const submit = getByText('Submit')

await act(async () => {
fireEvent.input(nameField, { target: { value: 'Vendor 1' } })
fireEvent.input(addressField, { target: { value: 'Address 1' } })
fireEvent.click(submit)
})

waitFor(() => {
expect(onSubmitMock).toHaveBeenCalledWith(defaultValues)
})
})

test('calls onClose when Cancel button is clicked', async () => {
const { getByText } = render(
<CreateForm
open={true}
onClose={onCloseMock}
onSubmit={onSubmitMock}
schema={schema}
/>
)

act(() => {
fireEvent.click(getByText('Cancel'))
})

waitFor(() => {
expect(onCloseMock).toHaveBeenCalledTimes(1)
})
})

test('submits form data only when all required fields are filled and form is not disabled', async () => {
const {
result: {
current: [values],
},
} = renderHook(() => useState(defaultValues))

const { getByText, getByLabelText, getByRole } = render(
<CreateForm
open={true}
schema={schema}
onClose={() => {}}
onSubmit={onSubmitMock}
FormProps={{
defaultValues,
values,
mode: 'onChange',
}}
submitLabel="Submit"
/>
)

const form = getByRole('form')
const nameField = getByLabelText(/Name/)
const addressField = getByLabelText(/Address/)
const submitButton = getByText('Submit')

act(() => {
userEvent.type(nameField, 'Test Name')
userEvent.type(addressField, 'Test Address')
})

waitFor(() => {
expect(submitButton).toBeEnabled()
})

act(() => {
fireEvent.submit(form)
})

waitFor(() => {
expect(onSubmitMock).toHaveBeenCalledTimes(1)
expect(onSubmitMock).toHaveBeenCalledWith(defaultValues)
})
})
})
149 changes: 149 additions & 0 deletions src/components/crud/CreateForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
/**
* @module sbom-harbor-ui/components/crud/CreateForm
*/
import React, { useCallback } from 'react'
import { useForm, Controller, FieldValues, UseFormProps } from 'react-hook-form'
import Box from '@mui/material/Box'
import Button from '@mui/material/Button'
import Dialog from '@mui/material/Dialog'
import DialogTitle from '@mui/material/DialogTitle'
import DialogContent from '@mui/material/DialogContent'
import DialogActions from '@mui/material/DialogActions'
import Stack from '@mui/material/Stack'
import { FormField } from '@/types'

export interface CreateFormProps {
open: boolean
onClose: () => void
onSubmit: (data: unknown) => void
schema: FormField[]
DialogProps?: React.ComponentProps<typeof Dialog>
title?: string
cancelLabel?: string
submitLabel?: string
FormProps?: UseFormProps<FieldValues>
}

/**
* The CreateForm component.
* @param {CreateFormProps} props - The props for the CreateForm component
* @param {boolean} props.open - Whether the form is open or not
* @param {FormField[]} props.schema - The schema for the form
* @param {() => void} props.onClose - The function to call when the form is closed
* @param {(data: unknown) => void} props.onSubmit - The function to call when the form is submitted
* @returns {JSX.Element}
* @example
* <CreateForm
* open={open}
* schema={[
* { id: 'name', field: 'name', label: 'Name', type: 'text', required: true, component: TextField },
* { id: 'address', field: 'address', label: 'Address', type: 'text', required: true, component: TextField },
* ]}
* onClose={handleClose}
* onSubmit={handleSubmit}
* />
*/
const CreateForm: React.FC<CreateFormProps> = ({
schema,
open,
onClose: onCloseProp,
onSubmit: onSubmitProp,
title,
cancelLabel,
submitLabel,
DialogProps,
FormProps,
}) => {
const {
control,
register,
handleSubmit,
formState: { errors = {}, isSubmitting, isValid, isValidating },
} = useForm<FieldValues>({
mode: 'all',
...FormProps,
})

const disabled =
!isValid || isValidating || isSubmitting || Object.keys(errors).length > 0

const onSubmit = useCallback(
(data: unknown) => {
if (disabled) return
onSubmitProp(data)
},
[onSubmitProp, disabled]
)

const onClose = useCallback(() => {
if (typeof onCloseProp !== 'function') return
onCloseProp()
}, [onCloseProp])

return (
<Dialog
open={open}
onClose={onClose}
autoFocus
fullWidth
maxWidth="sm"
{...DialogProps}
>
<DialogTitle sx={{ mb: 0 }}>{title || 'Create New'}</DialogTitle>
<DialogContent>
<Box component="form" onSubmit={handleSubmit(onSubmit)} role="form">
<Stack spacing={4} sx={{ pt: 1 }}>
{schema.map(({ component: Component, ...field }) => {
// dynamically register the fields
const { ref: inputRef, ...inputProps } = register(field.name, {
required: 'This field is required',
})

return (
<Controller
key={field.name}
name={field.name}
control={control}
rules={{ required: field.required }}
defaultValue={field.value}
render={({ field: _f, fieldState: { error } }) => (
<Component
{...field}
{...inputProps}
inputRef={inputRef}
label={field.label}
required={field.required}
error={!!error}
helperText={error ? error?.message : ' '}
InputProps={{
error: !!error,
fullWidth: true,
multiline: field.multiline,
required: field.required,
}}
/>
)}
/>
)
})}
</Stack>
<DialogActions>
<Button onClick={onClose}>{cancelLabel || 'Cancel'}</Button>
<Button
disabled={disabled ? true : false}
variant="contained"
type="submit"
onClick={onSubmit}
role="button"
aria-label="submit"
>
{submitLabel || 'Submit'}
</Button>
</DialogActions>
</Box>
</DialogContent>
</Dialog>
)
}

export default CreateForm
Loading

0 comments on commit 246f2d8

Please sign in to comment.