このコミットが含まれているのは:
@@ -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) => {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 = [
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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) }`)
|
||||||
}
|
}
|
||||||
|
|||||||
新しいイシューから参照
ユーザーをブロックする