Skip to content

Commit

Permalink
Use server actions for form submits (#637)
Browse files Browse the repository at this point in the history
  • Loading branch information
alimpens authored Dec 19, 2024
1 parent 053053d commit 53c8cec
Show file tree
Hide file tree
Showing 19 changed files with 313 additions and 450 deletions.
3 changes: 2 additions & 1 deletion apps/public/.eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
{
"files": ["**/page.tsx", "**/layout.tsx", "next.config.ts"],
"rules": {
"import/no-default-export": "off"
"import/no-default-export": "off",
"react/jsx-no-bind": "off"
}
}
]
Expand Down
69 changes: 15 additions & 54 deletions apps/public/src/app/(general)/Home.test.tsx
Original file line number Diff line number Diff line change
@@ -1,33 +1,11 @@
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'
import * as mockRouter from 'next-router-mock'
import { render, screen } from '@testing-library/react'
import { useActionState } from 'react'
import { vi } from 'vitest'

import { Home } from './Home'

const mockInput = 'This is test user input'
const mockQuestionText = /What is it about?/ // This is a regex to account for the label text being dynamic

const server = setupServer(
http.get('http://localhost:8000/form/classification/123', () => new HttpResponse('Succesful response')),
http.post('http://localhost:8000/melding', async ({ request }) => {
const data = (await request.json()) as { text: string }

// Check if request payload equals input. If not, throw an error.
if (data?.text !== mockInput) {
return new HttpResponse('Incorrect body text', { status: 400 })
}

return HttpResponse.json({ classification: '123' })
}),
)

beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())

