広場投稿追加画面の刷新 (#399) #413
@@ -226,6 +226,7 @@ class Post < ApplicationRecord
|
||||
end
|
||||
unless minute_precision_time?(time)
|
||||
errors.add field, ORIGINAL_CREATED_MINUTE_PRECISION_MESSAGE
|
||||
return nil
|
||||
end
|
||||
time
|
||||
end
|
||||
|
||||
@@ -242,4 +242,61 @@ RSpec.describe Post, type: :model do
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'original created datetime validation' do
|
||||
it 'adds only the minute-precision error for second precision values' do
|
||||
post = described_class.new(
|
||||
title: 'title',
|
||||
url: 'https://example.com/post',
|
||||
original_created_from: '2024-01-01T12:34:30',
|
||||
original_created_before: '2024-01-01T12:35'
|
||||
)
|
||||
|
||||
expect(post).to be_invalid
|
||||
expect(post.errors[:original_created_from]).to eq(
|
||||
[described_class::ORIGINAL_CREATED_MINUTE_PRECISION_MESSAGE]
|
||||
)
|
||||
expect(post.errors[:original_created_at]).to be_empty
|
||||
end
|
||||
|
||||
it 'adds only the minute-precision error for fractional-second values' do
|
||||
post = described_class.new(
|
||||
title: 'title',
|
||||
url: 'https://example.com/post',
|
||||
original_created_from: '2024-01-01T12:34:00.123',
|
||||
original_created_before: '2024-01-01T12:35'
|
||||
)
|
||||
|
||||
expect(post).to be_invalid
|
||||
expect(post.errors[:original_created_from]).to eq(
|
||||
[described_class::ORIGINAL_CREATED_MINUTE_PRECISION_MESSAGE]
|
||||
)
|
||||
expect(post.errors[:original_created_at]).to be_empty
|
||||
end
|
||||
|
||||
it 'checks range rules only for valid minute-precision endpoints' do
|
||||
post = described_class.new(
|
||||
title: 'title',
|
||||
url: 'https://example.com/post',
|
||||
original_created_from: '2024-01-01T12:34',
|
||||
original_created_before: '2024-01-01T12:34'
|
||||
)
|
||||
|
||||
expect(post).to be_invalid
|
||||
expect(post.errors[:original_created_at]).to eq(
|
||||
[described_class::ORIGINAL_CREATED_ORDER_MESSAGE]
|
||||
)
|
||||
end
|
||||
|
||||
it 'accepts a one-minute range at minute precision' do
|
||||
post = described_class.new(
|
||||
title: 'title',
|
||||
url: 'https://example.com/post',
|
||||
original_created_from: '2024-01-01T12:34',
|
||||
original_created_before: '2024-01-01T12:35'
|
||||
)
|
||||
|
||||
expect(post).to be_valid
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -10,8 +10,9 @@ import type { FC } from 'react'
|
||||
import type { PostImportRow } from '@/lib/postImportSession'
|
||||
|
||||
type Props = {
|
||||
row: PostImportRow
|
||||
onEdit: () => void }
|
||||
row: PostImportRow
|
||||
onEdit: () => void
|
||||
editDisabled?: boolean }
|
||||
|
||||
const summaryWarning = (row: PostImportRow): string | null =>
|
||||
Object.values (row.fieldWarnings ?? { }).flat ()[0]
|
||||
@@ -24,7 +25,7 @@ const summaryDate = (row: PostImportRow): string =>
|
||||
row.attributes.originalCreatedBefore?.toString () ?? null)
|
||||
|
||||
|
||||
const PostImportRowSummary: FC<Props> = ({ row, onEdit }) => {
|
||||
const PostImportRowSummary: FC<Props> = ({ row, onEdit, editDisabled }) => {
|
||||
const warning = summaryWarning (row)
|
||||
const displayStatus = displayPostImportStatus (row)
|
||||
|
||||
@@ -68,7 +69,7 @@ const PostImportRowSummary: FC<Props> = ({ row, onEdit }) => {
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onEdit}
|
||||
disabled={row.importStatus === 'created'}>
|
||||
disabled={row.importStatus === 'created' || editDisabled === true}>
|
||||
編輯
|
||||
</Button>
|
||||
</div>
|
||||
@@ -109,7 +110,7 @@ const PostImportRowSummary: FC<Props> = ({ row, onEdit }) => {
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onEdit}
|
||||
disabled={row.importStatus === 'created'}>
|
||||
disabled={row.importStatus === 'created' || editDisabled === true}>
|
||||
編輯
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -67,4 +67,40 @@ describe ('PostImportResultPage', () => {
|
||||
expect (screen.getByText ('サムネール画像を取得できませんでした.'))
|
||||
.toBeInTheDocument ()
|
||||
})
|
||||
|
||||
it ('keeps recoverable pending rows actionable and hides actions for hard failures', () => {
|
||||
savePostImportSession ('result-pending', {
|
||||
source: '',
|
||||
repairMode: 'failed',
|
||||
rows: [
|
||||
buildPostImportRow ({
|
||||
sourceRow: 1,
|
||||
importStatus: 'pending',
|
||||
recoverable: true,
|
||||
validationErrors: { title: ['invalid'] } }),
|
||||
buildPostImportRow ({
|
||||
sourceRow: 2,
|
||||
importStatus: 'pending',
|
||||
recoverable: true }),
|
||||
buildPostImportRow ({
|
||||
sourceRow: 3,
|
||||
importStatus: 'failed',
|
||||
importErrors: { base: ['hard failed'] } })] })
|
||||
|
||||
render (
|
||||
<HelmetProvider>
|
||||
<MemoryRouter initialEntries={['/posts/import/result-pending/result']}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/posts/import/:sessionId/result"
|
||||
element={<PostImportResultPage user={buildUser ()}/>}/>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</HelmetProvider>)
|
||||
|
||||
expect (screen.getAllByRole ('button', { name: '編輯' })).toHaveLength (1)
|
||||
expect (screen.getAllByRole ('button', { name: '再試行' })).toHaveLength (1)
|
||||
expect (screen.getByText ('invalid')).toBeInTheDocument ()
|
||||
expect (screen.getByText ('hard failed')).toBeInTheDocument ()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -19,9 +19,8 @@ import { canEditContent } from '@/lib/users'
|
||||
import { clearPostImportSourceDraft,
|
||||
initialisePreviewRows,
|
||||
loadPostImportSession,
|
||||
mergeValidatedImportRow,
|
||||
mergeImportResults,
|
||||
mergeValidatedImportRows,
|
||||
mergeValidatedImportRow,
|
||||
replaceImportRow,
|
||||
resultRepairMode,
|
||||
resultRowMessages,
|
||||
@@ -127,10 +126,6 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
|
||||
() => resultSummaryCounts (session?.rows ?? []),
|
||||
[session])
|
||||
|
||||
const updateSessionRows = (nextRows: PostImportRow[]) =>
|
||||
setSession (current =>
|
||||
current != null ? { ...current, rows: nextRows } : current)
|
||||
|
||||
const saveDraft = async (
|
||||
row: PostImportRow,
|
||||
{ draft, resetRequested }: {
|
||||
@@ -177,9 +172,31 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
|
||||
changed_row: urlChanged ? baseRow.sourceRow : -1 })
|
||||
const validatedRows = initialisePreviewRows (validated.rows)
|
||||
const target = validatedRows.find (_1 => _1.sourceRow === baseRow.sourceRow)
|
||||
if (target != null && Object.keys (target.validationErrors).length > 0)
|
||||
return { saved: false, row: target }
|
||||
updateSessionRows (mergeValidatedImportRows (nextRows, validatedRows))
|
||||
const latestSession = sessionRef.current
|
||||
if (latestSession == null)
|
||||
return { saved: false, row: null }
|
||||
if (target == null)
|
||||
{
|
||||
const restoredRows = replaceImportRow (latestSession.rows, row)
|
||||
const restoredSession = {
|
||||
...latestSession,
|
||||
rows: restoredRows,
|
||||
repairMode: resultRepairMode (restoredRows) }
|
||||
sessionRef.current = restoredSession
|
||||
setSession (restoredSession)
|
||||
toast ({ title: '行の再検証結果が不完全でした' })
|
||||
return { saved: false, row: null }
|
||||
}
|
||||
const rows = mergeValidatedImportRow (latestSession.rows, target)
|
||||
const nextSession = {
|
||||
...latestSession,
|
||||
rows,
|
||||
repairMode: resultRepairMode (rows) }
|
||||
sessionRef.current = nextSession
|
||||
setSession (nextSession)
|
||||
const mergedTarget = rows.find (_1 => _1.sourceRow === baseRow.sourceRow) ?? target
|
||||
if (Object.keys (mergedTarget.validationErrors).length > 0)
|
||||
return { saved: false, row: mergedTarget }
|
||||
return { saved: true, row: null }
|
||||
}
|
||||
catch
|
||||
@@ -225,7 +242,7 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
|
||||
}
|
||||
|
||||
const retry = async (sourceRow: number) => {
|
||||
if (sessionId == null)
|
||||
if (session == null || sessionId == null)
|
||||
return
|
||||
|
||||
const initialSession = sessionRef.current
|
||||
@@ -259,7 +276,18 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
|
||||
const validatedTarget = initialisePreviewRows (validated.rows)
|
||||
.find (_1 => _1.sourceRow === sourceRow)
|
||||
if (validatedTarget == null)
|
||||
return
|
||||
{
|
||||
const latest = sessionRef.current ?? pendingSession
|
||||
const restoredRows = replaceImportRow (latest.rows, originalRow)
|
||||
const restoredSession = {
|
||||
...latest,
|
||||
rows: restoredRows,
|
||||
repairMode: resultRepairMode (restoredRows) }
|
||||
sessionRef.current = restoredSession
|
||||
setSession (restoredSession)
|
||||
toast ({ title: '再検証結果が不完全でした' })
|
||||
return
|
||||
}
|
||||
const latestAfterValidate = sessionRef.current ?? pendingSession
|
||||
const validatedRows = mergeValidatedImportRow (
|
||||
latestAfterValidate.rows,
|
||||
@@ -272,7 +300,17 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
|
||||
setSession (nextSession)
|
||||
const target = validatedRows.find (_1 => _1.sourceRow === sourceRow)
|
||||
if (target == null)
|
||||
return
|
||||
{
|
||||
const restoredRows = replaceImportRow (latestAfterValidate.rows, originalRow)
|
||||
const restoredSession = {
|
||||
...latestAfterValidate,
|
||||
rows: restoredRows,
|
||||
repairMode: resultRepairMode (restoredRows) }
|
||||
sessionRef.current = restoredSession
|
||||
setSession (restoredSession)
|
||||
toast ({ title: '再検証結果が不完全でした' })
|
||||
return
|
||||
}
|
||||
if (Object.keys (target.validationErrors ?? { }).length > 0)
|
||||
{
|
||||
const saved = savePostImportSession (sessionId, nextSession, message =>
|
||||
@@ -350,10 +388,11 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
|
||||
}
|
||||
|
||||
const openRepair = (sourceRow: number) => {
|
||||
if (session == null || sessionId == null)
|
||||
const currentSession = sessionRef.current
|
||||
if (currentSession == null || sessionId == null)
|
||||
return
|
||||
|
||||
const nextSession = { ...session, repairMode: 'failed' as const }
|
||||
const nextSession = { ...currentSession, repairMode: 'failed' as const }
|
||||
sessionRef.current = nextSession
|
||||
setSession (nextSession)
|
||||
const saved = savePostImportSession (sessionId, nextSession, message =>
|
||||
@@ -460,7 +499,11 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
const nextSession = { ...session, repairMode: 'all' as const }
|
||||
const currentSession = sessionRef.current
|
||||
if (currentSession == null)
|
||||
return
|
||||
const nextSession = { ...currentSession, repairMode: 'all' as const }
|
||||
sessionRef.current = nextSession
|
||||
setSession (nextSession)
|
||||
const saved = savePostImportSession (sessionId, nextSession, message =>
|
||||
toast ({ title: '取込状態を保存できませんでした', description: message }))
|
||||
|
||||
@@ -8,6 +8,8 @@ import PostImportReviewPage from '@/pages/posts/PostImportReviewPage'
|
||||
import { buildUser } from '@/test/factories'
|
||||
import { buildPostImportRow } from '@/test/postImportFactories'
|
||||
|
||||
import type { PostImportRow } from '@/lib/postImportSession'
|
||||
|
||||
const api = vi.hoisted (() => ({
|
||||
apiPost: vi.fn (),
|
||||
}))
|
||||
@@ -71,4 +73,37 @@ describe ('PostImportReviewPage', () => {
|
||||
})
|
||||
expect (dialogue.form).not.toHaveBeenCalled ()
|
||||
})
|
||||
|
||||
it ('disables row editing and navigation while batch import is running', async () => {
|
||||
let resolveValidation: ((value: { rows: PostImportRow[] }) => void) | null = null
|
||||
savePostImportSession ('review-loading', {
|
||||
source: 'https://example.com/post',
|
||||
repairMode: 'all',
|
||||
rows: [buildPostImportRow ({ sourceRow: 1 })] })
|
||||
api.apiPost.mockImplementationOnce (() =>
|
||||
new Promise<{ rows: PostImportRow[] }> (resolve => {
|
||||
resolveValidation = resolve
|
||||
}))
|
||||
|
||||
render (
|
||||
<HelmetProvider>
|
||||
<MemoryRouter initialEntries={['/posts/import/review-loading/review']}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/posts/import/:sessionId/review"
|
||||
element={<PostImportReviewPage user={buildUser ()}/>}/>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</HelmetProvider>)
|
||||
|
||||
fireEvent.click (screen.getByRole ('button', { name: '取込実行' }))
|
||||
|
||||
await waitFor (() => {
|
||||
expect (screen.getByRole ('button', { name: '編輯' })).toBeDisabled ()
|
||||
expect (screen.getByRole ('button', { name: 'URL リスト入力へ戻る' })).toBeDisabled ()
|
||||
expect (screen.getByRole ('button', { name: '取込実行' })).toBeDisabled ()
|
||||
})
|
||||
|
||||
resolveValidation?.({ rows: [buildPostImportRow ({ sourceRow: 1 })] })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -16,8 +16,10 @@ import { loadPostImportSession,
|
||||
creatableImportRows,
|
||||
initialisePreviewRows,
|
||||
mergeImportResults,
|
||||
mergeValidatedImportRow,
|
||||
mergeValidatedImportRows,
|
||||
processableImportRows,
|
||||
replaceImportRow,
|
||||
resultRepairMode,
|
||||
reviewSummaryCounts,
|
||||
savePostImportSession } from '@/lib/postImportSession'
|
||||
@@ -94,10 +96,6 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
||||
element?.scrollIntoView ({ block: 'center', behavior: 'smooth' })
|
||||
}, [editingRow, session?.repairMode])
|
||||
|
||||
const updateSessionRows = (nextRows: PostImportRow[]) =>
|
||||
setSession (current =>
|
||||
current != null ? { ...current, rows: nextRows } : current)
|
||||
|
||||
const saveDraft = async (
|
||||
row: PostImportRow,
|
||||
{ draft, resetRequested }: {
|
||||
@@ -143,10 +141,32 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
||||
metadataUrl: row.metadataUrl })),
|
||||
changed_row: urlChanged ? baseRow.sourceRow : -1 })
|
||||
const validatedRows = initialisePreviewRows (validated.rows)
|
||||
const target = validatedRows.find (row => row.sourceRow === baseRow.sourceRow)
|
||||
if (target != null && Object.keys (target.validationErrors).length > 0)
|
||||
return { saved: false, row: target }
|
||||
updateSessionRows (mergeValidatedImportRows (nextRows, validatedRows))
|
||||
const target = validatedRows.find (_1 => _1.sourceRow === baseRow.sourceRow)
|
||||
const latestSession = sessionRef.current
|
||||
if (latestSession == null)
|
||||
return { saved: false, row: null }
|
||||
if (target == null)
|
||||
{
|
||||
const restoredRows = replaceImportRow (latestSession.rows, row)
|
||||
const restoredSession = {
|
||||
...latestSession,
|
||||
rows: restoredRows,
|
||||
repairMode: resultRepairMode (restoredRows) }
|
||||
sessionRef.current = restoredSession
|
||||
setSession (restoredSession)
|
||||
toast ({ title: '行の再検証結果が不完全でした' })
|
||||
return { saved: false, row: null }
|
||||
}
|
||||
const rows = mergeValidatedImportRow (latestSession.rows, target)
|
||||
const nextSession = {
|
||||
...latestSession,
|
||||
rows,
|
||||
repairMode: resultRepairMode (rows) }
|
||||
sessionRef.current = nextSession
|
||||
setSession (nextSession)
|
||||
const mergedTarget = rows.find (_1 => _1.sourceRow === baseRow.sourceRow) ?? target
|
||||
if (Object.keys (mergedTarget.validationErrors).length > 0)
|
||||
return { saved: false, row: mergedTarget }
|
||||
return { saved: true, row: null }
|
||||
}
|
||||
catch
|
||||
@@ -211,11 +231,15 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
||||
metadataUrl: row.metadataUrl })),
|
||||
changed_row: -1 })
|
||||
const validatedRows = initialisePreviewRows (validated.rows)
|
||||
const mergedRows = mergeValidatedImportRows (currentSession.rows, validatedRows)
|
||||
const latestAfterValidate = sessionRef.current ?? currentSession
|
||||
const mergedRows = mergeValidatedImportRows (latestAfterValidate.rows, validatedRows)
|
||||
const firstInvalid = mergedRows.find (row => Object.keys (row.validationErrors).length > 0)
|
||||
if (firstInvalid != null)
|
||||
{
|
||||
const nextSession = { ...currentSession, rows: mergedRows }
|
||||
const nextSession = {
|
||||
...latestAfterValidate,
|
||||
rows: mergedRows,
|
||||
repairMode: resultRepairMode (mergedRows) }
|
||||
sessionRef.current = nextSession
|
||||
setSession (nextSession)
|
||||
void editRow (firstInvalid)
|
||||
@@ -234,7 +258,11 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
||||
provenance: row.provenance,
|
||||
tagSources: row.tagSources,
|
||||
metadataUrl: row.metadataUrl })) })
|
||||
const mergedResults = mergeImportResults (mergedRows, result.rows)
|
||||
const latestAfterImport = sessionRef.current ?? {
|
||||
...latestAfterValidate,
|
||||
rows: mergedRows,
|
||||
repairMode: resultRepairMode (mergedRows) }
|
||||
const mergedResults = mergeImportResults (latestAfterImport.rows, result.rows)
|
||||
const recoverableRows = result.rows.filter (row =>
|
||||
row.status === 'failed'
|
||||
&& row.recoverable
|
||||
@@ -251,7 +279,7 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
||||
importErrors: undefined }
|
||||
})
|
||||
const nextSession = {
|
||||
...currentSession,
|
||||
...latestAfterImport,
|
||||
rows: nextRows,
|
||||
repairMode: resultRepairMode (nextRows) }
|
||||
sessionRef.current = nextSession
|
||||
@@ -304,6 +332,7 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
||||
<div key={row.sourceRow} id={`post-import-row-${ row.sourceRow }`}>
|
||||
<PostImportRowSummary
|
||||
row={row}
|
||||
editDisabled={loading}
|
||||
onEdit={() => void editRow (row)}/>
|
||||
</div>))}
|
||||
</div>
|
||||
@@ -344,7 +373,7 @@ const PostImportFooter = (
|
||||
<span>スキップ予定 {skipPlannedCount}件</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<Button type="button" variant="outline" onClick={onBack}>
|
||||
<Button type="button" variant="outline" onClick={onBack} disabled={loading}>
|
||||
URL リスト入力へ戻る
|
||||
</Button>
|
||||
<Button
|
||||
|
||||
新しい課題から参照
ユーザをブロックする