画面上でのテストは完了. 移行にす早さが求められるので,準備ができたらマージする. 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
|
||||
新しいイシューから参照
ユーザーをブロックする