このコミットが含まれているのは:
2026-07-16 22:31:32 +09:00
コミット f91b78bd47
11個のファイルの変更310行の追加98行の削除
+2 -2
ファイルの表示
@@ -13,7 +13,7 @@ class PostImportPreviewer
FETCH_WARNING_FIELDS = ['url', 'title', 'thumbnail_base'].freeze FETCH_WARNING_FIELDS = ['url', 'title', 'thumbnail_base'].freeze
TITLE_FETCH_WARNING = 'タイトルを取得できませんでした.'.freeze TITLE_FETCH_WARNING = 'タイトルを取得できませんでした.'.freeze
THUMBNAIL_FETCH_WARNING = 'サムネールを取得できませんでした.'.freeze THUMBNAIL_FETCH_WARNING = 'サムネールを取得できませんでした.'.freeze
METADATA_FETCH_WARNING = 'メタデータを取得できませんでした.'.freeze METADATA_FETCH_WARNING = '自動取得に失敗しました.'.freeze
def preview_rows rows:, fetch_metadata: true, metadata_cache: { } def preview_rows rows:, fetch_metadata: true, metadata_cache: { }
prepared_rows = rows.map { prepare_row(_1) } prepared_rows = rows.map { prepare_row(_1) }
@@ -151,7 +151,7 @@ class PostImportPreviewer
when true then true when true then true
when Integer then fetch_metadata == source_row when Integer then fetch_metadata == source_row
when false, nil then false when false, nil then false
else raise ArgumentError, 'メタデータ取得対象が不正です.' else raise ArgumentError, '取得対象が不正です.'
end end
end end
+40 -13
ファイルの表示
@@ -12,11 +12,15 @@ import type { PostImportRow } from '@/lib/postImportSession'
type Props = { type Props = {
row: PostImportRow row: PostImportRow
onEdit: () => void onEdit?: () => void
onRetry?: () => void onRetry?: () => void
onToggleSkip?: (checked: boolean) => void
rowMessages?: string[] rowMessages?: string[]
editDisabled?: boolean editDisabled?: boolean
retryDisabled?: boolean } retryDisabled?: boolean
skipDisabled?: boolean
showActions?: boolean
showSkipToggle?: 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]
@@ -33,12 +37,30 @@ const PostImportRowSummary: FC<Props> = (
{ row, { row,
onEdit, onEdit,
onRetry, onRetry,
onToggleSkip,
rowMessages, rowMessages,
editDisabled, editDisabled,
retryDisabled }, retryDisabled,
skipDisabled,
showActions = true,
showSkipToggle = false },
) => { ) => {
const warning = summaryWarning (row) const warning = summaryWarning (row)
const displayStatus = displayPostImportStatus (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
? (
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={skipChecked}
onChange={event => onToggleSkip?.(event.target.checked)}
disabled={skipDisabled === true}/>
<span></span>
</label>)
: null
return ( return (
<> <>
@@ -76,20 +98,22 @@ const PostImportRowSummary: FC<Props> = (
{displayStatus != null && <PostImportStatusBadge value={displayStatus}/>} {displayStatus != null && <PostImportStatusBadge value={displayStatus}/>}
</div> </div>
<div className="flex justify-end"> <div className="flex justify-end">
<div className="flex gap-2"> <div className="flex items-center gap-2">
{skipControl}
{showActions && editAllowed && (
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
onClick={onEdit} onClick={onEdit}
disabled={!(canEditReviewRow (row)) || editDisabled === true}> disabled={editDisabled === true}>
</Button> </Button>)}
{onRetry != null && ( {showActions && retryAllowed && (
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
onClick={onRetry} onClick={onRetry}
disabled={!(canRetryResultRow (row)) || retryDisabled === true}> disabled={retryDisabled === true}>
</Button>)} </Button>)}
</div> </div>
@@ -125,25 +149,28 @@ const PostImportRowSummary: FC<Props> = (
{warning} {warning}
</div>)} </div>)}
<FieldError messages={rowMessages}/> <FieldError messages={rowMessages}/>
{skipControl}
</div> </div>
</div> </div>
{showActions && (editAllowed || retryAllowed) && (
<div className="flex flex-col gap-2 sm:flex-row"> <div className="flex flex-col gap-2 sm:flex-row">
{editAllowed && (
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
onClick={onEdit} onClick={onEdit}
disabled={!(canEditReviewRow (row)) || editDisabled === true}> disabled={editDisabled === true}>
</Button> </Button>)}
{onRetry != null && ( {retryAllowed && (
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
onClick={onRetry} onClick={onRetry}
disabled={!(canRetryResultRow (row)) || retryDisabled === true}> disabled={retryDisabled === true}>
</Button>)} </Button>)}
</div> </div>)}
</div> </div>
</>) </>)
} }
+2
ファイルの表示
@@ -12,6 +12,8 @@ describe ('displayPostImportStatus', () => {
expect (displayPostImportStatus (buildPostImportRow ({ expect (displayPostImportStatus (buildPostImportRow ({
skipReason: 'existing', skipReason: 'existing',
existingPostId: 2 }))).toBe ('skipped') existingPostId: 2 }))).toBe ('skipped')
expect (displayPostImportStatus (buildPostImportRow ({
skipReason: 'manual' }))).toBe ('skipped')
}) })
it ('does not expose validation, failure, or created states as badges', () => { it ('does not expose validation, failure, or created states as badges', () => {
+1 -1
ファイルの表示
@@ -11,7 +11,7 @@ const hasWarnings = (row: PostImportRow): boolean =>
export const displayPostImportStatus = ( export const displayPostImportStatus = (
row: PostImportRow, row: PostImportRow,
): PostImportDisplayStatus | null => ): PostImportDisplayStatus | null =>
(row.skipReason === 'existing' || row.importStatus === 'skipped') (row.skipReason != null || row.importStatus === 'skipped')
? 'skipped' ? 'skipped'
: ((Object.keys (row.validationErrors ?? { }).length > 0 : ((Object.keys (row.validationErrors ?? { }).length > 0
|| row.importStatus === 'failed' || row.importStatus === 'failed'
+11 -7
ファイルの表示
@@ -26,22 +26,26 @@ describe ('post import row state', () => {
sourceRow: 2, sourceRow: 2,
skipReason: 'existing', skipReason: 'existing',
existingPostId: 20 }) existingPostId: 20 })
const invalid = buildPostImportRow ({ const manual = buildPostImportRow ({
sourceRow: 3, sourceRow: 3,
skipReason: 'manual' })
const invalid = buildPostImportRow ({
sourceRow: 4,
status: 'error', status: 'error',
validationErrors: { url: ['invalid'] } }) validationErrors: { url: ['invalid'] } })
const created = buildPostImportRow ({ const created = buildPostImportRow ({
sourceRow: 4, sourceRow: 5,
importStatus: 'created', importStatus: 'created',
createdPostId: 40 }) 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 (creatableImportRows (rows)).toEqual ([ready])
expect (reviewSummaryCounts (rows)).toEqual ({ expect (reviewSummaryCounts (rows)).toEqual ({
total: 4, creatable: 1,
submittable: 1, manualSkipped: 1,
skipPlanned: 1 }) existingSkipped: 1,
pendingOrError: 1 })
}) })
it ('preserves terminal rows while merging validation results', () => { it ('preserves terminal rows while merging validation results', () => {
+42 -12
ファイルの表示
@@ -2,9 +2,17 @@ import type { PostImportEditableDraft,
PostImportResultRow, PostImportResultRow,
PostImportRow } from '@/lib/postImportTypes' PostImportRow } from '@/lib/postImportTypes'
const hasSkipReason = (row: PostImportRow): boolean => export const isExistingSkipRow = (row: PostImportRow): boolean =>
row.skipReason === 'existing' 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 => const hasValidationErrors = (row: PostImportRow): boolean =>
Object.keys (row.validationErrors ?? { }).length > 0 Object.keys (row.validationErrors ?? { }).length > 0
@@ -15,6 +23,26 @@ const isRepairableImportStatus = (row: PostImportRow): boolean =>
isRecoverableRow (row) isRecoverableRow (row)
&& (row.importStatus === 'failed' || row.importStatus === 'pending') && (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) => ({ const buildResetSnapshot = (row: PostImportRow) => ({
url: row.url, url: row.url,
attributes: { ...row.attributes }, attributes: { ...row.attributes },
@@ -29,7 +57,7 @@ const buildResetSnapshot = (row: PostImportRow) => ({
export const processableImportRows = (rows: PostImportRow[]): PostImportRow[] => export const processableImportRows = (rows: PostImportRow[]): PostImportRow[] =>
rows.filter (row => { validatableImportRows (rows).filter (row => {
if (row.importStatus === 'created') if (row.importStatus === 'created')
return false return false
if (row.importStatus === 'skipped') if (row.importStatus === 'skipped')
@@ -46,13 +74,12 @@ export const creatableImportRows = (rows: PostImportRow[]): PostImportRow[] =>
export const reviewSummaryCounts = (rows: PostImportRow[]) => ({ export const reviewSummaryCounts = (rows: PostImportRow[]) => ({
total: rows.length, creatable: rows.filter (row => creatableImportRows ([row]).length > 0).length,
submittable: rows.filter (row => manualSkipped: rows.filter (row => isManualSkipRow (row)).length,
creatableImportRows ([row]).length > 0 existingSkipped: rows.filter (row => isExistingSkipRow (row)).length,
&& !(hasValidationErrors (row))).length, pendingOrError: rows.filter (row =>
skipPlanned: rows.filter (row => !(isCompletedReviewRow (row))
processableImportRows ([row]).length > 0 && creatableImportRows ([row]).length === 0).length })
&& hasSkipReason (row)).length })
export const resultSummaryCounts = (rows: PostImportRow[]) => export const resultSummaryCounts = (rows: PostImportRow[]) =>
@@ -85,19 +112,22 @@ export const resultRepairMode = (
export const canEditReviewRow = (row: PostImportRow): boolean => export const canEditReviewRow = (row: PostImportRow): boolean =>
!(row.importStatus === 'created' !(hasSkipReason (row)
|| row.importStatus === 'created'
|| row.importStatus === 'skipped' || row.importStatus === 'skipped'
|| (row.importStatus === 'failed' && row.recoverable !== true)) || (row.importStatus === 'failed' && row.recoverable !== true))
export const canEditResultRow = (row: PostImportRow): boolean => export const canEditResultRow = (row: PostImportRow): boolean =>
row.recoverable === true row.skipReason == null
&& row.recoverable === true
&& (row.importStatus === 'failed' && (row.importStatus === 'failed'
|| (row.importStatus === 'pending' && hasValidationErrors (row))) || (row.importStatus === 'pending' && hasValidationErrors (row)))
export const canRetryResultRow = (row: PostImportRow): boolean => export const canRetryResultRow = (row: PostImportRow): boolean =>
row.recoverable === true row.skipReason == null
&& row.recoverable === true
&& (row.importStatus === 'failed' && (row.importStatus === 'failed'
|| (row.importStatus === 'pending' && !(hasValidationErrors (row)))) || (row.importStatus === 'pending' && !(hasValidationErrors (row))))
+7 -2
ファイルの表示
@@ -24,10 +24,13 @@ describe ('post import storage', () => {
importStatus: 'skipped', importStatus: 'skipped',
skipReason: 'existing', skipReason: 'existing',
existingPostId: 10 }) existingPostId: 10 })
const manual = buildPostImportRow ({
sourceRow: 3,
skipReason: 'manual' })
expect (savePostImportSession ({ expect (savePostImportSession ({
source: skipped.url, source: skipped.url,
rows: [row, skipped], rows: [row, skipped, manual],
repairMode: 'all' })).toBe (true) repairMode: 'all' })).toBe (true)
expect (loadPostImportSession ()).toMatchObject ({ expect (loadPostImportSession ()).toMatchObject ({
version: 2, version: 2,
@@ -39,7 +42,9 @@ describe ('post import storage', () => {
{ {
importStatus: 'skipped', importStatus: 'skipped',
skipReason: 'existing', skipReason: 'existing',
existingPostId: 10 }] }) existingPostId: 10 },
{
skipReason: 'manual' }] })
expect (savePostImportSourceDraft ('https://example.com')).toBe (true) expect (savePostImportSourceDraft ('https://example.com')).toBe (true)
expect (loadPostImportSourceDraft ()).toEqual ({ source: 'https://example.com' }) expect (loadPostImportSourceDraft ()).toEqual ({ source: 'https://example.com' })
+1 -1
ファイルの表示
@@ -129,7 +129,7 @@ const isValidOrigin = (
const isValidSkipReason = ( const isValidSkipReason = (
value: unknown, value: unknown,
): value is PostImportSkipReason => ): value is PostImportSkipReason =>
value === 'existing' value === 'existing' || value === 'manual'
const isPositiveInteger = (value: unknown): value is number => const isPositiveInteger = (value: unknown): value is number =>
Number.isInteger (value) && Number (value) > 0 Number.isInteger (value) && Number (value) > 0
+1 -1
ファイルの表示
@@ -5,7 +5,7 @@ export type PostImportStatus =
| 'created' | 'created'
| 'skipped' | 'skipped'
| 'failed' | 'failed'
export type PostImportSkipReason = 'existing' export type PostImportSkipReason = 'existing' | 'manual'
export type PostImportResultStatus = 'created' | 'skipped' | 'failed' export type PostImportResultStatus = 'created' | 'skipped' | 'failed'
export type PostImportAttributeValue = string | number export type PostImportAttributeValue = string | number
+78 -1
ファイルの表示
@@ -71,11 +71,25 @@ describe ('PostImportReviewPage', () => {
renderReviewPage () renderReviewPage ()
expect (screen.getByRole ('heading', { name: '広場に投稿を追加' })).toBeInTheDocument () expect (screen.getByRole ('heading', { name: '追加内容確認' })).toBeInTheDocument ()
expect (screen.queryByText ('広場に投稿を追加')).not.toBeInTheDocument ()
expect (screen.queryByText ('投稿インポート')).not.toBeInTheDocument () expect (screen.queryByText ('投稿インポート')).not.toBeInTheDocument ()
expect (await screen.findByText ('restored row')).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 () => { it ('returns to /posts/new while keeping the current work', async () => {
savePostImportSession ({ savePostImportSession ({
source: 'https://example.com/post', 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 () => { it ('navigates to /posts when the last failed row completes', async () => {
savePostImportSession ({ savePostImportSession ({
source: 'https://example.com/post', source: 'https://example.com/post',
+99 -32
ファイルの表示
@@ -17,9 +17,11 @@ import { clearPostImportSession,
buildNextEditedRow, buildNextEditedRow,
canEditReviewRow, canEditReviewRow,
canRetryResultRow, canRetryResultRow,
creatableImportRows,
hasExactSourceRows, hasExactSourceRows,
initialisePreviewRows, initialisePreviewRows,
isCompletedReviewRow,
isExistingSkipRow,
isNonRecoverableFailedRow,
mergeImportResults, mergeImportResults,
mergeValidatedImportRow, mergeValidatedImportRow,
mergeValidatedImportRows, mergeValidatedImportRows,
@@ -29,7 +31,8 @@ import { clearPostImportSession,
resultRowMessages, resultRowMessages,
reviewSummaryCounts, reviewSummaryCounts,
retryImportRow, retryImportRow,
savePostImportSession } from '@/lib/postImportSession' savePostImportSession,
validatableImportRows } from '@/lib/postImportSession'
import { canEditContent } from '@/lib/users' import { canEditContent } from '@/lib/users'
import Forbidden from '@/pages/Forbidden' import Forbidden from '@/pages/Forbidden'
@@ -59,6 +62,7 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
const [loading, setLoading] = useState (false) const [loading, setLoading] = useState (false)
const [loadingRow, setLoadingRow] = useState<number | null> (null) const [loadingRow, setLoadingRow] = useState<number | null> (null)
const [editingRow, setEditingRow] = useState<PostImportRow | null> (null) const [editingRow, setEditingRow] = useState<PostImportRow | null> (null)
const [showExistingRows, setShowExistingRows] = useState (false)
const sessionRef = useRef<PostImportSession | null> (null) const sessionRef = useRef<PostImportSession | null> (null)
useEffect (() => { useEffect (() => {
@@ -78,13 +82,7 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
const rows = session?.rows ?? [] const rows = session?.rows ?? []
const counts = useMemo (() => reviewSummaryCounts (rows), [rows]) const counts = useMemo (() => reviewSummaryCounts (rows), [rows])
const processable = useMemo ( const sortedRows =
() => processableImportRows (rows),
[rows])
const creatable = useMemo (
() => creatableImportRows (rows),
[rows])
const reviewRows =
session?.repairMode === 'failed' session?.repairMode === 'failed'
? ( ? (
[...rows].sort ((a, b) => { [...rows].sort ((a, b) => {
@@ -93,6 +91,8 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
return aRepair - bRepair || a.sourceRow - b.sourceRow return aRepair - bRepair || a.sourceRow - b.sourceRow
})) }))
: rows : rows
const existingRows = sortedRows.filter (row => isExistingSkipRow (row))
const reviewRows = sortedRows.filter (row => !(isExistingSkipRow (row)))
const busy = loading || loadingRow != null const busy = loading || loadingRow != null
const persistSession = (nextSession: PostImportSession) => { const persistSession = (nextSession: PostImportSession) => {
@@ -110,6 +110,35 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
navigate ('/posts') 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 (() => { useEffect (() => {
if (editingRow == null || session?.repairMode !== 'failed') if (editingRow == null || session?.repairMode !== 'failed')
return return
@@ -154,8 +183,7 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
{ {
const validated = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/validate', { const validated = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/validate', {
rows: rows:
nextRows validatableImportRows (nextRows)
.filter (currentRow => currentRow.importStatus !== 'created')
.map (currentRow => ({ .map (currentRow => ({
sourceRow: currentRow.sourceRow, sourceRow: currentRow.sourceRow,
url: currentRow.url, url: currentRow.url,
@@ -256,7 +284,7 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
const pendingRows = retryImportRow (initialSession.rows, sourceRow) const pendingRows = retryImportRow (initialSession.rows, sourceRow)
const pendingSession = { ...initialSession, rows: pendingRows } const pendingSession = { ...initialSession, rows: pendingRows }
persistSession (pendingSession) persistSession (pendingSession)
const requestedRows = pendingRows.filter (row => row.importStatus !== 'created') const requestedRows = validatableImportRows (pendingRows)
const validated = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/validate', { const validated = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/validate', {
rows: requestedRows.map (row => ({ rows: requestedRows.map (row => ({
sourceRow: row.sourceRow, sourceRow: row.sourceRow,
@@ -362,8 +390,7 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
rows: nextRows, rows: nextRows,
repairMode: resultRepairMode (nextRows) } repairMode: resultRepairMode (nextRows) }
persistSession (resultSession) persistSession (resultSession)
if (nextRows.every (row => if (nextRows.every (row => isCompletedReviewRow (row)))
row.importStatus === 'created' || row.importStatus === 'skipped'))
{ {
finishImport () finishImport ()
return return
@@ -387,14 +414,23 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
const submit = async () => { const submit = async () => {
const currentSession = sessionRef.current const currentSession = sessionRef.current
if (currentSession == null || processable.length === 0 || busy) if (currentSession == null || busy)
return return
if (rows.every (row => isCompletedReviewRow (row)))
{
finishImport ()
return
}
setLoading (true) setLoading (true)
try try
{ {
const validatableRows = const validatableRows = validatableImportRows (currentSession.rows)
currentSession.rows.filter (row => row.importStatus !== 'created') if (validatableRows.length === 0)
{
finishImport ()
return
}
const validated = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/validate', { const validated = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/validate', {
rows: rows:
validatableRows.map (row => ({ validatableRows.map (row => ({
@@ -470,8 +506,7 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
rows: nextRows, rows: nextRows,
repairMode: resultRepairMode (nextRows) } repairMode: resultRepairMode (nextRows) }
persistSession (nextSession) persistSession (nextSession)
if (nextRows.every (row => if (nextRows.every (row => isCompletedReviewRow (row)))
row.importStatus === 'created' || row.importStatus === 'skipped'))
{ {
finishImport () finishImport ()
} }
@@ -495,21 +530,49 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
return ( return (
<> <>
<Helmet> <Helmet>
<title>{`広場に投稿を追加 | ${ SITE_TITLE }`}</title> <title>{`追加内容確認 | ${ SITE_TITLE }`}</title>
</Helmet> </Helmet>
<MainArea className="min-h-0"> <MainArea className="min-h-0">
<div className="mx-auto max-w-6xl space-y-4 p-4"> <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"> <div className="space-y-3">
{reviewRows.map (row => ( {reviewRows.map (row => (
<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}
showSkipToggle={!(isExistingSkipRow (row))}
editDisabled={busy} editDisabled={busy}
retryDisabled={busy} retryDisabled={busy}
skipDisabled={
busy
|| isExistingSkipRow (row)
|| row.importStatus === 'created'
|| isNonRecoverableFailedRow (row)}
onEdit={() => void editRow (row)} onEdit={() => void editRow (row)}
onToggleSkip={checked => toggleManualSkip (row.sourceRow, checked)}
onRetry={canRetryResultRow (row) ? () => void retry (row.sourceRow) : undefined} onRetry={canRetryResultRow (row) ? () => void retry (row.sourceRow) : undefined}
rowMessages={resultRowMessages (row)}/> rowMessages={resultRowMessages (row)}/>
</div>))} </div>))}
@@ -519,9 +582,10 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
<PostImportFooter <PostImportFooter
loading={busy} loading={busy}
processableCount={processable.length} creatableCount={counts.creatable}
creatableCount={creatable.length} manualSkippedCount={counts.manualSkipped}
skipPlannedCount={counts.skipPlanned} existingSkippedCount={counts.existingSkipped}
pendingOrErrorCount={counts.pendingOrError}
onBack={() => navigate ('/posts/new')} onBack={() => navigate ('/posts/new')}
onSubmit={submit}/> onSubmit={submit}/>
</>) </>)
@@ -529,14 +593,16 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
const PostImportFooter = ( const PostImportFooter = (
{ loading, { loading,
processableCount,
creatableCount, creatableCount,
skipPlannedCount, manualSkippedCount,
existingSkippedCount,
pendingOrErrorCount,
onBack, onBack,
onSubmit }: { loading: boolean onSubmit }: { loading: boolean
processableCount: number
creatableCount: number creatableCount: number
skipPlannedCount: number manualSkippedCount: number
existingSkippedCount: number
pendingOrErrorCount: number
onBack: () => void onBack: () => void
onSubmit: () => void }, onSubmit: () => void },
) => ( ) => (
@@ -546,9 +612,10 @@ const PostImportFooter = (
<div className="mx-auto flex max-w-6xl flex-col gap-3 md:flex-row <div className="mx-auto flex max-w-6xl flex-col gap-3 md:flex-row
md:items-center md:justify-between"> md:items-center md:justify-between">
<div className="flex flex-wrap items-center gap-2 text-sm"> <div className="flex flex-wrap items-center gap-2 text-sm">
<span> {processableCount}</span> <span> {creatableCount}</span>
<span> {creatableCount}</span> <span> {manualSkippedCount}</span>
<span> {skipPlannedCount}</span> <span>稿 {existingSkippedCount}</span>
<span>error {pendingOrErrorCount}</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} disabled={loading}> <Button type="button" variant="outline" onClick={onBack} disabled={loading}>
@@ -557,7 +624,7 @@ const PostImportFooter = (
<Button <Button
type="button" type="button"
onClick={onSubmit} onClick={onSubmit}
disabled={loading || processableCount === 0}> disabled={loading || creatableCount === 0}>
</Button> </Button>
</div> </div>