このコミットが含まれているのは:
2026-10-12 23:43:49 +09:00
コミット 398f4f6b82
6個のファイルの変更、103行の追加、13行の削除
+12
ファイルの表示
@@ -4,6 +4,18 @@ import { createHelpFixture } from '@/test/helpFixtures'
describe ('Help file catalogue', () => { describe ('Help file catalogue', () => {
it.each ([['Éclair', 'eclair'], ['home', 'HOME'], ['e\u0301clair', 'Éclair']]) (
'compares title %s and %s equally at primary strength', (a, b) => {
const { api } = createHelpFixture ()
expect (api.compareHelpTitles (a, b)).toBe (0)
})
it ('orders distinct Latin titles in both directions', () => {
const { api } = createHelpFixture ()
expect (api.compareHelpTitles ('Apple', 'Zebra')).toBeLessThan (0)
expect (api.compareHelpTitles ('Zebra', 'Apple')).toBeGreaterThan (0)
})
it.each ([['ほーむ', 'ホーム'], ['ホーム', 'ほーむ'], ['ホーム', 'ホーム'], it.each ([['ほーむ', 'ホーム'], ['ホーム', 'ほーむ'], ['ホーム', 'ホーム'],
['ABC', 'abc'], ['ガイド', 'がいど']]) ( ['ABC', 'abc'], ['ガイド', 'がいど']]) (
'normalises search text %s and %s identically', (query, text) => { 'normalises search text %s and %s identically', (query, text) => {
+8
ファイルの表示
@@ -33,6 +33,14 @@ export type HelpDiff = {
export type HelpEditionIdentifier = { date: string; revision: number | null } export type HelpEditionIdentifier = { date: string; revision: number | null }
// Fix the locale and primary strength; ICU versions still differ from MySQL UCA 9.0.
const helpTitleCollator = new Intl.Collator ('en', {
usage: 'sort', sensitivity: 'base', ignorePunctuation: false, numeric: false })
export const compareHelpTitles = (a: string, b: string): number =>
helpTitleCollator.compare (a, b)
export const normaliseHelpSearchText = (value: string): string => export const normaliseHelpSearchText = (value: string): string =>
value.normalize ('NFKC').toLowerCase ().replace (/[ァ-ヶヽヾ]/g, value.normalize ('NFKC').toLowerCase ().replace (/[ァ-ヶヽヾ]/g,
+14
ファイルの表示
@@ -90,6 +90,20 @@ describe ('Help search', () => {
}) })
// ai_ci equality is documented by MySQL; these are not captured DB sort results. // 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', it.each (['asc', 'desc']) ('uses identifier ties for MySQL ai_ci title order %s',
async direction => { async direction => {
helpFixture.assets.search.default.ja = [ helpFixture.assets.search.default.ja = [
+2 -1
ファイルの表示
@@ -11,6 +11,7 @@ import Pagination from '@/components/common/Pagination'
import MainArea from '@/components/layout/MainArea' import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config' import { SITE_TITLE } from '@/config'
import { compareHelpEditions, import { compareHelpEditions,
compareHelpTitles,
getHelpSearchEntries, getHelpSearchEntries,
helpEditionDate, helpEditionDate,
normaliseHelpSearchText } from '@/lib/help' normaliseHelpSearchText } from '@/lib/help'
@@ -70,7 +71,7 @@ const HelpSearchPage: FC = () => {
|| normaliseHelpSearchText (entry.body).includes (textQuery)))) || normaliseHelpSearchText (entry.body).includes (textQuery))))
.sort ((a, b) => { .sort ((a, b) => {
const comparison = field === 'title' const comparison = field === 'title'
? a.title.localeCompare (b.title, 'ja') ? compareHelpTitles (a.title, b.title)
: compareHelpEditions (a.edition, b.edition) : compareHelpEditions (a.edition, b.edition)
return (direction === 'asc' ? comparison : -comparison) return (direction === 'asc' ? comparison : -comparison)
+54 -6
ファイルの表示
@@ -1,9 +1,10 @@
import { act, fireEvent, screen, waitFor } from '@testing-library/react' import { act, fireEvent, screen, waitFor, within } from '@testing-library/react'
import { Route, Routes } from 'react-router-dom' import { Route, Routes, useLocation } from 'react-router-dom'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import { apiPost } from '@/lib/api' import { apiPost } from '@/lib/api'
import { getHelpTitleIndex } from '@/lib/help' import { getHelpTitleIndex } from '@/lib/help'
import { tagsKeys, wikiKeys } from '@/lib/queryKeys'
import WikiDetailPage from '@/pages/wiki/WikiDetailPage' import WikiDetailPage from '@/pages/wiki/WikiDetailPage'
import WikiNewPage from '@/pages/wiki/WikiNewPage' import WikiNewPage from '@/pages/wiki/WikiNewPage'
import { buildTag, buildUser, buildWikiPage } from '@/test/factories' import { buildTag, buildUser, buildWikiPage } from '@/test/factories'
@@ -27,7 +28,8 @@ vi.mock ('@/lib/wiki', () => wikiApi)
vi.mock ('@/lib/tags', () => tagsApi) vi.mock ('@/lib/tags', () => tagsApi)
vi.mock ('@/lib/posts', () => postsApi) vi.mock ('@/lib/posts', () => postsApi)
vi.mock ('@/lib/help', () => ({ getHelpTitleIndex: vi.fn () })) vi.mock ('@/lib/help', () => ({ getHelpTitleIndex: vi.fn () }))
vi.mock ('@/lib/api', () => ({ apiPost: 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 ('@/components/ui/use-toast', () => ({ toast: vi.fn () }))
vi.mock ('react-markdown-editor-lite', () => ({ vi.mock ('react-markdown-editor-lite', () => ({
default: ({ value, onChange }: { default: ({ value, onChange }: {
@@ -36,6 +38,13 @@ vi.mock ('react-markdown-editor-lite', () => ({
<textarea aria-label="本文エディタ" value={value} <textarea aria-label="本文エディタ" value={value}
onChange={event => onChange ({ text: event.target.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', () => { describe ('WikiDetailPage', () => {
beforeEach (() => { beforeEach (() => {
wikiApi.fetchWikiPageByTitle.mockReset ().mockResolvedValue (null) wikiApi.fetchWikiPageByTitle.mockReset ().mockResolvedValue (null)
@@ -70,25 +79,64 @@ describe ('WikiDetailPage', () => {
resourceType: 'tag_description' }) resourceType: 'tag_description' })
tagsApi.fetchTagByName.mockResolvedValue (buildTag ({ name: page.title, hasWiki: false })) tagsApi.fetchTagByName.mockResolvedValue (buildTag ({ name: page.title, hasWiki: false }))
vi.mocked (apiPost).mockResolvedValueOnce (page) vi.mocked (apiPost).mockResolvedValueOnce (page)
renderWithProviders ( const path = `/wiki/${ encodeURIComponent (page.title) }`
const { container, queryClient } = renderWithProviders (
<>
<Location/>
<Routes> <Routes>
<Route path="/wiki/new" element={<WikiNewPage user={buildUser ({ role: 'member' })}/>}/> <Route path="/wiki/new" element={<WikiNewPage user={buildUser ({ role: 'member' })}/>}/>
<Route path="/wiki/:title" element={<WikiDetailPage/>}/> <Route path="/wiki/:title" element={<WikiDetailPage/>}/>
</Routes>, { route: `/wiki/${ encodeURIComponent (page.title) }` }) </Routes>
</>, { route: path })
fireEvent.click (await screen.findByRole ('link', { name: '新規作成してください' })) fireEvent.click (await screen.findByRole ('link', { name: '新規作成してください' }))
await screen.findByRole ('heading', { name: '新規 Wiki ページ' }) await screen.findByRole ('heading', { name: '新規 Wiki ページ' })
expect (queryClient.getQueryData (wikiKeys.show (page.title, { }))).toBeNull ()
queryClient.setQueryData (wikiKeys.index ({ }), [])
wikiApi.fetchWikiPageByTitle.mockResolvedValue (page) 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) expect (screen.getAllByRole ('textbox')[0]).toHaveValue (page.title)
fireEvent.change (screen.getByRole ('textbox', { name: '本文エディタ' }), { fireEvent.change (screen.getByRole ('textbox', { name: '本文エディタ' }), {
target: { value: page.body } }) target: { value: page.body } })
fireEvent.click (screen.getByRole ('button', { name: '追加' })) fireEvent.click (screen.getByRole ('button', { name: '追加' }))
expect (await screen.findByText (page.body)).toBeInTheDocument () 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 const form = vi.mocked (apiPost).mock.calls[0][1] as FormData
expect (form.get ('title')).toBe (page.title) expect (form.get ('title')).toBe (page.title)
expect (form.get ('body')).toBe (page.body) expect (form.get ('body')).toBe (page.body)
expect (apiPost).toHaveBeenCalledWith ('/wiki', form, expect (apiPost).toHaveBeenCalledWith ('/wiki', form,
{ headers: { 'Content-Type': 'multipart/form-data' } }) { headers: { 'Content-Type': 'multipart/form-data' } })
expect (screen.queryByText ('404')).not.toBeInTheDocument () 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 () => { it ('shows NotFound when neither a tag nor a Help title exists', async () => {
+10 -3
ファイルの表示
@@ -1,10 +1,10 @@
import type { FC } from 'react' import { useQueryClient } from '@tanstack/react-query'
import MarkdownIt from 'markdown-it' import MarkdownIt from 'markdown-it'
import { useState } from 'react' import { useState } from 'react'
import { Helmet } from 'react-helmet-async' import { Helmet } from 'react-helmet-async'
import MdEditor from 'react-markdown-editor-lite' import MdEditor from 'react-markdown-editor-lite'
import { useLocation, useNavigate } from 'react-router-dom' import { useLocation, useNavigate } from 'react-router-dom'
import 'react-markdown-editor-lite/lib/index.css'
import FieldError from '@/components/common/FieldError' import FieldError from '@/components/common/FieldError'
import FormField from '@/components/common/FormField' import FormField from '@/components/common/FormField'
@@ -12,12 +12,13 @@ import MainArea from '@/components/layout/MainArea'
import { toast } from '@/components/ui/use-toast' import { toast } from '@/components/ui/use-toast'
import { SITE_TITLE } from '@/config' import { SITE_TITLE } from '@/config'
import { apiPost } from '@/lib/api' import { apiPost } from '@/lib/api'
import { tagsKeys, wikiKeys } from '@/lib/queryKeys'
import { canEditContent } from '@/lib/users' import { canEditContent } from '@/lib/users'
import { inputClass } from '@/lib/utils' import { inputClass } from '@/lib/utils'
import { useValidationErrors } from '@/lib/useValidationErrors' import { useValidationErrors } from '@/lib/useValidationErrors'
import Forbidden from '@/pages/Forbidden' import Forbidden from '@/pages/Forbidden'
import 'react-markdown-editor-lite/lib/index.css' import type { FC } from 'react'
import type { User, WikiPage } from '@/types' import type { User, WikiPage } from '@/types'
@@ -38,6 +39,7 @@ const WikiNewPage: FC<Props> = ({ user }) => {
const location = useLocation () const location = useLocation ()
const navigate = useNavigate () const navigate = useNavigate ()
const qc = useQueryClient ()
const query = new URLSearchParams (location.search) const query = new URLSearchParams (location.search)
const titleQuery = query.get ('title') ?? '' const titleQuery = query.get ('title') ?? ''
@@ -58,6 +60,11 @@ const WikiNewPage: FC<Props> = ({ user }) => {
{ {
const data = await apiPost<WikiPage> ('/wiki', formData, const data = await apiPost<WikiPage> ('/wiki', formData,
{ headers: { 'Content-Type': 'multipart/form-data' } }) { 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: '投稿成功!' }) toast ({ title: '投稿成功!' })
navigate (`/wiki/${ encodeURIComponent (data.title) }`) navigate (`/wiki/${ encodeURIComponent (data.title) }`)
} }