diff --git a/backend/app/services/post_import_previewer.rb b/backend/app/services/post_import_previewer.rb index 1df1306..7837896 100644 --- a/backend/app/services/post_import_previewer.rb +++ b/backend/app/services/post_import_previewer.rb @@ -13,7 +13,7 @@ class PostImportPreviewer FETCH_WARNING_FIELDS = ['url', 'title', 'thumbnail_base'].freeze TITLE_FETCH_WARNING = 'タイトルを取得できませんでした.'.freeze THUMBNAIL_FETCH_WARNING = 'サムネールを取得できませんでした.'.freeze - METADATA_FETCH_WARNING = 'メタデータを取得できませんでした.'.freeze + METADATA_FETCH_WARNING = '自動取得に失敗しました.'.freeze def preview_rows rows:, fetch_metadata: true, metadata_cache: { } prepared_rows = rows.map { prepare_row(_1) } @@ -151,7 +151,7 @@ class PostImportPreviewer when true then true when Integer then fetch_metadata == source_row when false, nil then false - else raise ArgumentError, 'メタデータ取得対象が不正です.' + else raise ArgumentError, '取得対象が不正です.' end end diff --git a/frontend/src/components/posts/import/PostImportRowSummary.tsx b/frontend/src/components/posts/import/PostImportRowSummary.tsx index e51bb54..c729a6a 100644 --- a/frontend/src/components/posts/import/PostImportRowSummary.tsx +++ b/frontend/src/components/posts/import/PostImportRowSummary.tsx @@ -11,12 +11,16 @@ import type { FC } from 'react' import type { PostImportRow } from '@/lib/postImportSession' type Props = { - row: PostImportRow - onEdit: () => void - onRetry?: () => void - rowMessages?: string[] - editDisabled?: boolean - retryDisabled?: boolean } + row: PostImportRow + onEdit?: () => void + onRetry?: () => void + onToggleSkip?: (checked: boolean) => void + rowMessages?: string[] + editDisabled?: boolean + retryDisabled?: boolean + skipDisabled?: boolean + showActions?: boolean + showSkipToggle?: boolean } const summaryWarning = (row: PostImportRow): string | null => Object.values (row.fieldWarnings ?? { }).flat ()[0] @@ -33,12 +37,30 @@ const PostImportRowSummary: FC = ( { row, onEdit, onRetry, + onToggleSkip, rowMessages, editDisabled, - retryDisabled }, + retryDisabled, + skipDisabled, + showActions = true, + showSkipToggle = false }, ) => { const warning = summaryWarning (row) const displayStatus = displayPostImportStatus (row) + const editAllowed = onEdit != null && canEditReviewRow (row) + const retryAllowed = onRetry != null && canRetryResultRow (row) + const skipChecked = row.skipReason === 'manual' + const skipControl = showSkipToggle + ? ( + ) + : null return ( <> @@ -76,20 +98,22 @@ const PostImportRowSummary: FC = ( {displayStatus != null && }
-
- - {onRetry != null && ( +
+ {skipControl} + {showActions && editAllowed && ( + )} + {showActions && retryAllowed && ( )}
@@ -125,25 +149,28 @@ const PostImportRowSummary: FC = ( {warning}
)} + {skipControl}
-
- - {onRetry != null && ( - )} -
+ {showActions && (editAllowed || retryAllowed) && ( +
+ {editAllowed && ( + )} + {retryAllowed && ( + )} +
)} ) } diff --git a/frontend/src/components/posts/import/postImportRowStatus.test.ts b/frontend/src/components/posts/import/postImportRowStatus.test.ts index 30e78fa..7ea08bf 100644 --- a/frontend/src/components/posts/import/postImportRowStatus.test.ts +++ b/frontend/src/components/posts/import/postImportRowStatus.test.ts @@ -12,6 +12,8 @@ describe ('displayPostImportStatus', () => { expect (displayPostImportStatus (buildPostImportRow ({ skipReason: 'existing', existingPostId: 2 }))).toBe ('skipped') + expect (displayPostImportStatus (buildPostImportRow ({ + skipReason: 'manual' }))).toBe ('skipped') }) it ('does not expose validation, failure, or created states as badges', () => { diff --git a/frontend/src/components/posts/import/postImportRowStatus.ts b/frontend/src/components/posts/import/postImportRowStatus.ts index 123ab23..7979aa4 100644 --- a/frontend/src/components/posts/import/postImportRowStatus.ts +++ b/frontend/src/components/posts/import/postImportRowStatus.ts @@ -11,7 +11,7 @@ const hasWarnings = (row: PostImportRow): boolean => export const displayPostImportStatus = ( row: PostImportRow, ): PostImportDisplayStatus | null => - (row.skipReason === 'existing' || row.importStatus === 'skipped') + (row.skipReason != null || row.importStatus === 'skipped') ? 'skipped' : ((Object.keys (row.validationErrors ?? { }).length > 0 || row.importStatus === 'failed' diff --git a/frontend/src/lib/postImportRows.test.ts b/frontend/src/lib/postImportRows.test.ts index 101718c..2847475 100644 --- a/frontend/src/lib/postImportRows.test.ts +++ b/frontend/src/lib/postImportRows.test.ts @@ -26,22 +26,26 @@ describe ('post import row state', () => { sourceRow: 2, skipReason: 'existing', existingPostId: 20 }) - const invalid = buildPostImportRow ({ + const manual = buildPostImportRow ({ sourceRow: 3, + skipReason: 'manual' }) + const invalid = buildPostImportRow ({ + sourceRow: 4, status: 'error', validationErrors: { url: ['invalid'] } }) const created = buildPostImportRow ({ - sourceRow: 4, + sourceRow: 5, importStatus: 'created', createdPostId: 40 }) - const rows = [ready, existing, invalid, created] + const rows = [ready, existing, manual, invalid, created] - expect (processableImportRows (rows)).toEqual ([ready, existing]) + expect (processableImportRows (rows)).toEqual ([ready]) expect (creatableImportRows (rows)).toEqual ([ready]) expect (reviewSummaryCounts (rows)).toEqual ({ - total: 4, - submittable: 1, - skipPlanned: 1 }) + creatable: 1, + manualSkipped: 1, + existingSkipped: 1, + pendingOrError: 1 }) }) it ('preserves terminal rows while merging validation results', () => { diff --git a/frontend/src/lib/postImportRows.ts b/frontend/src/lib/postImportRows.ts index 7b2989c..25e0c58 100644 --- a/frontend/src/lib/postImportRows.ts +++ b/frontend/src/lib/postImportRows.ts @@ -2,9 +2,17 @@ import type { PostImportEditableDraft, PostImportResultRow, PostImportRow } from '@/lib/postImportTypes' -const hasSkipReason = (row: PostImportRow): boolean => +export const isExistingSkipRow = (row: PostImportRow): boolean => row.skipReason === 'existing' + +export const isManualSkipRow = (row: PostImportRow): boolean => + row.skipReason === 'manual' + + +const hasSkipReason = (row: PostImportRow): boolean => + row.skipReason != null + const hasValidationErrors = (row: PostImportRow): boolean => Object.keys (row.validationErrors ?? { }).length > 0 @@ -15,6 +23,26 @@ const isRepairableImportStatus = (row: PostImportRow): boolean => isRecoverableRow (row) && (row.importStatus === 'failed' || row.importStatus === 'pending') + +export const isNonRecoverableFailedRow = (row: PostImportRow): boolean => + row.importStatus === 'failed' && row.recoverable !== true + + +export const isTerminalRow = (row: PostImportRow): boolean => + row.importStatus === 'created' + || row.importStatus === 'skipped' + || isNonRecoverableFailedRow (row) + + +export const isCompletedReviewRow = (row: PostImportRow): boolean => + row.importStatus === 'created' + || row.importStatus === 'skipped' + || hasSkipReason (row) + + +export const validatableImportRows = (rows: PostImportRow[]): PostImportRow[] => + rows.filter (row => !(hasSkipReason (row)) && !(isTerminalRow (row))) + const buildResetSnapshot = (row: PostImportRow) => ({ url: row.url, attributes: { ...row.attributes }, @@ -29,7 +57,7 @@ const buildResetSnapshot = (row: PostImportRow) => ({ export const processableImportRows = (rows: PostImportRow[]): PostImportRow[] => - rows.filter (row => { + validatableImportRows (rows).filter (row => { if (row.importStatus === 'created') return false if (row.importStatus === 'skipped') @@ -46,13 +74,12 @@ export const creatableImportRows = (rows: PostImportRow[]): PostImportRow[] => export const reviewSummaryCounts = (rows: PostImportRow[]) => ({ - total: rows.length, - submittable: rows.filter (row => - creatableImportRows ([row]).length > 0 - && !(hasValidationErrors (row))).length, - skipPlanned: rows.filter (row => - processableImportRows ([row]).length > 0 - && hasSkipReason (row)).length }) + creatable: rows.filter (row => creatableImportRows ([row]).length > 0).length, + manualSkipped: rows.filter (row => isManualSkipRow (row)).length, + existingSkipped: rows.filter (row => isExistingSkipRow (row)).length, + pendingOrError: rows.filter (row => + !(isCompletedReviewRow (row)) + && creatableImportRows ([row]).length === 0).length }) export const resultSummaryCounts = (rows: PostImportRow[]) => @@ -85,19 +112,22 @@ export const resultRepairMode = ( export const canEditReviewRow = (row: PostImportRow): boolean => - !(row.importStatus === 'created' + !(hasSkipReason (row) + || row.importStatus === 'created' || row.importStatus === 'skipped' || (row.importStatus === 'failed' && row.recoverable !== true)) export const canEditResultRow = (row: PostImportRow): boolean => - row.recoverable === true + row.skipReason == null + && row.recoverable === true && (row.importStatus === 'failed' || (row.importStatus === 'pending' && hasValidationErrors (row))) export const canRetryResultRow = (row: PostImportRow): boolean => - row.recoverable === true + row.skipReason == null + && row.recoverable === true && (row.importStatus === 'failed' || (row.importStatus === 'pending' && !(hasValidationErrors (row)))) diff --git a/frontend/src/lib/postImportStorage.test.ts b/frontend/src/lib/postImportStorage.test.ts index acd4aea..91f7baa 100644 --- a/frontend/src/lib/postImportStorage.test.ts +++ b/frontend/src/lib/postImportStorage.test.ts @@ -24,10 +24,13 @@ describe ('post import storage', () => { importStatus: 'skipped', skipReason: 'existing', existingPostId: 10 }) + const manual = buildPostImportRow ({ + sourceRow: 3, + skipReason: 'manual' }) expect (savePostImportSession ({ source: skipped.url, - rows: [row, skipped], + rows: [row, skipped, manual], repairMode: 'all' })).toBe (true) expect (loadPostImportSession ()).toMatchObject ({ version: 2, @@ -39,7 +42,9 @@ describe ('post import storage', () => { { importStatus: 'skipped', skipReason: 'existing', - existingPostId: 10 }] }) + existingPostId: 10 }, + { + skipReason: 'manual' }] }) expect (savePostImportSourceDraft ('https://example.com')).toBe (true) expect (loadPostImportSourceDraft ()).toEqual ({ source: 'https://example.com' }) diff --git a/frontend/src/lib/postImportStorage.ts b/frontend/src/lib/postImportStorage.ts index e7c55d6..00e3c89 100644 --- a/frontend/src/lib/postImportStorage.ts +++ b/frontend/src/lib/postImportStorage.ts @@ -129,7 +129,7 @@ const isValidOrigin = ( const isValidSkipReason = ( value: unknown, ): value is PostImportSkipReason => - value === 'existing' + value === 'existing' || value === 'manual' const isPositiveInteger = (value: unknown): value is number => Number.isInteger (value) && Number (value) > 0 diff --git a/frontend/src/lib/postImportTypes.ts b/frontend/src/lib/postImportTypes.ts index e2e9918..632a945 100644 --- a/frontend/src/lib/postImportTypes.ts +++ b/frontend/src/lib/postImportTypes.ts @@ -5,7 +5,7 @@ export type PostImportStatus = | 'created' | 'skipped' | 'failed' -export type PostImportSkipReason = 'existing' +export type PostImportSkipReason = 'existing' | 'manual' export type PostImportResultStatus = 'created' | 'skipped' | 'failed' export type PostImportAttributeValue = string | number diff --git a/frontend/src/pages/posts/PostImportReviewPage.test.tsx b/frontend/src/pages/posts/PostImportReviewPage.test.tsx index efbc89d..0ad9670 100644 --- a/frontend/src/pages/posts/PostImportReviewPage.test.tsx +++ b/frontend/src/pages/posts/PostImportReviewPage.test.tsx @@ -71,11 +71,25 @@ describe ('PostImportReviewPage', () => { renderReviewPage () - expect (screen.getByRole ('heading', { name: '広場に投稿を追加' })).toBeInTheDocument () + expect (screen.getByRole ('heading', { name: '追加内容確認' })).toBeInTheDocument () + expect (screen.queryByText ('広場に投稿を追加')).not.toBeInTheDocument () expect (screen.queryByText ('投稿インポート')).not.toBeInTheDocument () expect (await screen.findByText ('restored row')).toBeInTheDocument () }) + it ('does not expose メタデータ to the user', async () => { + savePostImportSession ({ + source: 'https://example.com/post', + repairMode: 'all', + rows: [buildPostImportRow ({ + sourceRow: 1, + fieldWarnings: { title: ['自動取得に失敗しました.'] } })] }) + + renderReviewPage () + + expect (screen.queryByText (/メタデータ/)).not.toBeInTheDocument () + }) + it ('returns to /posts/new while keeping the current work', async () => { savePostImportSession ({ source: 'https://example.com/post', @@ -309,6 +323,69 @@ describe ('PostImportReviewPage', () => { }) }) + it ('allows ready rows to be manually skipped and excludes them from API payloads', async () => { + savePostImportSession ({ + source: 'https://example.com/post', + repairMode: 'all', + rows: [buildPostImportRow ({ sourceRow: 1 })] }) + + renderReviewPage () + + fireEvent.click (screen.getByRole ('checkbox', { name: 'スキップ' })) + + await waitFor (() => { + expect (screen.getByText ('POSTS ROUTE')).toBeInTheDocument () + }) + expect (api.apiPost).not.toHaveBeenCalled () + }) + + it ('restores values and errors when manual skip is cleared', async () => { + savePostImportSession ({ + source: 'https://example.com/post', + repairMode: 'all', + rows: [buildPostImportRow ({ + sourceRow: 1, + status: 'error', + attributes: { title: 'kept title' }, + validationErrors: { title: ['invalid'] } })] }) + + renderReviewPage () + + const toggle = await screen.findByRole ('checkbox', { name: 'スキップ' }) + fireEvent.click (toggle) + fireEvent.click (screen.getByRole ('checkbox', { name: 'スキップ' })) + + expect (screen.getByText ('kept title')).toBeInTheDocument () + expect (screen.getByText ('invalid')).toBeInTheDocument () + }) + + it ('moves existing skipped rows into the collapsed section', async () => { + savePostImportSession ({ + source: 'https://example.com/post', + repairMode: 'all', + rows: [ + buildPostImportRow ({ + sourceRow: 1, + attributes: { title: 'existing row' }, + skipReason: 'existing', + existingPostId: 2 }), + buildPostImportRow ({ + sourceRow: 2, + attributes: { title: 'normal row' } })] }) + + renderReviewPage () + + expect (screen.getByText ('既存投稿による自動スキップ 1件')).toBeInTheDocument () + expect (screen.queryByText ('existing row')).not.toBeInTheDocument () + expect (screen.getByText ('normal row')).toBeInTheDocument () + + fireEvent.click (screen.getByRole ('button', { name: '既存投稿による自動スキップ 1件' })) + + expect (screen.getByText ('existing row')).toBeInTheDocument () + expect (screen.queryAllByRole ('button', { name: '編輯' })).toHaveLength (1) + expect (screen.queryAllByRole ('checkbox', { name: 'スキップ' })).toHaveLength (1) + }) + it ('navigates to /posts when the last failed row completes', async () => { savePostImportSession ({ source: 'https://example.com/post', diff --git a/frontend/src/pages/posts/PostImportReviewPage.tsx b/frontend/src/pages/posts/PostImportReviewPage.tsx index 2ed35f8..7bf370b 100644 --- a/frontend/src/pages/posts/PostImportReviewPage.tsx +++ b/frontend/src/pages/posts/PostImportReviewPage.tsx @@ -17,9 +17,11 @@ import { clearPostImportSession, buildNextEditedRow, canEditReviewRow, canRetryResultRow, - creatableImportRows, hasExactSourceRows, initialisePreviewRows, + isCompletedReviewRow, + isExistingSkipRow, + isNonRecoverableFailedRow, mergeImportResults, mergeValidatedImportRow, mergeValidatedImportRows, @@ -29,7 +31,8 @@ import { clearPostImportSession, resultRowMessages, reviewSummaryCounts, retryImportRow, - savePostImportSession } from '@/lib/postImportSession' + savePostImportSession, + validatableImportRows } from '@/lib/postImportSession' import { canEditContent } from '@/lib/users' import Forbidden from '@/pages/Forbidden' @@ -59,6 +62,7 @@ const PostImportReviewPage: FC = ({ user }) => { const [loading, setLoading] = useState (false) const [loadingRow, setLoadingRow] = useState (null) const [editingRow, setEditingRow] = useState (null) + const [showExistingRows, setShowExistingRows] = useState (false) const sessionRef = useRef (null) useEffect (() => { @@ -78,13 +82,7 @@ const PostImportReviewPage: FC = ({ user }) => { const rows = session?.rows ?? [] const counts = useMemo (() => reviewSummaryCounts (rows), [rows]) - const processable = useMemo ( - () => processableImportRows (rows), - [rows]) - const creatable = useMemo ( - () => creatableImportRows (rows), - [rows]) - const reviewRows = + const sortedRows = session?.repairMode === 'failed' ? ( [...rows].sort ((a, b) => { @@ -93,6 +91,8 @@ const PostImportReviewPage: FC = ({ user }) => { return aRepair - bRepair || a.sourceRow - b.sourceRow })) : rows + const existingRows = sortedRows.filter (row => isExistingSkipRow (row)) + const reviewRows = sortedRows.filter (row => !(isExistingSkipRow (row))) const busy = loading || loadingRow != null const persistSession = (nextSession: PostImportSession) => { @@ -110,6 +110,35 @@ const PostImportReviewPage: FC = ({ user }) => { navigate ('/posts') } + const toggleManualSkip = (sourceRow: number, checked: boolean) => { + if (busy) + return + + const currentSession = sessionRef.current + if (currentSession == null) + return + + const nextRows = currentSession.rows.map (row => { + if (row.sourceRow !== sourceRow) + return row + if (isExistingSkipRow (row) + || row.importStatus === 'created' + || isNonRecoverableFailedRow (row)) + return row + return { + ...row, + skipReason: checked ? 'manual' : undefined, + existingPostId: checked ? undefined : row.existingPostId } + }) + const nextSession = { + ...currentSession, + rows: nextRows, + repairMode: resultRepairMode (nextRows) } + persistSession (nextSession) + if (nextRows.every (row => isCompletedReviewRow (row))) + finishImport () + } + useEffect (() => { if (editingRow == null || session?.repairMode !== 'failed') return @@ -154,8 +183,7 @@ const PostImportReviewPage: FC = ({ user }) => { { const validated = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/validate', { rows: - nextRows - .filter (currentRow => currentRow.importStatus !== 'created') + validatableImportRows (nextRows) .map (currentRow => ({ sourceRow: currentRow.sourceRow, url: currentRow.url, @@ -256,7 +284,7 @@ const PostImportReviewPage: FC = ({ user }) => { const pendingRows = retryImportRow (initialSession.rows, sourceRow) const pendingSession = { ...initialSession, rows: pendingRows } persistSession (pendingSession) - const requestedRows = pendingRows.filter (row => row.importStatus !== 'created') + const requestedRows = validatableImportRows (pendingRows) const validated = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/validate', { rows: requestedRows.map (row => ({ sourceRow: row.sourceRow, @@ -362,8 +390,7 @@ const PostImportReviewPage: FC = ({ user }) => { rows: nextRows, repairMode: resultRepairMode (nextRows) } persistSession (resultSession) - if (nextRows.every (row => - row.importStatus === 'created' || row.importStatus === 'skipped')) + if (nextRows.every (row => isCompletedReviewRow (row))) { finishImport () return @@ -387,14 +414,23 @@ const PostImportReviewPage: FC = ({ user }) => { const submit = async () => { const currentSession = sessionRef.current - if (currentSession == null || processable.length === 0 || busy) + if (currentSession == null || busy) return + if (rows.every (row => isCompletedReviewRow (row))) + { + finishImport () + return + } setLoading (true) try { - const validatableRows = - currentSession.rows.filter (row => row.importStatus !== 'created') + const validatableRows = validatableImportRows (currentSession.rows) + if (validatableRows.length === 0) + { + finishImport () + return + } const validated = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/validate', { rows: validatableRows.map (row => ({ @@ -470,8 +506,7 @@ const PostImportReviewPage: FC = ({ user }) => { rows: nextRows, repairMode: resultRepairMode (nextRows) } persistSession (nextSession) - if (nextRows.every (row => - row.importStatus === 'created' || row.importStatus === 'skipped')) + if (nextRows.every (row => isCompletedReviewRow (row))) { finishImport () } @@ -492,24 +527,52 @@ const PostImportReviewPage: FC = ({ user }) => { if (session == null) return null - return ( + return ( <> - {`広場に投稿を追加 | ${ SITE_TITLE }`} + {`追加内容確認 | ${ SITE_TITLE }`}
- 広場に投稿を追加 + 追加内容確認 + + {counts.existingSkipped > 0 && ( +
+ + {showExistingRows && ( +
+ {existingRows.map (row => ( +
+ +
))} +
)} +
)}
{reviewRows.map (row => (
void editRow (row)} + onToggleSkip={checked => toggleManualSkip (row.sourceRow, checked)} onRetry={canRetryResultRow (row) ? () => void retry (row.sourceRow) : undefined} rowMessages={resultRowMessages (row)}/>
))} @@ -519,9 +582,10 @@ const PostImportReviewPage: FC = ({ user }) => { navigate ('/posts/new')} onSubmit={submit}/> ) @@ -529,16 +593,18 @@ const PostImportReviewPage: FC = ({ user }) => { const PostImportFooter = ( { loading, - processableCount, creatableCount, - skipPlannedCount, + manualSkippedCount, + existingSkippedCount, + pendingOrErrorCount, onBack, - onSubmit }: { loading: boolean - processableCount: number - creatableCount: number - skipPlannedCount: number - onBack: () => void - onSubmit: () => void }, + onSubmit }: { loading: boolean + creatableCount: number + manualSkippedCount: number + existingSkippedCount: number + pendingOrErrorCount: number + onBack: () => void + onSubmit: () => void }, ) => (
- 処理対象 {processableCount}件 - 登録対象 {creatableCount}件 - スキップ予定 {skipPlannedCount}件 + 作成対象 {creatableCount}件 + 手動スキップ {manualSkippedCount}件 + 既存投稿による自動スキップ {existingSkippedCount}件 + error/未処理 {pendingOrErrorCount}件