このコミットが含まれているのは:
2026-07-16 19:18:35 +09:00
コミット 6f3ccb0ffa
7個のファイルの変更235行の追加33行の削除
+1
ファイルの表示
@@ -226,6 +226,7 @@ class Post < ApplicationRecord
end end
unless minute_precision_time?(time) unless minute_precision_time?(time)
errors.add field, ORIGINAL_CREATED_MINUTE_PRECISION_MESSAGE errors.add field, ORIGINAL_CREATED_MINUTE_PRECISION_MESSAGE
return nil
end end
time time
end end
+57
ファイルの表示
@@ -242,4 +242,61 @@ RSpec.describe Post, type: :model do
end end
end 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 end
+5 -4
ファイルの表示
@@ -11,7 +11,8 @@ import type { PostImportRow } from '@/lib/postImportSession'
type Props = { type Props = {
row: PostImportRow row: PostImportRow
onEdit: () => void } onEdit: () => void
editDisabled?: boolean }
const summaryWarning = (row: PostImportRow): string | null => const summaryWarning = (row: PostImportRow): string | null =>
Object.values (row.fieldWarnings ?? { }).flat ()[0] Object.values (row.fieldWarnings ?? { }).flat ()[0]
@@ -24,7 +25,7 @@ const summaryDate = (row: PostImportRow): string =>
row.attributes.originalCreatedBefore?.toString () ?? null) row.attributes.originalCreatedBefore?.toString () ?? null)
const PostImportRowSummary: FC<Props> = ({ row, onEdit }) => { const PostImportRowSummary: FC<Props> = ({ row, onEdit, editDisabled }) => {
const warning = summaryWarning (row) const warning = summaryWarning (row)
const displayStatus = displayPostImportStatus (row) const displayStatus = displayPostImportStatus (row)
@@ -68,7 +69,7 @@ const PostImportRowSummary: FC<Props> = ({ row, onEdit }) => {
type="button" type="button"
variant="outline" variant="outline"
onClick={onEdit} onClick={onEdit}
disabled={row.importStatus === 'created'}> disabled={row.importStatus === 'created' || editDisabled === true}>
</Button> </Button>
</div> </div>
@@ -109,7 +110,7 @@ const PostImportRowSummary: FC<Props> = ({ row, onEdit }) => {
type="button" type="button"
variant="outline" variant="outline"
onClick={onEdit} onClick={onEdit}
disabled={row.importStatus === 'created'}> disabled={row.importStatus === 'created' || editDisabled === true}>
</Button> </Button>
</div> </div>
+36
ファイルの表示
@@ -67,4 +67,40 @@ describe ('PostImportResultPage', () => {
expect (screen.getByText ('サムネール画像を取得できませんでした.')) expect (screen.getByText ('サムネール画像を取得できませんでした.'))
.toBeInTheDocument () .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 ()
})
}) })
+56 -13
ファイルの表示
@@ -19,9 +19,8 @@ import { canEditContent } from '@/lib/users'
import { clearPostImportSourceDraft, import { clearPostImportSourceDraft,
initialisePreviewRows, initialisePreviewRows,
loadPostImportSession, loadPostImportSession,
mergeValidatedImportRow,
mergeImportResults, mergeImportResults,
mergeValidatedImportRows, mergeValidatedImportRow,
replaceImportRow, replaceImportRow,
resultRepairMode, resultRepairMode,
resultRowMessages, resultRowMessages,
@@ -127,10 +126,6 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
() => resultSummaryCounts (session?.rows ?? []), () => resultSummaryCounts (session?.rows ?? []),
[session]) [session])
const updateSessionRows = (nextRows: PostImportRow[]) =>
setSession (current =>
current != null ? { ...current, rows: nextRows } : current)
const saveDraft = async ( const saveDraft = async (
row: PostImportRow, row: PostImportRow,
{ draft, resetRequested }: { { draft, resetRequested }: {
@@ -177,9 +172,31 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
changed_row: urlChanged ? baseRow.sourceRow : -1 }) changed_row: urlChanged ? baseRow.sourceRow : -1 })
const validatedRows = initialisePreviewRows (validated.rows) const validatedRows = initialisePreviewRows (validated.rows)
const target = validatedRows.find (_1 => _1.sourceRow === baseRow.sourceRow) const target = validatedRows.find (_1 => _1.sourceRow === baseRow.sourceRow)
if (target != null && Object.keys (target.validationErrors).length > 0) const latestSession = sessionRef.current
return { saved: false, row: target } if (latestSession == null)
updateSessionRows (mergeValidatedImportRows (nextRows, validatedRows)) 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 } return { saved: true, row: null }
} }
catch catch
@@ -225,7 +242,7 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
} }
const retry = async (sourceRow: number) => { const retry = async (sourceRow: number) => {
if (sessionId == null) if (session == null || sessionId == null)
return return
const initialSession = sessionRef.current const initialSession = sessionRef.current
@@ -259,7 +276,18 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
const validatedTarget = initialisePreviewRows (validated.rows) const validatedTarget = initialisePreviewRows (validated.rows)
.find (_1 => _1.sourceRow === sourceRow) .find (_1 => _1.sourceRow === sourceRow)
if (validatedTarget == null) if (validatedTarget == null)
{
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 return
}
const latestAfterValidate = sessionRef.current ?? pendingSession const latestAfterValidate = sessionRef.current ?? pendingSession
const validatedRows = mergeValidatedImportRow ( const validatedRows = mergeValidatedImportRow (
latestAfterValidate.rows, latestAfterValidate.rows,
@@ -272,7 +300,17 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
setSession (nextSession) setSession (nextSession)
const target = validatedRows.find (_1 => _1.sourceRow === sourceRow) const target = validatedRows.find (_1 => _1.sourceRow === sourceRow)
if (target == null) if (target == null)
{
const restoredRows = replaceImportRow (latestAfterValidate.rows, originalRow)
const restoredSession = {
...latestAfterValidate,
rows: restoredRows,
repairMode: resultRepairMode (restoredRows) }
sessionRef.current = restoredSession
setSession (restoredSession)
toast ({ title: '再検証結果が不完全でした' })
return return
}
if (Object.keys (target.validationErrors ?? { }).length > 0) if (Object.keys (target.validationErrors ?? { }).length > 0)
{ {
const saved = savePostImportSession (sessionId, nextSession, message => const saved = savePostImportSession (sessionId, nextSession, message =>
@@ -350,10 +388,11 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
} }
const openRepair = (sourceRow: number) => { const openRepair = (sourceRow: number) => {
if (session == null || sessionId == null) const currentSession = sessionRef.current
if (currentSession == null || sessionId == null)
return return
const nextSession = { ...session, repairMode: 'failed' as const } const nextSession = { ...currentSession, repairMode: 'failed' as const }
sessionRef.current = nextSession sessionRef.current = nextSession
setSession (nextSession) setSession (nextSession)
const saved = savePostImportSession (sessionId, nextSession, message => const saved = savePostImportSession (sessionId, nextSession, message =>
@@ -460,7 +499,11 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
type="button" type="button"
variant="outline" variant="outline"
onClick={() => { 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) setSession (nextSession)
const saved = savePostImportSession (sessionId, nextSession, message => const saved = savePostImportSession (sessionId, nextSession, message =>
toast ({ title: '取込状態を保存できませんでした', description: message })) toast ({ title: '取込状態を保存できませんでした', description: message }))
+35
ファイルの表示
@@ -8,6 +8,8 @@ import PostImportReviewPage from '@/pages/posts/PostImportReviewPage'
import { buildUser } from '@/test/factories' import { buildUser } from '@/test/factories'
import { buildPostImportRow } from '@/test/postImportFactories' import { buildPostImportRow } from '@/test/postImportFactories'
import type { PostImportRow } from '@/lib/postImportSession'
const api = vi.hoisted (() => ({ const api = vi.hoisted (() => ({
apiPost: vi.fn (), apiPost: vi.fn (),
})) }))
@@ -71,4 +73,37 @@ describe ('PostImportReviewPage', () => {
}) })
expect (dialogue.form).not.toHaveBeenCalled () 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 })] })
})
}) })
+42 -13
ファイルの表示
@@ -16,8 +16,10 @@ import { loadPostImportSession,
creatableImportRows, creatableImportRows,
initialisePreviewRows, initialisePreviewRows,
mergeImportResults, mergeImportResults,
mergeValidatedImportRow,
mergeValidatedImportRows, mergeValidatedImportRows,
processableImportRows, processableImportRows,
replaceImportRow,
resultRepairMode, resultRepairMode,
reviewSummaryCounts, reviewSummaryCounts,
savePostImportSession } from '@/lib/postImportSession' savePostImportSession } from '@/lib/postImportSession'
@@ -94,10 +96,6 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
element?.scrollIntoView ({ block: 'center', behavior: 'smooth' }) element?.scrollIntoView ({ block: 'center', behavior: 'smooth' })
}, [editingRow, session?.repairMode]) }, [editingRow, session?.repairMode])
const updateSessionRows = (nextRows: PostImportRow[]) =>
setSession (current =>
current != null ? { ...current, rows: nextRows } : current)
const saveDraft = async ( const saveDraft = async (
row: PostImportRow, row: PostImportRow,
{ draft, resetRequested }: { { draft, resetRequested }: {
@@ -143,10 +141,32 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
metadataUrl: row.metadataUrl })), metadataUrl: row.metadataUrl })),
changed_row: urlChanged ? baseRow.sourceRow : -1 }) changed_row: urlChanged ? baseRow.sourceRow : -1 })
const validatedRows = initialisePreviewRows (validated.rows) const validatedRows = initialisePreviewRows (validated.rows)
const target = validatedRows.find (row => row.sourceRow === baseRow.sourceRow) const target = validatedRows.find (_1 => _1.sourceRow === baseRow.sourceRow)
if (target != null && Object.keys (target.validationErrors).length > 0) const latestSession = sessionRef.current
return { saved: false, row: target } if (latestSession == null)
updateSessionRows (mergeValidatedImportRows (nextRows, validatedRows)) 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 } return { saved: true, row: null }
} }
catch catch
@@ -211,11 +231,15 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
metadataUrl: row.metadataUrl })), metadataUrl: row.metadataUrl })),
changed_row: -1 }) changed_row: -1 })
const validatedRows = initialisePreviewRows (validated.rows) 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) const firstInvalid = mergedRows.find (row => Object.keys (row.validationErrors).length > 0)
if (firstInvalid != null) if (firstInvalid != null)
{ {
const nextSession = { ...currentSession, rows: mergedRows } const nextSession = {
...latestAfterValidate,
rows: mergedRows,
repairMode: resultRepairMode (mergedRows) }
sessionRef.current = nextSession sessionRef.current = nextSession
setSession (nextSession) setSession (nextSession)
void editRow (firstInvalid) void editRow (firstInvalid)
@@ -234,7 +258,11 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
provenance: row.provenance, provenance: row.provenance,
tagSources: row.tagSources, tagSources: row.tagSources,
metadataUrl: row.metadataUrl })) }) 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 => const recoverableRows = result.rows.filter (row =>
row.status === 'failed' row.status === 'failed'
&& row.recoverable && row.recoverable
@@ -251,7 +279,7 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
importErrors: undefined } importErrors: undefined }
}) })
const nextSession = { const nextSession = {
...currentSession, ...latestAfterImport,
rows: nextRows, rows: nextRows,
repairMode: resultRepairMode (nextRows) } repairMode: resultRepairMode (nextRows) }
sessionRef.current = nextSession sessionRef.current = nextSession
@@ -304,6 +332,7 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
<div key={row.sourceRow} id={`post-import-row-${ row.sourceRow }`}> <div key={row.sourceRow} id={`post-import-row-${ row.sourceRow }`}>
<PostImportRowSummary <PostImportRowSummary
row={row} row={row}
editDisabled={loading}
onEdit={() => void editRow (row)}/> onEdit={() => void editRow (row)}/>
</div>))} </div>))}
</div> </div>
@@ -344,7 +373,7 @@ const PostImportFooter = (
<span> {skipPlannedCount}</span> <span> {skipPlannedCount}</span>
</div> </div>
<div className="flex flex-col gap-2 sm:flex-row"> <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 URL
</Button> </Button>
<Button <Button