このコミットが含まれているのは:
2026-07-16 23:12:14 +09:00
コミット 3f75994bd4
14個のファイルの変更592行の追加404行の削除
+84 -15
ファイルの表示
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { Helmet } from 'react-helmet-async'
import { useNavigate } from 'react-router-dom'
import { useLocation, useNavigate } from 'react-router-dom'
import PageTitle from '@/components/common/PageTitle'
import MainArea from '@/components/layout/MainArea'
@@ -11,9 +11,8 @@ import { toast } from '@/components/ui/use-toast'
import { SITE_TITLE } from '@/config'
import { apiPost } from '@/lib/api'
import useDialogue from '@/lib/dialogues/useDialogue'
import { clearPostImportSession,
clearPostImportSourceDraft,
loadPostImportSession,
import { parsePostNewState, serialisePostNewState } from '@/lib/postNewQueryState'
import { clearPostImportSourceDraft,
buildNextEditedRow,
canEditReviewRow,
canRetryResultRow,
@@ -31,7 +30,6 @@ import { clearPostImportSession,
resultRowMessages,
reviewSummaryCounts,
retryImportRow,
savePostImportSession,
validatableImportRows } from '@/lib/postImportSession'
import { canEditContent } from '@/lib/users'
import Forbidden from '@/pages/Forbidden'
@@ -56,6 +54,7 @@ const isRepairRow = (row: PostImportRow): boolean =>
const PostImportReviewPage: FC<Props> = ({ user }) => {
const editable = canEditContent (user)
const dialogue = useDialogue ()
const location = useLocation ()
const navigate = useNavigate ()
const [session, setSession] = useState<PostImportSession | null> (null)
@@ -64,17 +63,84 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
const [editingRow, setEditingRow] = useState<PostImportRow | null> (null)
const [showExistingRows, setShowExistingRows] = useState (false)
const sessionRef = useRef<PostImportSession | null> (null)
const persistedSearchRef = useRef<string | null> (null)
useEffect (() => {
const loaded = loadPostImportSession (message =>
toast ({ title: '取込状態を復元できませんでした', description: message }))
if (loaded == null)
if (sessionRef.current != null && persistedSearchRef.current === location.search)
return
const parsed = parsePostNewState (location.search)
if (parsed == null)
{
navigate ('/posts/new', { replace: true })
return
}
const loaded = {
version: 2,
savedAt: new Date ().toISOString (),
source: parsed.source,
rows: parsed.rows,
repairMode: parsed.repairMode }
persistedSearchRef.current = location.search
sessionRef.current = loaded
setSession (loaded)
}, [navigate])
const hydrate = async () => {
if (parsed.shortcut)
{
try
{
const preview = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/preview', {
source: parsed.source })
const rows = initialisePreviewRows (preview.rows)
const nextSession = {
...loaded,
source: parsed.source,
rows,
repairMode: resultRepairMode (rows) }
persistSession (nextSession)
}
catch
{
navigate ('/posts/new', { replace: true })
}
return
}
const requestedRows = validatableImportRows (parsed.rows)
if (requestedRows.length === 0)
return
try
{
const validated = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/validate', {
rows: requestedRows.map (row => ({
sourceRow: row.sourceRow,
url: row.url,
attributes: row.attributes,
provenance: row.provenance,
tagSources: row.tagSources,
metadataUrl: row.metadataUrl })),
changed_row: 'all' })
const validatedRows = initialisePreviewRows (validated.rows)
if (!(hasExactSourceRows (requestedRows.map (row => row.sourceRow), validatedRows)))
return
const latest = sessionRef.current ?? loaded
const rows = mergeValidatedImportRows (latest.rows, validatedRows)
const nextSession = {
...latest,
rows,
repairMode: resultRepairMode (rows) }
sessionRef.current = nextSession
setSession (nextSession)
}
catch
{
}
}
void hydrate ()
}, [location.search, navigate])
useEffect (() => {
sessionRef.current = session
@@ -96,15 +162,18 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
const busy = loading || loadingRow != null
const persistSession = (nextSession: PostImportSession) => {
sessionRef.current = nextSession
setSession (nextSession)
savePostImportSession (nextSession, message =>
toast ({ title: '取込状態を保存できませんでした', description: message }))
const source = nextSession.rows.map (row => row.url).join ('\n')
const persistedSession = {
...nextSession,
source }
const nextPath = serialisePostNewState (persistedSession.rows)
persistedSearchRef.current = nextPath.replace ('/posts/new', '')
sessionRef.current = persistedSession
setSession (persistedSession)
navigate (nextPath, { replace: true })
}
const finishImport = () => {
clearPostImportSession (message =>
toast ({ title: '取込状態を削除できませんでした', description: message }))
clearPostImportSourceDraft (message =>
toast ({ title: '入力内容を削除できませんでした', description: message }))
navigate ('/posts')
+6 -6
ファイルの表示
@@ -75,7 +75,7 @@ describe ('PostImportSourcePage', () => {
expect.stringMatching (/^post-import-session:/))
})
it ('stores a successful preview before navigating to the review route', async () => {
it ('navigates to /posts/new with query state after a successful preview', async () => {
api.apiPost.mockResolvedValue ({ rows: [buildPostImportRow ()] })
renderWithProviders (<PostImportSourcePage user={buildUser ()}/>)
@@ -84,11 +84,11 @@ describe ('PostImportSourcePage', () => {
fireEvent.click (screen.getByRole ('button', { name: '次へ' }))
await waitFor (() => {
expect (router.navigate).toHaveBeenCalledWith ('/posts/new/review')
expect (router.navigate).toHaveBeenCalledWith (
expect.stringMatching (/^\/posts\/new\?/))
})
const sessionKeys = Array.from ({ length: sessionStorage.length }, (_, index) =>
sessionStorage.key (index)).filter (
key => key?.startsWith ('post-import-session:'))
expect (sessionKeys).toEqual (['post-import-session:current'])
expect (Array.from ({ length: sessionStorage.length }, (_, index) =>
sessionStorage.key (index))).not.toContainEqual(
expect.stringMatching (/^post-import-session:/))
})
})
+4 -16
ファイルの表示
@@ -12,14 +12,12 @@ import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/use-toast'
import { SITE_TITLE } from '@/config'
import { apiPost, isApiError } from '@/lib/api'
import { serialisePostNewState } from '@/lib/postNewQueryState'
import { canEditContent } from '@/lib/users'
import { countImportSourceLines,
cleanupExpiredPostImportSessions,
clearPostImportSession,
initialisePreviewRows,
loadPostImportSession,
loadPostImportSourceDraft,
savePostImportSession,
savePostImportSourceDraft,
validateImportSource } from '@/lib/postImportSession'
import Forbidden from '@/pages/Forbidden'
@@ -68,8 +66,6 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
cleanupExpiredPostImportSessions (message =>
toast ({ title: '保存済みデータを整理できませんでした', description: message }))
const currentSession = loadPostImportSession (message =>
toast ({ title: '保存済み入力を復元できませんでした', description: message }))
const draft = loadPostImportSourceDraft (message =>
toast ({ title: '保存済み入力を復元できませんでした', description: message }))
@@ -77,7 +73,7 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
{
setSource (current =>
current === ''
? (currentSession?.source || draft.source)
? draft.source
: current)
}
}, [])
@@ -125,16 +121,8 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
setSourceIssues (urlIssues)
return
}
clearPostImportSession (message =>
toast ({ title: '取込状態を保存できませんでした', description: message }))
const saved = savePostImportSession (
{ source,
rows: initialisePreviewRows (data.rows),
repairMode: 'all' },
message => toast ({ title: '取込状態を保存できませんでした', description: message }))
if (!(saved))
return
navigate ('/posts/new/review')
const nextRows = initialisePreviewRows (data.rows)
navigate (serialisePostNewState (nextRows))
}
catch (requestError)
{
+22 -150
ファイルの表示
@@ -1,172 +1,44 @@
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { screen } from '@testing-library/react'
import { MemoryRouter, Route, Routes } from 'react-router-dom'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { toMinutePrecisionIsoUtc } from '@/components/common/DateTimeField'
import PostNewPage from '@/pages/posts/PostNewPage'
import { buildUser } from '@/test/factories'
import { renderWithProviders } from '@/test/render'
const api = vi.hoisted (() => ({
apiGet: vi.fn (),
apiPost: vi.fn (),
isApiError: vi.fn (),
}))
const toastApi = vi.hoisted (() => ({
toast: vi.fn (),
}))
apiPost: vi.fn (),
isApiError: vi.fn () }))
vi.mock ('@/lib/api', () => api)
vi.mock ('@/components/ui/use-toast', () => toastApi)
describe ('PostNewPage', () => {
beforeEach (() => {
vi.clearAllMocks ()
api.isApiError.mockReturnValue (false)
sessionStorage.clear ()
})
it ('blocks guests', () => {
renderWithProviders (<PostNewPage user={buildUser ({ role: 'guest' })}/>)
it ('shows the source page on /posts/new', () => {
renderWithProviders (
<MemoryRouter initialEntries={['/posts/new']}>
<Routes>
<Route path="/posts/new" element={<PostNewPage user={buildUser ()}/>}/>
</Routes>
</MemoryRouter>)
expect (screen.getByText ('403')).toBeInTheDocument ()
expect (screen.getByRole ('heading', { name: '広場に投稿を追加' })).toBeInTheDocument ()
})
it ('submits a new post with manual title and thumbnail fetch UI', async () => {
api.apiPost.mockResolvedValueOnce ({})
api.apiGet.mockResolvedValue ([])
it ('shows the review page when query state is present', () => {
renderWithProviders (
<MemoryRouter initialEntries={['/posts/new?url=https%3A%2F%2Fexample.com%2Fpost']}>
<Routes>
<Route path="/posts/new" element={<PostNewPage user={buildUser ()}/>}/>
</Routes>
</MemoryRouter>)
renderWithProviders (<PostNewPage user={buildUser ({ role: 'member' })}/>)
fireEvent.change (screen.getByLabelText ('URL'), {
target: { value: 'https://example.com/post' } })
fireEvent.change (screen.getByLabelText ('タイトル'), {
target: { value: '投稿タイトル' } })
fireEvent.change (screen.getByLabelText ('親投稿'), {
target: { value: '1 2' } })
fireEvent.change (screen.getByLabelText ('タグ'), {
target: { value: 'tag1 tag2' } })
fireEvent.click (screen.getByRole ('button', { name: '追加' }))
await waitFor (() => {
expect (api.apiPost).toHaveBeenCalledWith (
'/posts',
expect.any (FormData),
{ headers: { 'Content-Type': 'multipart/form-data' } },
)
})
const formData = api.apiPost.mock.calls[0]?.[1] as FormData
expect (formData.get ('url')).toBe ('https://example.com/post')
expect (formData.get ('title')).toBe ('投稿タイトル')
expect (formData.get ('parent_post_ids')).toBe ('1 2')
expect (formData.get ('tags')).toBe ('tag1 tag2')
expect (toastApi.toast).toHaveBeenCalledWith ({ title: '投稿成功!' })
expect (screen.getByRole ('heading', { name: '追加内容確認' })).toBeInTheDocument ()
expect (screen.queryByText ('広場に投稿を追加')).not.toBeInTheDocument ()
})
it ('shows the shared creation field order without duration or unrequested helper copy', () => {
api.apiGet.mockResolvedValue ([])
const { container } =
renderWithProviders (<PostNewPage user={buildUser ({ role: 'member' })}/>)
const labels = Array.from (container.querySelectorAll ('label'))
.map (node => node.textContent?.trim ())
expect (labels.slice (0, 6)).toEqual ([
'URL',
'サムネール',
'タイトル',
'オリジナルの作成日時',
'タグ',
'親投稿'])
expect (screen.queryByPlaceholderText ('例: 2 / 2.5 / 1:23')).not.toBeInTheDocument ()
expect (screen.queryByText ('必要なタイミングで URL から取得できます.'))
.not.toBeInTheDocument ()
expect (screen.getAllByRole ('button', { name: '取得' })).toHaveLength (2)
expect (screen.queryByRole ('button', { name: '変更をリセット' })).not.toBeInTheDocument ()
})
it ('shows 422 validation errors for post fields', async () => {
api.apiGet.mockResolvedValue ([])
api.isApiError.mockReturnValue (true)
api.apiPost.mockRejectedValueOnce ({
response: {
status: 422,
data: {
type: 'validation_error',
message: '入力内容を確認してください.',
errors: { tags: ['ニコニコ・タグは直接指定できません.'] },
base_errors: ['投稿内容を確認してください.'],
},
},
})
renderWithProviders (<PostNewPage user={buildUser ({ role: 'member' })}/>)
fireEvent.change (screen.getByLabelText ('URL'), {
target: { value: 'https://example.com/post' } })
fireEvent.change (screen.getByLabelText ('タイトル'), {
target: { value: '投稿タイトル' } })
fireEvent.change (screen.getByLabelText ('タグ'), {
target: { value: 'nico:nico_tag' } })
fireEvent.click (screen.getByRole ('button', { name: '追加' }))
expect (await screen.findByText ('投稿内容を確認してください.')).toBeInTheDocument ()
expect (screen.getByText ('ニコニコ・タグは直接指定できません.')).toBeInTheDocument ()
expect (screen.getByLabelText ('タグ')).toHaveAttribute ('aria-invalid', 'true')
})
it ('keeps shared tags and datetime submission values without showing duration', async () => {
api.apiPost.mockResolvedValueOnce ({})
api.apiGet.mockResolvedValue ([])
const { container } = renderWithProviders (
<PostNewPage user={buildUser ({ role: 'member' })}/>)
fireEvent.change (screen.getByLabelText ('URL'), {
target: { value: 'https://example.com/post' } })
fireEvent.change (screen.getByLabelText ('タイトル'), {
target: { value: '投稿タイトル' } })
fireEvent.change (screen.getByLabelText ('タグ'), {
target: { value: '動画 tag1 tag2' } })
const datetimeInputs = container.querySelectorAll ('input[type="datetime-local"]')
fireEvent.change (datetimeInputs[0] as HTMLInputElement, {
target: { value: '2024-01-01T12:34' } })
fireEvent.click (screen.getByRole ('button', { name: '追加' }))
await waitFor (() => expect (api.apiPost).toHaveBeenCalled ())
const formData = api.apiPost.mock.calls[0]?.[1] as FormData
expect (container.querySelector ('input[type="file"]')).not.toBeNull ()
expect (formData.get ('duration')).toBeNull ()
expect (formData.get ('tags')).toBe ('動画 tag1 tag2')
expect (formData.get ('original_created_from')).toBe (
toMinutePrecisionIsoUtc ('2024-01-01T12:34'))
})
it (
'shows deduplicated original-created endpoint errors on the shared datetime field',
async () => {
api.apiGet.mockResolvedValue ([])
api.isApiError.mockReturnValue (true)
api.apiPost.mockRejectedValueOnce ({
response: {
status: 422,
data: {
type: 'validation_error',
errors: {
original_created_at: ['日時を確認してください.'],
original_created_from: ['日時を確認してください.'],
original_created_before: ['終了を確認してください.'] },
},
},
})
renderWithProviders (<PostNewPage user={buildUser ({ role: 'member' })}/>)
fireEvent.click (screen.getByRole ('button', { name: '追加' }))
expect (await screen.findByText ('日時を確認してください.')).toBeInTheDocument ()
expect (screen.getByText ('終了を確認してください.')).toBeInTheDocument ()
expect (screen.getAllByText ('日時を確認してください.')).toHaveLength (1)
})
})
+12 -202
ファイルの表示
@@ -1,21 +1,9 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Helmet } from 'react-helmet-async'
import { useNavigate } from 'react-router-dom'
import { useMemo } from 'react'
import { useLocation } from 'react-router-dom'
import FieldError from '@/components/common/FieldError'
import Form from '@/components/common/Form'
import PageTitle from '@/components/common/PageTitle'
import MainArea from '@/components/layout/MainArea'
import PostCreationDataFields from '@/components/posts/PostCreationDataFields'
import PostTextField from '@/components/posts/PostTextField'
import PostThumbnailPreview from '@/components/posts/PostThumbnailPreview'
import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/use-toast'
import { SITE_TITLE } from '@/config'
import { apiGet, apiPost } from '@/lib/api'
import { canEditContent } from '@/lib/users'
import { useValidationErrors } from '@/lib/useValidationErrors'
import Forbidden from '@/pages/Forbidden'
import PostImportReviewPage from '@/pages/posts/PostImportReviewPage'
import PostImportSourcePage from '@/pages/posts/PostImportSourcePage'
import { hasPostNewReviewState } from '@/lib/postNewQueryState'
import type { FC } from 'react'
@@ -23,194 +11,16 @@ import type { User } from '@/types'
type Props = { user: User | null }
type PostFormField =
'url' | 'title' | 'tags' | 'parentPostIds'
| 'originalCreatedAt' | 'originalCreatedFrom'
| 'originalCreatedBefore' | 'thumbnail'
const PostNewPage: FC<Props> = ({ user }) => {
const editable = canEditContent (user)
const location = useLocation ()
const reviewMode = useMemo (
() => hasPostNewReviewState (location.search),
[location.search])
const navigate = useNavigate ()
const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } =
useValidationErrors<PostFormField> ()
const [originalCreatedBefore, setOriginalCreatedBefore] =
useState<string | null> (null)
const [originalCreatedFrom, setOriginalCreatedFrom] =
useState<string | null> (null)
const [parentPostIds, setParentPostIds] = useState ('')
const [tags, setTags] = useState ('')
const [thumbnailFile, setThumbnailFile] = useState<File | null> (null)
const [thumbnailLoading, setThumbnailLoading] = useState (false)
const [thumbnailPreview, setThumbnailPreview] = useState<string> ('')
const [title, setTitle] = useState ('')
const [titleLoading, setTitleLoading] = useState (false)
const [url, setURL] = useState ('')
const thumbnailPreviewRef = useRef ('')
const handleSubmit = async () => {
clearValidationErrors ()
const formData = new FormData
formData.append ('title', title)
formData.append ('url', url)
formData.append ('tags', tags)
formData.append ('parent_post_ids', parentPostIds)
if (thumbnailFile)
formData.append ('thumbnail', thumbnailFile)
if (originalCreatedFrom)
formData.append ('original_created_from', originalCreatedFrom)
if (originalCreatedBefore)
formData.append ('original_created_before', originalCreatedBefore)
try
{
await apiPost ('/posts', formData, { headers: { 'Content-Type': 'multipart/form-data' } })
toast ({ title: '投稿成功!' })
navigate ('/posts')
}
catch (e)
{
applyValidationError (e)
toast ({ title: '投稿失敗', description: '入力を確認してください.' })
}
}
const fetchTitle = useCallback (async () => {
setTitleLoading (true)
try
{
const data = await apiGet<{ title: string }> ('/preview/title', { params: { url } })
setTitle (data.title || '')
}
finally
{
setTitleLoading (false)
}
}, [url])
const fetchThumbnail = useCallback (async () => {
setThumbnailPreview ('')
setThumbnailFile (null)
setThumbnailLoading (true)
if (thumbnailPreviewRef.current)
URL.revokeObjectURL (thumbnailPreviewRef.current)
try
{
const data = await apiGet<Blob> ('/preview/thumbnail',
{ params: { url },
responseType: 'blob' })
const imageURL = URL.createObjectURL (data)
setThumbnailPreview (imageURL)
setThumbnailFile (new File ([data],
'thumbnail.png',
{ type: data.type || 'image/png' }))
}
finally
{
setThumbnailLoading (false)
}
}, [url])
useEffect (() => {
thumbnailPreviewRef.current = thumbnailPreview
}, [thumbnailPreview])
if (!(editable))
return <Forbidden/>
return (
<MainArea>
<Helmet>
<title>{`広場に投稿を追加 | ${ SITE_TITLE }`}</title>
</Helmet>
<Form>
<PageTitle>稿</PageTitle>
<FieldError messages={baseErrors}/>
<PostCreationDataFields
url={{
value: url,
onChange: setURL,
errors: fieldErrors.url,
type: 'url',
placeholder: '例:https://www.nicovideo.jp/watch/...' }}
thumbnailField={
<div className="space-y-2">
<PostTextField
label="サムネール"
value={thumbnailFile?.name ?? ''}
errors={fieldErrors.thumbnail}
disabled
onChange={() => {}}
after={
<Button
type="button"
variant="outline"
onClick={() => void fetchThumbnail ()}
disabled={!(url) || thumbnailLoading}>
</Button>}/>
<input
type="file"
accept="image/*"
onChange={e => {
const file = e.target.files?.[0]
if (file)
{
setThumbnailFile (file)
setThumbnailPreview (URL.createObjectURL (file))
}
}}/>
<PostThumbnailPreview
url={thumbnailPreview}
alt="preview"
className="h-28 w-28"/>
</div>}
core={{
title: {
value: title,
onChange: setTitle,
errors: fieldErrors.title,
disabled: titleLoading,
after: (
<Button
type="button"
variant="outline"
onClick={() => void fetchTitle ()}
disabled={!(url) || titleLoading}>
</Button>) },
originalCreated: {
originalCreatedFrom,
setOriginalCreatedFrom,
originalCreatedBefore,
setOriginalCreatedBefore,
errors: {
originalCreatedAt: fieldErrors.originalCreatedAt,
originalCreatedFrom: fieldErrors.originalCreatedFrom,
originalCreatedBefore: fieldErrors.originalCreatedBefore } },
tags: {
value: tags,
onChange: setTags,
errors: fieldErrors.tags },
parentPostIds: {
value: parentPostIds,
onChange: setParentPostIds,
errors: fieldErrors.parentPostIds } }}/>
{/* 送信 */}
<Button onClick={handleSubmit}
className="px-4 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400"
disabled={titleLoading || thumbnailLoading}>
</Button>
</Form>
</MainArea>)
return reviewMode
? <PostImportReviewPage user={user}/>
: <PostImportSourcePage user={user}/>
}
export default PostNewPage