このコミットが含まれているのは:
2026-07-16 22:31:32 +09:00
コミット f91b78bd47
11個のファイルの変更310行の追加98行の削除
+78 -1
ファイルの表示
@@ -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',
+104 -37
ファイルの表示
@@ -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<Props> = ({ user }) => {
const [loading, setLoading] = useState (false)
const [loadingRow, setLoadingRow] = useState<number | null> (null)
const [editingRow, setEditingRow] = useState<PostImportRow | null> (null)
const [showExistingRows, setShowExistingRows] = useState (false)
const sessionRef = useRef<PostImportSession | null> (null)
useEffect (() => {
@@ -78,13 +82,7 @@ const PostImportReviewPage: FC<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ user }) => {
if (session == null)
return null
return (
return (
<>
<Helmet>
<title>{`広場に投稿を追加 | ${ SITE_TITLE }`}</title>
<title>{`追加内容確認 | ${ SITE_TITLE }`}</title>
</Helmet>
<MainArea className="min-h-0">
<div className="mx-auto max-w-6xl space-y-4 p-4">
<PageTitle>稿</PageTitle>
<PageTitle></PageTitle>
{counts.existingSkipped > 0 && (
<div className="rounded-lg border p-4">
<button
type="button"
className="text-left text-sm font-medium"
onClick={() => setShowExistingRows (current => !(current))}>
稿 {counts.existingSkipped}
</button>
{showExistingRows && (
<div className="mt-3 space-y-3">
{existingRows.map (row => (
<div key={row.sourceRow} id={`post-import-row-${ row.sourceRow }`}>
<PostImportRowSummary
row={row}
showActions={false}
showSkipToggle={false}
rowMessages={resultRowMessages (row)}/>
</div>))}
</div>)}
</div>)}
<div className="space-y-3">
{reviewRows.map (row => (
<div key={row.sourceRow} id={`post-import-row-${ row.sourceRow }`}>
<PostImportRowSummary
row={row}
showSkipToggle={!(isExistingSkipRow (row))}
editDisabled={busy}
retryDisabled={busy}
skipDisabled={
busy
|| isExistingSkipRow (row)
|| row.importStatus === 'created'
|| isNonRecoverableFailedRow (row)}
onEdit={() => void editRow (row)}
onToggleSkip={checked => toggleManualSkip (row.sourceRow, checked)}
onRetry={canRetryResultRow (row) ? () => void retry (row.sourceRow) : undefined}
rowMessages={resultRowMessages (row)}/>
</div>))}
@@ -519,9 +582,10 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
<PostImportFooter
loading={busy}
processableCount={processable.length}
creatableCount={creatable.length}
skipPlannedCount={counts.skipPlanned}
creatableCount={counts.creatable}
manualSkippedCount={counts.manualSkipped}
existingSkippedCount={counts.existingSkipped}
pendingOrErrorCount={counts.pendingOrError}
onBack={() => navigate ('/posts/new')}
onSubmit={submit}/>
</>)
@@ -529,16 +593,18 @@ const PostImportReviewPage: FC<Props> = ({ 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 },
) => (
<div
className="shrink-0 border-t bg-white/95 p-4 backdrop-blur
@@ -546,9 +612,10 @@ const PostImportFooter = (
<div className="mx-auto flex max-w-6xl flex-col gap-3 md:flex-row
md:items-center md:justify-between">
<div className="flex flex-wrap items-center gap-2 text-sm">
<span> {processableCount}</span>
<span> {creatableCount}</span>
<span> {skipPlannedCount}</span>
<span> {creatableCount}</span>
<span> {manualSkippedCount}</span>
<span>稿 {existingSkippedCount}</span>
<span>error {pendingOrErrorCount}</span>
</div>
<div className="flex flex-col gap-2 sm:flex-row">
<Button type="button" variant="outline" onClick={onBack} disabled={loading}>
@@ -557,7 +624,7 @@ const PostImportFooter = (
<Button
type="button"
onClick={onSubmit}
disabled={loading || processableCount === 0}>
disabled={loading || creatableCount === 0}>
</Button>
</div>