このコミットが含まれているのは:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -11,12 +11,16 @@ import type { FC } from 'react'
|
|||||||
import type { PostImportRow } from '@/lib/postImportSession'
|
import type { PostImportRow } from '@/lib/postImportSession'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
row: PostImportRow
|
row: PostImportRow
|
||||||
onEdit: () => void
|
onEdit?: () => void
|
||||||
onRetry?: () => void
|
onRetry?: () => void
|
||||||
rowMessages?: string[]
|
onToggleSkip?: (checked: boolean) => void
|
||||||
editDisabled?: boolean
|
rowMessages?: string[]
|
||||||
retryDisabled?: boolean }
|
editDisabled?: 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">
|
||||||
<Button
|
{skipControl}
|
||||||
type="button"
|
{showActions && editAllowed && (
|
||||||
variant="outline"
|
<Button
|
||||||
onClick={onEdit}
|
type="button"
|
||||||
disabled={!(canEditReviewRow (row)) || editDisabled === true}>
|
variant="outline"
|
||||||
編輯
|
onClick={onEdit}
|
||||||
</Button>
|
disabled={editDisabled === true}>
|
||||||
{onRetry != null && (
|
編輯
|
||||||
|
</Button>)}
|
||||||
|
{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>
|
||||||
<div className="flex flex-col gap-2 sm:flex-row">
|
{showActions && (editAllowed || retryAllowed) && (
|
||||||
<Button
|
<div className="flex flex-col gap-2 sm:flex-row">
|
||||||
type="button"
|
{editAllowed && (
|
||||||
variant="outline"
|
<Button
|
||||||
onClick={onEdit}
|
type="button"
|
||||||
disabled={!(canEditReviewRow (row)) || editDisabled === true}>
|
variant="outline"
|
||||||
編輯
|
onClick={onEdit}
|
||||||
</Button>
|
disabled={editDisabled === true}>
|
||||||
{onRetry != null && (
|
編輯
|
||||||
<Button
|
</Button>)}
|
||||||
type="button"
|
{retryAllowed && (
|
||||||
variant="outline"
|
<Button
|
||||||
onClick={onRetry}
|
type="button"
|
||||||
disabled={!(canRetryResultRow (row)) || retryDisabled === true}>
|
variant="outline"
|
||||||
再試行
|
onClick={onRetry}
|
||||||
</Button>)}
|
disabled={retryDisabled === true}>
|
||||||
</div>
|
再試行
|
||||||
|
</Button>)}
|
||||||
|
</div>)}
|
||||||
</div>
|
</div>
|
||||||
</>)
|
</>)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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))))
|
||||||
|
|
||||||
|
|||||||
@@ -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' })
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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 ()
|
||||||
}
|
}
|
||||||
@@ -492,24 +527,52 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
|||||||
if (session == null)
|
if (session == null)
|
||||||
return null
|
return null
|
||||||
|
|
||||||
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,16 +593,18 @@ 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
|
manualSkippedCount: number
|
||||||
skipPlannedCount: number
|
existingSkippedCount: number
|
||||||
onBack: () => void
|
pendingOrErrorCount: number
|
||||||
onSubmit: () => void },
|
onBack: () => void
|
||||||
|
onSubmit: () => void },
|
||||||
) => (
|
) => (
|
||||||
<div
|
<div
|
||||||
className="shrink-0 border-t bg-white/95 p-4 backdrop-blur
|
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
|
<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>
|
||||||
|
|||||||
新しい課題から参照
ユーザをブロックする