アイヌ語追加 (#428) (#433)

Reviewed-on: #433
Co-authored-by: miteruzo <miteruzo@naver.com>
このコミットはプルリクエスト #433 でマージされました。
このコミットが含まれているのは:
2026-10-20 20:44:14 +09:00
committed by みてるぞ
コミット 084a090cab
294個のファイルの変更、13978行の追加、3461行の削除
+26 -18
ファイルの表示
@@ -24,7 +24,15 @@ const postsApi = vi.hoisted (() => ({
fetchPosts: vi.fn (),
}))
vi.mock ('@/lib/wiki', () => wikiApi)
vi.mock ('@/lib/wiki', () => ({
...wikiApi,
fetchWikiAutolinkCandidates: async () => [],
fetchWikiTag: (reference: { name?: string; wikiId?: string }, locale: string) =>
tagsApi.fetchTagByName (reference.name ?? reference.wikiId, locale),
fetchWikiPageByReference: (reference: string, ...args: unknown[]) =>
/^\d+$/.test (reference)
? wikiApi.fetchWikiPage (reference, ...args)
: wikiApi.fetchWikiPageByTitle (reference, ...args) }))
vi.mock ('@/lib/tags', () => tagsApi)
vi.mock ('@/lib/posts', () => postsApi)
vi.mock ('@/lib/help', () => ({ getHelpTitleIndex: vi.fn () }))
@@ -63,15 +71,15 @@ describe ('WikiDetailPage', () => {
vi.mocked (getHelpTitleIndex).mockReturnValueOnce (pending)
tagsApi.fetchTagByName.mockResolvedValue (buildTag ({ name: '既存タグ', hasWiki: false }))
renderWithProviders (
<Routes><Route path="/wiki/:title" element={<WikiDetailPage/>}/></Routes>,
{ route: `/wiki/${ encodeURIComponent ('既存タグ') }` })
<Routes><Route path="/ja/wiki/:title" element={<WikiDetailPage/>}/></Routes>,
{ route: `/ja/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 ('既存タグ') }`)
.toHaveAttribute ('href', `/ja/wiki/new?title=${ encodeURIComponent ('既存タグ') }`)
})
it ('creates a tag description through the existing form and displays its content', async () => {
@@ -79,13 +87,13 @@ describe ('WikiDetailPage', () => {
resourceType: 'tag_description' })
tagsApi.fetchTagByName.mockResolvedValue (buildTag ({ name: page.title, hasWiki: false }))
vi.mocked (apiPost).mockResolvedValueOnce (page)
const path = `/wiki/${ encodeURIComponent (page.title) }`
const path = `/ja/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/>}/>
<Route path="/ja/wiki/new" element={<WikiNewPage user={buildUser ({ role: 'member' })}/>}/>
<Route path="/ja/wiki/:title" element={<WikiDetailPage/>}/>
</Routes>
</>, { route: path })
fireEvent.click (await screen.findByRole ('link', { name: '新規作成してください' }))
@@ -108,12 +116,12 @@ describe ('WikiDetailPage', () => {
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' } })
{ locale: 'ja', 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)))
expect (queryClient.getQueryState (wikiKeys.index ({ }))?.isInvalidated).toBe (true)
await waitFor (() => expect (queryClient.getQueryData (
wikiKeys.tag ({ name: page.title }, 'ja')))
.toMatchObject ({ hasWiki: true }))
expect (getHelpTitleIndex).toHaveBeenCalledTimes (1)
})
@@ -121,7 +129,7 @@ describe ('WikiDetailPage', () => {
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) }`
const path = `/ja/wiki/new?title=${ encodeURIComponent (title) }`
vi.mocked (apiPost).mockRejectedValueOnce (new Error ('save failed'))
const { queryClient } = renderWithProviders (
<><Location/><WikiNewPage user={buildUser ({ role: 'member' })}/></>, { route: path })
@@ -141,8 +149,8 @@ describe ('WikiDetailPage', () => {
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' })
<Routes><Route path="/ja/wiki/:title" element={<WikiDetailPage/>}/></Routes>,
{ route: '/ja/wiki/missing' })
expect (await screen.findByText ('404')).toBeInTheDocument ()
expect (screen.queryByRole ('link', { name: '新規作成してください' }))
.not.toBeInTheDocument ()
@@ -153,8 +161,8 @@ describe ('WikiDetailPage', () => {
wikiApi.fetchWikiPageByTitle.mockRejectedValue (new Error (message))
tagsApi.fetchTagByName.mockResolvedValue (buildTag ({ name: '既存タグ' }))
renderWithProviders (
<Routes><Route path="/wiki/:title" element={<WikiDetailPage/>}/></Routes>,
{ route: `/wiki/${ encodeURIComponent ('既存タグ') }` })
<Routes><Route path="/ja/wiki/:title" element={<WikiDetailPage/>}/></Routes>,
{ route: `/ja/wiki/${ encodeURIComponent ('既存タグ') }` })
expect (await screen.findByRole ('alert')).toHaveTextContent (message)
expect (getHelpTitleIndex).not.toHaveBeenCalled ()
expect (screen.queryByRole ('link', { name: '新規作成してください' }))
@@ -174,9 +182,9 @@ describe ('WikiDetailPage', () => {
renderWithProviders (
<Routes>
<Route path="/wiki/:title" element={<WikiDetailPage/>}/>
<Route path="/ja/wiki/:title" element={<WikiDetailPage/>}/>
</Routes>,
{ route: '/wiki/%E6%97%A7%E3%82%BF%E3%82%B0' },
{ route: '/ja/wiki/%E6%97%A7%E3%82%BF%E3%82%B0' },
)
const marker = await screen.findByText ('(廃止)')
+56 -60
ファイルの表示
@@ -1,34 +1,38 @@
import { useQuery } from '@tanstack/react-query'
import { useEffect, useMemo } from 'react'
import { Helmet } from 'react-helmet-async'
import { useLocation, useNavigate, useParams } from 'react-router-dom'
import { useLocation, useParams } from 'react-router-dom'
import { useLocale, useLocaleNavigate } from '@/lib/useLocale'
import { useUITranslation } from '@/lib/useUITranslation'
import PostList from '@/components/PostList'
import PrefetchLink from '@/components/PrefetchLink'
import TagLink from '@/components/TagLink'
import WikiBody from '@/components/WikiBody'
import TabGroup, { Tab } from '@/components/common/TabGroup'
import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config'
import PageHead from '@/components/common/PageHead'
import { WikiIdBus } from '@/lib/eventBus/WikiIdBus'
import { getHelpTitleIndex } from '@/lib/help'
import { localeLanguage } from '@/lib/locales'
import { fetchPosts } from '@/lib/posts'
import { helpKeys, postsKeys, tagsKeys, wikiKeys } from '@/lib/queryKeys'
import { fetchTagByName } from '@/lib/tags'
import { fetchWikiPage, fetchWikiPageByTitle } from '@/lib/wiki'
import { helpKeys, postsKeys, wikiKeys } from '@/lib/queryKeys'
import { fetchWikiPageByReference, fetchWikiTag } from '@/lib/wiki'
import NotFound from '@/pages/NotFound'
import type { FC } from 'react'
import type { Tag } from '@/types'
import type { Tag, WikiTagReference } from '@/types'
const WikiDetailPage: FC = () => {
const { message, label } = useUITranslation ('shared')
const locale = useLocale ()
const params = useParams ()
const title = params.title ?? ''
const numericReference = /^\d+$/.test (title)
const location = useLocation ()
const navigate = useNavigate ()
const navigate = useLocaleNavigate ()
const defaultTag = useMemo (() => ({ name: title, category: 'general' } as Tag), [title])
@@ -36,14 +40,14 @@ const WikiDetailPage: FC = () => {
const version = query.get ('version') || undefined
const { data: wikiPage, isLoading: loading, isSuccess, isFetching, error } = useQuery ({
enabled: Boolean (title) && !(/^\d+$/.test (title)),
queryKey: wikiKeys.show (title, { version }),
queryFn: () => fetchWikiPageByTitle (title, { version }) })
enabled: Boolean (title),
queryKey: wikiKeys.show (title, { version }, locale),
queryFn: () => fetchWikiPageByReference (title, { version }, locale) })
const missingWiki = isSuccess && !(isFetching) && wikiPage == null
const { data: titleIndex, isPending: lookupPending,
isFetching: lookupFetching, error: lookupError } = useQuery ({
enabled: missingWiki,
enabled: missingWiki && !numericReference,
queryKey: helpKeys.titles ('ja'),
queryFn: () => getHelpTitleIndex ('ja'),
staleTime: Infinity })
@@ -53,28 +57,33 @@ const WikiDetailPage: FC = () => {
? titleIndex[lookupTitle] : []), [titleIndex, lookupTitle])
useEffect (() => {
if (missingWiki && !(lookupFetching) && lookupError == null && identifiers.length === 1)
if (missingWiki && !numericReference
&& !(lookupFetching) && lookupError == null && identifiers.length === 1)
navigate (`/help/${ encodeURIComponent (identifiers[0]) }`, { replace: true })
}, [missingWiki, lookupFetching, lookupError, identifiers, navigate])
}, [missingWiki, numericReference, lookupFetching, lookupError, identifiers, navigate])
const effectiveTitle = wikiPage?.title ?? title
const deprecated = wikiPage?.deprecatedAt != null
const tagReference: WikiTagReference = (wikiPage != null || !numericReference)
? { name: effectiveTitle } : { wikiId: title }
const { data: tag, isPending: tagPending,
isFetching: tagFetching, error: tagError } = useQuery ({
enabled: Boolean (effectiveTitle),
queryKey: tagsKeys.show (effectiveTitle),
queryFn: () => fetchTagByName (effectiveTitle) })
enabled: Boolean (title) && (missingWiki || wikiPage != null),
queryKey: wikiKeys.tag (tagReference, locale),
queryFn: () => fetchWikiTag (tagReference, locale) })
const displayTitle = wikiPage?.title ?? tag?.name ?? title
const keys = {
tags: effectiveTitle, match: 'all', page: 1, limit: 8, url: '', title: '',
tags: displayTitle, match: 'all', page: 1, limit: 8, url: '', title: '',
originalCreatedFrom: '', originalCreatedTo: '',
createdFrom: '', createdTo: '', updatedFrom: '', updatedTo: '',
order: 'original_created_at:desc' } as const
const { data } = useQuery ({
enabled: Boolean (effectiveTitle) && !(version),
queryKey: postsKeys.index (keys),
queryFn: () => fetchPosts (keys) })
enabled: Boolean (effectiveTitle) && !(version)
&& (!numericReference || wikiPage != null || tag != null),
queryKey: postsKeys.index (keys, locale),
queryFn: () => fetchPosts (keys, locale) })
const posts = data?.posts || []
useEffect (() => {
@@ -83,85 +92,72 @@ const WikiDetailPage: FC = () => {
WikiIdBus.set (wikiPage.id)
if (wikiPage.title !== title)
navigate (`/wiki/${ encodeURIComponent(wikiPage.title) }`, { replace: true })
if (!numericReference && wikiPage.title !== title)
{
navigate ({ pathname: `/wiki/${ encodeURIComponent (wikiPage.title) }`,
search: location.search, hash: location.hash }, { replace: true })
}
return () => WikiIdBus.set (null)
}, [wikiPage, title, navigate])
useEffect (() => {
if (!(/^\d+$/.test (title)))
return
let active = true
void (async () => {
try
{
const data = await fetchWikiPage (title, { })
if (active)
navigate (`/wiki/${ encodeURIComponent (data.title) }`, { replace: true })
}
catch
{
;
}
}) ()
return () => {
active = false
}
}, [title, navigate])
}, [wikiPage, title, numericReference, navigate, location.search, location.hash])
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))
|| (missingWiki && ((!numericReference
&& (lookupPending || lookupFetching || identifiers.length === 1))
|| tagPending || (tagFetching && tag == null)))
if (missingWiki && !(waiting) && (tag == null || version != null))
return <NotFound/>
return (
<MainArea>
<Helmet>
<title>{`${ effectiveTitle }${ deprecated ? '(廃止)' : '' } Wiki | ${ SITE_TITLE }`}</title>
<PageHead title={{
text: `${displayTitle}${deprecated
? message ('labels.deprecatedSuffix').text : ''} Wiki`,
language: deprecated ? message ('labels.deprecatedSuffix').language : locale }}>
{!(wikiPage?.body) && <meta name="robots" content="noindex"/>}
</Helmet>
</PageHead>
{(wikiPage && version) && (
<div className="text-sm flex gap-3 items-center justify-center
border border-gray-700 rounded px-2 py-1 mb-4">
{wikiPage.pred ? (
<PrefetchLink to={`/wiki/${ encodeURIComponent (title) }?version=${ wikiPage.pred }`}>
&lt; 古
</PrefetchLink>) : '(最古)'}
{label ('actions.old')}
</PrefetchLink>) : label ('actions.oldest')}
<span>{wikiPage.updatedAt}</span>
{wikiPage.succ ? (
<PrefetchLink to={`/wiki/${ encodeURIComponent (title) }?version=${ wikiPage.succ }`}>
新 &gt;
</PrefetchLink>) : '(最新)'}
{label ('actions.new')}
</PrefetchLink>) : label ('actions.latest')}
</div>)}
<article className="prose dark:prose-invert mx-auto p-4">
<h1 className="prose-a:no-underline">
<TagLink tag={tag ?? { ...defaultTag,
name: effectiveTitle,
name: displayTitle,
deprecatedAt: wikiPage?.deprecatedAt ?? null }}
withWiki={false}
withCount={false}
{...(version && { to: `/wiki/${ encodeURIComponent (title) }` })}/>
{deprecated && <span>(廃止)</span>}
{deprecated && label ('labels.deprecatedSuffix')}
</h1>
{waiting ? <div>Loading...</div> : <WikiBody title={title} body={wikiPage?.body}/>}
{waiting
? label ('common:loading')
: <WikiBody title={displayTitle} body={wikiPage?.body}
language={wikiPage?.languageCode ?? (wikiPage?.resourceType === 'tag_description'
? localeLanguage (locale) : 'und')}/>}
{(!(version) && posts.length > 0) && (
<div className="not-prose">
<TabGroup>
<Tab name="広場">
<Tab name={message ('navigation:posts').text}>
<PostList posts={posts}/>
</Tab>
</TabGroup>
+2 -2
ファイルの表示
@@ -36,10 +36,10 @@ describe ('WikiDiffPage', () => {
await waitFor (() => {
expect (api.apiGet).toHaveBeenCalledWith (
'/wiki/3/diff',
{ params: { from: '1', to: '2' } },
{ locale: 'ja', params: { from: '1', to: '2' } },
)
})
expect (screen.getByText ('差分対象')).toBeInTheDocument ()
expect (await screen.findByText ('差分対象')).toBeInTheDocument ()
expect (screen.getByText ('same')).toBeInTheDocument ()
expect (screen.getByText ('added line')).toBeInTheDocument ()
expect (screen.getByText ('removed line')).toBeInTheDocument ()
+15 -16
ファイルの表示
@@ -1,43 +1,42 @@
import type { FC } from 'react'
import { useEffect, useState } from 'react'
import { Helmet } from 'react-helmet-async'
import { useQuery } from '@tanstack/react-query'
import { useLocation, useParams } from 'react-router-dom'
import { useLocale } from '@/lib/useLocale'
import { useUITranslation } from '@/lib/useUITranslation'
import PageTitle from '@/components/common/PageTitle'
import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config'
import PageHead from '@/components/common/PageHead'
import { apiGet } from '@/lib/api'
import { wikiKeys } from '@/lib/queryKeys'
import { cn } from '@/lib/utils'
import type { WikiPageDiff } from '@/types'
const WikiDiffPage: FC = () => {
const { message, label } = useUITranslation ('wiki')
const locale = useLocale ()
const { id } = useParams ()
const location = useLocation ()
const [diff, setDiff] = useState<WikiPageDiff | null> (null)
const query = new URLSearchParams (location.search)
const from = query.get ('from')
const to = query.get ('to')
const { data: diff } = useQuery ({
queryKey: wikiKeys.diff (id ?? '', { from, to }, locale),
queryFn: () => apiGet<WikiPageDiff> (
`/wiki/${ encodeURIComponent (id ?? '') }/diff`, { locale, params: { from, to } }) })
const displayTitle = diff
? `${ diff.title }${ diff.deprecatedAt != null ? '(廃止)' : '' }`
? `${ diff.title }${ diff.deprecatedAt != null
? message ('shared:labels.deprecatedSuffix').text : '' }`
: ''
useEffect (() => {
void (async () => {
setDiff (await apiGet<WikiPageDiff> (`/wiki/${ id }/diff`, { params: { from, to } }))
}) ()
}, [from, id, to])
return (
<MainArea>
<Helmet>
<title>{`Wiki 差分: ${ displayTitle } | ${ SITE_TITLE }`}</title>
</Helmet>
<PageHead title={message ('diffTitle', { title: displayTitle })}/>
<PageTitle>{displayTitle}</PageTitle>
<div className="prose mx-auto p-4">
{diff
@@ -47,7 +46,7 @@ const WikiDiffPage: FC = () => {
d.type === 'removed' && 'bg-red-200 dark:bg-red-800')}>
{d.content}
</p>)))
: 'Loading...'}
: label ('common:loading')}
</div>
</MainArea>)
}
+6 -4
ファイルの表示
@@ -2,6 +2,7 @@ import { fireEvent, screen, waitFor } from '@testing-library/react'
import { Route, Routes } from 'react-router-dom'
import { describe, expect, it, vi } from 'vitest'
import { textContentOf } from '@/test/text'
import WikiEditPage from '@/pages/wiki/WikiEditPage'
import { buildUser, buildWikiPage } from '@/test/factories'
import { renderWithProviders } from '@/test/render'
@@ -50,9 +51,9 @@ describe ('WikiEditPage', () => {
renderPage ()
await waitFor (() => {
expect (api.apiGet).toHaveBeenCalledWith ('/wiki/9')
expect (api.apiGet).toHaveBeenCalledWith ('/wiki/9', { locale: 'ja' })
})
expect (screen.getAllByRole ('textbox')[0]).toHaveValue ('既存')
expect ((await screen.findAllByRole ('textbox'))[0]).toHaveValue ('既存')
expect (screen.getByLabelText ('本文エディタ')).toHaveValue ('本文')
})
@@ -71,12 +72,13 @@ describe ('WikiEditPage', () => {
expect (api.apiPut).toHaveBeenCalledWith (
'/wiki/9',
expect.any (FormData),
{ headers: { 'Content-Type': 'multipart/form-data' } },
{ locale: 'ja', headers: { 'Content-Type': 'multipart/form-data' } },
)
})
const formData = api.apiPut.mock.calls[0]?.[1] as FormData
expect (formData.get ('title')).toBe ('更新済み')
expect (formData.get ('body')).toBe ('更新本文')
expect (toastApi.toast).toHaveBeenCalledWith ({ title: '投稿成功!' })
expect (toastApi.toast).toHaveBeenCalledTimes (1)
expect (textContentOf (toastApi.toast.mock.calls[0][0].title)).toBe ('投稿成功!')
})
})
+38 -32
ファイルの表示
@@ -1,15 +1,16 @@
import { useQueryClient } from '@tanstack/react-query'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import MarkdownIt from 'markdown-it'
import { useEffect, useState } from 'react'
import { Helmet } from 'react-helmet-async'
import MdEditor from 'react-markdown-editor-lite'
import { useParams, useNavigate } from 'react-router-dom'
import { useParams } from 'react-router-dom'
import { useLocale, useLocaleNavigate } from '@/lib/useLocale'
import { useUITranslation } from '@/lib/useUITranslation'
import FieldError from '@/components/common/FieldError'
import FormField from '@/components/common/FormField'
import MainArea from '@/components/layout/MainArea'
import { toast } from '@/components/ui/use-toast'
import { SITE_TITLE } from '@/config'
import PageHead from '@/components/common/PageHead'
import { apiGet, apiPut } from '@/lib/api'
import { wikiKeys } from '@/lib/queryKeys'
import { canEditContent } from '@/lib/users'
@@ -36,21 +37,31 @@ type WikiFormField = 'title' | 'body'
const WikiEditPage: FC<Props> = ({ user }) => {
const { message, label } = useUITranslation ('wiki')
const locale = useLocale ()
const editable = canEditContent (user)
const { id } = useParams ()
const navigate = useNavigate ()
const navigate = useLocaleNavigate ()
const qc = useQueryClient ()
const [body, setBody] = useState ('')
const [loading, setLoading] = useState (true)
const [title, setTitle] = useState ('')
const source = `${locale}:${id}`
const { data: wiki, isPending: loading } = useQuery ({
queryKey: wikiKeys.show (id ?? '', { }, locale),
queryFn: () => apiGet<WikiPage> (`/wiki/${ encodeURIComponent (id ?? '') }`, { locale }),
enabled: editable })
const [body, setBody] = useState (wiki?.body ?? '')
const [loadedSource, setLoadedSource] = useState (wiki == null ? '' : source)
const pending = loading || loadedSource !== source
const [title, setTitle] = useState (wiki?.title ?? '')
const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } =
useValidationErrors<WikiFormField> ()
const handleSubmit = async () => {
if (pending || !editable)
return
clearValidationErrors ()
const formData = new FormData ()
@@ -60,51 +71,46 @@ const WikiEditPage: FC<Props> = ({ user }) => {
try
{
await apiPut (`/wiki/${ id }`, formData,
{ headers: { 'Content-Type': 'multipart/form-data' } })
qc.setQueryData (wikiKeys.show (title, { }),
(prev: WikiPage) => ({ ...prev, title, body }))
{ locale, headers: { 'Content-Type': 'multipart/form-data' } })
qc.setQueryData (wikiKeys.show (title, { }, locale),
(prev: WikiPage | undefined) =>
prev == null ? prev : { ...prev, title, body })
qc.invalidateQueries ({ queryKey: wikiKeys.root })
toast ({ title: '投稿成功!' })
navigate (`/wiki/${ title }`)
toast ({ title: label ('shared:messages.postSuccess') })
navigate (`/wiki/${ encodeURIComponent (title) }`)
}
catch (e)
{
applyValidationError (e)
toast ({ title: '投稿失敗', description: '入力を確認してください。' })
toast ({ title: label ('shared:messages.postFailed'),
description: label ('shared:messages.checkInputModern') })
}
}
useEffect (() => {
if (!(editable))
if (!(editable) || wiki == null)
return
void (async () => {
setLoading (true)
const data = await apiGet<WikiPage> (`/wiki/${ id }`)
setTitle (data.title)
setBody (data.body)
setLoading (false)
}) ()
}, [editable, id])
setTitle (wiki.title)
setBody (wiki.body)
setLoadedSource (source)
}, [editable, source, wiki])
if (!(editable))
return <Forbidden/>
return (
<MainArea>
<Helmet>
<title>{`Wiki ページを編輯 | ${ SITE_TITLE }`}</title>
</Helmet>
<PageHead title={message ('editPage')}/>
<div className="max-w-xl mx-auto p-4 space-y-4">
<h1 className="text-2xl font-bold mb-2">Wiki ページを編輯</h1>
<h1 className="text-2xl font-bold mb-2">{label ('editPage')}</h1>
{loading ? 'Loading...' : (
{pending ? label ('common:loading') : (
<>
<FieldError messages={baseErrors}/>
{/* タイトル */}
{/* TODO: タグ補完 */}
<FormField label="タイトル" messages={fieldErrors.title}>
<FormField label={label ('shared:labels.title')} messages={fieldErrors.title}>
{({ describedBy, invalid }) => (
<input type="text"
value={title}
@@ -115,7 +121,7 @@ const WikiEditPage: FC<Props> = ({ user }) => {
</FormField>
{/* 本文 */}
<FormField label="本文" messages={fieldErrors.body}>
<FormField label={label ('shared:labels.body')} messages={fieldErrors.body}>
{() => (
<MdEditor value={body}
style={{ height: '500px' }}
@@ -127,7 +133,7 @@ const WikiEditPage: FC<Props> = ({ user }) => {
{/* 送信 */}
<button onClick={handleSubmit}
className="px-4 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400">
編輯
{label ('shared:actions.edit')}
</button>
</>)}
</div>
+1 -1
ファイルの表示
@@ -36,7 +36,7 @@ const renderWiki = (title: string) => {
<Location/>
<Routes>
<Route path="/wiki/:title" element={<WikiDetailPage/>}/>
<Route path="/help/:identifier" element={<HelpDetailPage/>}/>
<Route path="/ja/help/:identifier" element={<HelpDetailPage/>}/>
<Route path="/404" element={<div>unexpected redirect</div>}/>
</Routes>
</>, { route })
+1 -1
ファイルの表示
@@ -32,7 +32,7 @@ describe ('WikiHistoryPage', () => {
const link = await screen.findByRole ('link', { name: '旧タグ' })
const marker = screen.getByText ('(廃止)')
expect (link).toHaveAttribute ('href', '/wiki/%E6%97%A7%E3%82%BF%E3%82%B0?version=2')
expect (link).toHaveAttribute ('href', '/ja/wiki/3?version=2')
expect (marker.closest ('a')).toBeNull ()
})
})
+27 -26
ファイルの表示
@@ -1,47 +1,47 @@
import type { FC } from 'react'
import { useEffect, useState } from 'react'
import { Helmet } from 'react-helmet-async'
import { useQuery } from '@tanstack/react-query'
import { useLocation } from 'react-router-dom'
import { useLocale } from '@/lib/useLocale'
import { useUITranslation } from '@/lib/useUITranslation'
import PrefetchLink from '@/components/PrefetchLink'
import PageTitle from '@/components/common/PageTitle'
import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config'
import { apiGet } from '@/lib/api'
import { dateString } from '@/lib/utils'
import PageHead from '@/components/common/PageHead'
import { wikiKeys } from '@/lib/queryKeys'
import { localisedDateString } from '@/lib/utils'
import { fetchWikiChanges } from '@/lib/wiki'
import type { WikiPageChange } from '@/types'
import type { FC } from 'react'
const WikiHistoryPage: FC = () => {
const [changes, setChanges] = useState<WikiPageChange[]> ([])
const { message, label } = useUITranslation ('wiki')
const locale = useLocale ()
const location = useLocation ()
const query = new URLSearchParams (location.search)
const id = query.get ('id')
useEffect (() => {
void (async () => {
setChanges (await apiGet<WikiPageChange[]> ('/wiki/changes', { params: id ? { id } : { } }))
}) ()
}, [id, location.search])
const params = id ? { id } : { }
const { data: changes = [], isLoading, isError } = useQuery ({
queryKey: wikiKeys.changes (params, locale),
queryFn: () => fetchWikiChanges (params, locale) })
return (
<MainArea>
<Helmet>
<title>{`Wiki 変更履歴 | ${ SITE_TITLE }`}</title>
</Helmet>
<PageHead title={message ('history')}/>
<PageTitle>Wiki 履歴</PageTitle>
<PageTitle>{label ('historyHeading')}</PageTitle>
{isLoading && <p>{label ('common:loading')}</p>}
{isError && <p role="alert">{label ('common:fetchError')}</p>}
<table className="table-auto w-full border-collapse">
<thead className="border-b-2 border-black dark:border-white">
<tr>
<th></th>
<th className="p-2 text-left">タイトル</th>
<th className="p-2 text-left">変更</th>
<th className="p-2 text-left">日時</th>
<th className="p-2 text-left">{label ('shared:labels.title')}</th>
<th className="p-2 text-left">{label ('change')}</th>
<th className="p-2 text-left">{label ('dateTime')}</th>
</tr>
</thead>
<tbody>
@@ -51,25 +51,26 @@ const WikiHistoryPage: FC = () => {
{change.pred != null && (
<PrefetchLink
to={`/wiki/${ change.wikiPage.id }/diff?from=${ change.pred }&to=${ change.revisionId }`}>
差分
{label ('shared:actions.diff')}
</PrefetchLink>)}
</td>
<td className="p-2">
<PrefetchLink
to={`/wiki/${ encodeURIComponent (change.wikiPage.title) }?version=${ change.revisionId }`}>
to={`/wiki/${ change.wikiPage.id }?version=${ change.revisionId }`}>
{change.wikiPage.title}
</PrefetchLink>
{change.wikiPage.deprecatedAt != null && <span>(廃止)</span>}
{change.wikiPage.deprecatedAt != null && label ('shared:labels.deprecatedSuffix')}
</td>
<td className="p-2">
{change.pred == null ? '新規' : '更新'}
{change.pred == null
? label ('navigation:create') : label ('shared:actions.update')}
</td>
<td className="p-2">
<PrefetchLink to={`/users/${ change.user.id }`}>
{change.user.name}
</PrefetchLink>
<br/>
{dateString (change.timestamp)}
{localisedDateString (change.timestamp, locale)}
</td>
</tr>))}
</tbody>
+4 -2
ファイルの表示
@@ -1,6 +1,7 @@
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { textContentOf } from '@/test/text'
import WikiNewPage from '@/pages/wiki/WikiNewPage'
import { buildUser, buildWikiPage } from '@/test/factories'
import { renderWithProviders } from '@/test/render'
@@ -50,12 +51,13 @@ describe ('WikiNewPage', () => {
expect (api.apiPost).toHaveBeenCalledWith (
'/wiki',
expect.any (FormData),
{ headers: { 'Content-Type': 'multipart/form-data' } },
{ locale: 'ja', headers: { 'Content-Type': 'multipart/form-data' } },
)
})
const formData = api.apiPost.mock.calls[0]?.[1] as FormData
expect (formData.get ('title')).toBe ('下書き')
expect (formData.get ('body')).toBe ('本文')
expect (toastApi.toast).toHaveBeenCalledWith ({ title: '投稿成功!' })
expect (toastApi.toast).toHaveBeenCalledTimes (1)
expect (textContentOf (toastApi.toast.mock.calls[0][0].title)).toBe ('投稿成功!')
})
})
+17 -15
ファイルの表示
@@ -1,16 +1,17 @@
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 { useLocation } from 'react-router-dom'
import 'react-markdown-editor-lite/lib/index.css'
import { useLocale, useLocaleNavigate } from '@/lib/useLocale'
import { useUITranslation } from '@/lib/useUITranslation'
import FieldError from '@/components/common/FieldError'
import FormField from '@/components/common/FormField'
import MainArea from '@/components/layout/MainArea'
import { toast } from '@/components/ui/use-toast'
import { SITE_TITLE } from '@/config'
import PageHead from '@/components/common/PageHead'
import { apiPost } from '@/lib/api'
import { tagsKeys, wikiKeys } from '@/lib/queryKeys'
import { canEditContent } from '@/lib/users'
@@ -35,10 +36,12 @@ type WikiFormField = 'title' | 'body'
const WikiNewPage: FC<Props> = ({ user }) => {
const { message, label } = useUITranslation ('wiki')
const locale = useLocale ()
const editable = canEditContent (user)
const location = useLocation ()
const navigate = useNavigate ()
const navigate = useLocaleNavigate ()
const qc = useQueryClient ()
const query = new URLSearchParams (location.search)
@@ -59,19 +62,20 @@ const WikiNewPage: FC<Props> = ({ user }) => {
try
{
const data = await apiPost<WikiPage> ('/wiki', formData,
{ headers: { 'Content-Type': 'multipart/form-data' } })
{ locale, headers: { 'Content-Type': 'multipart/form-data' } })
await qc.cancelQueries ({ queryKey: wikiKeys.root })
qc.setQueryData (wikiKeys.show (data.title, { }), data)
qc.setQueryData (wikiKeys.show (data.title, { }, locale), data)
await Promise.all ([
qc.invalidateQueries ({ queryKey: wikiKeys.root }),
qc.invalidateQueries ({ queryKey: tagsKeys.root })])
toast ({ title: '投稿成功!' })
toast ({ title: label ('shared:messages.postSuccess') })
navigate (`/wiki/${ encodeURIComponent (data.title) }`)
}
catch (e)
{
applyValidationError (e)
toast ({ title: '投稿失敗', description: '入力を確認してください。' })
toast ({ title: label ('shared:messages.postFailed'),
description: label ('shared:messages.checkInputModern') })
}
}
@@ -80,16 +84,14 @@ const WikiNewPage: FC<Props> = ({ user }) => {
return (
<MainArea>
<Helmet>
<title>{`新規 Wiki ページ | ${ SITE_TITLE }`}</title>
</Helmet>
<PageHead title={message ('newPage')}/>
<div className="max-w-xl mx-auto p-4 space-y-4">
<h1 className="text-2xl font-bold mb-2">新規 Wiki ページ</h1>
<h1 className="text-2xl font-bold mb-2">{label ('newPage')}</h1>
<FieldError messages={baseErrors}/>
{/* タイトル */}
{/* TODO: タグ補完 */}
<FormField label="タイトル" messages={fieldErrors.title}>
<FormField label={label ('shared:labels.title')} messages={fieldErrors.title}>
{({ describedBy, invalid }) => (
<input type="text"
value={title}
@@ -100,7 +102,7 @@ const WikiNewPage: FC<Props> = ({ user }) => {
</FormField>
{/* 本文 */}
<FormField label="本文" messages={fieldErrors.body}>
<FormField label={label ('shared:labels.body')} messages={fieldErrors.body}>
{() => (
<MdEditor value={body}
style={{ height: '500px' }}
@@ -112,7 +114,7 @@ const WikiNewPage: FC<Props> = ({ user }) => {
{/* 送信 */}
<button onClick={handleSubmit}
className="px-4 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400">
追加
{label ('shared:actions.add')}
</button>
</div>
</MainArea>)
+4 -4
ファイルの表示
@@ -16,8 +16,8 @@ describe ('Wiki resource identity', () => {
vi.mocked (apiGet).mockResolvedValueOnce ([buildWikiPage ({ title: '虹夏' })])
renderWithProviders (<WikiSearchPage/>)
expect (await screen.findByRole ('link', { name: '虹夏' }))
.toHaveAttribute ('href', '/wiki/%E8%99%B9%E5%A4%8F')
expect (apiGet).toHaveBeenCalledWith ('/wiki', { params: { title: '' } })
.toHaveAttribute ('href', '/ja/wiki/%E8%99%B9%E5%A4%8F')
expect (apiGet).toHaveBeenCalledWith ('/wiki', { locale: 'ja', params: { title: '' } })
})
it ('searches by title on submit', async () => {
@@ -28,7 +28,7 @@ describe ('Wiki resource identity', () => {
fireEvent.change (screen.getAllByRole ('textbox')[0], { target: { value: '検索' } })
fireEvent.click (screen.getByRole ('button', { name: '検索' }))
await waitFor (() => expect (apiGet).toHaveBeenLastCalledWith (
'/wiki', { params: { title: '検索' } }))
'/wiki', { locale: 'ja', params: { title: '検索' } }))
expect (await screen.findByRole ('link', { name: '検索結果' })).toBeInTheDocument ()
})
@@ -49,7 +49,7 @@ describe ('Wiki resource identity', () => {
await screen.findByRole ('link', { name: '説明' })
expect (screen.getByRole ('link', { name: 'タググループ:一覧' }))
.toHaveAttribute ('href', `/wiki/${ encodeURIComponent (group.title) }`)
.toHaveAttribute ('href', `/ja/wiki/${ encodeURIComponent (group.title) }`)
await waitFor (() => {
expect (errors.mock.calls.some (args => args.some (value =>
String (value).includes ('same key')))).toBe (false)
+26 -22
ファイルの表示
@@ -1,12 +1,15 @@
import { useEffect, useState } from 'react'
import { Helmet } from 'react-helmet-async'
import { useQuery } from '@tanstack/react-query'
import { useLocale } from '@/lib/useLocale'
import { useUITranslation } from '@/lib/useUITranslation'
import PrefetchLink from '@/components/PrefetchLink'
import FormField from '@/components/common/FormField'
import PageTitle from '@/components/common/PageTitle'
import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config'
import PageHead from '@/components/common/PageHead'
import { apiGet } from '@/lib/api'
import { wikiKeys } from '@/lib/queryKeys'
import { dateString, inputClass } from '@/lib/utils'
import type { FormEvent , FC } from 'react'
@@ -15,36 +18,37 @@ import type { WikiPage } from '@/types'
const WikiSearchPage: FC = () => {
const { message, label } = useUITranslation ('shared')
const locale = useLocale ()
const [title, setTitle] = useState ('')
const [text, setText] = useState ('')
const [results, setResults] = useState<WikiPage[]> ([])
const search = async () => {
setResults (await apiGet ('/wiki', { params: { title } }))
}
const [submittedTitle, setSubmittedTitle] = useState ('')
const { data: results = [], refetch } = useQuery ({
queryKey: wikiKeys.index ({ title: submittedTitle }, locale),
queryFn: () => apiGet<WikiPage[]> ('/wiki', {
locale, params: { title: submittedTitle } }) })
const handleSearch = (ev: FormEvent) => {
ev.preventDefault ()
search ()
if (submittedTitle === title)
void refetch ()
else
setSubmittedTitle (title)
}
useEffect (() => {
void (async () => {
setResults (await apiGet ('/wiki', { params: { title: '' } }))
}) ()
}, [])
setSubmittedTitle ('')
}, [locale])
return (
<MainArea>
<Helmet>
<title>Wiki | {SITE_TITLE}</title>
</Helmet>
<PageHead title={message ('navigation:wiki')}/>
<div className="max-w-xl">
<PageTitle>Wiki</PageTitle>
<PageTitle>{label ('navigation:wiki')}</PageTitle>
<form onSubmit={handleSearch} className="space-y-2">
{/* タイトル */}
<FormField label="タイトル">
<FormField label={label ('labels.title')}>
{({ invalid }) => (
<input type="text"
value={title}
@@ -53,7 +57,7 @@ const WikiSearchPage: FC = () => {
</FormField>
{/* 内容 */}
<FormField label="内容">
<FormField label={label ('labels.content')}>
{({ invalid }) => (
<input type="text"
value={text}
@@ -65,7 +69,7 @@ const WikiSearchPage: FC = () => {
<div className="py-3">
<button type="submit"
className="bg-blue-500 text-white px-4 py-2 rounded">
検索
{label ('common:search')}
</button>
</div>
</form>
@@ -75,8 +79,8 @@ const WikiSearchPage: FC = () => {
<table className="table-auto w-full 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">{label ('labels.title')}</th>
<th className="p-2 text-left">{label ('labels.lastUpdated')}</th>
</tr>
</thead>
<tbody>
@@ -87,7 +91,7 @@ const WikiSearchPage: FC = () => {
<PrefetchLink to={`/wiki/${ encodeURIComponent (page.title) }`}>
{page.title}
</PrefetchLink>
{page.deprecatedAt != null && <span>(廃止)</span>}
{page.deprecatedAt != null && label ('labels.deprecatedSuffix')}
</td>
<td className="p-2">
{dateString (page.updatedAt)}