diff --git a/backend/app/models/post.rb b/backend/app/models/post.rb index c0cc34a..92b2d3e 100644 --- a/backend/app/models/post.rb +++ b/backend/app/models/post.rb @@ -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 diff --git a/backend/spec/models/post_spec.rb b/backend/spec/models/post_spec.rb index f1d1175..b82136e 100644 --- a/backend/spec/models/post_spec.rb +++ b/backend/spec/models/post_spec.rb @@ -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 diff --git a/frontend/src/components/posts/import/PostImportRowSummary.tsx b/frontend/src/components/posts/import/PostImportRowSummary.tsx index b68e270..6bb49b4 100644 --- a/frontend/src/components/posts/import/PostImportRowSummary.tsx +++ b/frontend/src/components/posts/import/PostImportRowSummary.tsx @@ -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 = ({ row, onEdit }) => { +const PostImportRowSummary: FC = ({ row, onEdit, editDisabled }) => { const warning = summaryWarning (row) const displayStatus = displayPostImportStatus (row) @@ -68,7 +69,7 @@ const PostImportRowSummary: FC = ({ row, onEdit }) => { type="button" variant="outline" onClick={onEdit} - disabled={row.importStatus === 'created'}> + disabled={row.importStatus === 'created' || editDisabled === true}> 編輯 @@ -109,7 +110,7 @@ const PostImportRowSummary: FC = ({ row, onEdit }) => { type="button" variant="outline" onClick={onEdit} - disabled={row.importStatus === 'created'}> + disabled={row.importStatus === 'created' || editDisabled === true}> 編輯 diff --git a/frontend/src/pages/posts/PostImportResultPage.test.tsx b/frontend/src/pages/posts/PostImportResultPage.test.tsx index 467de30..7004866 100644 --- a/frontend/src/pages/posts/PostImportResultPage.test.tsx +++ b/frontend/src/pages/posts/PostImportResultPage.test.tsx @@ -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 ( + + + + }/> + + + ) + + 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 () + }) }) diff --git a/frontend/src/pages/posts/PostImportResultPage.tsx b/frontend/src/pages/posts/PostImportResultPage.tsx index 8021ff7..bd85c12 100644 --- a/frontend/src/pages/posts/PostImportResultPage.tsx +++ b/frontend/src/pages/posts/PostImportResultPage.tsx @@ -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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 })) diff --git a/frontend/src/pages/posts/PostImportReviewPage.test.tsx b/frontend/src/pages/posts/PostImportReviewPage.test.tsx index bf9b899..85e59ea 100644 --- a/frontend/src/pages/posts/PostImportReviewPage.test.tsx +++ b/frontend/src/pages/posts/PostImportReviewPage.test.tsx @@ -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 ( + + + + }/> + + + ) + + 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 })] }) + }) }) diff --git a/frontend/src/pages/posts/PostImportReviewPage.tsx b/frontend/src/pages/posts/PostImportReviewPage.tsx index 930cdd1..9087cb0 100644 --- a/frontend/src/pages/posts/PostImportReviewPage.tsx +++ b/frontend/src/pages/posts/PostImportReviewPage.tsx @@ -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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ user }) => { importErrors: undefined } }) const nextSession = { - ...currentSession, + ...latestAfterImport, rows: nextRows, repairMode: resultRepairMode (nextRows) } sessionRef.current = nextSession @@ -304,6 +332,7 @@ const PostImportReviewPage: FC = ({ user }) => {
void editRow (row)}/>
))} @@ -344,7 +373,7 @@ const PostImportFooter = ( スキップ予定 {skipPlannedCount}件
-