画面上でのテストは完了. 移行にす早さが求められるので,準備ができたらマージする. Reviewed-on: #430 Co-authored-by: miteruzo <miteruzo@naver.com>
このコミットはプルリクエスト #430 でマージされました。
このコミットが含まれているのは:
@@ -0,0 +1,55 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { HelmetProvider } from 'react-helmet-async'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { apiGet } from '@/lib/api'
|
||||
import App from '@/App'
|
||||
import { helpFixture } from '@/test/helpFixtures'
|
||||
|
||||
vi.hoisted (() => {
|
||||
window.history.replaceState (null, '', '/help/changes')
|
||||
})
|
||||
|
||||
vi.mock ('@/lib/help', async () => (await import ('@/test/helpFixtures')).helpFixture.api)
|
||||
vi.mock ('@/lib/api', () => ({ apiGet: vi.fn (), apiPost: async () => ({}),
|
||||
isApiError: () => false }))
|
||||
vi.mock ('@/components/TopNav', () => ({ default: () => null }))
|
||||
vi.mock ('@/components/DevModeWatermark', () => ({ default: () => null }))
|
||||
vi.mock ('@/lib/useClientBehaviourSettings', () => ({
|
||||
useClientBehaviourSettings: () => ({ animation: 'off', linkPreload: 'off' }) }))
|
||||
|
||||
|
||||
describe ('Help global edition history', () => {
|
||||
beforeEach (() => helpFixture.reset ())
|
||||
|
||||
it ('routes /help/changes to published editions in date and numeric order', async () => {
|
||||
const client = new QueryClient ({ defaultOptions: { queries: { retry: false } } })
|
||||
const { container } = render (
|
||||
<QueryClientProvider client={client}>
|
||||
<HelmetProvider><App/></HelmetProvider>
|
||||
</QueryClientProvider>)
|
||||
const links = await screen.findAllByRole ('link')
|
||||
const editions = links.map (link => link.getAttribute ('href'))
|
||||
.filter (href => href?.startsWith ('/help/') && href.includes ('edition='))
|
||||
expect (editions).toEqual ([
|
||||
`/help/${encodeURIComponent ('設定 手順')}?edition=2026-10-03`,
|
||||
'/help/guide?edition=2026-10-02.10', '/help/guide?edition=2026-10-02.3',
|
||||
'/help/guide?edition=2026-10-02.2', '/help/guide?edition=2026-10-02',
|
||||
'/help/guide?edition=2026-10-01'])
|
||||
expect (container).toHaveTextContent ('旧ホーム')
|
||||
expect (screen.getAllByRole ('columnheader').map (cell => cell.textContent))
|
||||
.toEqual (['差分', 'タイトル', '有効開始年月日'])
|
||||
expect (container).not.toHaveTextContent ('未公開ホーム')
|
||||
expect (container).not.toHaveTextContent ('Invalid Date')
|
||||
expect (container.textContent).not.toMatch (/\d{1,2}:\d{2}/)
|
||||
expect (screen.getAllByRole ('link', { name: '差分' }).map (link =>
|
||||
link.getAttribute ('href'))).toEqual ([
|
||||
'/help/guide/diff?from=2026-10-02.3&to=2026-10-02.10',
|
||||
'/help/guide/diff?from=2026-10-02.2&to=2026-10-02.3',
|
||||
'/help/guide/diff?from=2026-10-02&to=2026-10-02.2',
|
||||
'/help/guide/diff?from=2026-10-01&to=2026-10-02'])
|
||||
expect (apiGet).not.toHaveBeenCalled ()
|
||||
expect (window.location.pathname).toBe ('/help/changes')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,119 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useMemo } from 'react'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
|
||||
import PrefetchLink from '@/components/PrefetchLink'
|
||||
import PageTitle from '@/components/common/PageTitle'
|
||||
import Pagination from '@/components/common/Pagination'
|
||||
import MainArea from '@/components/layout/MainArea'
|
||||
import { SITE_TITLE } from '@/config'
|
||||
import { getHelpChanges, helpEditionDate } from '@/lib/help'
|
||||
import { helpKeys } from '@/lib/queryKeys'
|
||||
import { LIST_LIMIT_OPTIONS } from '@/lib/settings'
|
||||
import { dateString } from '@/lib/utils'
|
||||
|
||||
import type { FC } from 'react'
|
||||
|
||||
|
||||
const HelpChangesPage: FC = () => {
|
||||
const [searchParams, setSearchParams] = useSearchParams ()
|
||||
const identifier = searchParams.get ('identifier') ?? ''
|
||||
const pageValue = searchParams.get ('page') ?? '1'
|
||||
const pageNumber = Number (pageValue)
|
||||
const requestedPage =
|
||||
(/^[1-9]\d*$/.test (pageValue) && Number.isSafeInteger (pageNumber))
|
||||
? pageNumber : 1
|
||||
const limit = LIST_LIMIT_OPTIONS.find (
|
||||
value => String (value) === searchParams.get ('limit')) ?? 20
|
||||
const { data: changes = [], isLoading, error } = useQuery ({
|
||||
queryKey: helpKeys.changes ('ja'),
|
||||
queryFn: () => getHelpChanges ('ja'),
|
||||
staleTime: Infinity })
|
||||
|
||||
const results = useMemo (() => identifier
|
||||
? changes.filter (entry => entry.identifier === identifier)
|
||||
: changes, [changes, identifier])
|
||||
const totalPages = Math.ceil (results.length / limit)
|
||||
const page = requestedPage <= Math.max (1, totalPages) ? requestedPage : 1
|
||||
const pageResults = results.slice ((page - 1) * limit, page * limit)
|
||||
|
||||
const handleLimit = (value: string) => {
|
||||
const next = new URLSearchParams (searchParams)
|
||||
next.set ('limit', value)
|
||||
next.set ('page', '1')
|
||||
setSearchParams (next)
|
||||
}
|
||||
|
||||
return (
|
||||
<MainArea className="min-w-0 max-w-full">
|
||||
<Helmet>
|
||||
<title>{`ヘルプ改定履歴 | ${ SITE_TITLE }`}</title>
|
||||
</Helmet>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<PageTitle>ヘルプ改定履歴</PageTitle>
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>件数</span>
|
||||
<select value={limit} onChange={ev => handleLimit (ev.target.value)}
|
||||
className="rounded border border-border bg-background px-2 py-1 text-sm
|
||||
text-foreground">
|
||||
{LIST_LIMIT_OPTIONS.map (value => (
|
||||
<option key={value} value={value}>{value}</option>))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{isLoading
|
||||
? <p className="mt-4">読み込み中...</p>
|
||||
: (error
|
||||
? <p className="mt-4 text-red-600" role="alert">{error.message}</p>
|
||||
: (
|
||||
<div className="mt-4">
|
||||
<div className="min-w-0 max-w-full overflow-x-auto">
|
||||
<table className="table-auto w-full min-w-[900px] whitespace-nowrap
|
||||
border-collapse">
|
||||
<thead className="border-b-2 border-black dark:border-white">
|
||||
<tr>
|
||||
<th className="p-2 text-left">差分</th>
|
||||
<th className="p-2 text-left">タイトル</th>
|
||||
<th className="p-2 text-left">有効開始年月日</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pageResults.map (entry => {
|
||||
const path = `/help/${ encodeURIComponent (entry.identifier) }`
|
||||
const diffQuery = new URLSearchParams ({
|
||||
from: entry.pred ?? '', to: entry.edition })
|
||||
const diffTo = `${ path }/diff?${ diffQuery.toString () }`
|
||||
const updatedAt = helpEditionDate (entry.edition)
|
||||
|
||||
return (
|
||||
<tr key={`${ entry.identifier }/${ entry.edition }`}
|
||||
className="even:bg-gray-100 text-gray-900
|
||||
dark:even:bg-gray-700 dark:text-gray-100">
|
||||
<td className="p-2">
|
||||
{entry.pred != null && (
|
||||
<PrefetchLink to={diffTo}>
|
||||
差分
|
||||
</PrefetchLink>)}
|
||||
</td>
|
||||
<td className="p-2">
|
||||
<PrefetchLink to={`${ path }?edition=${ entry.edition }`}>
|
||||
{entry.title}
|
||||
</PrefetchLink>
|
||||
</td>
|
||||
<td className="p-2">
|
||||
{updatedAt != null && dateString (updatedAt, 'hour')}
|
||||
</td>
|
||||
</tr>)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{totalPages > 0 && <Pagination page={page} totalPages={totalPages}/>}
|
||||
</div>))}
|
||||
</MainArea>)
|
||||
}
|
||||
|
||||
|
||||
export default HelpChangesPage
|
||||
@@ -0,0 +1,128 @@
|
||||
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { Route, Routes, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import HelpDetailPage from '@/pages/help/HelpDetailPage'
|
||||
import { helpFixture } from '@/test/helpFixtures'
|
||||
import { renderWithProviders } from '@/test/render'
|
||||
|
||||
import type { ReactElement } from 'react'
|
||||
|
||||
vi.mock ('@/lib/help', async () => (await import ('@/test/helpFixtures')).helpFixture.api)
|
||||
|
||||
|
||||
const Navigation = () => {
|
||||
const navigate = useNavigate ()
|
||||
const location = useLocation ()
|
||||
return (
|
||||
<>
|
||||
<output data-testid="location">{location.pathname}{location.search}</output>
|
||||
<button onClick={() => navigate ('/help/設定%20手順')}>switch article</button>
|
||||
<button onClick={() => navigate ('/help/guide?edition=2026-10-02.3')}>
|
||||
switch edition
|
||||
</button>
|
||||
</>)
|
||||
}
|
||||
|
||||
|
||||
const renderDetail = (route = '/help/guide') => renderWithProviders (
|
||||
<>
|
||||
<Navigation/>
|
||||
<Routes>
|
||||
<Route path="/help/:identifier" element={<HelpDetailPage/>}/>
|
||||
</Routes>
|
||||
</>, { route })
|
||||
|
||||
|
||||
describe ('Help details', () => {
|
||||
beforeEach (() => helpFixture.reset ())
|
||||
|
||||
it.each (['2026-10-02', '2026-10-02.2', '2026-10-02.3', '2026-10-02.10']) (
|
||||
'displays a date without inventing a time for edition %s', async edition => {
|
||||
const { container } = renderDetail (`/help/guide?edition=${ edition }`)
|
||||
await screen.findByRole ('heading')
|
||||
expect (container).toHaveTextContent ('10月2日')
|
||||
expect (container).not.toHaveTextContent ('Invalid Date')
|
||||
expect (container.textContent).not.toMatch (/\d{1,2}:\d{2}/)
|
||||
const revision = edition.split ('.')[1]
|
||||
if (revision != null)
|
||||
expect (container).toHaveTextContent (` .${ revision }`)
|
||||
expect (helpFixture.modules[`../../help/guide/editions/ja/${ edition }.mdx`])
|
||||
.toHaveBeenCalled ()
|
||||
})
|
||||
|
||||
it ('displays current_edition H1 and body even when newer files exist', async () => {
|
||||
renderDetail ()
|
||||
expect (await screen.findByRole ('heading', { name: 'ホーム第十版' }))
|
||||
.toBeInTheDocument ()
|
||||
expect (screen.getByText ('タグを検索する 第十版本文')).toBeInTheDocument ()
|
||||
expect (screen.queryByText ('未公開限定本文')).not.toBeInTheDocument ()
|
||||
await waitFor (() => expect (document.title).toContain ('ホーム第十版 ヘルプ'))
|
||||
})
|
||||
|
||||
it ('uses the historical H1 in both page and document titles', async () => {
|
||||
renderDetail ('/help/guide?edition=2026-10-01')
|
||||
expect (await screen.findByRole ('heading', { name: '旧ホーム' })).toBeInTheDocument ()
|
||||
expect (screen.getByText ('過去限定本文')).toBeInTheDocument ()
|
||||
await waitFor (() => expect (document.title).toContain ('旧ホーム ヘルプ'))
|
||||
expect (screen.getByText ('(最古)')).toBeInTheDocument ()
|
||||
expect (screen.getByRole ('link', { name: '新 >' }))
|
||||
.toHaveAttribute ('href', '/help/guide?edition=2026-10-02')
|
||||
})
|
||||
|
||||
it ('navigates .2, .3 and .10 in numeric edition order', async () => {
|
||||
renderDetail ('/help/guide?edition=2026-10-02.3')
|
||||
await screen.findByRole ('heading', { name: 'ホーム第三版' })
|
||||
expect (screen.getByRole ('link', { name: '< 古' }))
|
||||
.toHaveAttribute ('href', '/help/guide?edition=2026-10-02.2')
|
||||
const next = screen.getByRole ('link', { name: '新 >' })
|
||||
expect (next).toHaveAttribute ('href', '/help/guide?edition=2026-10-02.10')
|
||||
fireEvent.click (next)
|
||||
await screen.findByRole ('heading', { name: 'ホーム第十版' })
|
||||
expect (screen.getByText ('(最新)')).toBeInTheDocument ()
|
||||
expect (screen.queryByText ('第三版本文')).not.toBeInTheDocument ()
|
||||
})
|
||||
|
||||
it.each (['/help/missing', '/help/guide?edition=missing',
|
||||
'/help/guide?edition=2026-02-30', '/help/guide?edition=2026-10-02.2.extra',
|
||||
'/help/guide?edition=2026-10-02.11']) (
|
||||
'shows NotFound without redirecting %s', async route => {
|
||||
renderDetail (route)
|
||||
expect (await screen.findByText ('404')).toBeInTheDocument ()
|
||||
expect (screen.getByTestId ('location')).toHaveTextContent (route)
|
||||
})
|
||||
|
||||
it ('hides the old body and title immediately while another article loads', async () => {
|
||||
let finish!: (value: { default: () => ReactElement }) => void
|
||||
const pending = new Promise<{ default: () => ReactElement }> (resolve => {
|
||||
finish = resolve
|
||||
})
|
||||
helpFixture.modules['../../help/設定 手順/editions/ja/2026-10-03.mdx']
|
||||
.mockReturnValueOnce (pending)
|
||||
renderDetail ()
|
||||
await screen.findByRole ('heading', { name: 'ホーム第十版' })
|
||||
fireEvent.click (screen.getByRole ('button', { name: 'switch article' }))
|
||||
expect (screen.queryByText ('タグを検索する 第十版本文')).not.toBeInTheDocument ()
|
||||
await waitFor (() => expect (document.title).not.toContain ('ホーム'))
|
||||
await act (async () => finish ({ default: () => <h1>設定</h1> }))
|
||||
await screen.findByRole ('heading', { name: '設定' })
|
||||
await waitFor (() => expect (document.title).toContain ('設定 ヘルプ'))
|
||||
})
|
||||
|
||||
it ('ignores a late response for the previous edition', async () => {
|
||||
let finish!: (value: { default: () => ReactElement }) => void
|
||||
const pending = new Promise<{ default: () => ReactElement }> (resolve => {
|
||||
finish = resolve
|
||||
})
|
||||
const loader = helpFixture.modules['../../help/guide/editions/ja/2026-10-02.10.mdx']
|
||||
loader.mockReturnValueOnce (pending)
|
||||
renderDetail ()
|
||||
await waitFor (() => expect (loader).toHaveBeenCalled ())
|
||||
fireEvent.click (screen.getByRole ('button', { name: 'switch edition' }))
|
||||
await screen.findByRole ('heading', { name: 'ホーム第三版' })
|
||||
await act (async () => finish ({ default: () => <h1>遅延した旧本文</h1> }))
|
||||
expect (screen.queryByText ('遅延した旧本文')).not.toBeInTheDocument ()
|
||||
expect (screen.getByRole ('heading', { name: 'ホーム第三版' })).toBeInTheDocument ()
|
||||
await waitFor (() => expect (document.title).toContain ('ホーム第三版 ヘルプ'))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,106 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
import { useLocation, useParams } from 'react-router-dom'
|
||||
|
||||
import PrefetchLink from '@/components/PrefetchLink'
|
||||
import MainArea from '@/components/layout/MainArea'
|
||||
import { SITE_TITLE } from '@/config'
|
||||
import { getHelpArticle,
|
||||
getHelpEditionContent,
|
||||
helpEditionDate,
|
||||
parseHelpEdition } from '@/lib/help'
|
||||
import { dateString } from '@/lib/utils'
|
||||
import { helpKeys } from '@/lib/queryKeys'
|
||||
import NotFound from '@/pages/NotFound'
|
||||
|
||||
import type { FC } from 'react'
|
||||
|
||||
|
||||
const HelpDetailPage: FC = () => {
|
||||
const { identifier = '' } = useParams ()
|
||||
const location = useLocation ()
|
||||
|
||||
// Locale 対応が入るまでは ja 固定
|
||||
const language = 'ja'
|
||||
|
||||
const { data: article, isLoading: articleLoading } = useQuery ({
|
||||
queryKey: helpKeys.article (identifier),
|
||||
queryFn: () => getHelpArticle (identifier),
|
||||
staleTime: Infinity })
|
||||
|
||||
const query = new URLSearchParams (location.search)
|
||||
const requestedEdition = query.get ('edition')
|
||||
|
||||
const languageMeta = article?.languages[language]
|
||||
|
||||
const edition = requestedEdition ?? languageMeta?.currentEdition
|
||||
const editionInfo = parseHelpEdition (edition ?? '')
|
||||
const editionDate = helpEditionDate (edition ?? '')
|
||||
|
||||
const editions = languageMeta?.publishedEditions ?? []
|
||||
|
||||
const index = edition ? editions.indexOf (edition) : -1
|
||||
const pred = index > 0 ? editions[index - 1] : undefined
|
||||
const succ =
|
||||
index >= 0 && index < editions.length - 1
|
||||
? editions[index + 1]
|
||||
: undefined
|
||||
|
||||
const { data: current, isSuccess, isError } = useQuery ({
|
||||
enabled: edition != null && editionInfo != null && editions.includes (edition),
|
||||
queryKey: helpKeys.edition (identifier, language, edition ?? ''),
|
||||
queryFn: () => getHelpEditionContent (identifier, language, edition ?? ''),
|
||||
staleTime: Infinity })
|
||||
|
||||
if (!articleLoading
|
||||
&& (article == null
|
||||
|| languageMeta == null
|
||||
|| edition == null
|
||||
|| editionInfo == null
|
||||
|| !(editions.includes (edition))))
|
||||
return <NotFound/>
|
||||
|
||||
if (isError || (isSuccess && current == null))
|
||||
return <NotFound/>
|
||||
|
||||
const Content = current?.content
|
||||
|
||||
return (
|
||||
<MainArea>
|
||||
<Helmet>
|
||||
<title>{current?.title || identifier} ヘルプ | {SITE_TITLE}</title>
|
||||
</Helmet>
|
||||
|
||||
{requestedEdition && (
|
||||
<div className="text-sm flex gap-3 items-center justify-center
|
||||
border border-gray-700 rounded px-2 py-1 mb-4">
|
||||
{pred
|
||||
? (
|
||||
<PrefetchLink
|
||||
to={`/help/${ encodeURIComponent (identifier) }?edition=${ pred }`}>
|
||||
< 古
|
||||
</PrefetchLink>)
|
||||
: '(最古)'}
|
||||
|
||||
<span>
|
||||
{editionDate != null && dateString (editionDate, 'hour')}
|
||||
{editionInfo?.revision != null && ` .${ editionInfo.revision }`}
|
||||
</span>
|
||||
|
||||
{succ
|
||||
? (
|
||||
<PrefetchLink
|
||||
to={`/help/${ encodeURIComponent (identifier) }?edition=${ succ }`}>
|
||||
新 >
|
||||
</PrefetchLink>)
|
||||
: '(最新)'}
|
||||
</div>)}
|
||||
|
||||
<article className="prose dark:prose-invert mx-auto p-4">
|
||||
{Content ? <Content/> : <div>Loading...</div>}
|
||||
</article>
|
||||
</MainArea>)
|
||||
}
|
||||
|
||||
|
||||
export default HelpDetailPage
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
import { useParams, useSearchParams } from 'react-router-dom'
|
||||
|
||||
import PrefetchLink from '@/components/PrefetchLink'
|
||||
import PageTitle from '@/components/common/PageTitle'
|
||||
import MainArea from '@/components/layout/MainArea'
|
||||
import { SITE_TITLE } from '@/config'
|
||||
import { getHelpDiff } from '@/lib/help'
|
||||
import { helpKeys } from '@/lib/queryKeys'
|
||||
import { cn } from '@/lib/utils'
|
||||
import NotFound from '@/pages/NotFound'
|
||||
|
||||
import type { FC } from 'react'
|
||||
|
||||
const markers = { added: '+ ', removed: '- ', unchanged: ' ' }
|
||||
|
||||
|
||||
const HelpDiffPage: FC = () => {
|
||||
const { identifier = '' } = useParams ()
|
||||
const [searchParams] = useSearchParams ()
|
||||
const from = searchParams.get ('from') ?? ''
|
||||
const to = searchParams.get ('to') ?? ''
|
||||
const validParams = Boolean (identifier && from && to)
|
||||
&& searchParams.getAll ('from').length === 1
|
||||
&& searchParams.getAll ('to').length === 1
|
||||
const { data: diff, isLoading, isError } = useQuery ({
|
||||
enabled: validParams,
|
||||
queryKey: helpKeys.diff (identifier, 'ja', from, to),
|
||||
queryFn: () => getHelpDiff (identifier, 'ja', from, to),
|
||||
staleTime: Infinity })
|
||||
|
||||
if (!(validParams) || isError || (!(isLoading) && diff == null))
|
||||
return <NotFound/>
|
||||
|
||||
const title = `${ diff?.to.title ?? identifier } (${ from } → ${ to })`
|
||||
const path = `/help/${ encodeURIComponent (identifier) }`
|
||||
|
||||
return (
|
||||
<MainArea className="min-w-0 max-w-full">
|
||||
<Helmet>
|
||||
<title>{`ヘルプ 差分: ${ title } | ${ SITE_TITLE }`}</title>
|
||||
</Helmet>
|
||||
<PageTitle>{title}</PageTitle>
|
||||
|
||||
{diff
|
||||
? (
|
||||
<>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<PrefetchLink to={`${ path }?edition=${ encodeURIComponent (from) }`}>
|
||||
{diff.from.title} ({from})
|
||||
</PrefetchLink>
|
||||
<span>→</span>
|
||||
<PrefetchLink to={`${ path }?edition=${ encodeURIComponent (to) }`}>
|
||||
{diff.to.title} ({to})
|
||||
</PrefetchLink>
|
||||
</div>
|
||||
<pre className="mt-4 overflow-x-auto whitespace-pre">
|
||||
<code>
|
||||
{diff.lines.map ((line, index) => {
|
||||
const colour = cn (
|
||||
'block text-gray-900 dark:text-gray-100',
|
||||
line.type === 'added' && 'bg-green-200 dark:bg-green-800',
|
||||
line.type === 'added' && 'text-green-950 dark:text-green-50',
|
||||
line.type === 'removed' && 'bg-red-200 dark:bg-red-800',
|
||||
line.type === 'removed' && 'text-red-950 dark:text-red-50')
|
||||
|
||||
return (
|
||||
<span key={index} className={colour}>
|
||||
{markers[line.type]}{line.content.replace (/\r?\n$/, '')}
|
||||
</span>)
|
||||
})}
|
||||
</code>
|
||||
</pre>
|
||||
</>)
|
||||
: <div>Loading...</div>}
|
||||
</MainArea>)
|
||||
}
|
||||
|
||||
|
||||
export default HelpDiffPage
|
||||
@@ -0,0 +1,130 @@
|
||||
import { fireEvent, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import HelpListPage from '@/pages/help/HelpListPage'
|
||||
import { helpFixture } from '@/test/helpFixtures'
|
||||
import { renderWithProviders } from '@/test/render'
|
||||
|
||||
vi.mock ('@/lib/help', async () => (await import ('@/test/helpFixtures')).helpFixture.api)
|
||||
|
||||
|
||||
describe ('Help search', () => {
|
||||
beforeEach (() => helpFixture.reset ())
|
||||
|
||||
it ('initially lists each current article once with encoded detail links', async () => {
|
||||
renderWithProviders (<HelpListPage/>, { route: '/help' })
|
||||
expect (await screen.findByRole ('link', { name: 'ホーム第十版' }))
|
||||
.toHaveAttribute ('href', '/help/guide')
|
||||
expect (screen.getByRole ('link', { name: '設定' }))
|
||||
.toHaveAttribute ('href', `/help/${encodeURIComponent ('設定 手順')}`)
|
||||
expect (screen.getByText ('2 件')).toBeInTheDocument ()
|
||||
expect (screen.queryByText ('旧ホーム')).not.toBeInTheDocument ()
|
||||
expect (screen.queryByText ('未公開ホーム')).not.toBeInTheDocument ()
|
||||
})
|
||||
|
||||
it.each ([
|
||||
['ホーム', '', ['ホーム第十版']],
|
||||
['', '検索', ['ホーム第十版']],
|
||||
['', 'タグ', ['設定', 'ホーム第十版']],
|
||||
['ホーム', '設定', []],
|
||||
['ホーム', '検索', ['ホーム第十版']],
|
||||
['旧ホーム', '', []],
|
||||
['', '過去限定本文', []]]) (
|
||||
'searches title %s and body %s together', async (title, body, expected) => {
|
||||
const { container } = renderWithProviders (<HelpListPage/>)
|
||||
await screen.findByText ('2 件')
|
||||
fireEvent.change (screen.getByRole ('textbox', { name: 'タイトル' }), {
|
||||
target: { value: title } })
|
||||
fireEvent.change (screen.getByRole ('textbox', { name: '内容' }), {
|
||||
target: { value: body } })
|
||||
fireEvent.click (screen.getByRole ('button', { name: '検索' }))
|
||||
expect (Array.from (container.querySelectorAll ('tbody a'), link => link.textContent))
|
||||
.toEqual (expected)
|
||||
if (expected.length === 0)
|
||||
expect (screen.getByText ('該当する記事がありません.')).toBeInTheDocument ()
|
||||
})
|
||||
|
||||
it ('displays loading failures without stale results', async () => {
|
||||
helpFixture.loads.search.mockImplementationOnce (() => {
|
||||
throw new Error ('fixture loading failed')
|
||||
})
|
||||
renderWithProviders (<HelpListPage/>)
|
||||
expect (await screen.findByRole ('alert')).toHaveTextContent ('fixture loading failed')
|
||||
expect (screen.queryByRole ('link', { name: 'ホーム第十版' })).not.toBeInTheDocument ()
|
||||
})
|
||||
|
||||
it ('sorts edition suffixes numerically and displays their date without a time', async () => {
|
||||
const editions = ['2026-10-02', '2026-10-02.2', '2026-10-02.3', '2026-10-02.10']
|
||||
helpFixture.assets.search.default.ja = editions.map ((edition, index) => ({
|
||||
identifier: `article-${ index }`, edition, title: `article-${ index }`, body: '' }))
|
||||
const { container } = renderWithProviders (<HelpListPage/>, { route: '/help' })
|
||||
await screen.findByText ('4 件')
|
||||
expect (Array.from (container.querySelectorAll ('tbody a'), link => link.textContent))
|
||||
.toEqual (['article-3', 'article-2', 'article-1', 'article-0'])
|
||||
const dates = Array.from (container.querySelectorAll ('tbody tr'), row =>
|
||||
row.children[1].textContent)
|
||||
expect (dates).toHaveLength (4)
|
||||
dates.forEach (date => {
|
||||
expect (date).toContain ('10月2日')
|
||||
expect (date).not.toContain ('Invalid Date')
|
||||
expect (date).not.toMatch (/\d{1,2}:\d{2}/)
|
||||
})
|
||||
})
|
||||
|
||||
it.each ([['ホーム', 'ほーむ', '', true], ['ほーむ', 'ホーム', '', true],
|
||||
['ホーム', 'ホーム', '', true], ['ホーム', '', 'ホーム', true],
|
||||
['ホーム', 'ほーむ', 'ホーム', true], ['ホーム', 'ほーむ', '不一致', false]] as const) (
|
||||
'searches title %s with %s and body with %s', async (title, query, text, found) => {
|
||||
const entry = { identifier: 'guide', edition: '2026-10-02.10',
|
||||
title, body: 'ほーむの案内' }
|
||||
helpFixture.assets.search.default.ja = [entry]
|
||||
const params = new URLSearchParams ({ title: query, text })
|
||||
renderWithProviders (<HelpListPage/>, { route: `/help?${ params }` })
|
||||
await screen.findByText (`${ found ? 1 : 0 } 件`)
|
||||
if (found)
|
||||
expect (screen.getByRole ('link', { name: title })).toHaveAttribute ('href', '/help/guide')
|
||||
else
|
||||
expect (screen.queryByRole ('link', { name: title })).not.toBeInTheDocument ()
|
||||
expect (entry).toEqual ({ identifier: 'guide', edition: '2026-10-02.10',
|
||||
title, body: 'ほーむの案内' })
|
||||
})
|
||||
|
||||
// ai_ci equality is documented by MySQL; these are not captured DB sort results.
|
||||
it.each (['asc', 'desc']) ('orders different titles before identifiers in order %s',
|
||||
async direction => {
|
||||
helpFixture.assets.search.default.ja = [
|
||||
{ identifier: 'alpha', edition: '2026-10-02', title: 'Zebra', body: '' },
|
||||
{ identifier: 'beta', edition: '2026-10-02', title: 'Apple', body: '' }]
|
||||
const { container } = renderWithProviders (<HelpListPage/>, {
|
||||
route: `/help?order=title:${ direction }` })
|
||||
await screen.findByText ('2 件')
|
||||
const expected = direction === 'asc'
|
||||
? ['/help/beta', '/help/alpha'] : ['/help/alpha', '/help/beta']
|
||||
expect (Array.from (container.querySelectorAll ('tbody a'), link =>
|
||||
link.getAttribute ('href'))).toEqual (expected)
|
||||
})
|
||||
|
||||
it.each (['asc', 'desc']) ('uses identifier ties for MySQL ai_ci title order %s',
|
||||
async direction => {
|
||||
helpFixture.assets.search.default.ja = [
|
||||
{ identifier: 'alpha', edition: '2026-10-02', title: 'Éclair', body: '' },
|
||||
{ identifier: 'beta', edition: '2026-10-02', title: 'eclair', body: '' }]
|
||||
const { container } = renderWithProviders (<HelpListPage/>, {
|
||||
route: `/help?order=title:${ direction }` })
|
||||
await screen.findByText ('2 件')
|
||||
expect (Array.from (container.querySelectorAll ('tbody a'), link =>
|
||||
link.getAttribute ('href'))).toEqual (['/help/alpha', '/help/beta'])
|
||||
})
|
||||
|
||||
it.each (['asc', 'desc']) ('ignores title case before identifier ties in order %s',
|
||||
async direction => {
|
||||
helpFixture.assets.search.default.ja = [
|
||||
{ identifier: 'alpha', edition: '2026-10-02', title: 'home', body: '' },
|
||||
{ identifier: 'beta', edition: '2026-10-02', title: 'HOME', body: '' }]
|
||||
const { container } = renderWithProviders (<HelpListPage/>, {
|
||||
route: `/help?order=title:${ direction }` })
|
||||
await screen.findByText ('2 件')
|
||||
expect (Array.from (container.querySelectorAll ('tbody a'), link =>
|
||||
link.getAttribute ('href'))).toEqual (['/help/alpha', '/help/beta'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,212 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
|
||||
import PrefetchLink from '@/components/PrefetchLink'
|
||||
import SortHeader from '@/components/SortHeader'
|
||||
import FormField from '@/components/common/FormField'
|
||||
import PageTitle from '@/components/common/PageTitle'
|
||||
import Pagination from '@/components/common/Pagination'
|
||||
import MainArea from '@/components/layout/MainArea'
|
||||
import { SITE_TITLE } from '@/config'
|
||||
import { compareHelpEditions,
|
||||
compareHelpTitles,
|
||||
getHelpSearchEntries,
|
||||
helpEditionDate,
|
||||
normaliseHelpSearchText } from '@/lib/help'
|
||||
import { helpKeys } from '@/lib/queryKeys'
|
||||
import { LIST_LIMIT_OPTIONS } from '@/lib/settings'
|
||||
import { dateString, inputClass } from '@/lib/utils'
|
||||
|
||||
import type { FormEvent, FC } from 'react'
|
||||
|
||||
type SearchConditions = { title: string; text: string }
|
||||
type HelpOrderField = 'title' | 'updated_at'
|
||||
type HelpOrder = `${ HelpOrderField }:${ 'asc' | 'desc' }`
|
||||
|
||||
const defaultDirection: Record<HelpOrderField, 'asc' | 'desc'> = {
|
||||
title: 'asc', updated_at: 'desc' }
|
||||
|
||||
|
||||
const HelpSearchPage: FC = () => {
|
||||
const [searchParams, setSearchParams] = useSearchParams ()
|
||||
const pageValue = searchParams.get ('page') ?? '1'
|
||||
const pageNumber = Number (pageValue)
|
||||
const requestedPage =
|
||||
(/^[1-9]\d*$/.test (pageValue) && Number.isSafeInteger (pageNumber))
|
||||
? pageNumber : 1
|
||||
const limit = LIST_LIMIT_OPTIONS.find (
|
||||
value => String (value) === searchParams.get ('limit')) ?? 20
|
||||
const requestedOrder = searchParams.get ('order') ?? 'updated_at:desc'
|
||||
const order: HelpOrder =
|
||||
['title:asc', 'title:desc', 'updated_at:asc', 'updated_at:desc'].includes (requestedOrder)
|
||||
? requestedOrder as HelpOrder
|
||||
: 'updated_at:desc'
|
||||
const conditions = useMemo<SearchConditions> (() => ({
|
||||
title: searchParams.get ('title') ?? '',
|
||||
text: searchParams.get ('text') ?? '' }), [searchParams])
|
||||
const [title, setTitle] = useState (conditions.title)
|
||||
const [text, setText] = useState (conditions.text)
|
||||
const { data: entries = [], isLoading: loading, error } = useQuery ({
|
||||
queryKey: helpKeys.index ('ja'),
|
||||
queryFn: () => getHelpSearchEntries ('ja'),
|
||||
staleTime: Infinity })
|
||||
|
||||
useEffect (() => {
|
||||
setTitle (conditions.title)
|
||||
setText (conditions.text)
|
||||
}, [conditions.title, conditions.text])
|
||||
|
||||
const results = useMemo (() => {
|
||||
const titleQuery = normaliseHelpSearchText (conditions.title.trim ())
|
||||
const textQuery = normaliseHelpSearchText (conditions.text.trim ())
|
||||
const [field, direction] = order.split (':')
|
||||
|
||||
return (
|
||||
entries.filter (entry => (
|
||||
(!(titleQuery)
|
||||
|| normaliseHelpSearchText (entry.title).includes (titleQuery))
|
||||
&& (!(textQuery)
|
||||
|| normaliseHelpSearchText (entry.body).includes (textQuery))))
|
||||
.sort ((a, b) => {
|
||||
const comparison = field === 'title'
|
||||
? compareHelpTitles (a.title, b.title)
|
||||
: compareHelpEditions (a.edition, b.edition)
|
||||
|
||||
return (direction === 'asc' ? comparison : -comparison)
|
||||
|| a.identifier.localeCompare (b.identifier)
|
||||
}))
|
||||
}, [entries, conditions, order])
|
||||
|
||||
const totalPages = Math.ceil (results.length / limit)
|
||||
const page = requestedPage <= Math.max (1, totalPages) ? requestedPage : 1
|
||||
const pageResults = results.slice ((page - 1) * limit, page * limit)
|
||||
|
||||
const handleLimit = (value: string) => {
|
||||
const next = new URLSearchParams (searchParams)
|
||||
next.set ('limit', value)
|
||||
next.set ('page', '1')
|
||||
setSearchParams (next)
|
||||
}
|
||||
|
||||
const handleSearch = (ev: FormEvent<HTMLFormElement>) => {
|
||||
ev.preventDefault ()
|
||||
const next = new URLSearchParams (searchParams)
|
||||
if (title)
|
||||
next.set ('title', title)
|
||||
else
|
||||
next.delete ('title')
|
||||
if (text)
|
||||
next.set ('text', text)
|
||||
else
|
||||
next.delete ('text')
|
||||
next.set ('page', '1')
|
||||
setSearchParams (next)
|
||||
}
|
||||
|
||||
return (
|
||||
<MainArea className="min-w-0 max-w-full">
|
||||
<Helmet>
|
||||
<title>{`ヘルプ | ${ SITE_TITLE }`}</title>
|
||||
</Helmet>
|
||||
|
||||
<div className="max-w-xl">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<PageTitle>ヘルプ検索</PageTitle>
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>件数</span>
|
||||
<select value={limit} onChange={ev => handleLimit (ev.target.value)}
|
||||
className="rounded border border-border bg-background px-2 py-1 text-sm
|
||||
text-foreground">
|
||||
{LIST_LIMIT_OPTIONS.map (value => (
|
||||
<option key={value} value={value}>{value}</option>))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<form onSubmit={handleSearch} className="space-y-2">
|
||||
<FormField label="タイトル">
|
||||
{({ invalid }) => (
|
||||
<input type="text"
|
||||
aria-label="タイトル"
|
||||
value={title}
|
||||
onChange={e => setTitle (e.target.value)}
|
||||
className={inputClass (invalid)}/>)}
|
||||
</FormField>
|
||||
|
||||
<FormField label="内容">
|
||||
{({ invalid }) => (
|
||||
<input type="text"
|
||||
aria-label="内容"
|
||||
value={text}
|
||||
onChange={e => setText (e.target.value)}
|
||||
className={inputClass (invalid)}/>)}
|
||||
</FormField>
|
||||
|
||||
<div className="py-3">
|
||||
<button type="submit"
|
||||
className="bg-blue-500 text-white px-4 py-2 rounded">
|
||||
検索
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{loading
|
||||
? <p className="mt-4">読み込み中...</p>
|
||||
: (error
|
||||
? <p className="mt-4 text-red-600" role="alert">{error.message}</p>
|
||||
: (
|
||||
<div className="mt-4">
|
||||
<p className="mb-2 text-sm" aria-live="polite">{results.length} 件</p>
|
||||
<div className="min-w-0 max-w-full overflow-x-auto">
|
||||
<table className="table-auto w-full min-w-[640px] whitespace-nowrap
|
||||
border-collapse text-gray-900 dark:text-gray-100">
|
||||
<thead className="border-b-2 border-black dark:border-white">
|
||||
<tr>
|
||||
<th className="p-2 text-left">
|
||||
<SortHeader<HelpOrderField>
|
||||
by="title"
|
||||
label="タイトル"
|
||||
currentOrder={order}
|
||||
defaultDirection={defaultDirection}/>
|
||||
</th>
|
||||
<th className="p-2 text-left">
|
||||
<SortHeader<HelpOrderField>
|
||||
by="updated_at"
|
||||
label="有効開始年月日"
|
||||
currentOrder={order}
|
||||
defaultDirection={defaultDirection}/>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pageResults.map (entry => {
|
||||
const updatedAt = helpEditionDate (entry.edition)
|
||||
|
||||
return (
|
||||
<tr key={entry.identifier}
|
||||
className="even:bg-gray-100 text-gray-900
|
||||
dark:even:bg-gray-700 dark:text-gray-100">
|
||||
<td className="p-2">
|
||||
<PrefetchLink
|
||||
to={`/help/${encodeURIComponent (entry.identifier)}`}>
|
||||
{entry.title}
|
||||
</PrefetchLink>
|
||||
</td>
|
||||
<td className="p-2">
|
||||
{updatedAt != null && dateString (updatedAt, 'hour')}
|
||||
</td>
|
||||
</tr>)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{totalPages > 0 && <Pagination page={page} totalPages={totalPages}/>}
|
||||
{results.length === 0 && <p className="mt-4">該当する記事がありません.</p>}
|
||||
</div>))}
|
||||
</MainArea>)
|
||||
}
|
||||
|
||||
|
||||
export default HelpSearchPage
|
||||
@@ -1,14 +1,19 @@
|
||||
import { screen } from '@testing-library/react'
|
||||
import { Route, Routes } from 'react-router-dom'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { act, fireEvent, screen, waitFor, within } from '@testing-library/react'
|
||||
import { Route, Routes, useLocation } from 'react-router-dom'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { apiPost } from '@/lib/api'
|
||||
import { getHelpTitleIndex } from '@/lib/help'
|
||||
import { tagsKeys, wikiKeys } from '@/lib/queryKeys'
|
||||
import WikiDetailPage from '@/pages/wiki/WikiDetailPage'
|
||||
import { buildTag, buildWikiPage } from '@/test/factories'
|
||||
import WikiNewPage from '@/pages/wiki/WikiNewPage'
|
||||
import { buildTag, buildUser, buildWikiPage } from '@/test/factories'
|
||||
import { renderWithProviders } from '@/test/render'
|
||||
|
||||
const wikiApi = vi.hoisted (() => ({
|
||||
fetchWikiPage: vi.fn (),
|
||||
fetchWikiPageByTitle: vi.fn (),
|
||||
fetchWikiPages: vi.fn (),
|
||||
}))
|
||||
|
||||
const tagsApi = vi.hoisted (() => ({
|
||||
@@ -22,8 +27,140 @@ const postsApi = vi.hoisted (() => ({
|
||||
vi.mock ('@/lib/wiki', () => wikiApi)
|
||||
vi.mock ('@/lib/tags', () => tagsApi)
|
||||
vi.mock ('@/lib/posts', () => postsApi)
|
||||
vi.mock ('@/lib/help', () => ({ getHelpTitleIndex: vi.fn () }))
|
||||
vi.mock ('@/lib/api', async importOriginal => ({
|
||||
...await importOriginal<typeof import ('@/lib/api')> (), apiPost: vi.fn () }))
|
||||
vi.mock ('@/components/ui/use-toast', () => ({ toast: vi.fn () }))
|
||||
vi.mock ('react-markdown-editor-lite', () => ({
|
||||
default: ({ value, onChange }: {
|
||||
value: string
|
||||
onChange: (event: { text: string }) => void }) => (
|
||||
<textarea aria-label="本文エディタ" value={value}
|
||||
onChange={event => onChange ({ text: event.target.value })}/>) }))
|
||||
|
||||
|
||||
const Location = () => {
|
||||
const location = useLocation ()
|
||||
return <output data-testid="location">{location.pathname}{location.search}</output>
|
||||
}
|
||||
|
||||
|
||||
describe ('WikiDetailPage', () => {
|
||||
beforeEach (() => {
|
||||
wikiApi.fetchWikiPageByTitle.mockReset ().mockResolvedValue (null)
|
||||
wikiApi.fetchWikiPages.mockReset ().mockResolvedValue ([])
|
||||
tagsApi.fetchTagByName.mockReset ().mockResolvedValue (null)
|
||||
postsApi.fetchPosts.mockReset ().mockResolvedValue ({ posts: [], count: 0 })
|
||||
vi.mocked (getHelpTitleIndex).mockReset ().mockResolvedValue ({ })
|
||||
vi.mocked (apiPost).mockReset ()
|
||||
})
|
||||
|
||||
it ('offers creation for an existing tag after the Help lookup completes', async () => {
|
||||
let finish!: (index: Record<string, string[]>) => void
|
||||
const pending = new Promise<Record<string, string[]>> (resolve => {
|
||||
finish = resolve
|
||||
})
|
||||
vi.mocked (getHelpTitleIndex).mockReturnValueOnce (pending)
|
||||
tagsApi.fetchTagByName.mockResolvedValue (buildTag ({ name: '既存タグ', hasWiki: false }))
|
||||
renderWithProviders (
|
||||
<Routes><Route path="/wiki/:title" element={<WikiDetailPage/>}/></Routes>,
|
||||
{ route: `/wiki/${ encodeURIComponent ('既存タグ') }` })
|
||||
await waitFor (() => expect (getHelpTitleIndex).toHaveBeenCalled ())
|
||||
expect (screen.queryByText ('404')).not.toBeInTheDocument ()
|
||||
expect (screen.queryByRole ('link', { name: '新規作成してください' }))
|
||||
.not.toBeInTheDocument ()
|
||||
await act (async () => finish ({ }))
|
||||
expect (await screen.findByRole ('link', { name: '新規作成してください' }))
|
||||
.toHaveAttribute ('href', `/wiki/new?title=${ encodeURIComponent ('既存タグ') }`)
|
||||
})
|
||||
|
||||
it ('creates a tag description through the existing form and displays its content', async () => {
|
||||
const page = buildWikiPage ({ title: '既存タグ', body: '説明文',
|
||||
resourceType: 'tag_description' })
|
||||
tagsApi.fetchTagByName.mockResolvedValue (buildTag ({ name: page.title, hasWiki: false }))
|
||||
vi.mocked (apiPost).mockResolvedValueOnce (page)
|
||||
const path = `/wiki/${ encodeURIComponent (page.title) }`
|
||||
const { container, queryClient } = renderWithProviders (
|
||||
<>
|
||||
<Location/>
|
||||
<Routes>
|
||||
<Route path="/wiki/new" element={<WikiNewPage user={buildUser ({ role: 'member' })}/>}/>
|
||||
<Route path="/wiki/:title" element={<WikiDetailPage/>}/>
|
||||
</Routes>
|
||||
</>, { route: path })
|
||||
fireEvent.click (await screen.findByRole ('link', { name: '新規作成してください' }))
|
||||
await screen.findByRole ('heading', { name: '新規 Wiki ページ' })
|
||||
expect (queryClient.getQueryData (wikiKeys.show (page.title, { }))).toBeNull ()
|
||||
queryClient.setQueryData (wikiKeys.index ({ }), [])
|
||||
wikiApi.fetchWikiPageByTitle.mockResolvedValue (page)
|
||||
wikiApi.fetchWikiPages.mockResolvedValue ([page])
|
||||
tagsApi.fetchTagByName.mockResolvedValue (buildTag ({ name: page.title, hasWiki: true }))
|
||||
expect (screen.getAllByRole ('textbox')[0]).toHaveValue (page.title)
|
||||
fireEvent.change (screen.getByRole ('textbox', { name: '本文エディタ' }), {
|
||||
target: { value: page.body } })
|
||||
fireEvent.click (screen.getByRole ('button', { name: '追加' }))
|
||||
await screen.findByRole ('heading', { name: page.title })
|
||||
expect (screen.getByTestId ('location')).toHaveTextContent (path)
|
||||
expect (screen.queryByRole ('textbox', { name: '本文エディタ' })).not.toBeInTheDocument ()
|
||||
expect (within (container.querySelector ('article')!).getByText (page.body))
|
||||
.toBeInTheDocument ()
|
||||
const form = vi.mocked (apiPost).mock.calls[0][1] as FormData
|
||||
expect (form.get ('title')).toBe (page.title)
|
||||
expect (form.get ('body')).toBe (page.body)
|
||||
expect (apiPost).toHaveBeenCalledWith ('/wiki', form,
|
||||
{ headers: { 'Content-Type': 'multipart/form-data' } })
|
||||
expect (screen.queryByText ('404')).not.toBeInTheDocument ()
|
||||
expect (queryClient.getQueryData (wikiKeys.show (page.title, { }))).toEqual (page)
|
||||
await waitFor (() => expect (queryClient.getQueryData (wikiKeys.index ({ })))
|
||||
.toEqual ([page]))
|
||||
await waitFor (() => expect (queryClient.getQueryData (tagsKeys.show (page.title)))
|
||||
.toMatchObject ({ hasWiki: true }))
|
||||
expect (getHelpTitleIndex).toHaveBeenCalledTimes (1)
|
||||
})
|
||||
|
||||
it ('keeps failed saves on the form without changing cached Wiki or tag state', async () => {
|
||||
const title = '既存タグ'
|
||||
const tag = buildTag ({ name: title, hasWiki: false })
|
||||
const path = `/wiki/new?title=${ encodeURIComponent (title) }`
|
||||
vi.mocked (apiPost).mockRejectedValueOnce (new Error ('save failed'))
|
||||
const { queryClient } = renderWithProviders (
|
||||
<><Location/><WikiNewPage user={buildUser ({ role: 'member' })}/></>, { route: path })
|
||||
queryClient.setQueryData (wikiKeys.show (title, { }), null)
|
||||
queryClient.setQueryData (wikiKeys.index ({ }), [])
|
||||
queryClient.setQueryData (tagsKeys.show (title), tag)
|
||||
fireEvent.change (screen.getByRole ('textbox', { name: '本文エディタ' }), {
|
||||
target: { value: '保存しない本文' } })
|
||||
await act (async () => fireEvent.click (screen.getByRole ('button', { name: '追加' })))
|
||||
expect (apiPost).toHaveBeenCalledTimes (1)
|
||||
expect (screen.getByTestId ('location')).toHaveTextContent (path)
|
||||
expect (screen.getByRole ('heading', { name: '新規 Wiki ページ' })).toBeInTheDocument ()
|
||||
expect (queryClient.getQueryData (wikiKeys.show (title, { }))).toBeNull ()
|
||||
expect (queryClient.getQueryData (tagsKeys.show (title))).toEqual (tag)
|
||||
expect (queryClient.getQueryState (wikiKeys.index ({ }))?.isInvalidated).toBe (false)
|
||||
})
|
||||
|
||||
it ('shows NotFound when neither a tag nor a Help title exists', async () => {
|
||||
renderWithProviders (
|
||||
<Routes><Route path="/wiki/:title" element={<WikiDetailPage/>}/></Routes>,
|
||||
{ route: '/wiki/missing' })
|
||||
expect (await screen.findByText ('404')).toBeInTheDocument ()
|
||||
expect (screen.queryByRole ('link', { name: '新規作成してください' }))
|
||||
.not.toBeInTheDocument ()
|
||||
})
|
||||
|
||||
it.each (['403', '500', 'offline']) ('keeps Wiki error %s distinct from missing content',
|
||||
async message => {
|
||||
wikiApi.fetchWikiPageByTitle.mockRejectedValue (new Error (message))
|
||||
tagsApi.fetchTagByName.mockResolvedValue (buildTag ({ name: '既存タグ' }))
|
||||
renderWithProviders (
|
||||
<Routes><Route path="/wiki/:title" element={<WikiDetailPage/>}/></Routes>,
|
||||
{ route: `/wiki/${ encodeURIComponent ('既存タグ') }` })
|
||||
expect (await screen.findByRole ('alert')).toHaveTextContent (message)
|
||||
expect (getHelpTitleIndex).not.toHaveBeenCalled ()
|
||||
expect (screen.queryByRole ('link', { name: '新規作成してください' }))
|
||||
.not.toBeInTheDocument ()
|
||||
})
|
||||
|
||||
it ('renders deprecated state outside the wiki title link', async () => {
|
||||
wikiApi.fetchWikiPageByTitle.mockResolvedValueOnce (buildWikiPage ({
|
||||
title: '旧タグ',
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import type { FC } from 'react'
|
||||
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
@@ -13,10 +11,14 @@ import TabGroup, { Tab } from '@/components/common/TabGroup'
|
||||
import MainArea from '@/components/layout/MainArea'
|
||||
import { SITE_TITLE } from '@/config'
|
||||
import { WikiIdBus } from '@/lib/eventBus/WikiIdBus'
|
||||
import { getHelpTitleIndex } from '@/lib/help'
|
||||
import { fetchPosts } from '@/lib/posts'
|
||||
import { postsKeys, tagsKeys, wikiKeys } from '@/lib/queryKeys'
|
||||
import { helpKeys, postsKeys, tagsKeys, wikiKeys } from '@/lib/queryKeys'
|
||||
import { fetchTagByName } from '@/lib/tags'
|
||||
import { fetchWikiPage, fetchWikiPageByTitle } from '@/lib/wiki'
|
||||
import NotFound from '@/pages/NotFound'
|
||||
|
||||
import type { FC } from 'react'
|
||||
|
||||
import type { Tag } from '@/types'
|
||||
|
||||
@@ -33,15 +35,33 @@ const WikiDetailPage: FC = () => {
|
||||
const query = new URLSearchParams (location.search)
|
||||
const version = query.get ('version') || undefined
|
||||
|
||||
const { data: wikiPage, isLoading: loading } = useQuery ({
|
||||
const { data: wikiPage, isLoading: loading, isSuccess, isFetching, error } = useQuery ({
|
||||
enabled: Boolean (title) && !(/^\d+$/.test (title)),
|
||||
queryKey: wikiKeys.show (title, { version }),
|
||||
queryFn: () => fetchWikiPageByTitle (title, { version }) })
|
||||
|
||||
const missingWiki = isSuccess && !(isFetching) && wikiPage == null
|
||||
const { data: titleIndex, isPending: lookupPending,
|
||||
isFetching: lookupFetching, error: lookupError } = useQuery ({
|
||||
enabled: missingWiki,
|
||||
queryKey: helpKeys.titles ('ja'),
|
||||
queryFn: () => getHelpTitleIndex ('ja'),
|
||||
staleTime: Infinity })
|
||||
const lookupTitle = title.trim ().normalize ('NFKC').toLowerCase ().replace (/^ヘルプ:/, '')
|
||||
const identifiers = useMemo (() => (
|
||||
(titleIndex != null && Object.hasOwn (titleIndex, lookupTitle))
|
||||
? titleIndex[lookupTitle] : []), [titleIndex, lookupTitle])
|
||||
|
||||
useEffect (() => {
|
||||
if (missingWiki && !(lookupFetching) && lookupError == null && identifiers.length === 1)
|
||||
navigate (`/help/${ encodeURIComponent (identifiers[0]) }`, { replace: true })
|
||||
}, [missingWiki, lookupFetching, lookupError, identifiers, navigate])
|
||||
|
||||
const effectiveTitle = wikiPage?.title ?? title
|
||||
const deprecated = wikiPage?.deprecatedAt != null
|
||||
|
||||
const { data: tag } = useQuery ({
|
||||
const { data: tag, isPending: tagPending,
|
||||
isFetching: tagFetching, error: tagError } = useQuery ({
|
||||
enabled: Boolean (effectiveTitle),
|
||||
queryKey: tagsKeys.show (effectiveTitle),
|
||||
queryFn: () => fetchTagByName (effectiveTitle) })
|
||||
@@ -73,19 +93,36 @@ const WikiDetailPage: FC = () => {
|
||||
if (!(/^\d+$/.test (title)))
|
||||
return
|
||||
|
||||
let active = true
|
||||
void (async () => {
|
||||
try
|
||||
{
|
||||
const data = await fetchWikiPage (title, { })
|
||||
navigate (`/wiki/${ encodeURIComponent(data.title) }`, { replace: true })
|
||||
if (active)
|
||||
navigate (`/wiki/${ encodeURIComponent (data.title) }`, { replace: true })
|
||||
}
|
||||
catch
|
||||
{
|
||||
;
|
||||
}
|
||||
}) ()
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [title, navigate])
|
||||
|
||||
const displayError = error ?? (missingWiki ? lookupError ?? tagError : null)
|
||||
if (displayError != null)
|
||||
return <MainArea><p role="alert">{displayError.message}</p></MainArea>
|
||||
|
||||
const waiting = loading || (isFetching && wikiPage == null)
|
||||
|| (missingWiki && (lookupPending || lookupFetching || tagPending
|
||||
|| (tagFetching && tag == null) || identifiers.length === 1))
|
||||
|
||||
if (missingWiki && !(waiting) && (tag == null || version != null))
|
||||
return <NotFound/>
|
||||
|
||||
return (
|
||||
<MainArea>
|
||||
<Helmet>
|
||||
@@ -119,7 +156,7 @@ const WikiDetailPage: FC = () => {
|
||||
{...(version && { to: `/wiki/${ encodeURIComponent (title) }` })}/>
|
||||
{deprecated && <span>(廃止)</span>}
|
||||
</h1>
|
||||
{loading ? <div>Loading...</div> : <WikiBody title={title} body={wikiPage?.body}/>}
|
||||
{waiting ? <div>Loading...</div> : <WikiBody title={title} body={wikiPage?.body}/>}
|
||||
|
||||
{(!(version) && posts.length > 0) && (
|
||||
<div className="not-prose">
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { Route, Routes, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { apiGet } from '@/lib/api'
|
||||
import * as help from '@/lib/help'
|
||||
import HelpDetailPage from '@/pages/help/HelpDetailPage'
|
||||
import WikiDetailPage from '@/pages/wiki/WikiDetailPage'
|
||||
import { buildWikiPage } from '@/test/factories'
|
||||
import { helpFixture } from '@/test/helpFixtures'
|
||||
import { renderWithProviders } from '@/test/render'
|
||||
|
||||
vi.mock ('@/lib/api', () => ({ apiGet: vi.fn () }))
|
||||
vi.mock ('@/lib/help', async () => (await import ('@/test/helpFixtures')).helpFixture.api)
|
||||
vi.mock ('@/lib/tags', () => ({ fetchTagByName: async () => null }))
|
||||
vi.mock ('@/lib/posts', () => ({ fetchPosts: async () => ({ posts: [], count: 0 }) }))
|
||||
|
||||
|
||||
const Location = () => {
|
||||
const location = useLocation ()
|
||||
const navigate = useNavigate ()
|
||||
return (
|
||||
<>
|
||||
<output data-testid="location">{location.pathname}{location.search}</output>
|
||||
<button onClick={() => navigate (`/wiki/${ encodeURIComponent ('該当なし') }`)}>
|
||||
switch title
|
||||
</button>
|
||||
</>)
|
||||
}
|
||||
|
||||
|
||||
const renderWiki = (title: string) => {
|
||||
const route = `/wiki/${encodeURIComponent (title)}`
|
||||
const { queryClient } = renderWithProviders (
|
||||
<>
|
||||
<Location/>
|
||||
<Routes>
|
||||
<Route path="/wiki/:title" element={<WikiDetailPage/>}/>
|
||||
<Route path="/help/:identifier" element={<HelpDetailPage/>}/>
|
||||
<Route path="/404" element={<div>unexpected redirect</div>}/>
|
||||
</Routes>
|
||||
</>, { route })
|
||||
return { route, queryClient }
|
||||
}
|
||||
|
||||
|
||||
describe ('Legacy Wiki to Help compatibility', () => {
|
||||
beforeEach (() => {
|
||||
helpFixture.reset ()
|
||||
vi.mocked (apiGet).mockReset ()
|
||||
vi.mocked (apiGet).mockImplementation (async path => {
|
||||
if (path === '/wiki')
|
||||
return []
|
||||
throw { isAxiosError: true, response: { status: 404 } }
|
||||
})
|
||||
})
|
||||
|
||||
it ('keeps a successful Wiki result even when Help has the same title', async () => {
|
||||
vi.mocked (apiGet).mockResolvedValueOnce (buildWikiPage ({
|
||||
title: 'ホーム', body: 'Wiki の現行本文' }))
|
||||
const { route } = renderWiki ('ホーム')
|
||||
await screen.findByText ('Wiki の現行本文')
|
||||
expect (screen.getByTestId ('location')).toHaveTextContent (route)
|
||||
expect (screen.queryByText ('タグを検索する')).not.toBeInTheDocument ()
|
||||
expect (helpFixture.loads.titles).not.toHaveBeenCalled ()
|
||||
})
|
||||
|
||||
it.each (['ホーム', 'ヘルプ:ホーム', '旧ホーム', 'ヘルプ:旧ホーム', 'ホーム第十版']) (
|
||||
'redirects a unique current or historical Japanese title: %s', async title => {
|
||||
renderWiki (title)
|
||||
await waitFor (() => {
|
||||
expect (screen.getByTestId ('location')).toHaveTextContent ('/help/guide')
|
||||
})
|
||||
expect (await screen.findByRole ('heading', { name: 'ホーム第十版' }))
|
||||
.toBeInTheDocument ()
|
||||
Object.values (helpFixture.raw).forEach (load => expect (load).not.toHaveBeenCalled ())
|
||||
expect (apiGet).toHaveBeenCalledWith (
|
||||
`/wiki/title/${encodeURIComponent (title)}`, expect.anything ())
|
||||
})
|
||||
|
||||
it ('keeps the original URL and displays NotFound when no title matches', async () => {
|
||||
const { route, queryClient } = renderWiki ('該当なし')
|
||||
await screen.findByText ('404')
|
||||
await waitFor (() => {
|
||||
expect (helpFixture.loads.titles).toHaveBeenCalled ()
|
||||
expect (queryClient.isFetching ()).toBe (0)
|
||||
})
|
||||
expect (screen.getByTestId ('location')).toHaveTextContent (route)
|
||||
expect (screen.queryByText ('unexpected redirect')).not.toBeInTheDocument ()
|
||||
})
|
||||
|
||||
it ('does not choose between articles with the same title', async () => {
|
||||
helpFixture.assets.titles.default.ja['ホーム'] = ['guide', '設定 手順']
|
||||
const { route, queryClient } = renderWiki ('ホーム')
|
||||
await screen.findByText ('404')
|
||||
await waitFor (() => {
|
||||
expect (helpFixture.loads.titles).toHaveBeenCalled ()
|
||||
expect (queryClient.isFetching ()).toBe (0)
|
||||
})
|
||||
expect (screen.getByTestId ('location')).toHaveTextContent (route)
|
||||
})
|
||||
|
||||
it ('waits for title lookup and does not redirect a later Wiki URL using an old title',
|
||||
async () => {
|
||||
let finish!: (index: Record<string, string[]>) => void
|
||||
const pending = new Promise<Record<string, string[]>> (resolve => {
|
||||
finish = resolve
|
||||
})
|
||||
const lookup = vi.spyOn (help, 'getHelpTitleIndex').mockReturnValueOnce (pending)
|
||||
renderWiki ('ホーム')
|
||||
await waitFor (() => expect (lookup).toHaveBeenCalled ())
|
||||
expect (screen.queryByText ('404')).not.toBeInTheDocument ()
|
||||
fireEvent.click (screen.getByRole ('button', { name: 'switch title' }))
|
||||
await waitFor (() => expect (apiGet).toHaveBeenCalledWith (
|
||||
`/wiki/title/${ encodeURIComponent ('該当なし') }`, expect.anything ()))
|
||||
await act (async () => finish ({ 'ホーム': ['guide'] }))
|
||||
await screen.findByText ('404')
|
||||
expect (screen.getByTestId ('location'))
|
||||
.toHaveTextContent (`/wiki/${ encodeURIComponent ('該当なし') }`)
|
||||
})
|
||||
|
||||
it ('does not treat a Wiki network failure as a missing Wiki', async () => {
|
||||
vi.mocked (apiGet).mockRejectedValueOnce (new Error ('offline'))
|
||||
const { route } = renderWiki ('ホーム')
|
||||
expect (await screen.findByRole ('alert')).toHaveTextContent ('offline')
|
||||
expect (screen.getByTestId ('location')).toHaveTextContent (route)
|
||||
expect (helpFixture.loads.titles).not.toHaveBeenCalled ()
|
||||
expect (screen.queryByText ('404')).not.toBeInTheDocument ()
|
||||
expect (screen.queryByRole ('link', { name: '新規作成してください' }))
|
||||
.not.toBeInTheDocument ()
|
||||
})
|
||||
|
||||
it.each ([403, 500]) ('does not redirect HTTP %s to Help', async status => {
|
||||
vi.mocked (apiGet).mockRejectedValueOnce ({ isAxiosError: true, response: { status } })
|
||||
const { route, queryClient } = renderWiki ('ホーム')
|
||||
await waitFor (() => {
|
||||
expect (apiGet).toHaveBeenCalled ()
|
||||
expect (queryClient.isFetching ()).toBe (0)
|
||||
})
|
||||
expect (screen.getByTestId ('location')).toHaveTextContent (route)
|
||||
expect (screen.queryByText ('タグを検索する')).not.toBeInTheDocument ()
|
||||
expect (helpFixture.loads.titles).not.toHaveBeenCalled ()
|
||||
})
|
||||
})
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { FC } from 'react'
|
||||
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import { useState } from 'react'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
import MdEditor from 'react-markdown-editor-lite'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import 'react-markdown-editor-lite/lib/index.css'
|
||||
|
||||
import FieldError from '@/components/common/FieldError'
|
||||
import FormField from '@/components/common/FormField'
|
||||
@@ -12,12 +12,13 @@ import MainArea from '@/components/layout/MainArea'
|
||||
import { toast } from '@/components/ui/use-toast'
|
||||
import { SITE_TITLE } from '@/config'
|
||||
import { apiPost } from '@/lib/api'
|
||||
import { tagsKeys, wikiKeys } from '@/lib/queryKeys'
|
||||
import { canEditContent } from '@/lib/users'
|
||||
import { inputClass } from '@/lib/utils'
|
||||
import { useValidationErrors } from '@/lib/useValidationErrors'
|
||||
import Forbidden from '@/pages/Forbidden'
|
||||
|
||||
import 'react-markdown-editor-lite/lib/index.css'
|
||||
import type { FC } from 'react'
|
||||
|
||||
import type { User, WikiPage } from '@/types'
|
||||
|
||||
@@ -38,6 +39,7 @@ const WikiNewPage: FC<Props> = ({ user }) => {
|
||||
|
||||
const location = useLocation ()
|
||||
const navigate = useNavigate ()
|
||||
const qc = useQueryClient ()
|
||||
|
||||
const query = new URLSearchParams (location.search)
|
||||
const titleQuery = query.get ('title') ?? ''
|
||||
@@ -58,8 +60,13 @@ const WikiNewPage: FC<Props> = ({ user }) => {
|
||||
{
|
||||
const data = await apiPost<WikiPage> ('/wiki', formData,
|
||||
{ headers: { 'Content-Type': 'multipart/form-data' } })
|
||||
await qc.cancelQueries ({ queryKey: wikiKeys.root })
|
||||
qc.setQueryData (wikiKeys.show (data.title, { }), data)
|
||||
await Promise.all ([
|
||||
qc.invalidateQueries ({ queryKey: wikiKeys.root }),
|
||||
qc.invalidateQueries ({ queryKey: tagsKeys.root })])
|
||||
toast ({ title: '投稿成功!' })
|
||||
navigate (`/wiki/${ data.title }`)
|
||||
navigate (`/wiki/${ encodeURIComponent (data.title) }`)
|
||||
}
|
||||
catch (e)
|
||||
{
|
||||
|
||||
@@ -1,62 +1,58 @@
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { apiGet } from '@/lib/api'
|
||||
import WikiSearchPage from '@/pages/wiki/WikiSearchPage'
|
||||
import { buildWikiPage } from '@/test/factories'
|
||||
import { renderWithProviders } from '@/test/render'
|
||||
|
||||
const api = vi.hoisted (() => ({
|
||||
apiGet: vi.fn (),
|
||||
}))
|
||||
vi.mock ('@/lib/api', () => ({ apiGet: vi.fn () }))
|
||||
|
||||
vi.mock ('@/lib/api', () => api)
|
||||
|
||||
describe ('WikiSearchPage', () => {
|
||||
describe ('Wiki resource identity', () => {
|
||||
beforeEach (() => vi.mocked (apiGet).mockReset ())
|
||||
|
||||
it ('loads initial wiki pages and renders links', async () => {
|
||||
api.apiGet.mockResolvedValueOnce ([buildWikiPage ({ title: '虹夏' })])
|
||||
|
||||
vi.mocked (apiGet).mockResolvedValueOnce ([buildWikiPage ({ title: '虹夏' })])
|
||||
renderWithProviders (<WikiSearchPage/>)
|
||||
|
||||
expect (await screen.findByRole ('link', { name: '虹夏' })).toHaveAttribute (
|
||||
'href',
|
||||
'/wiki/%E8%99%B9%E5%A4%8F',
|
||||
)
|
||||
expect (api.apiGet).toHaveBeenCalledWith ('/wiki', { params: { title: '' } })
|
||||
expect (await screen.findByRole ('link', { name: '虹夏' }))
|
||||
.toHaveAttribute ('href', '/wiki/%E8%99%B9%E5%A4%8F')
|
||||
expect (apiGet).toHaveBeenCalledWith ('/wiki', { params: { title: '' } })
|
||||
})
|
||||
|
||||
it ('searches by title on submit', async () => {
|
||||
api.apiGet
|
||||
.mockResolvedValueOnce ([])
|
||||
.mockResolvedValueOnce ([buildWikiPage ({ title: '検索結果' })])
|
||||
|
||||
vi.mocked (apiGet).mockResolvedValueOnce ([])
|
||||
.mockResolvedValueOnce ([buildWikiPage ({ title: '検索結果' })])
|
||||
renderWithProviders (<WikiSearchPage/>)
|
||||
|
||||
await waitFor (() => expect (apiGet).toHaveBeenCalledTimes (1))
|
||||
fireEvent.change (screen.getAllByRole ('textbox')[0], { target: { value: '検索' } })
|
||||
fireEvent.click (screen.getByRole ('button', { name: '検索' }))
|
||||
|
||||
await waitFor (() => {
|
||||
expect (api.apiGet).toHaveBeenLastCalledWith (
|
||||
'/wiki',
|
||||
{ params: { title: '検索' } },
|
||||
)
|
||||
})
|
||||
await waitFor (() => expect (apiGet).toHaveBeenLastCalledWith (
|
||||
'/wiki', { params: { title: '検索' } }))
|
||||
expect (await screen.findByRole ('link', { name: '検索結果' })).toBeInTheDocument ()
|
||||
})
|
||||
|
||||
it ('marks deprecated wiki tags in the result title', async () => {
|
||||
api.apiGet.mockResolvedValueOnce ([
|
||||
buildWikiPage ({
|
||||
title: '旧タグ',
|
||||
deprecatedAt: '2026-06-01T00:00:00.000Z',
|
||||
}),
|
||||
])
|
||||
vi.mocked (apiGet).mockResolvedValueOnce ([buildWikiPage ({
|
||||
title: '旧タグ', deprecatedAt: '2026-06-01T00:00:00.000Z' })])
|
||||
renderWithProviders (<WikiSearchPage/>)
|
||||
expect (await screen.findByRole ('link', { name: '旧タグ' })).toBeInTheDocument ()
|
||||
expect (screen.getByText ('(廃止)').closest ('a')).toBeNull ()
|
||||
})
|
||||
|
||||
it ('renders colliding description and group IDs with stable distinct keys', async () => {
|
||||
const errors = vi.spyOn (console, 'error').mockImplementation (() => {})
|
||||
const description = buildWikiPage ({ id: 51, title: '説明', resourceType: 'tag_description' })
|
||||
const group = buildWikiPage ({ id: 51, title: 'タググループ:一覧', resourceType: 'tag_group' })
|
||||
vi.mocked (apiGet).mockResolvedValue ([description, group])
|
||||
renderWithProviders (<WikiSearchPage/>)
|
||||
|
||||
const link = await screen.findByRole ('link', { name: '旧タグ' })
|
||||
const marker = screen.getByText ('(廃止)')
|
||||
|
||||
expect (link).toBeInTheDocument ()
|
||||
expect (marker.closest ('a')).toBeNull ()
|
||||
await screen.findByRole ('link', { name: '説明' })
|
||||
expect (screen.getByRole ('link', { name: 'タググループ:一覧' }))
|
||||
.toHaveAttribute ('href', `/wiki/${ encodeURIComponent (group.title) }`)
|
||||
await waitFor (() => {
|
||||
expect (errors.mock.calls.some (args => args.some (value =>
|
||||
String (value).includes ('same key')))).toBe (false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -81,7 +81,8 @@ const WikiSearchPage: FC = () => {
|
||||
</thead>
|
||||
<tbody>
|
||||
{results.map (page => (
|
||||
<tr key={page.id} className="even:bg-gray-100 dark:even:bg-gray-700">
|
||||
<tr key={`${ page.resourceType }:${ page.id }`}
|
||||
className="even:bg-gray-100 dark:even:bg-gray-700">
|
||||
<td className="p-2">
|
||||
<PrefetchLink to={`/wiki/${ encodeURIComponent (page.title) }`}>
|
||||
{page.title}
|
||||
|
||||
新しいイシューから参照
ユーザーをブロックする