このコミットが含まれているのは:
2026-07-16 20:05:09 +09:00
コミット 90d8d3ff08
27個のファイルの変更725行の追加257行の削除
+50 -1
ファイルの表示
@@ -10,6 +10,7 @@ import {
import PostImportResultPage from '@/pages/posts/PostImportResultPage'
import { buildUser } from '@/test/factories'
import { buildPostImportRow } from '@/test/postImportFactories'
import { originalCreatedAtString } from '@/lib/utils'
import type { PostImportRow } from '@/lib/postImportSession'
import type { DialogueFormAction, DialogueFormControls } from '@/lib/dialogues/useDialogue'
@@ -56,7 +57,10 @@ describe ('PostImportResultPage', () => {
importErrors: { base: ['hard failed'] } }),
buildPostImportRow ({
sourceRow: 3,
attributes: { title: 'created row' },
attributes: {
title: 'created row',
originalCreatedFrom: '2024-01-01T00:00:00Z',
originalCreatedBefore: '2024-01-02T00:00:00Z' },
importStatus: 'created',
createdPostId: 3,
fieldWarnings: {
@@ -77,6 +81,9 @@ describe ('PostImportResultPage', () => {
expect (screen.getAllByRole ('button', { name: '再試行' })).toHaveLength (1)
expect (screen.getByText ('サムネール画像を取得できませんでした.'))
.toBeInTheDocument ()
expect (screen.getByText (
originalCreatedAtString ('2024-01-01T00:00:00Z', '2024-01-02T00:00:00Z'),
)).toBeInTheDocument ()
})
it ('keeps recoverable pending rows actionable and hides actions for hard failures', () => {
@@ -317,4 +324,46 @@ describe ('PostImportResultPage', () => {
expect (saved?.rows[0]?.recoverable).toBe (true)
})
})
it ('restores the failed row when the retry import response is incomplete', async () => {
savePostImportSession ('result-retry-missing-row', {
source: '',
repairMode: 'failed',
rows: [buildPostImportRow ({
sourceRow: 1,
importStatus: 'failed',
recoverable: true,
importErrors: { base: ['failed'] } })] })
api.apiPost
.mockResolvedValueOnce ({
rows: [buildPostImportRow ({
sourceRow: 1,
importStatus: 'pending',
recoverable: true })] })
.mockResolvedValueOnce ({
created: 1,
skipped: 0,
failed: 0,
rows: [] })
render (
<HelmetProvider>
<MemoryRouter initialEntries={['/posts/import/result-retry-missing-row/result']}>
<Routes>
<Route
path="/posts/import/:sessionId/result"
element={<PostImportResultPage user={buildUser ()}/>}/>
</Routes>
</MemoryRouter>
</HelmetProvider>)
fireEvent.click (screen.getByRole ('button', { name: '再試行' }))
await waitFor (() => {
const saved = loadPostImportSession ('result-retry-missing-row')
expect (saved?.rows[0]?.importStatus).toBe ('failed')
expect (toastApi.toast).toHaveBeenCalledWith (
expect.objectContaining ({ title: '登録結果が不完全でした' }))
})
})
})
+53 -18
ファイルの表示
@@ -17,6 +17,9 @@ import { apiPost } from '@/lib/api'
import useDialogue from '@/lib/dialogues/useDialogue'
import { canEditContent } from '@/lib/users'
import { clearPostImportSourceDraft,
canEditResultRow,
canRetryResultRow,
hasExactSourceRows,
initialisePreviewRows,
loadPostImportSession,
mergeImportResults,
@@ -28,6 +31,7 @@ import { clearPostImportSourceDraft,
resultSummaryCounts,
retryImportRow,
savePostImportSession } from '@/lib/postImportSession'
import { originalCreatedAtString } from '@/lib/utils'
import Forbidden from '@/pages/Forbidden'
import type { FC } from 'react'
@@ -145,6 +149,8 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
const currentSession = sessionRef.current
if (currentSession == null)
return { saved: false, row: null }
if (!(canEditResultRow (row)))
return { saved: false, row: null }
const baseRow =
resetRequested
@@ -274,10 +280,9 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
sessionRef.current = pendingSession
setSession (pendingSession)
persistSession (pendingSession)
const requestedRows = pendingRows.filter (row => row.importStatus !== 'created')
const validated = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/validate', {
rows: pendingRows
.filter (row => row.importStatus !== 'created')
.map (row => ({
rows: requestedRows.map (row => ({
sourceRow: row.sourceRow,
url: row.url,
attributes: row.attributes,
@@ -285,8 +290,22 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
tagSources: row.tagSources,
metadataUrl: row.metadataUrl })),
changed_row: -1 })
const validatedTarget = initialisePreviewRows (validated.rows)
.find (_1 => _1.sourceRow === sourceRow)
const validatedRows = initialisePreviewRows (validated.rows)
if (!(hasExactSourceRows (requestedRows.map (row => row.sourceRow), validatedRows)))
{
const latest = sessionRef.current ?? pendingSession
const restoredRows = replaceImportRow (latest.rows, originalRow)
const restoredSession = {
...latest,
rows: restoredRows,
repairMode: resultRepairMode (restoredRows) }
sessionRef.current = restoredSession
setSession (restoredSession)
persistSession (restoredSession)
toast ({ title: '再検証結果が不完全でした' })
return
}
const validatedTarget = validatedRows.find (_1 => _1.sourceRow === sourceRow)
if (validatedTarget == null)
{
const latest = sessionRef.current ?? pendingSession
@@ -302,17 +321,17 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
return
}
const latestAfterValidate = sessionRef.current ?? pendingSession
const validatedRows = mergeValidatedImportRow (
const mergedValidatedRows = mergeValidatedImportRow (
latestAfterValidate.rows,
validatedTarget)
const nextSession = {
...latestAfterValidate,
rows: validatedRows,
repairMode: resultRepairMode (validatedRows) }
rows: mergedValidatedRows,
repairMode: resultRepairMode (mergedValidatedRows) }
sessionRef.current = nextSession
setSession (nextSession)
persistSession (nextSession)
const target = validatedRows.find (_1 => _1.sourceRow === sourceRow)
const target = mergedValidatedRows.find (_1 => _1.sourceRow === sourceRow)
if (target == null)
{
const restoredRows = replaceImportRow (latestAfterValidate.rows, originalRow)
@@ -347,6 +366,20 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
provenance: target.provenance,
tagSources: target.tagSources,
metadataUrl: target.metadataUrl }] })
if (!(hasExactSourceRows ([sourceRow], result.rows)))
{
const latest = sessionRef.current ?? nextSession
const restoredRows = replaceImportRow (latest.rows, originalRow)
const restoredSession = {
...latest,
rows: restoredRows,
repairMode: resultRepairMode (restoredRows) }
sessionRef.current = restoredSession
setSession (restoredSession)
persistSession (restoredSession)
toast ({ title: '登録結果が不完全でした' })
return
}
const latestAfterImport = sessionRef.current ?? nextSession
const mergedRows = mergeImportResults (latestAfterImport.rows, result.rows)
const recoverableRows = result.rows.filter (row =>
@@ -454,15 +487,8 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
<div className="space-y-3">
{session.rows.map (row => {
const displayStatus = displayPostImportStatus (row)
const hasValidationErrors = Object.keys (row.validationErrors).length > 0
const canEdit =
row.recoverable === true
&& (row.importStatus === 'failed'
|| (row.importStatus === 'pending' && hasValidationErrors))
const canRetry =
row.recoverable === true
&& (row.importStatus === 'failed' || row.importStatus === 'pending')
&& !(hasValidationErrors)
const canEdit = canEditResultRow (row)
const canRetry = canRetryResultRow (row)
return (
<div
key={row.sourceRow}
@@ -480,6 +506,15 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
<div className="text-xs text-neutral-500 dark:text-neutral-400">
{row.url}
</div>
<div className="text-xs text-neutral-500 dark:text-neutral-400">
{String (row.attributes.tags ?? '') || 'タグなし'}
</div>
<div className="text-xs text-neutral-500 dark:text-neutral-400">
{originalCreatedAtString (
row.attributes.originalCreatedFrom?.toString () ?? null,
row.attributes.originalCreatedBefore?.toString () ?? null)}
{row.attributes.duration ? ` / ${ row.attributes.duration }` : ''}
</div>
<FieldWarning messages={resultRowWarnings (row)}/>
<FieldError messages={resultRowMessages (row)}/>
</div>
+37
ファイルの表示
@@ -141,4 +141,41 @@ describe ('PostImportReviewPage', () => {
})
expect (api.apiPost).toHaveBeenCalledTimes (1)
})
it ('does not navigate to result when import omits a requested source row', async () => {
savePostImportSession ('review-missing-import-row', {
source: 'https://example.com/post',
repairMode: 'all',
rows: [buildPostImportRow ({ sourceRow: 1 })] })
api.apiPost
.mockResolvedValueOnce ({
rows: [buildPostImportRow ({ sourceRow: 1 })] })
.mockResolvedValueOnce ({
created: 1,
skipped: 0,
failed: 0,
rows: [] })
render (
<HelmetProvider>
<MemoryRouter initialEntries={['/posts/import/review-missing-import-row/review']}>
<Routes>
<Route
path="/posts/import/:sessionId/review"
element={<PostImportReviewPage user={buildUser ()}/>}/>
<Route
path="/posts/import/:sessionId/result"
element={<div>RESULT ROUTE</div>}/>
</Routes>
</MemoryRouter>
</HelmetProvider>)
fireEvent.click (screen.getByRole ('button', { name: '取込実行' }))
await waitFor (() => {
expect (toastApi.toast).toHaveBeenCalledWith (
expect.objectContaining ({ title: '登録結果が不完全でした' }))
})
expect (screen.queryByText ('RESULT ROUTE')).not.toBeInTheDocument ()
})
})
+22 -6
ファイルの表示
@@ -13,7 +13,9 @@ import { apiPost } from '@/lib/api'
import useDialogue from '@/lib/dialogues/useDialogue'
import { canEditContent } from '@/lib/users'
import { loadPostImportSession,
canEditReviewRow,
creatableImportRows,
hasExactSourceRows,
initialisePreviewRows,
mergeImportResults,
mergeValidatedImportRow,
@@ -106,6 +108,8 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
const currentSession = sessionRef.current
if (currentSession == null)
return { saved: false, row: null }
if (!(canEditReviewRow (row)))
return { saved: false, row: null }
const baseRow =
resetRequested
@@ -234,8 +238,7 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
const expectedSourceRows = currentSession.rows
.filter (row => row.importStatus !== 'created')
.map (row => row.sourceRow)
const validatedSourceRows = new Set (validatedRows.map (_1 => _1.sourceRow))
if (expectedSourceRows.some (_1 => !(validatedSourceRows.has (_1))))
if (!(hasExactSourceRows (expectedSourceRows, validatedRows)))
{
toast ({ title: '再検証結果が不完全でした' })
return
@@ -254,6 +257,16 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
void editRow (firstInvalid)
return
}
const validatedSession = {
...latestAfterValidate,
rows: mergedRows,
repairMode: resultRepairMode (mergedRows) }
sessionRef.current = validatedSession
setSession (validatedSession)
const savedValidated = savePostImportSession (sessionId, validatedSession, message =>
toast ({ title: '取込状態を保存できませんでした', description: message }))
if (!(savedValidated))
return
const result = await apiPost<{
created: number
@@ -267,10 +280,13 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
provenance: row.provenance,
tagSources: row.tagSources,
metadataUrl: row.metadataUrl })) })
const latestAfterImport = sessionRef.current ?? {
...latestAfterValidate,
rows: mergedRows,
repairMode: resultRepairMode (mergedRows) }
const expectedImportRows = processableImportRows (mergedRows).map (row => row.sourceRow)
if (!(hasExactSourceRows (expectedImportRows, result.rows)))
{
toast ({ title: '登録結果が不完全でした' })
return
}
const latestAfterImport = sessionRef.current ?? validatedSession
const mergedResults = mergeImportResults (latestAfterImport.rows, result.rows)
const recoverableRows = result.rows.filter (row =>
row.status === 'failed'
+24 -8
ファイルの表示
@@ -1,3 +1,5 @@
import { readFileSync } from 'node:fs'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -39,8 +41,8 @@ describe ('PostNewPage', () => {
const textboxes = screen.getAllByRole ('textbox')
fireEvent.change (textboxes[0], { target: { value: 'https://example.com/post' } })
fireEvent.change (textboxes[1], { target: { value: '投稿タイトル' } })
fireEvent.change (textboxes[2], { target: { value: '1 2' } })
fireEvent.change (textboxes[3], { target: { value: 'tag1 tag2' } })
fireEvent.change (textboxes[3], { target: { value: '1 2' } })
fireEvent.change (textboxes[4], { target: { value: 'tag1 tag2' } })
fireEvent.click (screen.getByRole ('button', { name: '追加' }))
await waitFor (() => {
@@ -63,17 +65,19 @@ describe ('PostNewPage', () => {
renderWithProviders (<PostNewPage user={buildUser ({ role: 'member' })}/>)
const tags = screen.getAllByRole ('textbox')[3]
const tags = screen.getAllByRole ('textbox')[4]
fireEvent.change (tags, { target: { value: '動画' } })
fireEvent.change (screen.getByRole ('spinbutton'), { target: { value: '180.5' } })
fireEvent.change (
screen.getByPlaceholderText ('例: 2 / 2.5 / 1:23'),
{ target: { value: '180.5' } })
fireEvent.change (tags, { target: { value: 'general-tag' } })
expect (screen.queryByRole ('spinbutton')).not.toBeInTheDocument ()
expect (screen.queryByPlaceholderText ('例: 2 / 2.5 / 1:23')).not.toBeInTheDocument ()
fireEvent.change (tags, {
target: { value: '動画 general-tag' },
})
expect (screen.getByRole ('spinbutton')).toHaveValue (180.5)
expect (screen.getByPlaceholderText ('例: 2 / 2.5 / 1:23')).toHaveValue ('180.5')
})
it ('shows 422 validation errors for post fields', async () => {
@@ -96,11 +100,23 @@ describe ('PostNewPage', () => {
const textboxes = screen.getAllByRole ('textbox')
fireEvent.change (textboxes[0], { target: { value: 'https://example.com/post' } })
fireEvent.change (textboxes[1], { target: { value: '投稿タイトル' } })
fireEvent.change (textboxes[3], { target: { value: 'nico:nico_tag' } })
fireEvent.change (textboxes[4], { target: { value: 'nico:nico_tag' } })
fireEvent.click (screen.getByRole ('button', { name: '追加' }))
expect (await screen.findByText ('投稿内容を確認してください.')).toBeInTheDocument ()
expect (screen.getByText ('ニコニコ・タグは直接指定できません.')).toBeInTheDocument ()
expect (screen.getAllByRole ('textbox')[3]).toHaveAttribute ('aria-invalid', 'true')
expect (screen.getAllByRole ('textbox')[4]).toHaveAttribute ('aria-invalid', 'true')
})
it ('shares the common post field components with the import form', () => {
const newPage = readFileSync ('src/pages/posts/PostNewPage.tsx', 'utf8')
const importForm = readFileSync ('src/components/posts/import/PostImportRowForm.tsx', 'utf8')
expect (newPage).toContain ("@/components/posts/PostTextField")
expect (newPage).toContain ("@/components/posts/PostDurationField")
expect (newPage).toContain ("@/components/posts/PostTagsField")
expect (importForm).toContain ("@/components/posts/PostTextField")
expect (importForm).toContain ("@/components/posts/PostDurationField")
expect (importForm).toContain ("@/components/posts/PostTagsField")
})
})
+66 -89
ファイルの表示
@@ -2,19 +2,20 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Helmet } from 'react-helmet-async'
import { useNavigate } from 'react-router-dom'
import PostFormTagsArea from '@/components/PostFormTagsArea'
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
import FieldError from '@/components/common/FieldError'
import Form from '@/components/common/Form'
import FormField from '@/components/common/FormField'
import PageTitle from '@/components/common/PageTitle'
import MainArea from '@/components/layout/MainArea'
import PostDurationField from '@/components/posts/PostDurationField'
import PostTagsField from '@/components/posts/PostTagsField'
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 { inputClass } from '@/lib/utils'
import { useValidationErrors } from '@/lib/useValidationErrors'
import Forbidden from '@/pages/Forbidden'
@@ -138,30 +139,22 @@ const PostNewPage: FC<Props> = ({ user }) => {
<PageTitle>稿</PageTitle>
<FieldError messages={baseErrors}/>
{/* URL */}
<FormField label="URL" messages={fieldErrors.url}>
{({ describedBy, invalid }) => (
<input type="url"
placeholder="例:https://www.nicovideo.jp/watch/..."
value={url}
onChange={e => setURL (e.target.value)}
aria-describedby={describedBy}
aria-invalid={invalid}
className={inputClass (invalid)}/>)}
</FormField>
<PostTextField
label="URL"
type="url"
value={url}
errors={fieldErrors.url}
placeholder="例:https://www.nicovideo.jp/watch/..."
onChange={setURL}/>
{/* タイトル */}
<FormField label="タイトル" messages={fieldErrors.title}>
{({ describedBy, invalid }) => (
<div className="space-y-2">
<input type="text"
aria-describedby={describedBy}
aria-invalid={invalid}
className={inputClass (invalid)}
value={title}
placeholder={titleLoading ? 'Loading...' : ''}
onChange={ev => setTitle (ev.target.value)}
disabled={titleLoading}/>
<PostTextField
label="タイトル"
value={title}
errors={fieldErrors.title}
disabled={titleLoading}
placeholder={titleLoading ? 'Loading...' : undefined}
onChange={setTitle}
after={
<div className="flex flex-wrap items-center gap-2 text-sm">
<span> URL </span>
<Button
@@ -171,59 +164,51 @@ const PostNewPage: FC<Props> = ({ user }) => {
disabled={!(url) || titleLoading}>
</Button>
</div>
</div>)}
</FormField>
</div>}/>
{/* サムネール */}
<FormField label="サムネール" messages={fieldErrors.thumbnail}>
{({ describedBy, invalid }) => (
<>
<div className="mb-2 flex flex-wrap items-center gap-2 text-sm">
<span> URL </span>
<Button
type="button"
variant="outline"
onClick={() => void fetchThumbnail ()}
disabled={!(url) || thumbnailLoading}>
</Button>
</div>
{thumbnailLoading && (
<p className="text-gray-500 text-sm">Loading...</p>)}
<input type="file"
accept="image/*"
aria-describedby={describedBy}
aria-invalid={invalid}
onChange={e => {
const file = e.target.files?.[0]
if (file)
{
setThumbnailFile (file)
setThumbnailPreview (URL.createObjectURL (file))
}
}}/>
{thumbnailPreview && (
<img src={thumbnailPreview}
alt="preview"
className="mt-2 max-h-48 rounded border"/>)}
</>)}
</FormField>
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2 text-sm">
<span> URL </span>
<Button
type="button"
variant="outline"
onClick={() => void fetchThumbnail ()}
disabled={!(url) || thumbnailLoading}>
</Button>
</div>
{thumbnailLoading && (
<p className="text-gray-500 text-sm">Loading...</p>)}
<PostTextField
label="サムネール"
value={thumbnailFile?.name ?? ''}
errors={fieldErrors.thumbnail}
disabled
onChange={() => {}}/>
<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>
{/* 親投稿 */}
<FormField label="親投稿" messages={fieldErrors.parentPostIds}>
{({ describedBy, invalid }) => (
<input
type="text"
value={parentPostIds}
onChange={e => setParentPostIds (e.target.value)}
aria-describedby={describedBy}
aria-invalid={invalid}
className={inputClass (invalid)}/>)}
</FormField>
<PostTextField
label="親投稿"
value={parentPostIds}
errors={fieldErrors.parentPostIds}
onChange={setParentPostIds}/>
{/* タグ */}
<PostFormTagsArea tags={tags} setTags={setTags} errors={fieldErrors.tags}/>
<PostTagsField tags={tags} setTags={setTags} errors={fieldErrors.tags}/>
{/* オリジナルの作成日時 */}
<PostOriginalCreatedTimeField
@@ -233,19 +218,11 @@ const PostNewPage: FC<Props> = ({ user }) => {
setOriginalCreatedBefore={setOriginalCreatedBefore}
errors={fieldErrors.originalCreatedAt}/>
{/* 動画時間 */}
{(videoFlg &&
<FormField label="動画時間" messages={fieldErrors.videoMs}>
{({ invalid }) => (
<input
type="number"
min="0.001"
step="0.001"
value={duration}
onChange={e => setDuration (e.target.value)}
aria-invalid={invalid}
className={inputClass (invalid)}/>)}
</FormField>)}
{videoFlg && (
<PostDurationField
value={duration}
errors={fieldErrors.videoMs}
onChange={setDuration}/>)}
{/* 送信 */}
<Button onClick={handleSubmit}