このコミットが含まれているのは:
@@ -4,6 +4,18 @@ import { createHelpFixture } from '@/test/helpFixtures'
|
||||
|
||||
|
||||
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 ([['ほーむ', 'ホーム'], ['ホーム', 'ほーむ'], ['ホーム', 'ホーム'],
|
||||
['ABC', 'abc'], ['ガイド', 'がいど']]) (
|
||||
'normalises search text %s and %s identically', (query, text) => {
|
||||
|
||||
@@ -33,6 +33,14 @@ export type HelpDiff = {
|
||||
|
||||
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 =>
|
||||
value.normalize ('NFKC').toLowerCase ().replace (/[ァ-ヶヽヾ]/g,
|
||||
|
||||
@@ -90,6 +90,20 @@ describe ('Help search', () => {
|
||||
})
|
||||
|
||||
// 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 = [
|
||||
|
||||
@@ -11,6 +11,7 @@ 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'
|
||||
@@ -70,7 +71,7 @@ const HelpSearchPage: FC = () => {
|
||||
|| normaliseHelpSearchText (entry.body).includes (textQuery))))
|
||||
.sort ((a, b) => {
|
||||
const comparison = field === 'title'
|
||||
? a.title.localeCompare (b.title, 'ja')
|
||||
? compareHelpTitles (a.title, b.title)
|
||||
: compareHelpEditions (a.edition, b.edition)
|
||||
|
||||
return (direction === 'asc' ? comparison : -comparison)
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { Route, Routes } from 'react-router-dom'
|
||||
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 WikiNewPage from '@/pages/wiki/WikiNewPage'
|
||||
import { buildTag, buildUser, buildWikiPage } from '@/test/factories'
|
||||
@@ -27,7 +28,8 @@ 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', () => ({ 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 ('react-markdown-editor-lite', () => ({
|
||||
default: ({ value, onChange }: {
|
||||
@@ -36,6 +38,13 @@ vi.mock ('react-markdown-editor-lite', () => ({
|
||||
<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)
|
||||
@@ -70,25 +79,64 @@ describe ('WikiDetailPage', () => {
|
||||
resourceType: 'tag_description' })
|
||||
tagsApi.fetchTagByName.mockResolvedValue (buildTag ({ name: page.title, hasWiki: false }))
|
||||
vi.mocked (apiPost).mockResolvedValueOnce (page)
|
||||
renderWithProviders (
|
||||
<Routes>
|
||||
<Route path="/wiki/new" element={<WikiNewPage user={buildUser ({ role: 'member' })}/>}/>
|
||||
<Route path="/wiki/:title" element={<WikiDetailPage/>}/>
|
||||
</Routes>, { route: `/wiki/${ encodeURIComponent (page.title) }` })
|
||||
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: '追加' }))
|
||||
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
|
||||
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 () => {
|
||||
|
||||
@@ -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,6 +60,11 @@ 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/${ encodeURIComponent (data.title) }`)
|
||||
}
|
||||
|
||||
新しいイシューから参照
ユーザーをブロックする