このコミットが含まれているのは:
@@ -4,6 +4,36 @@ import { createHelpFixture } from '@/test/helpFixtures'
|
|||||||
|
|
||||||
|
|
||||||
describe ('Help file catalogue', () => {
|
describe ('Help file catalogue', () => {
|
||||||
|
it.each ([['ほーむ', 'ホーム'], ['ホーム', 'ほーむ'], ['ホーム', 'ホーム'],
|
||||||
|
['ABC', 'abc'], ['ガイド', 'がいど']]) (
|
||||||
|
'normalises search text %s and %s identically', (query, text) => {
|
||||||
|
const { api } = createHelpFixture ()
|
||||||
|
expect (api.normaliseHelpSearchText (query)).toBe (api.normaliseHelpSearchText (text))
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each ([['2026-10-02', null], ['2026-10-02.2', 2],
|
||||||
|
['2026-10-02.3', 3], ['2026-10-02.10', 10]] as const) (
|
||||||
|
'separates the date and revision of %s', (edition, revision) => {
|
||||||
|
const { api } = createHelpFixture ()
|
||||||
|
expect (api.parseHelpEdition (edition)).toEqual ({ date: '2026-10-02', revision })
|
||||||
|
const date = api.helpEditionDate (edition)!
|
||||||
|
expect ([date.getFullYear (), date.getMonth (), date.getDate ()]).toEqual ([2026, 9, 2])
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each (['missing', '2026-02-30', '2026-10-02.0', '2026-10-02.2.extra',
|
||||||
|
'2026-10-02.9007199254740992']) ('rejects invalid edition %s', edition => {
|
||||||
|
const { api } = createHelpFixture ()
|
||||||
|
expect (api.parseHelpEdition (edition)).toBeNull ()
|
||||||
|
expect (api.helpEditionDate (edition)).toBeNull ()
|
||||||
|
})
|
||||||
|
|
||||||
|
it ('compares numeric revision suffixes without losing the edition identifiers', () => {
|
||||||
|
const { api } = createHelpFixture ()
|
||||||
|
expect (['2026-10-02.10', '2026-10-02.3', '2026-10-02', '2026-10-02.2']
|
||||||
|
.sort (api.compareHelpEditions)).toEqual ([
|
||||||
|
'2026-10-02', '2026-10-02.2', '2026-10-02.3', '2026-10-02.10'])
|
||||||
|
})
|
||||||
|
|
||||||
it ('uses current_edition rather than the newest file, without YAML titles', async () => {
|
it ('uses current_edition rather than the newest file, without YAML titles', async () => {
|
||||||
const { api } = createHelpFixture ()
|
const { api } = createHelpFixture ()
|
||||||
expect ((await api.getHelpArticle ('guide'))?.languages.ja.currentEdition)
|
expect ((await api.getHelpArticle ('guide'))?.languages.ja.currentEdition)
|
||||||
|
|||||||
@@ -31,6 +31,42 @@ export type HelpDiff = {
|
|||||||
to: HelpEditionSource
|
to: HelpEditionSource
|
||||||
lines: HelpDiffLine[] }
|
lines: HelpDiffLine[] }
|
||||||
|
|
||||||
|
export type HelpEditionIdentifier = { date: string; revision: number | null }
|
||||||
|
|
||||||
|
|
||||||
|
export const normaliseHelpSearchText = (value: string): string =>
|
||||||
|
value.normalize ('NFKC').toLowerCase ().replace (/[ァ-ヶヽヾ]/g,
|
||||||
|
character => String.fromCodePoint (character.codePointAt (0)! - 0x60))
|
||||||
|
|
||||||
|
|
||||||
|
export const parseHelpEdition = (edition: string): HelpEditionIdentifier | null => {
|
||||||
|
const matched = edition.match (/^(\d{4}-\d{2}-\d{2})(?:\.([1-9]\d*))?$/)
|
||||||
|
if (matched == null)
|
||||||
|
return null
|
||||||
|
const date = new Date (matched[1])
|
||||||
|
const revision = matched[2] == null ? null : Number (matched[2])
|
||||||
|
if (Number.isNaN (date.getTime ()) || date.toISOString ().slice (0, 10) !== matched[1]
|
||||||
|
|| (revision != null && !(Number.isSafeInteger (revision))))
|
||||||
|
return null
|
||||||
|
return { date: matched[1], revision }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const compareHelpEditions = (a: string, b: string): number => {
|
||||||
|
const before = parseHelpEdition (a)
|
||||||
|
const after = parseHelpEdition (b)
|
||||||
|
if (before == null || after == null)
|
||||||
|
return a.localeCompare (b)
|
||||||
|
return before.date.localeCompare (after.date)
|
||||||
|
|| (before.revision ?? 1) - (after.revision ?? 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const helpEditionDate = (edition: string): Date | null => {
|
||||||
|
const parsed = parseHelpEdition (edition)
|
||||||
|
return parsed == null ? null : new Date (`${ parsed.date }T00:00:00`)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export const getHelpArticle = async (identifier: string): Promise<ArticleMeta | null> => {
|
export const getHelpArticle = async (identifier: string): Promise<ArticleMeta | null> => {
|
||||||
const { default: articles } = await import ('virtual:help/articles')
|
const { default: articles } = await import ('virtual:help/articles')
|
||||||
|
|||||||
@@ -72,11 +72,25 @@ describe ('tags API functions', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it ('returns null when tag fetches fail', async () => {
|
it ('returns null for a missing named tag while retaining the ID helper contract', async () => {
|
||||||
api.apiGet.mockRejectedValueOnce (new Error ('missing'))
|
|
||||||
api.apiGet.mockRejectedValueOnce (new Error ('missing'))
|
api.apiGet.mockRejectedValueOnce (new Error ('missing'))
|
||||||
|
api.apiGet.mockRejectedValueOnce ({ isAxiosError: true, response: { status: 404 } })
|
||||||
|
|
||||||
await expect (fetchTag ('1')).resolves.toBeNull ()
|
await expect (fetchTag ('1')).resolves.toBeNull ()
|
||||||
await expect (fetchTagByName ('unknown')).resolves.toBeNull ()
|
await expect (fetchTagByName ('unknown')).resolves.toBeNull ()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it.each ([403, 500]) ('propagates named tag HTTP %s', async status => {
|
||||||
|
const error = { isAxiosError: true, response: { status } }
|
||||||
|
api.apiGet.mockRejectedValueOnce (error)
|
||||||
|
await expect (fetchTagByName ('ホーム')).rejects.toBe (error)
|
||||||
|
})
|
||||||
|
|
||||||
|
it ('propagates named tag network failures and encodes the name', async () => {
|
||||||
|
const error = new Error ('offline')
|
||||||
|
api.apiGet.mockRejectedValueOnce (error)
|
||||||
|
await expect (fetchTagByName ('名前/#%')).rejects.toBe (error)
|
||||||
|
expect (api.apiGet).toHaveBeenLastCalledWith (
|
||||||
|
`/tags/name/${ encodeURIComponent ('名前/#%') }`)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { isAxiosError } from 'axios'
|
||||||
|
|
||||||
import { apiGet } from '@/lib/api'
|
import { apiGet } from '@/lib/api'
|
||||||
|
|
||||||
import type { Deerjikist,
|
import type { Deerjikist,
|
||||||
@@ -68,11 +70,13 @@ export const fetchExternalTag = async (id: string): Promise<Tag | null> => {
|
|||||||
export const fetchTagByName = async (name: string): Promise<Tag | null> => {
|
export const fetchTagByName = async (name: string): Promise<Tag | null> => {
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
return await apiGet (`/tags/name/${ name }`)
|
return await apiGet (`/tags/name/${ encodeURIComponent (name) }`)
|
||||||
}
|
}
|
||||||
catch
|
catch (error)
|
||||||
{
|
{
|
||||||
return null
|
if (isAxiosError (error) && error.response?.status === 404)
|
||||||
|
return null
|
||||||
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ describe ('Help global edition history', () => {
|
|||||||
expect (screen.getAllByRole ('columnheader').map (cell => cell.textContent))
|
expect (screen.getAllByRole ('columnheader').map (cell => cell.textContent))
|
||||||
.toEqual (['差分', 'タイトル', '有効開始年月日'])
|
.toEqual (['差分', 'タイトル', '有効開始年月日'])
|
||||||
expect (container).not.toHaveTextContent ('未公開ホーム')
|
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 =>
|
expect (screen.getAllByRole ('link', { name: '差分' }).map (link =>
|
||||||
link.getAttribute ('href'))).toEqual ([
|
link.getAttribute ('href'))).toEqual ([
|
||||||
'/help/guide/diff?from=2026-10-02.3&to=2026-10-02.10',
|
'/help/guide/diff?from=2026-10-02.3&to=2026-10-02.10',
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import PageTitle from '@/components/common/PageTitle'
|
|||||||
import Pagination from '@/components/common/Pagination'
|
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 { getHelpChanges } from '@/lib/help'
|
import { getHelpChanges, helpEditionDate } from '@/lib/help'
|
||||||
import { helpKeys } from '@/lib/queryKeys'
|
import { helpKeys } from '@/lib/queryKeys'
|
||||||
import { LIST_LIMIT_OPTIONS } from '@/lib/settings'
|
import { LIST_LIMIT_OPTIONS } from '@/lib/settings'
|
||||||
import { dateString } from '@/lib/utils'
|
import { dateString } from '@/lib/utils'
|
||||||
@@ -85,7 +85,7 @@ const HelpChangesPage: FC = () => {
|
|||||||
const diffQuery = new URLSearchParams ({
|
const diffQuery = new URLSearchParams ({
|
||||||
from: entry.pred ?? '', to: entry.edition })
|
from: entry.pred ?? '', to: entry.edition })
|
||||||
const diffTo = `${ path }/diff?${ diffQuery.toString () }`
|
const diffTo = `${ path }/diff?${ diffQuery.toString () }`
|
||||||
const updatedAt = `${ entry.date }T00:00:00`
|
const updatedAt = helpEditionDate (entry.edition)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr key={`${ entry.identifier }/${ entry.edition }`}
|
<tr key={`${ entry.identifier }/${ entry.edition }`}
|
||||||
@@ -102,7 +102,9 @@ const HelpChangesPage: FC = () => {
|
|||||||
{entry.title}
|
{entry.title}
|
||||||
</PrefetchLink>
|
</PrefetchLink>
|
||||||
</td>
|
</td>
|
||||||
<td className="p-2">{dateString (updatedAt, 'hour')}</td>
|
<td className="p-2">
|
||||||
|
{updatedAt != null && dateString (updatedAt, 'hour')}
|
||||||
|
</td>
|
||||||
</tr>)
|
</tr>)
|
||||||
})}
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -37,6 +37,20 @@ const renderDetail = (route = '/help/guide') => renderWithProviders (
|
|||||||
describe ('Help details', () => {
|
describe ('Help details', () => {
|
||||||
beforeEach (() => helpFixture.reset ())
|
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 () => {
|
it ('displays current_edition H1 and body even when newer files exist', async () => {
|
||||||
renderDetail ()
|
renderDetail ()
|
||||||
expect (await screen.findByRole ('heading', { name: 'ホーム第十版' }))
|
expect (await screen.findByRole ('heading', { name: 'ホーム第十版' }))
|
||||||
@@ -70,6 +84,7 @@ describe ('Help details', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it.each (['/help/missing', '/help/guide?edition=missing',
|
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']) (
|
'/help/guide?edition=2026-10-02.11']) (
|
||||||
'shows NotFound without redirecting %s', async route => {
|
'shows NotFound without redirecting %s', async route => {
|
||||||
renderDetail (route)
|
renderDetail (route)
|
||||||
|
|||||||
@@ -5,7 +5,10 @@ import { useLocation, useParams } from 'react-router-dom'
|
|||||||
import PrefetchLink from '@/components/PrefetchLink'
|
import PrefetchLink from '@/components/PrefetchLink'
|
||||||
import MainArea from '@/components/layout/MainArea'
|
import MainArea from '@/components/layout/MainArea'
|
||||||
import { SITE_TITLE } from '@/config'
|
import { SITE_TITLE } from '@/config'
|
||||||
import { getHelpArticle, getHelpEditionContent } from '@/lib/help'
|
import { getHelpArticle,
|
||||||
|
getHelpEditionContent,
|
||||||
|
helpEditionDate,
|
||||||
|
parseHelpEdition } from '@/lib/help'
|
||||||
import { dateString } from '@/lib/utils'
|
import { dateString } from '@/lib/utils'
|
||||||
import { helpKeys } from '@/lib/queryKeys'
|
import { helpKeys } from '@/lib/queryKeys'
|
||||||
import NotFound from '@/pages/NotFound'
|
import NotFound from '@/pages/NotFound'
|
||||||
@@ -31,6 +34,8 @@ const HelpDetailPage: FC = () => {
|
|||||||
const languageMeta = article?.languages[language]
|
const languageMeta = article?.languages[language]
|
||||||
|
|
||||||
const edition = requestedEdition ?? languageMeta?.currentEdition
|
const edition = requestedEdition ?? languageMeta?.currentEdition
|
||||||
|
const editionInfo = parseHelpEdition (edition ?? '')
|
||||||
|
const editionDate = helpEditionDate (edition ?? '')
|
||||||
|
|
||||||
const editions = languageMeta?.publishedEditions ?? []
|
const editions = languageMeta?.publishedEditions ?? []
|
||||||
|
|
||||||
@@ -42,7 +47,7 @@ const HelpDetailPage: FC = () => {
|
|||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
const { data: current, isSuccess, isError } = useQuery ({
|
const { data: current, isSuccess, isError } = useQuery ({
|
||||||
enabled: edition != null && editions.includes (edition),
|
enabled: edition != null && editionInfo != null && editions.includes (edition),
|
||||||
queryKey: helpKeys.edition (identifier, language, edition ?? ''),
|
queryKey: helpKeys.edition (identifier, language, edition ?? ''),
|
||||||
queryFn: () => getHelpEditionContent (identifier, language, edition ?? ''),
|
queryFn: () => getHelpEditionContent (identifier, language, edition ?? ''),
|
||||||
staleTime: Infinity })
|
staleTime: Infinity })
|
||||||
@@ -51,6 +56,7 @@ const HelpDetailPage: FC = () => {
|
|||||||
&& (article == null
|
&& (article == null
|
||||||
|| languageMeta == null
|
|| languageMeta == null
|
||||||
|| edition == null
|
|| edition == null
|
||||||
|
|| editionInfo == null
|
||||||
|| !(editions.includes (edition))))
|
|| !(editions.includes (edition))))
|
||||||
return <NotFound/>
|
return <NotFound/>
|
||||||
|
|
||||||
@@ -76,7 +82,10 @@ const HelpDetailPage: FC = () => {
|
|||||||
</PrefetchLink>)
|
</PrefetchLink>)
|
||||||
: '(最古)'}
|
: '(最古)'}
|
||||||
|
|
||||||
<span>{dateString (edition, 'hour')}</span>
|
<span>
|
||||||
|
{editionDate != null && dateString (editionDate, 'hour')}
|
||||||
|
{editionInfo?.revision != null && ` .${ editionInfo.revision }`}
|
||||||
|
</span>
|
||||||
|
|
||||||
{succ
|
{succ
|
||||||
? (
|
? (
|
||||||
|
|||||||
@@ -52,4 +52,65 @@ describe ('Help search', () => {
|
|||||||
expect (await screen.findByRole ('alert')).toHaveTextContent ('fixture loading failed')
|
expect (await screen.findByRole ('alert')).toHaveTextContent ('fixture loading failed')
|
||||||
expect (screen.queryByRole ('link', { name: 'ホーム第十版' })).not.toBeInTheDocument ()
|
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']) ('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'])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -10,7 +10,10 @@ import PageTitle from '@/components/common/PageTitle'
|
|||||||
import Pagination from '@/components/common/Pagination'
|
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 { getHelpSearchEntries } from '@/lib/help'
|
import { compareHelpEditions,
|
||||||
|
getHelpSearchEntries,
|
||||||
|
helpEditionDate,
|
||||||
|
normaliseHelpSearchText } from '@/lib/help'
|
||||||
import { helpKeys } from '@/lib/queryKeys'
|
import { helpKeys } from '@/lib/queryKeys'
|
||||||
import { LIST_LIMIT_OPTIONS } from '@/lib/settings'
|
import { LIST_LIMIT_OPTIONS } from '@/lib/settings'
|
||||||
import { dateString, inputClass } from '@/lib/utils'
|
import { dateString, inputClass } from '@/lib/utils'
|
||||||
@@ -55,22 +58,20 @@ const HelpSearchPage: FC = () => {
|
|||||||
}, [conditions.title, conditions.text])
|
}, [conditions.title, conditions.text])
|
||||||
|
|
||||||
const results = useMemo (() => {
|
const results = useMemo (() => {
|
||||||
const titleQuery = conditions.title.trim ().normalize ('NFKC').toLowerCase ()
|
const titleQuery = normaliseHelpSearchText (conditions.title.trim ())
|
||||||
const textQuery = conditions.text.trim ().normalize ('NFKC').toLowerCase ()
|
const textQuery = normaliseHelpSearchText (conditions.text.trim ())
|
||||||
const [field, direction] = order.split (':')
|
const [field, direction] = order.split (':')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
entries.filter (entry => (
|
entries.filter (entry => (
|
||||||
(!(titleQuery)
|
(!(titleQuery)
|
||||||
|| entry.title.normalize ('NFKC').toLowerCase ().includes (titleQuery))
|
|| normaliseHelpSearchText (entry.title).includes (titleQuery))
|
||||||
&& (!(textQuery)
|
&& (!(textQuery)
|
||||||
|| entry.body.normalize ('NFKC').toLowerCase ().includes (textQuery))))
|
|| normaliseHelpSearchText (entry.body).includes (textQuery))))
|
||||||
.sort ((a, b) => {
|
.sort ((a, b) => {
|
||||||
const [dateA, revisionA = '1'] = a.edition.split ('.')
|
|
||||||
const [dateB, revisionB = '1'] = b.edition.split ('.')
|
|
||||||
const comparison = field === 'title'
|
const comparison = field === 'title'
|
||||||
? a.title.localeCompare (b.title, 'ja')
|
? a.title.localeCompare (b.title, 'ja')
|
||||||
: (dateA.localeCompare (dateB) || Number (revisionA) - Number (revisionB))
|
: compareHelpEditions (a.edition, b.edition)
|
||||||
|
|
||||||
return (direction === 'asc' ? comparison : -comparison)
|
return (direction === 'asc' ? comparison : -comparison)
|
||||||
|| a.identifier.localeCompare (b.identifier)
|
|| a.identifier.localeCompare (b.identifier)
|
||||||
@@ -180,7 +181,7 @@ const HelpSearchPage: FC = () => {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{pageResults.map (entry => {
|
{pageResults.map (entry => {
|
||||||
const updatedAt = new Date (entry.edition.replace (/\..*$/, ''))
|
const updatedAt = helpEditionDate (entry.edition)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr key={entry.identifier}
|
<tr key={entry.identifier}
|
||||||
@@ -192,7 +193,9 @@ const HelpSearchPage: FC = () => {
|
|||||||
{entry.title}
|
{entry.title}
|
||||||
</PrefetchLink>
|
</PrefetchLink>
|
||||||
</td>
|
</td>
|
||||||
<td className="p-2">{dateString (updatedAt, 'hour')}</td>
|
<td className="p-2">
|
||||||
|
{updatedAt != null && dateString (updatedAt, 'hour')}
|
||||||
|
</td>
|
||||||
</tr>)
|
</tr>)
|
||||||
})}
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
import { screen } from '@testing-library/react'
|
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
|
||||||
import { Route, Routes } from 'react-router-dom'
|
import { Route, Routes } from 'react-router-dom'
|
||||||
import { describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import { apiPost } from '@/lib/api'
|
||||||
|
import { getHelpTitleIndex } from '@/lib/help'
|
||||||
import WikiDetailPage from '@/pages/wiki/WikiDetailPage'
|
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'
|
import { renderWithProviders } from '@/test/render'
|
||||||
|
|
||||||
const wikiApi = vi.hoisted (() => ({
|
const wikiApi = vi.hoisted (() => ({
|
||||||
fetchWikiPage: vi.fn (),
|
fetchWikiPage: vi.fn (),
|
||||||
fetchWikiPageByTitle: vi.fn (),
|
fetchWikiPageByTitle: vi.fn (),
|
||||||
|
fetchWikiPages: vi.fn (),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const tagsApi = vi.hoisted (() => ({
|
const tagsApi = vi.hoisted (() => ({
|
||||||
@@ -22,8 +26,93 @@ const postsApi = vi.hoisted (() => ({
|
|||||||
vi.mock ('@/lib/wiki', () => wikiApi)
|
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/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 })}/>) }))
|
||||||
|
|
||||||
describe ('WikiDetailPage', () => {
|
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)
|
||||||
|
renderWithProviders (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/wiki/new" element={<WikiNewPage user={buildUser ({ role: 'member' })}/>}/>
|
||||||
|
<Route path="/wiki/:title" element={<WikiDetailPage/>}/>
|
||||||
|
</Routes>, { route: `/wiki/${ encodeURIComponent (page.title) }` })
|
||||||
|
fireEvent.click (await screen.findByRole ('link', { name: '新規作成してください' }))
|
||||||
|
await screen.findByRole ('heading', { name: '新規 Wiki ページ' })
|
||||||
|
wikiApi.fetchWikiPageByTitle.mockResolvedValue (page)
|
||||||
|
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 ()
|
||||||
|
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 ()
|
||||||
|
})
|
||||||
|
|
||||||
|
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 () => {
|
it ('renders deprecated state outside the wiki title link', async () => {
|
||||||
wikiApi.fetchWikiPageByTitle.mockResolvedValueOnce (buildWikiPage ({
|
wikiApi.fetchWikiPageByTitle.mockResolvedValueOnce (buildWikiPage ({
|
||||||
title: '旧タグ',
|
title: '旧タグ',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useEffect, useMemo } from 'react'
|
import { useEffect, useMemo } from 'react'
|
||||||
import { Helmet } from 'react-helmet-async'
|
import { Helmet } from 'react-helmet-async'
|
||||||
import { useLocation, useNavigate, useParams } from 'react-router-dom'
|
import { useLocation, useNavigate, useParams } from 'react-router-dom'
|
||||||
@@ -29,48 +29,39 @@ const WikiDetailPage: FC = () => {
|
|||||||
|
|
||||||
const location = useLocation ()
|
const location = useLocation ()
|
||||||
const navigate = useNavigate ()
|
const navigate = useNavigate ()
|
||||||
const queryClient = useQueryClient ()
|
|
||||||
|
|
||||||
const defaultTag = useMemo (() => ({ name: title, category: 'general' } as Tag), [title])
|
const defaultTag = useMemo (() => ({ name: title, category: 'general' } as Tag), [title])
|
||||||
|
|
||||||
const query = new URLSearchParams (location.search)
|
const query = new URLSearchParams (location.search)
|
||||||
const version = query.get ('version') || undefined
|
const version = query.get ('version') || undefined
|
||||||
|
|
||||||
const { data: wikiPage, isLoading: loading, isSuccess, isFetching } = useQuery ({
|
const { data: wikiPage, isLoading: loading, isSuccess, isFetching, error } = useQuery ({
|
||||||
enabled: Boolean (title) && !(/^\d+$/.test (title)),
|
enabled: Boolean (title) && !(/^\d+$/.test (title)),
|
||||||
queryKey: wikiKeys.show (title, { version }),
|
queryKey: wikiKeys.show (title, { version }),
|
||||||
queryFn: () => fetchWikiPageByTitle (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 (() => {
|
useEffect (() => {
|
||||||
if (!(isSuccess) || isFetching || wikiPage != null)
|
if (missingWiki && !(lookupFetching) && lookupError == null && identifiers.length === 1)
|
||||||
return
|
navigate (`/help/${ encodeURIComponent (identifiers[0]) }`, { replace: true })
|
||||||
|
}, [missingWiki, lookupFetching, lookupError, identifiers, navigate])
|
||||||
let active = true
|
|
||||||
const lookupTitle = title.trim ().normalize ('NFKC').toLowerCase ().replace (/^ヘルプ:/, '')
|
|
||||||
|
|
||||||
void queryClient.fetchQuery ({
|
|
||||||
queryKey: helpKeys.titles ('ja'),
|
|
||||||
queryFn: () => getHelpTitleIndex ('ja'),
|
|
||||||
staleTime: Infinity }).then (index => {
|
|
||||||
if (!(active))
|
|
||||||
return
|
|
||||||
|
|
||||||
const identifiers = Object.hasOwn (index, lookupTitle) ? index[lookupTitle] : []
|
|
||||||
if (identifiers.length === 1)
|
|
||||||
navigate (`/help/${ encodeURIComponent (identifiers[0]) }`, { replace: true })
|
|
||||||
}).catch (() => {
|
|
||||||
// A failed Help lookup leaves the original missing Wiki URL in place.
|
|
||||||
})
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
active = false
|
|
||||||
}
|
|
||||||
}, [isSuccess, isFetching, wikiPage, title, version, queryClient, navigate])
|
|
||||||
|
|
||||||
const effectiveTitle = wikiPage?.title ?? title
|
const effectiveTitle = wikiPage?.title ?? title
|
||||||
const deprecated = wikiPage?.deprecatedAt != null
|
const deprecated = wikiPage?.deprecatedAt != null
|
||||||
|
|
||||||
const { data: tag } = useQuery ({
|
const { data: tag, isPending: tagPending,
|
||||||
|
isFetching: tagFetching, error: tagError } = useQuery ({
|
||||||
enabled: Boolean (effectiveTitle),
|
enabled: Boolean (effectiveTitle),
|
||||||
queryKey: tagsKeys.show (effectiveTitle),
|
queryKey: tagsKeys.show (effectiveTitle),
|
||||||
queryFn: () => fetchTagByName (effectiveTitle) })
|
queryFn: () => fetchTagByName (effectiveTitle) })
|
||||||
@@ -121,7 +112,15 @@ const WikiDetailPage: FC = () => {
|
|||||||
}
|
}
|
||||||
}, [title, navigate])
|
}, [title, navigate])
|
||||||
|
|
||||||
if (isSuccess && wikiPage == null)
|
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 <NotFound/>
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -157,7 +156,7 @@ const WikiDetailPage: FC = () => {
|
|||||||
{...(version && { to: `/wiki/${ encodeURIComponent (title) }` })}/>
|
{...(version && { to: `/wiki/${ encodeURIComponent (title) }` })}/>
|
||||||
{deprecated && <span>(廃止)</span>}
|
{deprecated && <span>(廃止)</span>}
|
||||||
</h1>
|
</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) && (
|
{(!(version) && posts.length > 0) && (
|
||||||
<div className="not-prose">
|
<div className="not-prose">
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { screen, waitFor } from '@testing-library/react'
|
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
|
||||||
import { Route, Routes, useLocation } from 'react-router-dom'
|
import { Route, Routes, useLocation, useNavigate } from 'react-router-dom'
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
import { apiGet } from '@/lib/api'
|
import { apiGet } from '@/lib/api'
|
||||||
|
import * as help from '@/lib/help'
|
||||||
import HelpDetailPage from '@/pages/help/HelpDetailPage'
|
import HelpDetailPage from '@/pages/help/HelpDetailPage'
|
||||||
import WikiDetailPage from '@/pages/wiki/WikiDetailPage'
|
import WikiDetailPage from '@/pages/wiki/WikiDetailPage'
|
||||||
import { buildWikiPage } from '@/test/factories'
|
import { buildWikiPage } from '@/test/factories'
|
||||||
@@ -17,7 +18,14 @@ vi.mock ('@/lib/posts', () => ({ fetchPosts: async () => ({ posts: [], count: 0
|
|||||||
|
|
||||||
const Location = () => {
|
const Location = () => {
|
||||||
const location = useLocation ()
|
const location = useLocation ()
|
||||||
return <output data-testid="location">{location.pathname}{location.search}</output>
|
const navigate = useNavigate ()
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<output data-testid="location">{location.pathname}{location.search}</output>
|
||||||
|
<button onClick={() => navigate (`/wiki/${ encodeURIComponent ('該当なし') }`)}>
|
||||||
|
switch title
|
||||||
|
</button>
|
||||||
|
</>)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -92,6 +100,36 @@ describe ('Legacy Wiki to Help compatibility', () => {
|
|||||||
expect (screen.getByTestId ('location')).toHaveTextContent (route)
|
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 => {
|
it.each ([403, 500]) ('does not redirect HTTP %s to Help', async status => {
|
||||||
vi.mocked (apiGet).mockRejectedValueOnce ({ isAxiosError: true, response: { status } })
|
vi.mocked (apiGet).mockRejectedValueOnce ({ isAxiosError: true, response: { status } })
|
||||||
const { route, queryClient } = renderWiki ('ホーム')
|
const { route, queryClient } = renderWiki ('ホーム')
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ 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' } })
|
||||||
toast ({ title: '投稿成功!' })
|
toast ({ title: '投稿成功!' })
|
||||||
navigate (`/wiki/${ data.title }`)
|
navigate (`/wiki/${ encodeURIComponent (data.title) }`)
|
||||||
}
|
}
|
||||||
catch (e)
|
catch (e)
|
||||||
{
|
{
|
||||||
|
|||||||
新しいイシューから参照
ユーザーをブロックする