const mockFormData = [
{
type: 'textarea',
Expand All @@ -41,44 +19,27 @@ const mockFormData = [
},
]

const { useRouter } = mockRouter

vi.mock('next/navigation', () => ({
...mockRouter,
useSearchParams: () => {
const router = useRouter()
const path = router.query
return new URLSearchParams(path as never)
},
}))
vi.mock('react', async (importOriginal) => {
const actual = await importOriginal()
return {
...(typeof actual === 'object' ? actual : {}),
useActionState: vi.fn().mockReturnValue([{}, vi.fn()]),
}
})

describe('Page', () => {
it('should render a form', async () => {
it('should render a form', () => {
render(<Home formData={mockFormData} />)

await waitFor(() => {
expect(screen.queryByRole('textbox', { name: mockQuestionText })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Volgende vraag' })).toBeInTheDocument()
})
expect(screen.queryByRole('textbox', { name: mockQuestionText })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Volgende vraag' })).toBeInTheDocument()
})

it('should send a filled form and navigate to /aanvullende-vragen', async () => {
const user = userEvent.setup()
it('should render an error message', () => {
;(useActionState as jest.Mock).mockReturnValue([{ message: 'Test error message' }, vi.fn()])

render(<Home formData={mockFormData} />)

const input = screen.getByRole('textbox', { name: mockQuestionText })

await user.type(input, mockInput)

const submit = screen.getByRole('button', { name: 'Volgende vraag' })

await user.click(submit)

await waitFor(() => {
expect(mockRouter.default).toMatchObject({
pathname: '/aanvullende-vragen/123/undefined',
})
})
expect(screen.queryByText('Test error message')).toBeInTheDocument()
})
})
31 changes: 9 additions & 22 deletions apps/public/src/app/(general)/Home.tsx
Original file line number Diff line number Diff line change
@@ -1,38 +1,25 @@
'use client'

import { Paragraph } from '@amsterdam/design-system-react'
import type { StaticFormPanelComponentOutput, StaticFormTextFieldInputComponentOutput } from '@meldingen/api-client'
import { getFormClassificationByClassificationId, postMelding } from '@meldingen/api-client'
import { FormRenderer } from '@meldingen/form-renderer'
import { Grid } from '@meldingen/ui'
import { useRouter } from 'next/navigation'
import type { FormEvent } from 'react'
import { useActionState } from 'react'

type Component = StaticFormPanelComponentOutput | StaticFormTextFieldInputComponentOutput

export const Home = ({ formData }: { formData: Component[] }) => {
const router = useRouter()
import { postPrimaryForm } from './actions'

const onSubmit = (e: FormEvent) => {
e.preventDefault()

const data = new FormData(e.target as HTMLFormElement)
const values = Object.fromEntries(data)
const firstKey = Object.keys(values)[0]
type Component = StaticFormPanelComponentOutput | StaticFormTextFieldInputComponentOutput

postMelding({ requestBody: { text: values[firstKey].toString() } }).then(async ({ id, token, classification }) => {
if (classification) {
const nextFormData = await getFormClassificationByClassificationId({ classificationId: classification })
const nextFormFirstKey = nextFormData.components && nextFormData.components[0].key
const initialState: { message?: string } = {}

router.push(`/aanvullende-vragen/${classification}/${nextFormFirstKey}?token=${token}&id=${id}`)
}
})
}
export const Home = ({ formData }: { formData: Component[] }) => {
const [formState, formAction] = useActionState(postPrimaryForm, initialState)

return (
<Grid paddingBottom="large" paddingTop="medium">
<Grid.Cell span={{ narrow: 4, medium: 6, wide: 7 }} start={{ narrow: 1, medium: 2, wide: 2 }}>
<FormRenderer formData={formData} onSubmit={onSubmit} />
{formState?.message && <Paragraph>{formState.message}</Paragraph>}
<FormRenderer formData={formData} action={formAction} />
</Grid.Cell>
</Grid>
)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { render, screen } from '@testing-library/react'
import { useActionState } from 'react'
import { vi } from 'vitest'

import mockFormData from 'apps/public/src/mocks/mockFormData.json'

import { AanvullendeVragen } from './AanvullendeVragen'

vi.mock('react', async (importOriginal) => {
const actual = await importOriginal()
return {
...(typeof actual === 'object' ? actual : {}),
useActionState: vi.fn().mockReturnValue([{}, vi.fn()]),
}
})

describe('AanvullendeVragen', () => {
it('renders a heading', () => {
const action = vi.fn()

render(
<AanvullendeVragen action={action} formData={mockFormData.components[0].components} previousPanelPath="/prev" />,
)

const heading = screen.getByRole('heading', { name: 'Beschrijf uw melding' })

expect(heading).toBeInTheDocument()
})

it('renders form data', () => {
const action = vi.fn()

render(
<AanvullendeVragen action={action} formData={mockFormData.components[0].components} previousPanelPath="/prev" />,
)

const question = screen.getByRole('textbox', { name: /First question/ })

expect(question).toBeInTheDocument()
})

it('should render an error message', () => {
;(useActionState as jest.Mock).mockReturnValue([{ message: 'Test error message' }, vi.fn()])

const action = vi.fn()

render(
<AanvullendeVragen action={action} formData={mockFormData.components[0].components} previousPanelPath="/prev" />,
)

expect(screen.queryByText('Test error message')).toBeInTheDocument()
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
'use client'

import { Grid, Heading, Paragraph } from '@amsterdam/design-system-react'
import { FormRenderer } from '@meldingen/form-renderer'
import { useActionState } from 'react'

import { BackLink } from '../../../_components/BackLink'

// TODO: fix types
type Props = {
action: any
formData: any[]
previousPanelPath: string
}

const initialState: { message?: string } = {}

export const AanvullendeVragen = ({ action, formData, previousPanelPath }: Props) => {
const [formState, formAction] = useActionState(action, initialState)

return (
<Grid paddingBottom="large" paddingTop="medium">
<Grid.Cell span={{ narrow: 4, medium: 6, wide: 7 }} start={{ narrow: 1, medium: 2, wide: 2 }}>
{formState?.message && <Paragraph>{formState.message}</Paragraph>}
<BackLink href={previousPanelPath}>Vorige vraag</BackLink>
<Heading>Beschrijf uw melding</Heading>
<FormRenderer formData={formData} action={formAction} />
</Grid.Cell>
</Grid>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
'use server'

import { postMeldingByMeldingIdQuestionByQuestionId } from '@meldingen/api-client'
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'

import { mergeCheckboxAnswers } from './_utils/mergeCheckboxAnswers'

type ArgsType = {
questionIds: { key: string; id: number }[]
lastPanelPath: string
nextPanelPath: string
}

export const postForm = async (args: ArgsType, _: unknown, formData: FormData) => {
// Get session variables from cookies
const cookieStore = await cookies()
const meldingId = cookieStore.get('id')?.value
const token = cookieStore.get('token')?.value

// Set last panel path in cookies
cookieStore.set('lastPanelPath', args.lastPanelPath)

// Checkbox answers are stored as separate key-value pairs in the FormData object.
// This function merges these answers into a single string value per question, using an identifier in the Checkbox component.
// TODO: This isn't the most robust solution.
const formDataObj = Object.fromEntries(formData)
const entries = Object.entries(formDataObj)
const entriesWithMergedCheckboxes = Object.entries(mergeCheckboxAnswers(entries))

const promiseArray = entriesWithMergedCheckboxes.map(([key, value]) => {
if (value instanceof File) return undefined

// Filter out empty answers
if (value.length === 0) return undefined

const questionId = args.questionIds.find((component) => component.key === key)?.id

if (!meldingId || !questionId || !token) return undefined

return postMeldingByMeldingIdQuestionByQuestionId({
meldingId: parseInt(meldingId, 10),
questionId,
token,
requestBody: { text: value },
}).catch((error) => error)
})

const results = await Promise.all(promiseArray)

// Return a string of all error messages and do not redirect if one of the requests failed
const erroredResults = results.filter((result) => result instanceof Error)

if (erroredResults.length > 0) {
return { message: erroredResults.map((error) => error.message).join(', ') }
}

return redirect(args.nextPanelPath)
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import type { FormPanelComponentOutput } from '@meldingen/api-client'
import { getFormClassificationByClassificationId } from '@meldingen/api-client'
import { Suspense } from 'react'
import { cookies } from 'next/headers'

import { AanvullendeVragenRenderer } from '../../_components/AanvullendeVragenRenderer'
import { AanvullendeVragen } from './AanvullendeVragen'
import { postForm } from './actions'

// TODO: pagina's die niet bestaan moeten redirect krijgen
// TODO: pagina's die wel bestaan maar geen token in url param moeten redirect krijgen
Expand Down Expand Up @@ -60,22 +61,35 @@ export default async ({ params }: { params: Params }) => {

if (formData.components[0].type !== 'panel') return undefined

// Get current panel questions
const currentPanelIndex = formData.components.findIndex((component) => component.key === panelId)

const panel = formData.components[currentPanelIndex] as FormPanelComponentOutput
const panelQuestions = panel.components

// Pass question ids to the action
const questionIds = panelQuestions.map((question) => ({
key: question.key,
id: question.question,
}))

// Pass last panel path to the action
const lastPanelPath = `/aanvullende-vragen/${classification}/${formData.components[formData.components.length - 1].key}`

// Pass next panel path to the action
const nextPanelPath = getNextPanelPath(classification, currentPanelIndex, formData)

const extraArgs = {
questionIds,
lastPanelPath,
nextPanelPath,
}

const postFormWithExtraArgs = postForm.bind(null, extraArgs)

// Pass previous panel path to the Aanvullende vragen component
const previousPanelPath = getPreviousPanelPath(classification, currentPanelIndex, formData)

return (
<Suspense>
<AanvullendeVragenRenderer
formData={panelQuestions}
nextPanelPath={nextPanelPath}
previousPanelPath={previousPanelPath}
/>
</Suspense>
<AanvullendeVragen action={postFormWithExtraArgs} formData={panelQuestions} previousPanelPath={previousPanelPath} />
)
}
Loading

0 comments on commit 53c8cec

Please sign in to comment.