フォームのバリデーションとニコ連携の画面変更 (#090) (#355)

Reviewed-on: #355
Co-authored-by: miteruzo <miteruzo@naver.com>
Co-committed-by: miteruzo <miteruzo@naver.com>
このコミットはPull リクエスト #355 でマージされました.
このコミットが含まれているのは:
2026-06-05 01:59:46 +09:00
committed by みてるぞ
コミット 750aa40e8e
66個のファイルの変更2624行の追加802行の削除
+72
ファイルの表示
@@ -0,0 +1,72 @@
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { Route, Routes } from 'react-router-dom'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import DeerjikistDetailPage from '@/pages/deerjikists/DeerjikistDetailPage'
import { buildTag } from '@/test/factories'
import { renderWithProviders } from '@/test/render'
const tagsApi = vi.hoisted (() => ({
fetchDeerjikistsByTag: vi.fn (),
}))
const api = vi.hoisted (() => ({
apiPut: vi.fn (),
isApiError: vi.fn (),
}))
const toastApi = vi.hoisted (() => ({
toast: vi.fn (),
}))
vi.mock ('@/lib/tags', () => tagsApi)
vi.mock ('@/lib/api', () => api)
vi.mock ('@/components/ui/use-toast', () => toastApi)
const renderPage = () =>
renderWithProviders (
<Routes>
<Route path="/tags/:id/deerjikists" element={<DeerjikistDetailPage/>}/>
</Routes>,
{ route: '/tags/7/deerjikists' },
)
describe ('DeerjikistDetailPage', () => {
beforeEach (() => {
vi.clearAllMocks ()
api.isApiError.mockReturnValue (false)
})
it ('shows indexed validation errors returned for deerjikist rows', async () => {
tagsApi.fetchDeerjikistsByTag.mockResolvedValueOnce ({
tag: buildTag ({ id: 7, name: 'deerjika', category: 'deerjikist' }),
deerjikists: [{ platform: null, code: 'abc' }],
})
api.isApiError.mockReturnValue (true)
api.apiPut.mockRejectedValueOnce ({
response: {
status: 422,
data: {
type: 'validation_error',
message: '入力内容を確認してください.',
errors: { 'deerjikists.0.platform': ['プラットフォームを入力してください.'] },
base_errors: [],
},
},
})
renderPage ()
await screen.findByDisplayValue ('abc')
fireEvent.submit (screen.getByRole ('button', { name: '更新' }).closest ('form')!)
await waitFor (() => {
expect (api.apiPut).toHaveBeenCalledWith (
'/tags/7/deerjikists',
[{ platform: null, code: 'abc' }],
)
})
expect (await screen.findByText ('プラットフォームを入力してください.')).toBeInTheDocument ()
expect (screen.getByRole ('combobox')).toHaveAttribute ('aria-invalid', 'true')
})
})
+55 -35
ファイルの表示
@@ -3,7 +3,8 @@ import { useEffect, useMemo, useState } from 'react'
import { useParams } from 'react-router-dom'
import TagLink from '@/components/TagLink'
import Label from '@/components/common/Label'
import FieldError from '@/components/common/FieldError'
import FormField from '@/components/common/FormField'
import PageTitle from '@/components/common/PageTitle'
import MainArea from '@/components/layout/MainArea'
import { toast } from '@/components/ui/use-toast'
@@ -11,12 +12,16 @@ import { PLATFORM_NAMES, PLATFORMS } from '@/consts'
import { apiPut } from '@/lib/api'
import { tagsKeys } from '@/lib/queryKeys'
import { fetchDeerjikistsByTag } from '@/lib/tags'
import { cn } from '@/lib/utils'
import { cn, inputClass } from '@/lib/utils'
import { useValidationErrors } from '@/lib/useValidationErrors'
import type { FC, FormEvent } from 'react'
import type { Deerjikist, Platform } from '@/types'
type DeerjikistFormField =
'deerjikists' | `deerjikists${ number }Platform` | `deerjikists${ number }Code`
const DeerjikistDetailPage: FC = () => {
const { id } = useParams ()
@@ -31,11 +36,14 @@ const DeerjikistDetailPage: FC = () => {
const [data, setData] =
useState<(Omit<Deerjikist, 'platform'> & { platform: Platform | null })[]> ([])
const [disabled, setDisabled] = useState (true)
const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } =
useValidationErrors<DeerjikistFormField> ()
const qc = useQueryClient ()
const handleSubmit = async (e: FormEvent) => {
e.preventDefault ()
clearValidationErrors ()
try
{
@@ -46,8 +54,9 @@ const DeerjikistDetailPage: FC = () => {
toast ({ description: '更新しました.' })
}
catch
catch (e)
{
applyValidationError (e)
toast ({ title: '更新失敗', description: '入力内容を確認してください.' })
}
finally
@@ -76,6 +85,9 @@ const DeerjikistDetailPage: FC = () => {
</PageTitle>
<form onSubmit={handleSubmit} className="my-4 space-y-2">
<FieldError messages={baseErrors}/>
<FieldError messages={fieldErrors.deerjikists}/>
{data.map ((datum, i) => (
<fieldset key={i} className="min-w-0 rounded-lg border border-gray-300
dark:border-gray-700 p-4">
@@ -91,40 +103,48 @@ const DeerjikistDetailPage: FC = () => {
</legend>
{/* プラットフォーム */}
<div>
<Label></Label>
<select
className="w-full border p-2 rounded"
disabled={disabled}
value={datum.platform ?? ''}
onChange={e => setData (prev => {
const rtn = [...prev]
rtn[i] = { ...rtn[i],
platform: (e.target.value || null) as Platform | null }
return rtn
})}>
<option value="">&nbsp;</option>
{PLATFORMS.map (p => (
<option key={p} value={p}>
{PLATFORM_NAMES[p]}
</option>))}
</select>
</div>
<FormField
label="プラットフォーム"
messages={fieldErrors[`deerjikists${ i }Platform`]}>
{({ describedBy, invalid }) => (
<select
disabled={disabled}
value={datum.platform ?? ''}
aria-describedby={describedBy}
aria-invalid={invalid}
className={inputClass (invalid)}
onChange={e => setData (prev => {
const rtn = [...prev]
rtn[i] = { ...rtn[i],
platform: (e.target.value || null) as Platform | null }
return rtn
})}>
<option value="">&nbsp;</option>
{PLATFORMS.map (p => (
<option key={p} value={p}>
{PLATFORM_NAMES[p]}
</option>))}
</select>)}
</FormField>
{/* コード */}
<div>
<Label></Label>
<input
type="text"
disabled={disabled}
className="w-full border p-2 rounded"
value={datum.code}
onChange={e => setData (prev => {
const rtn = [...prev]
rtn[i] = { ...rtn[i], code: e.target.value }
return rtn
})}/>
</div>
<FormField
label="コード"
messages={fieldErrors[`deerjikists${ i }Code`]}>
{({ describedBy, invalid }) => (
<input
type="text"
disabled={disabled}
value={datum.code}
aria-describedby={describedBy}
aria-invalid={invalid}
className={inputClass (invalid)}
onChange={e => setData (prev => {
const rtn = [...prev]
rtn[i] = { ...rtn[i], code: e.target.value }
return rtn
})}/>)}
</FormField>
</fieldset>
))}