このコミットが含まれているのは:
2026-07-16 21:45:02 +09:00
コミット 66c738cbff
26個のファイルの変更1083行の追加951行の削除
+12 -10
ファイルの表示
@@ -41,11 +41,9 @@ import NotFound from '@/pages/NotFound'
import TOSPage from '@/pages/TOSPage.mdx'
import PostDetailPage from '@/pages/posts/PostDetailPage'
import PostHistoryPage from '@/pages/posts/PostHistoryPage'
import PostImportResultPage from '@/pages/posts/PostImportResultPage'
import PostImportReviewPage from '@/pages/posts/PostImportReviewPage'
import PostImportSourcePage from '@/pages/posts/PostImportSourcePage'
import PostListPage from '@/pages/posts/PostListPage'
import PostNewPage from '@/pages/posts/PostNewPage'
import PostSearchPage from '@/pages/posts/PostSearchPage'
import ServiceUnavailable from '@/pages/ServiceUnavailable'
import SettingPage from '@/pages/users/SettingPage'
@@ -77,10 +75,12 @@ const RouteTransitionWrapper = ({ animationMode, user, setUser }: {
<Routes location={location}>
<Route path="/" element={<Navigate to="/posts" replace/>}/>
<Route path="/posts" element={<PostListPage/>}/>
<Route path="/posts/new" element={<PostNewPage user={user}/>}/>
<Route path="/posts/import" element={<PostImportSourcePage user={user}/>}/>
<Route path="/posts/import/:sessionId/review" element={<PostImportReviewPage user={user}/>}/>
<Route path="/posts/import/:sessionId/result" element={<PostImportResultPage user={user}/>}/>
<Route path="/posts/new" element={<PostImportSourcePage user={user}/>}/>
<Route path="/posts/new/review" element={<PostImportReviewPage user={user}/>}/>
<Route path="/posts/import" element={<Navigate to="/posts/new" replace/>}/>
<Route path="/posts/import/:sessionId/review" element={<Navigate to="/posts/new" replace/>}/>
<Route path="/posts/import/:sessionId/result" element={<Navigate to="/posts" replace/>}/>
<Route path="/posts/new/:sessionId/result" element={<Navigate to="/posts" replace/>}/>
<Route path="/posts/search" element={<PostSearchPage/>}/>
<Route path="/posts/:id" element={<PostDetailRoute user={user}/>}/>
<Route path="/posts/changes" element={<PostHistoryPage/>}/>
@@ -118,10 +118,12 @@ const RouteTransitionWrapper = ({ animationMode, user, setUser }: {
<Routes location={location}>
<Route path="/" element={<Navigate to="/posts" replace/>}/>
<Route path="/posts" element={<PostListPage/>}/>
<Route path="/posts/new" element={<PostNewPage user={user}/>}/>
<Route path="/posts/import" element={<PostImportSourcePage user={user}/>}/>
<Route path="/posts/import/:sessionId/review" element={<PostImportReviewPage user={user}/>}/>
<Route path="/posts/import/:sessionId/result" element={<PostImportResultPage user={user}/>}/>
<Route path="/posts/new" element={<PostImportSourcePage user={user}/>}/>
<Route path="/posts/new/review" element={<PostImportReviewPage user={user}/>}/>
<Route path="/posts/import" element={<Navigate to="/posts/new" replace/>}/>
<Route path="/posts/import/:sessionId/review" element={<Navigate to="/posts/new" replace/>}/>
<Route path="/posts/import/:sessionId/result" element={<Navigate to="/posts" replace/>}/>
<Route path="/posts/new/:sessionId/result" element={<Navigate to="/posts" replace/>}/>
<Route path="/posts/search" element={<PostSearchPage/>}/>
<Route path="/posts/:id" element={<PostDetailRoute user={user}/>}/>
<Route path="/posts/changes" element={<PostHistoryPage/>}/>
+4
ファイルの表示
@@ -30,6 +30,10 @@ describe ('menuOutline', () => {
expect (submenuItem ('guest', 'Wiki', '編輯')?.visible).toBe (false)
})
it ('uses /posts/new as the import entrypoint', () => {
expect (submenuItem ('member', '広場', '取込')?.to).toBe ('/posts/new')
})
it ('keeps material suppression admin-only', () => {
expect (submenuItem ('member', '素材', '抑止')?.visible).toBe (false)
expect (submenuItem ('admin', '素材', '抑止')?.visible).toBe (true)
+1 -1
ファイルの表示
@@ -45,7 +45,7 @@ export const menuOutline = (
{ name: '一覧', to: '/posts' },
{ name: '検索', to: '/posts/search' },
{ name: '追加', to: '/posts/new', visible: editable },
{ name: '取込', to: '/posts/import', visible: editable },
{ name: '取込', to: '/posts/new', visible: editable },
{ name: '全体履歴', to: '/posts/changes' },
{ name: 'ヘルプ', to: '/wiki/ヘルプ:広場' }] },
{ name: 'タグ', to: '/tags', subMenu: [
+94
ファイルの表示
@@ -0,0 +1,94 @@
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
import PostTagsField from '@/components/posts/PostTagsField'
import PostTextField from '@/components/posts/PostTextField'
import type { FC, ReactNode } from 'react'
type TextMessages = string[] | undefined
type CoreField = {
value: string
onChange: (value: string) => void
errors?: TextMessages
warnings?: TextMessages
disabled?: boolean }
type OriginalCreatedField = {
originalCreatedAt?: TextMessages
originalCreatedFrom?: TextMessages
originalCreatedBefore?: TextMessages }
type PostCoreDataFieldsProps = {
title: {
value: string
onChange: (value: string) => void
errors?: TextMessages
warnings?: TextMessages
disabled?: boolean
after?: ReactNode }
originalCreated: {
disabled?: boolean
originalCreatedFrom: string | null
setOriginalCreatedFrom: (value: string | null) => void
originalCreatedBefore: string | null
setOriginalCreatedBefore: (value: string | null) => void
errors?: OriginalCreatedField }
tags: {
value: string
onChange: (value: string) => void
errors?: TextMessages
warnings?: TextMessages
disabled?: boolean
rows?: number }
parentPostIds: CoreField }
const groupedMessages = (...values: (TextMessages | null | undefined)[]): string[] =>
[...new Set (values.flatMap (value => value ?? []))]
const PostCoreDataFields: FC<PostCoreDataFieldsProps> = (
{ title,
originalCreated,
tags,
parentPostIds },
) => (
<>
<PostTextField
label="タイトル"
value={title.value}
disabled={title.disabled}
warnings={title.warnings}
errors={title.errors}
after={title.after}
onChange={title.onChange}/>
<PostOriginalCreatedTimeField
disabled={originalCreated.disabled}
originalCreatedFrom={originalCreated.originalCreatedFrom}
setOriginalCreatedFrom={originalCreated.setOriginalCreatedFrom}
originalCreatedBefore={originalCreated.originalCreatedBefore}
setOriginalCreatedBefore={originalCreated.setOriginalCreatedBefore}
errors={groupedMessages (
originalCreated.errors?.originalCreatedAt,
originalCreated.errors?.originalCreatedFrom,
originalCreated.errors?.originalCreatedBefore)}/>
<PostTagsField
tags={tags.value}
disabled={tags.disabled}
setTags={tags.onChange}
warnings={tags.warnings}
errors={tags.errors}
rows={tags.rows}/>
<PostTextField
label="親投稿"
value={parentPostIds.value}
disabled={parentPostIds.disabled}
warnings={parentPostIds.warnings}
errors={parentPostIds.errors}
onChange={parentPostIds.onChange}/>
</>)
export default PostCoreDataFields
export type { PostCoreDataFieldsProps }
+44
ファイルの表示
@@ -0,0 +1,44 @@
import PostCoreDataFields from '@/components/posts/PostCoreDataFields'
import PostTextField from '@/components/posts/PostTextField'
import type { FC, ReactNode } from 'react'
import type { PostCoreDataFieldsProps } from '@/components/posts/PostCoreDataFields'
type TextMessages = string[] | undefined
type Props = {
url: {
value: string
onChange: (value: string) => void
errors?: TextMessages
warnings?: TextMessages
disabled?: boolean
type?: string
placeholder?: string }
thumbnailField: ReactNode
core: PostCoreDataFieldsProps }
const PostCreationDataFields: FC<Props> = (
{ url,
thumbnailField,
core },
) => (
<>
<PostTextField
label="URL"
type={url.type}
value={url.value}
disabled={url.disabled}
warnings={url.warnings}
errors={url.errors}
placeholder={url.placeholder}
onChange={url.onChange}/>
{thumbnailField}
<PostCoreDataFields {...core}/>
</>)
export default PostCreationDataFields
+41
ファイルの表示
@@ -0,0 +1,41 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { buildPostImportRow } from '@/test/postImportFactories'
import { buildUser } from '@/test/factories'
import { renderWithProviders } from '@/test/render'
import type { DialogueFormControls } from '@/lib/dialogues/useDialogue'
const sharedFieldsSpy = vi.hoisted (() => vi.fn (() => <div data-testid="shared-fields"/>))
vi.mock ('@/components/posts/PostCreationDataFields', () => ({
default: sharedFieldsSpy,
}))
describe ('PostCreationDataFields usage', () => {
it ('is used by PostNewPage', async () => {
const { default: PostNewPage } = await import ('@/pages/posts/PostNewPage')
renderWithProviders (<PostNewPage user={buildUser ({ role: 'member' })}/>)
expect (screen.getByTestId ('shared-fields')).toBeInTheDocument ()
})
it ('is used by PostImportRowForm', async () => {
const { default: PostImportRowForm } = await import (
'@/components/posts/import/PostImportRowForm')
render (
<PostImportRowForm
row={buildPostImportRow ()}
controls={{
close: vi.fn (),
confirm: vi.fn (),
setActions: vi.fn (),
} as DialogueFormControls}
onSave={vi.fn ()}/>)
expect (screen.getByTestId ('shared-fields')).toBeInTheDocument ()
})
})
+13 -7
ファイルの表示
@@ -1,5 +1,7 @@
import { useEffect, useState } from 'react'
import { cn } from '@/lib/utils'
import type { FC } from 'react'
type Props = {
@@ -21,8 +23,10 @@ const PostThumbnailPreview: FC<Props> = (
{
return (
<div
className={`${ className } flex items-center justify-center rounded border
border-border bg-muted text-xs text-muted-foreground`}>
className={cn (
className,
'flex items-center justify-center rounded border',
'border-border bg-muted text-xs text-muted-foreground')}>
</div>)
}
@@ -31,10 +35,12 @@ const PostThumbnailPreview: FC<Props> = (
{
return (
<div
className={`${ className } flex items-center justify-center rounded border
border-amber-300 bg-amber-50 p-2 text-center text-xs
text-amber-700 dark:border-amber-900 dark:bg-amber-950
dark:text-amber-200`}>
className={cn (
className,
'flex items-center justify-center rounded border',
'border-amber-300 bg-amber-50 p-2 text-center text-xs',
'text-amber-700 dark:border-amber-900 dark:bg-amber-950',
'dark:text-amber-200')}>
</div>)
}
@@ -43,7 +49,7 @@ const PostThumbnailPreview: FC<Props> = (
<img
src={url}
alt={alt}
className={`${ className } rounded border border-border object-cover`}
className={cn (className, 'rounded border border-border object-cover')}
onError={() => setFailed (true)}/>)
}
+26 -11
ファイルの表示
@@ -85,7 +85,7 @@ describe ('PostImportRowForm', () => {
it ('marks edited fields and areas invalid from field errors', () => {
const row = buildPostImportRow ({
validationErrors: { url: ['URL error'], tags: ['tag error'] },
importErrors: { duration: ['duration error'] },
importErrors: { title: ['title error'] },
fieldWarnings: { title: ['title warning'] } })
render (
@@ -96,7 +96,7 @@ describe ('PostImportRowForm', () => {
expect (screen.getByText ('URL error')).toBeInTheDocument ()
expect (screen.getByText ('tag error')).toBeInTheDocument ()
expect (screen.getByText ('duration error')).toBeInTheDocument ()
expect (screen.getByText ('title error')).toBeInTheDocument ()
expect (screen.getByText ('title warning')).toBeInTheDocument ()
expect (screen.getAllByRole ('textbox').filter (
textbox => textbox.getAttribute ('aria-invalid') === 'true')).toHaveLength (3)
@@ -134,7 +134,9 @@ describe ('PostImportRowForm', () => {
resetRequested: false })
})
it ('keeps the shared duration and tags string contract without leaking file upload UI', async () => {
it (
'shows the shared creation field order without duration and without file upload UI',
async () => {
let actions: DialogueFormAction[] = []
const controls: DialogueFormControls = {
close: vi.fn (),
@@ -152,8 +154,18 @@ describe ('PostImportRowForm', () => {
onSave={onSave}/>)
await waitFor (() => expect (actions.length).toBe (2))
const labels = Array.from (container.querySelectorAll ('label'))
.map (node => node.textContent?.trim ())
expect (labels.slice (0, 6)).toEqual ([
'URL',
'サムネール',
'タイトル',
'オリジナルの作成日時',
'タグ',
'親投稿'])
expect (container.querySelector ('input[type="file"]')).toBeNull ()
expect (screen.getByPlaceholderText ('例: 2 / 2.5 / 1:23')).toHaveValue ('2')
expect (screen.queryByPlaceholderText ('例: 2 / 2.5 / 1:23')).not.toBeInTheDocument ()
expect (screen.getByDisplayValue ('tag1')).toBeInTheDocument ()
await act (async () => {
@@ -162,10 +174,9 @@ describe ('PostImportRowForm', () => {
expect (onSave).toHaveBeenCalledWith ({
draft: expect.objectContaining ({
duration: '2',
tags: 'tag1' }),
resetRequested: false })
})
})
it ('keeps reset enabled when the value matches but provenance still differs', async () => {
const row = buildPostImportRow ({
@@ -178,7 +189,6 @@ describe ('PostImportRowForm', () => {
thumbnailBase: '',
originalCreatedFrom: '',
originalCreatedBefore: '',
duration: '',
tags: '',
parentPostIds: '' },
provenance: {
@@ -187,7 +197,6 @@ describe ('PostImportRowForm', () => {
thumbnailBase: 'automatic',
originalCreatedFrom: 'automatic',
originalCreatedBefore: 'automatic',
duration: 'automatic',
tags: 'automatic',
parentPostIds: 'automatic' },
tagSources: { automatic: '', manual: '' },
@@ -210,7 +219,9 @@ describe ('PostImportRowForm', () => {
expect (actions.find (action => action.label === '変更をリセット')?.disabled).toBe (false)
})
it ('disables every field while save validation is pending and re-enables them afterwards', async () => {
it (
'disables every field while save validation is pending and re-enables them afterwards',
async () => {
let actions: DialogueFormAction[] = []
let resolveSave:
((value: { saved: boolean
@@ -234,8 +245,9 @@ describe ('PostImportRowForm', () => {
onSave={onSave}/>)
await waitFor (() => expect (actions.length).toBe (2))
let savePromise: Promise<boolean | void> | undefined
await act (async () => {
await actions.find (action => action.label === '編輯内容を保存')?.onSelect ()
savePromise = actions.find (action => action.label === '編輯内容を保存')?.onSelect ()
})
await waitFor (() => {
@@ -249,6 +261,9 @@ describe ('PostImportRowForm', () => {
row: buildPostImportRow ({
attributes: { title: 'draft title' },
validationErrors: { title: ['タイトルを確認してください.'] } }) })
await act (async () => {
await savePromise
})
await waitFor (() => {
screen.getAllByRole ('textbox').forEach (textbox => {
@@ -257,5 +272,5 @@ describe ('PostImportRowForm', () => {
})
expect (screen.getByDisplayValue ('draft title')).toBeInTheDocument ()
expect (screen.getByText ('タイトルを確認してください.')).toBeInTheDocument ()
})
})
})
+65 -72
ファイルの表示
@@ -1,10 +1,8 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
import FieldError from '@/components/common/FieldError'
import FieldWarning from '@/components/common/FieldWarning'
import PostDurationField from '@/components/posts/PostDurationField'
import PostTagsField from '@/components/posts/PostTagsField'
import PostCreationDataFields from '@/components/posts/PostCreationDataFields'
import PostTextField from '@/components/posts/PostTextField'
import PostImportThumbnailPreview from '@/components/posts/import/PostImportThumbnailPreview'
@@ -29,7 +27,6 @@ const buildDraft = (row: PostImportRow): Draft => ({
thumbnailBase: String (row.attributes.thumbnailBase ?? ''),
originalCreatedFrom: String (row.attributes.originalCreatedFrom ?? ''),
originalCreatedBefore: String (row.attributes.originalCreatedBefore ?? ''),
duration: String (row.attributes.duration ?? ''),
tags: String (row.attributes.tags ?? ''),
parentPostIds: String (row.attributes.parentPostIds ?? '') })
@@ -39,7 +36,6 @@ const buildResetDraft = (row: PostImportRow): Draft => ({
thumbnailBase: String (row.resetSnapshot.attributes.thumbnailBase ?? ''),
originalCreatedFrom: String (row.resetSnapshot.attributes.originalCreatedFrom ?? ''),
originalCreatedBefore: String (row.resetSnapshot.attributes.originalCreatedBefore ?? ''),
duration: String (row.resetSnapshot.attributes.duration ?? ''),
tags: String (row.resetSnapshot.attributes.tags ?? ''),
parentPostIds: String (row.resetSnapshot.attributes.parentPostIds ?? '') })
@@ -52,7 +48,6 @@ const sameDraft = (left: Draft, right: Draft): boolean =>
&& left.thumbnailBase === right.thumbnailBase
&& left.originalCreatedFrom === right.originalCreatedFrom
&& left.originalCreatedBefore === right.originalCreatedBefore
&& left.duration === right.duration
&& left.tags === right.tags
&& left.parentPostIds === right.parentPostIds
@@ -169,72 +164,70 @@ const PostImportRowForm: FC<Props> = (
</div>
<div className="space-y-4">
<PostTextField
label="URL"
value={draft.url}
disabled={saving}
warnings={displayRow.fieldWarnings.url}
errors={groupedMessages (
displayRow.validationErrors.url,
displayRow.importErrors?.url)}
onChange={value => update ('url', value)}/>
<PostTextField
label="タイトル"
value={draft.title}
disabled={saving}
warnings={displayRow.fieldWarnings.title}
errors={groupedMessages (
displayRow.validationErrors.title,
displayRow.importErrors?.title)}
onChange={value => update ('title', value)}/>
<PostTextField
label="サムネール基底 URL"
value={draft.thumbnailBase}
disabled={saving}
warnings={displayRow.fieldWarnings.thumbnailBase}
errors={groupedMessages (
displayRow.validationErrors.thumbnailBase,
displayRow.importErrors?.thumbnailBase)}
onChange={value => update ('thumbnailBase', value)}/>
<PostOriginalCreatedTimeField
disabled={saving}
originalCreatedFrom={draft.originalCreatedFrom || null}
setOriginalCreatedFrom={value => update ('originalCreatedFrom', value ?? '')}
originalCreatedBefore={draft.originalCreatedBefore || null}
setOriginalCreatedBefore={value => update ('originalCreatedBefore', value ?? '')}
errors={groupedMessages (
displayRow.validationErrors.originalCreatedAt,
displayRow.validationErrors.originalCreatedFrom,
displayRow.validationErrors.originalCreatedBefore,
displayRow.importErrors?.originalCreatedAt,
displayRow.importErrors?.originalCreatedFrom,
displayRow.importErrors?.originalCreatedBefore)}/>
<PostDurationField
value={draft.duration}
disabled={saving}
errors={groupedMessages (
displayRow.validationErrors.duration,
displayRow.validationErrors.videoMs,
displayRow.importErrors?.duration,
displayRow.importErrors?.videoMs)}
onChange={value => update ('duration', value)}/>
<PostTagsField
tags={draft.tags}
disabled={saving}
setTags={value => update ('tags', value)}
warnings={displayRow.fieldWarnings.tags}
errors={groupedMessages (
displayRow.validationErrors.tags,
displayRow.importErrors?.tags)}
rows={4}/>
<PostTextField
label="親投稿"
value={draft.parentPostIds}
disabled={saving}
errors={groupedMessages (
displayRow.validationErrors.parentPostIds,
displayRow.importErrors?.parentPostIds)}
onChange={value => update ('parentPostIds', value)}/>
<PostCreationDataFields
url={{
value: draft.url,
onChange: value => update ('url', value),
disabled: saving,
warnings: displayRow.fieldWarnings.url,
errors: groupedMessages (
displayRow.validationErrors.url,
displayRow.importErrors?.url) }}
thumbnailField={
<>
<PostTextField
label="サムネール"
value={draft.thumbnailBase}
disabled={saving}
warnings={displayRow.fieldWarnings.thumbnailBase}
errors={groupedMessages (
displayRow.validationErrors.thumbnailBase,
displayRow.importErrors?.thumbnailBase)}
onChange={value => update ('thumbnailBase', value)}/>
</>}
core={{
title: {
value: draft.title,
onChange: value => update ('title', value),
disabled: saving,
warnings: displayRow.fieldWarnings.title,
errors: groupedMessages (
displayRow.validationErrors.title,
displayRow.importErrors?.title) },
originalCreated: {
disabled: saving,
originalCreatedFrom: draft.originalCreatedFrom || null,
setOriginalCreatedFrom: value =>
update ('originalCreatedFrom', value ?? ''),
originalCreatedBefore: draft.originalCreatedBefore || null,
setOriginalCreatedBefore: value =>
update ('originalCreatedBefore', value ?? ''),
errors: {
originalCreatedAt: groupedMessages (
displayRow.validationErrors.originalCreatedAt,
displayRow.importErrors?.originalCreatedAt),
originalCreatedFrom: groupedMessages (
displayRow.validationErrors.originalCreatedFrom,
displayRow.importErrors?.originalCreatedFrom),
originalCreatedBefore: groupedMessages (
displayRow.validationErrors.originalCreatedBefore,
displayRow.importErrors?.originalCreatedBefore) } },
tags: {
value: draft.tags,
onChange: value => update ('tags', value),
disabled: saving,
warnings: displayRow.fieldWarnings.tags,
errors: groupedMessages (
displayRow.validationErrors.tags,
displayRow.importErrors?.tags),
rows: 4 },
parentPostIds: {
value: draft.parentPostIds,
onChange: value => update ('parentPostIds', value),
disabled: saving,
errors: groupedMessages (
displayRow.validationErrors.parentPostIds,
displayRow.importErrors?.parentPostIds) } }}/>
<FieldWarning messages={displayRow.baseWarnings}/>
<FieldError messages={displayRow.validationErrors.base}/>
<FieldError messages={displayRow.importErrors?.base}/>
+50 -19
ファイルの表示
@@ -1,8 +1,9 @@
import FieldError from '@/components/common/FieldError'
import { Button } from '@/components/ui/button'
import PostImportThumbnailPreview from '@/components/posts/import/PostImportThumbnailPreview'
import PostImportStatusBadge from '@/components/posts/import/PostImportStatusBadge'
import { displayPostImportStatus } from '@/components/posts/import/postImportRowStatus'
import { canEditReviewRow } from '@/lib/postImportSession'
import { canEditReviewRow, canRetryResultRow } from '@/lib/postImportSession'
import { cn, originalCreatedAtString } from '@/lib/utils'
import type { FC } from 'react'
@@ -12,7 +13,10 @@ import type { PostImportRow } from '@/lib/postImportSession'
type Props = {
row: PostImportRow
onEdit: () => void
editDisabled?: boolean }
onRetry?: () => void
rowMessages?: string[]
editDisabled?: boolean
retryDisabled?: boolean }
const summaryWarning = (row: PostImportRow): string | null =>
Object.values (row.fieldWarnings ?? { }).flat ()[0]
@@ -25,7 +29,14 @@ const summaryDate = (row: PostImportRow): string =>
row.attributes.originalCreatedBefore?.toString () ?? null)
const PostImportRowSummary: FC<Props> = ({ row, onEdit, editDisabled }) => {
const PostImportRowSummary: FC<Props> = (
{ row,
onEdit,
onRetry,
rowMessages,
editDisabled,
retryDisabled },
) => {
const warning = summaryWarning (row)
const displayStatus = displayPostImportStatus (row)
@@ -54,24 +65,34 @@ const PostImportRowSummary: FC<Props> = ({ row, onEdit, editDisabled }) => {
</div>
<div className="text-xs text-neutral-500 dark:text-neutral-400">
{summaryDate (row)}
{row.attributes.duration ? ` / ${ row.attributes.duration }` : ''}
</div>
{warning && (
<div className="text-xs text-amber-700 dark:text-amber-200">
{warning}
</div>)}
<FieldError messages={rowMessages}/>
</div>
<div className="space-y-1">
{displayStatus != null && <PostImportStatusBadge value={displayStatus}/>}
</div>
<div className="flex justify-end">
<Button
type="button"
variant="outline"
onClick={onEdit}
disabled={!(canEditReviewRow (row)) || editDisabled === true}>
</Button>
<div className="flex gap-2">
<Button
type="button"
variant="outline"
onClick={onEdit}
disabled={!(canEditReviewRow (row)) || editDisabled === true}>
</Button>
{onRetry != null && (
<Button
type="button"
variant="outline"
onClick={onRetry}
disabled={!(canRetryResultRow (row)) || retryDisabled === true}>
</Button>)}
</div>
</div>
</div>
@@ -98,21 +119,31 @@ const PostImportRowSummary: FC<Props> = ({ row, onEdit, editDisabled }) => {
</div>
<div className="text-xs text-neutral-500 dark:text-neutral-400">
{summaryDate (row)}
{row.attributes.duration ? ` / ${ row.attributes.duration }` : ''}
</div>
{warning && (
<div className="text-xs text-amber-700 dark:text-amber-200">
{warning}
</div>)}
<FieldError messages={rowMessages}/>
</div>
</div>
<Button
type="button"
variant="outline"
onClick={onEdit}
disabled={!(canEditReviewRow (row)) || editDisabled === true}>
</Button>
<div className="flex flex-col gap-2 sm:flex-row">
<Button
type="button"
variant="outline"
onClick={onEdit}
disabled={!(canEditReviewRow (row)) || editDisabled === true}>
</Button>
{onRetry != null && (
<Button
type="button"
variant="outline"
onClick={onRetry}
disabled={!(canRetryResultRow (row)) || retryDisabled === true}>
</Button>)}
</div>
</div>
</>)
}
+6 -4
ファイルの表示
@@ -71,10 +71,12 @@ const PostImportThumbnailPreview: FC<Props> = (
{
return (
<div
className={`${ className } flex items-center justify-center rounded border
border-amber-300 bg-amber-50 p-2 text-center text-xs
text-amber-700 dark:border-amber-900 dark:bg-amber-950
dark:text-amber-200`}>
className={cn (
className,
'flex items-center justify-center rounded border',
'border-amber-300 bg-amber-50 p-2 text-center text-xs',
'text-amber-700 dark:border-amber-900 dark:bg-amber-950',
'dark:text-amber-200')}>
</div>)
}
+1 -2
ファイルの表示
@@ -270,7 +270,6 @@ describe ('post import row state', () => {
thumbnailBase: '',
originalCreatedFrom: '',
originalCreatedBefore: '',
duration: '2.5',
tags: 'edited-tag',
parentPostIds: '' },
true)
@@ -279,7 +278,7 @@ describe ('post import row state', () => {
expect (nextRow.importErrors).toBeUndefined ()
expect (nextRow.url).toBe ('https://example.com/edited')
expect (nextRow.attributes.title).toBe ('edited title')
expect (nextRow.attributes.duration).toBe ('2.5')
expect (nextRow.attributes.duration).toBe ('2')
expect (nextRow.attributes.tags).toBe ('edited-tag')
expect (nextRow.provenance.title).toBe ('manual')
expect (nextRow.provenance.url).toBe ('manual')
-1
ファイルの表示
@@ -128,7 +128,6 @@ export const buildNextEditedRow = (
['thumbnailBase', draft.thumbnailBase],
['originalCreatedFrom', draft.originalCreatedFrom],
['originalCreatedBefore', draft.originalCreatedBefore],
['duration', draft.duration],
['parentPostIds', draft.parentPostIds]] as const
draftFields.forEach (([field, value]) => {
nextAttributes[field] = value
+11 -9
ファイルの表示
@@ -25,11 +25,11 @@ describe ('post import storage', () => {
skipReason: 'existing',
existingPostId: 10 })
expect (savePostImportSession ('session', {
expect (savePostImportSession ({
source: skipped.url,
rows: [row, skipped],
repairMode: 'all' })).toBe (true)
expect (loadPostImportSession ('session')).toMatchObject ({
expect (loadPostImportSession ()).toMatchObject ({
version: 2,
source: skipped.url,
rows: [{
@@ -63,12 +63,13 @@ describe ('post import storage', () => {
{ ...session.rows[0], importStatus: 'skipped', recoverable: true },
{ ...session.rows[0], recoverable: true }]
for (const [index, row] of invalidRows.entries ())
for (const row of invalidRows)
{
sessionStorage.setItem (`post-import-session:invalid-${ index }`, JSON.stringify ({
sessionStorage.setItem ('post-import-session:current', JSON.stringify ({
...session,
rows: [row] }))
expect (loadPostImportSession (`invalid-${ index }`)).toBeNull ()
expect (loadPostImportSession ()).toBeNull ()
sessionStorage.clear ()
}
})
@@ -82,18 +83,19 @@ describe ('post import storage', () => {
{ ...row, resetSnapshot: { ...row.resetSnapshot,
fieldWarnings: { title: 'warning' } } }]
invalidRows.forEach ((invalidRow, index) => {
sessionStorage.setItem (`post-import-session:shape-${ index }`, JSON.stringify ({
invalidRows.forEach (invalidRow => {
sessionStorage.setItem ('post-import-session:current', JSON.stringify ({
version: 2,
savedAt: new Date ().toISOString (),
source: '',
rows: [invalidRow],
repairMode: 'all' }))
expect (loadPostImportSession (`shape-${ index }`)).toBeNull ()
expect (loadPostImportSession ()).toBeNull ()
sessionStorage.clear ()
})
})
it ('removes expired and malformed sessions without touching current sessions', () => {
it ('keeps only the current fixed-key session and removes legacy prefixed entries', () => {
const current = {
version: 2,
savedAt: new Date ().toISOString (),
+39 -16
ファイルの表示
@@ -8,6 +8,7 @@ import type { PostImportOrigin,
const SESSION_VERSION = 2
const SESSION_PREFIX = 'post-import-session:'
const CURRENT_SESSION_KEY = `${ SESSION_PREFIX }current`
const SOURCE_DRAFT_KEY = 'post-import-source-draft'
const SESSION_MAX_AGE_MS = 24 * 60 * 60 * 1000
const ATTRIBUTE_KEYS = [
@@ -28,7 +29,7 @@ const isPlainObject = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value != null && !(Array.isArray (value))
const sessionKey = (sessionId: string): string => `${ SESSION_PREFIX }${ sessionId }`
const sessionKey = (): string => CURRENT_SESSION_KEY
const readStorage = (
@@ -288,12 +289,6 @@ const isExpiredSession = (savedAt: string): boolean => {
}
export const createPostImportSessionId = (): string =>
typeof crypto !== 'undefined' && 'randomUUID' in crypto
? crypto.randomUUID ()
: `${ Date.now () }-${ Math.random ().toString (36).slice (2) }`
export const cleanupExpiredPostImportSessions = (
onError?: StorageErrorHandler,
) => {
@@ -311,6 +306,13 @@ export const cleanupExpiredPostImportSessions = (
const raw = sessionStorage.getItem (key)
if (raw == null)
continue
if (key !== CURRENT_SESSION_KEY)
{
sessionStorage.removeItem (key)
--i
continue
}
try
{
const value = JSON.parse (raw) as { savedAt?: string }
@@ -368,24 +370,38 @@ export const clearPostImportSourceDraft = (
export const savePostImportSession = (
sessionId: string,
session: Omit<PostImportSession, 'version' | 'savedAt'>,
sessionOrLegacyId: string | Omit<PostImportSession, 'version' | 'savedAt'>,
sessionOrOnError?: Omit<PostImportSession, 'version' | 'savedAt'> | StorageErrorHandler,
onError?: StorageErrorHandler,
): boolean =>
writeStorage (
sessionKey (sessionId),
): boolean => {
const session =
typeof sessionOrLegacyId === 'string'
? sessionOrOnError as Omit<PostImportSession, 'version' | 'savedAt'>
: sessionOrLegacyId
const errorHandler =
typeof sessionOrLegacyId === 'string'
? onError
: sessionOrOnError as StorageErrorHandler | undefined
return writeStorage (
sessionKey (),
JSON.stringify ({
...session,
version: SESSION_VERSION,
savedAt: new Date ().toISOString () }),
onError)
errorHandler)
}
export const loadPostImportSession = (
sessionId: string,
legacyIdOrOnError?: string | StorageErrorHandler,
onError?: StorageErrorHandler,
): PostImportSession | null => {
const raw = readStorage (sessionKey (sessionId), onError)
const errorHandler =
typeof legacyIdOrOnError === 'string'
? onError
: legacyIdOrOnError
const raw = readStorage (sessionKey (), errorHandler)
if (raw == null)
return null
@@ -396,7 +412,7 @@ export const loadPostImportSession = (
return null
if (typeof value.savedAt !== 'string' || isExpiredSession (value.savedAt))
{
removeStorage (sessionKey (sessionId), onError)
removeStorage (sessionKey (), errorHandler)
return null
}
@@ -416,3 +432,10 @@ export const loadPostImportSession = (
return null
}
}
export const clearPostImportSession = (
onError?: StorageErrorHandler,
) => {
removeStorage (sessionKey (), onError)
}
-1
ファイルの表示
@@ -78,7 +78,6 @@ export type PostImportEditableDraft = {
thumbnailBase: string
originalCreatedFrom: string
originalCreatedBefore: string
duration: string
tags: string
parentPostIds: string }
+36 -390
ファイルの表示
@@ -1,402 +1,48 @@
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { HelmetProvider } from 'react-helmet-async'
import { MemoryRouter, Route, Routes } from 'react-router-dom'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import { MemoryRouter, Navigate, Route, Routes } from 'react-router-dom'
import { describe, expect, it } from 'vitest'
import {
loadPostImportSession,
savePostImportSession,
} from '@/lib/postImportSession'
import PostImportResultPage from '@/pages/posts/PostImportResultPage'
import { buildUser } from '@/test/factories'
import { buildPostImportRow } from '@/test/postImportFactories'
import { originalCreatedAtString } from '@/lib/utils'
describe ('legacy post import routes', () => {
it ('redirects /posts/import to /posts/new', () => {
render (
<MemoryRouter initialEntries={['/posts/import']}>
<Routes>
<Route path="/posts/import" element={<Navigate to="/posts/new" replace/>}/>
<Route path="/posts/new" element={<div>SOURCE ROUTE</div>}/>
</Routes>
</MemoryRouter>)
import type { PostImportRow } from '@/lib/postImportSession'
import type { DialogueFormAction, DialogueFormControls } from '@/lib/dialogues/useDialogue'
const api = vi.hoisted (() => ({
apiGet: vi.fn (),
apiPost: vi.fn (),
}))
const toastApi = vi.hoisted (() => ({
toast: vi.fn (),
}))
const dialogue = vi.hoisted (() => ({
form: vi.fn (() => Promise.resolve ()),
}))
vi.mock ('@/components/ui/use-toast', () => toastApi)
vi.mock ('@/lib/api', () => api)
vi.mock ('@/lib/dialogues/useDialogue', () => ({
default: () => dialogue,
}))
describe ('PostImportResultPage', () => {
beforeEach (() => {
sessionStorage.clear ()
vi.clearAllMocks ()
globalThis.URL.createObjectURL = vi.fn (() => 'blob:preview')
globalThis.URL.revokeObjectURL = vi.fn ()
api.apiGet.mockResolvedValue (new Blob (['img'], { type: 'image/png' }))
expect (screen.getByText ('SOURCE ROUTE')).toBeInTheDocument ()
})
it ('shows edit and retry only for recoverable failures and shows warnings', () => {
savePostImportSession ('result-session', {
source: '',
repairMode: 'failed',
rows: [
buildPostImportRow ({
sourceRow: 1,
attributes: { title: 'recoverable row' },
importStatus: 'failed',
recoverable: true,
importErrors: { base: ['recoverable failed'] } }),
buildPostImportRow ({
sourceRow: 2,
attributes: { title: 'hard failed row' },
importStatus: 'failed',
importErrors: { base: ['hard failed'] } }),
buildPostImportRow ({
sourceRow: 3,
attributes: {
title: 'created row',
originalCreatedFrom: '2024-01-01T00:00:00Z',
originalCreatedBefore: '2024-01-02T00:00:00Z' },
importStatus: 'created',
createdPostId: 3,
fieldWarnings: {
thumbnailBase: ['サムネール画像を取得できませんでした.'] } })] })
it ('redirects legacy review routes to /posts/new', () => {
render (
<HelmetProvider>
<MemoryRouter initialEntries={['/posts/import/result-session/result']}>
<Routes>
<Route
path="/posts/import/:sessionId/result"
element={<PostImportResultPage user={buildUser ()}/>}/>
</Routes>
</MemoryRouter>
</HelmetProvider>)
<MemoryRouter initialEntries={['/posts/import/legacy/review']}>
<Routes>
<Route
path="/posts/import/:sessionId/review"
element={<Navigate to="/posts/new" replace/>}/>
<Route path="/posts/new" element={<div>SOURCE ROUTE</div>}/>
</Routes>
</MemoryRouter>)
expect (screen.getAllByRole ('button', { name: '編輯' })).toHaveLength (1)
expect (screen.getAllByRole ('button', { name: '再試行' })).toHaveLength (1)
expect (screen.getByText ('サムネール画像を取得できませんでした.'))
.toBeInTheDocument ()
expect (screen.getByText (
originalCreatedAtString ('2024-01-01T00:00:00Z', '2024-01-02T00:00:00Z'),
)).toBeInTheDocument ()
expect (screen.getByText ('SOURCE ROUTE')).toBeInTheDocument ()
})
it ('shows hour-precision original-created ranges with the shared utility output', () => {
const from = '2024-01-01T12:00:00Z'
const before = '2024-01-01T13:00:00Z'
savePostImportSession ('result-hour-range', {
source: '',
repairMode: 'all',
rows: [buildPostImportRow ({
sourceRow: 1,
importStatus: 'created',
createdPostId: 1,
attributes: {
title: 'created row',
originalCreatedFrom: from,
originalCreatedBefore: before } })] })
it ('redirects legacy result routes to /posts', () => {
render (
<HelmetProvider>
<MemoryRouter initialEntries={['/posts/import/result-hour-range/result']}>
<Routes>
<Route
path="/posts/import/:sessionId/result"
element={<PostImportResultPage user={buildUser ()}/>}/>
</Routes>
</MemoryRouter>
</HelmetProvider>)
<MemoryRouter initialEntries={['/posts/import/legacy/result']}>
<Routes>
<Route
path="/posts/import/:sessionId/result"
element={<Navigate to="/posts" replace/>}/>
<Route
path="/posts/new/:sessionId/result"
element={<Navigate to="/posts" replace/>}/>
<Route path="/posts" element={<div>POSTS ROUTE</div>}/>
</Routes>
</MemoryRouter>)
expect (screen.getByText (originalCreatedAtString (from, before))).toBeInTheDocument ()
})
it ('keeps recoverable pending rows actionable and hides actions for hard failures', () => {
savePostImportSession ('result-pending', {
source: '',
repairMode: 'failed',
rows: [
buildPostImportRow ({
sourceRow: 1,
importStatus: 'pending',
recoverable: true,
validationErrors: { title: ['invalid'] } }),
buildPostImportRow ({
sourceRow: 2,
importStatus: 'pending',
recoverable: true }),
buildPostImportRow ({
sourceRow: 3,
importStatus: 'failed',
importErrors: { base: ['hard failed'] } })] })
render (
<HelmetProvider>
<MemoryRouter initialEntries={['/posts/import/result-pending/result']}>
<Routes>
<Route
path="/posts/import/:sessionId/result"
element={<PostImportResultPage user={buildUser ()}/>}/>
</Routes>
</MemoryRouter>
</HelmetProvider>)
expect (screen.getAllByRole ('button', { name: '編輯' })).toHaveLength (1)
expect (screen.getAllByRole ('button', { name: '再試行' })).toHaveLength (1)
expect (screen.getByText ('invalid')).toBeInTheDocument ()
expect (screen.getByText ('hard failed')).toBeInTheDocument ()
})
it ('disables editing, retry, and navigation while retry is running', async () => {
let resolveValidation: ((value: { rows: PostImportRow[] }) => void) | null = null
savePostImportSession ('result-retry-busy', {
source: '',
repairMode: 'failed',
rows: [
buildPostImportRow ({
sourceRow: 1,
importStatus: 'failed',
recoverable: true,
importErrors: { base: ['failed'] } }),
buildPostImportRow ({
sourceRow: 2,
importStatus: 'failed',
recoverable: true,
importErrors: { base: ['failed'] } })] })
api.apiPost.mockImplementationOnce (() =>
new Promise<{ rows: PostImportRow[] }> (resolve => {
resolveValidation = resolve
}))
api.apiPost.mockResolvedValueOnce ({
created: 0,
skipped: 0,
failed: 0,
rows: [] })
render (
<HelmetProvider>
<MemoryRouter initialEntries={['/posts/import/result-retry-busy/result']}>
<Routes>
<Route
path="/posts/import/:sessionId/result"
element={<PostImportResultPage user={buildUser ()}/>}/>
</Routes>
</MemoryRouter>
</HelmetProvider>)
fireEvent.click (screen.getAllByRole ('button', { name: '再試行' })[0])
await waitFor (() => {
screen.getAllByRole ('button', { name: '編輯' }).forEach (button => {
expect (button).toBeDisabled ()
})
screen.getAllByRole ('button', { name: '再試行' }).forEach (button => {
expect (button).toBeDisabled ()
})
expect (screen.getByRole ('button', { name: '確認画面へ戻る' })).toBeDisabled ()
expect (screen.getByRole ('button', { name: '新しい URL リストを入力' })).toBeDisabled ()
})
resolveValidation?.({ rows: [buildPostImportRow ({ sourceRow: 1 })] })
})
it ('persists retry success to sessionStorage', async () => {
savePostImportSession ('result-retry-success', {
source: '',
repairMode: 'failed',
rows: [buildPostImportRow ({
sourceRow: 1,
importStatus: 'failed',
recoverable: true,
importErrors: { base: ['failed'] } })] })
api.apiPost
.mockResolvedValueOnce ({
rows: [buildPostImportRow ({
sourceRow: 1,
importStatus: 'pending',
recoverable: true })] })
.mockResolvedValueOnce ({
created: 1,
skipped: 0,
failed: 0,
rows: [{
sourceRow: 1,
status: 'created',
post: { id: 10 } }] })
render (
<HelmetProvider>
<MemoryRouter initialEntries={['/posts/import/result-retry-success/result']}>
<Routes>
<Route
path="/posts/import/:sessionId/result"
element={<PostImportResultPage user={buildUser ()}/>}/>
</Routes>
</MemoryRouter>
</HelmetProvider>)
fireEvent.click (screen.getByRole ('button', { name: '再試行' }))
await waitFor (() => {
expect (loadPostImportSession ('result-retry-success')?.rows[0]?.importStatus)
.toBe ('created')
})
})
it ('keeps edited recoverable rows in session and retries with the edited values', async () => {
savePostImportSession ('result-edit-retry', {
source: '',
repairMode: 'failed',
rows: [buildPostImportRow ({
sourceRow: 1,
attributes: { title: 'old title' },
importStatus: 'failed',
recoverable: true,
importErrors: { base: ['failed'] } })] })
api.apiPost
.mockResolvedValueOnce ({
rows: [buildPostImportRow ({
sourceRow: 1,
attributes: { title: 'edited title' },
importStatus: 'pending',
recoverable: true })] })
.mockResolvedValueOnce ({
rows: [buildPostImportRow ({
sourceRow: 1,
attributes: { title: 'edited title' },
importStatus: 'pending',
recoverable: true })] })
.mockResolvedValueOnce ({
created: 1,
skipped: 0,
failed: 0,
rows: [{
sourceRow: 1,
status: 'created',
post: { id: 11 } }] })
dialogue.form.mockImplementationOnce (async options => {
let actions: DialogueFormAction[] = []
const controls: DialogueFormControls = {
close: vi.fn (),
confirm: vi.fn (),
setActions: next => {
actions = next
} }
render (options.body (controls))
await waitFor (() => expect (actions.length).toBe (2))
fireEvent.change (screen.getByDisplayValue ('old title'), {
target: { value: 'edited title' } })
await act (async () => {
await actions.find (action => action.label === '編輯内容を保存')?.onSelect ()
})
})
render (
<HelmetProvider>
<MemoryRouter initialEntries={['/posts/import/result-edit-retry/result']}>
<Routes>
<Route
path="/posts/import/:sessionId/result"
element={<PostImportResultPage user={buildUser ()}/>}/>
</Routes>
</MemoryRouter>
</HelmetProvider>)
fireEvent.click (screen.getByRole ('button', { name: '編輯' }))
await waitFor (() => {
const saved = loadPostImportSession ('result-edit-retry')
expect (saved?.rows[0]?.attributes.title).toBe ('edited title')
expect (saved?.rows[0]?.importStatus).toBe ('pending')
})
fireEvent.click (screen.getByRole ('button', { name: '再試行' }))
await waitFor (() => {
expect (api.apiPost.mock.calls[2]?.[1]?.rows?.[0]?.attributes?.title)
.toBe ('edited title')
})
})
it ('persists restored failed state when retry fails', async () => {
savePostImportSession ('result-retry-failure', {
source: '',
repairMode: 'failed',
rows: [buildPostImportRow ({
sourceRow: 1,
importStatus: 'failed',
recoverable: true,
importErrors: { base: ['failed'] } })] })
api.apiPost.mockRejectedValueOnce (new Error ('network error'))
render (
<HelmetProvider>
<MemoryRouter initialEntries={['/posts/import/result-retry-failure/result']}>
<Routes>
<Route
path="/posts/import/:sessionId/result"
element={<PostImportResultPage user={buildUser ()}/>}/>
</Routes>
</MemoryRouter>
</HelmetProvider>)
fireEvent.click (screen.getByRole ('button', { name: '再試行' }))
await waitFor (() => {
const saved = loadPostImportSession ('result-retry-failure')
expect (saved?.rows[0]?.importStatus).toBe ('failed')
expect (saved?.rows[0]?.recoverable).toBe (true)
})
})
it ('restores the failed row when the retry import response is incomplete', async () => {
savePostImportSession ('result-retry-missing-row', {
source: '',
repairMode: 'failed',
rows: [buildPostImportRow ({
sourceRow: 1,
importStatus: 'failed',
recoverable: true,
importErrors: { base: ['failed'] } })] })
api.apiPost
.mockResolvedValueOnce ({
rows: [buildPostImportRow ({
sourceRow: 1,
importStatus: 'pending',
recoverable: true })] })
.mockResolvedValueOnce ({
created: 1,
skipped: 0,
failed: 0,
rows: [] })
render (
<HelmetProvider>
<MemoryRouter initialEntries={['/posts/import/result-retry-missing-row/result']}>
<Routes>
<Route
path="/posts/import/:sessionId/result"
element={<PostImportResultPage user={buildUser ()}/>}/>
</Routes>
</MemoryRouter>
</HelmetProvider>)
fireEvent.click (screen.getByRole ('button', { name: '再試行' }))
await waitFor (() => {
const saved = loadPostImportSession ('result-retry-missing-row')
expect (saved?.rows[0]?.importStatus).toBe ('failed')
expect (toastApi.toast).toHaveBeenCalledWith (
expect.objectContaining ({ title: '登録結果が不完全でした' }))
})
expect (screen.getByText ('POSTS ROUTE')).toBeInTheDocument ()
})
})
+227 -153
ファイルの表示
@@ -30,6 +30,20 @@ vi.mock ('@/lib/dialogues/useDialogue', () => ({
default: () => dialogue,
}))
const renderReviewPage = (route = '/posts/new/review') =>
render (
<HelmetProvider>
<MemoryRouter initialEntries={[route]}>
<Routes>
<Route path="/posts/new" element={<div>SOURCE ROUTE</div>}/>
<Route
path="/posts/new/review"
element={<PostImportReviewPage user={buildUser ()}/>}/>
<Route path="/posts" element={<div>POSTS ROUTE</div>}/>
</Routes>
</MemoryRouter>
</HelmetProvider>)
describe ('PostImportReviewPage', () => {
beforeEach (() => {
sessionStorage.clear ()
@@ -39,8 +53,124 @@ describe ('PostImportReviewPage', () => {
api.apiGet.mockResolvedValue (new Blob (['img'], { type: 'image/png' }))
})
it ('navigates to the result route after an initial recoverable failure', async () => {
savePostImportSession ('review-session', {
it ('redirects to /posts/new when no current work exists', async () => {
renderReviewPage ()
await waitFor (() => {
expect (screen.getByText ('SOURCE ROUTE')).toBeInTheDocument ()
})
})
it ('restores the current work on review reload', async () => {
savePostImportSession ({
source: 'https://example.com/post',
repairMode: 'all',
rows: [buildPostImportRow ({
sourceRow: 1,
attributes: { title: 'restored row' } })] })
renderReviewPage ()
expect (await screen.findByText ('restored row')).toBeInTheDocument ()
})
it ('returns to /posts/new while keeping the current work', async () => {
savePostImportSession ({
source: 'https://example.com/post',
repairMode: 'all',
rows: [buildPostImportRow ({
sourceRow: 1,
attributes: { title: 'restored row' } })] })
renderReviewPage ()
fireEvent.click (await screen.findByRole ('button', { name: 'URL リスト入力へ戻る' }))
await waitFor (() => {
expect (screen.getByText ('SOURCE ROUTE')).toBeInTheDocument ()
})
expect (loadPostImportSession ()?.rows[0]?.attributes.title).toBe ('restored row')
})
it ('disables row editing and navigation while batch import is running', async () => {
let resolveValidation: ((value: { rows: PostImportRow[] }) => void) | null = null
savePostImportSession ({
source: 'https://example.com/post',
repairMode: 'all',
rows: [buildPostImportRow ({ sourceRow: 1 })] })
api.apiPost.mockImplementationOnce (() =>
new Promise<{ rows: PostImportRow[] }> (resolve => {
resolveValidation = resolve
}))
api.apiPost.mockResolvedValueOnce ({
created: 0,
skipped: 0,
failed: 0,
rows: [] })
renderReviewPage ()
fireEvent.click (screen.getByRole ('button', { name: '取込実行' }))
await waitFor (() => {
expect (screen.getByRole ('button', { name: '編輯' })).toBeDisabled ()
expect (screen.getByRole ('button', { name: 'URL リスト入力へ戻る' })).toBeDisabled ()
expect (screen.getByRole ('button', { name: '取込実行' })).toBeDisabled ()
})
resolveValidation?.({ rows: [buildPostImportRow ({ sourceRow: 1 })] })
})
it ('does not call import when validation omits a requested source row', async () => {
savePostImportSession ({
source: 'https://example.com/post-1\nhttps://example.com/post-2',
repairMode: 'all',
rows: [
buildPostImportRow ({ sourceRow: 1 }),
buildPostImportRow ({ sourceRow: 2 })] })
api.apiPost.mockResolvedValueOnce ({
rows: [buildPostImportRow ({ sourceRow: 1 })] })
renderReviewPage ()
fireEvent.click (screen.getByRole ('button', { name: '取込実行' }))
await waitFor (() => {
expect (toastApi.toast).toHaveBeenCalledWith (
expect.objectContaining ({ title: '再検証結果が不完全でした' }))
})
expect (api.apiPost).toHaveBeenCalledTimes (1)
})
it ('navigates to /posts when all rows finish as created or skipped', async () => {
savePostImportSession ({
source: 'https://example.com/post',
repairMode: 'all',
rows: [buildPostImportRow ({ sourceRow: 1 })] })
api.apiPost
.mockResolvedValueOnce ({
rows: [buildPostImportRow ({ sourceRow: 1 })] })
.mockResolvedValueOnce ({
created: 1,
skipped: 0,
failed: 0,
rows: [{
sourceRow: 1,
status: 'created',
post: { id: 1 } }] })
renderReviewPage ()
fireEvent.click (screen.getByRole ('button', { name: '取込実行' }))
await waitFor (() => {
expect (screen.getByText ('POSTS ROUTE')).toBeInTheDocument ()
})
expect (loadPostImportSession ()).toBeNull ()
})
it ('stays on review when a recoverable failed row remains', async () => {
savePostImportSession ({
source: 'https://example.com/post',
repairMode: 'all',
rows: [buildPostImportRow ({ sourceRow: 1 })] })
@@ -57,140 +187,21 @@ describe ('PostImportReviewPage', () => {
recoverable: true,
errors: { title: ['invalid'] } }] })
render (
<HelmetProvider>
<MemoryRouter initialEntries={['/posts/import/review-session/review']}>
<Routes>
<Route
path="/posts/import/:sessionId/review"
element={<PostImportReviewPage user={buildUser ()}/>}/>
<Route
path="/posts/import/:sessionId/result"
element={<div>RESULT ROUTE</div>}/>
</Routes>
</MemoryRouter>
</HelmetProvider>)
renderReviewPage ()
fireEvent.click (screen.getByRole ('button', { name: '取込実行' }))
await waitFor (() => {
expect (screen.getByText ('RESULT ROUTE')).toBeInTheDocument ()
})
expect (dialogue.form).not.toHaveBeenCalled ()
})
it ('disables row editing and navigation while batch import is running', async () => {
let resolveValidation: ((value: { rows: PostImportRow[] }) => void) | null = null
savePostImportSession ('review-loading', {
source: 'https://example.com/post',
repairMode: 'all',
rows: [buildPostImportRow ({ sourceRow: 1 })] })
api.apiPost.mockImplementationOnce (() =>
new Promise<{ rows: PostImportRow[] }> (resolve => {
resolveValidation = resolve
}))
api.apiPost.mockResolvedValueOnce ({
created: 0,
skipped: 0,
failed: 0,
rows: [] })
render (
<HelmetProvider>
<MemoryRouter initialEntries={['/posts/import/review-loading/review']}>
<Routes>
<Route
path="/posts/import/:sessionId/review"
element={<PostImportReviewPage user={buildUser ()}/>}/>
</Routes>
</MemoryRouter>
</HelmetProvider>)
fireEvent.click (screen.getByRole ('button', { name: '取込実行' }))
await waitFor (() => {
expect (screen.getByRole ('button', { name: '編輯' })).toBeDisabled ()
expect (screen.getByRole ('button', { name: 'URL リスト入力へ戻る' })).toBeDisabled ()
expect (screen.getByRole ('button', { name: '取込実行' })).toBeDisabled ()
})
resolveValidation?.({ rows: [buildPostImportRow ({ sourceRow: 1 })] })
})
it ('does not call import when validation omits a requested source row', async () => {
savePostImportSession ('review-missing-row', {
source: 'https://example.com/post-1\nhttps://example.com/post-2',
repairMode: 'all',
rows: [
buildPostImportRow ({ sourceRow: 1 }),
buildPostImportRow ({ sourceRow: 2 })] })
api.apiPost.mockResolvedValueOnce ({
rows: [buildPostImportRow ({ sourceRow: 1 })] })
render (
<HelmetProvider>
<MemoryRouter initialEntries={['/posts/import/review-missing-row/review']}>
<Routes>
<Route
path="/posts/import/:sessionId/review"
element={<PostImportReviewPage user={buildUser ()}/>}/>
</Routes>
</MemoryRouter>
</HelmetProvider>)
fireEvent.click (screen.getByRole ('button', { name: '取込実行' }))
await waitFor (() => {
expect (toastApi.toast).toHaveBeenCalledWith (
expect.objectContaining ({ title: '再検証結果が不完全でした' }))
})
expect (api.apiPost).toHaveBeenCalledTimes (1)
})
it ('does not navigate to result when import omits a requested source row', async () => {
savePostImportSession ('review-missing-import-row', {
source: 'https://example.com/post',
repairMode: 'all',
rows: [buildPostImportRow ({ sourceRow: 1 })] })
api.apiPost
.mockResolvedValueOnce ({
rows: [buildPostImportRow ({ sourceRow: 1 })] })
.mockResolvedValueOnce ({
created: 1,
skipped: 0,
failed: 0,
rows: [] })
render (
<HelmetProvider>
<MemoryRouter initialEntries={['/posts/import/review-missing-import-row/review']}>
<Routes>
<Route
path="/posts/import/:sessionId/review"
element={<PostImportReviewPage user={buildUser ()}/>}/>
<Route
path="/posts/import/:sessionId/result"
element={<div>RESULT ROUTE</div>}/>
</Routes>
</MemoryRouter>
</HelmetProvider>)
fireEvent.click (screen.getByRole ('button', { name: '取込実行' }))
await waitFor (() => {
expect (toastApi.toast).toHaveBeenCalledWith (
expect.objectContaining ({ title: '登録結果が不完全でした' }))
})
expect (screen.queryByText ('RESULT ROUTE')).not.toBeInTheDocument ()
expect (await screen.findByText ('invalid')).toBeInTheDocument ()
expect (screen.queryByText ('POSTS ROUTE')).not.toBeInTheDocument ()
})
it ('keeps edited recoverable rows in session before the next batch submit', async () => {
savePostImportSession ('review-edit-retry', {
savePostImportSession ({
source: 'https://example.com/post',
repairMode: 'failed',
rows: [buildPostImportRow ({
sourceRow: 1,
attributes: { title: 'old title' },
attributes: { title: 'old title', duration: '2' },
importStatus: 'failed',
recoverable: true,
importErrors: { base: ['failed'] } })] })
@@ -198,13 +209,13 @@ describe ('PostImportReviewPage', () => {
.mockResolvedValueOnce ({
rows: [buildPostImportRow ({
sourceRow: 1,
attributes: { title: 'edited title' },
attributes: { title: 'edited title', duration: '2' },
importStatus: 'pending',
recoverable: true })] })
.mockResolvedValueOnce ({
rows: [buildPostImportRow ({
sourceRow: 1,
attributes: { title: 'edited title' },
attributes: { title: 'edited title', duration: '2' },
importStatus: 'pending',
recoverable: true })] })
.mockResolvedValueOnce ({
@@ -232,24 +243,12 @@ describe ('PostImportReviewPage', () => {
await actions.find (action => action.label === '編輯内容を保存')?.onSelect ()
})
render (
<HelmetProvider>
<MemoryRouter initialEntries={['/posts/import/review-edit-retry/review']}>
<Routes>
<Route
path="/posts/import/:sessionId/review"
element={<PostImportReviewPage user={buildUser ()}/>}/>
<Route
path="/posts/import/:sessionId/result"
element={<div>RESULT ROUTE</div>}/>
</Routes>
</MemoryRouter>
</HelmetProvider>)
renderReviewPage ()
fireEvent.click (screen.getByRole ('button', { name: '編輯' }))
await waitFor (() => {
const saved = loadPostImportSession ('review-edit-retry')
const saved = loadPostImportSession ()
expect (saved?.rows[0]?.attributes.title).toBe ('edited title')
expect (saved?.rows[0]?.importStatus).toBe ('pending')
})
@@ -259,11 +258,95 @@ describe ('PostImportReviewPage', () => {
await waitFor (() => {
expect (api.apiPost.mock.calls[1]?.[1]?.rows?.[0]?.attributes?.title)
.toBe ('edited title')
expect (api.apiPost.mock.calls[1]?.[1]?.rows?.[0]?.attributes?.duration)
.toBe ('2')
})
})
it ('retries only failed rows and does not resend created or skipped rows', async () => {
savePostImportSession ({
source: 'https://example.com/post',
repairMode: 'failed',
rows: [
buildPostImportRow ({
sourceRow: 1,
importStatus: 'created',
createdPostId: 1 }),
buildPostImportRow ({
sourceRow: 2,
importStatus: 'skipped',
skipReason: 'existing',
existingPostId: 2 }),
buildPostImportRow ({
sourceRow: 3,
importStatus: 'failed',
recoverable: true,
importErrors: { base: ['failed'] } })] })
api.apiPost
.mockResolvedValueOnce ({
rows: [buildPostImportRow ({
sourceRow: 3,
importStatus: 'pending',
recoverable: true })] })
.mockResolvedValueOnce ({
created: 0,
skipped: 1,
failed: 0,
rows: [{
sourceRow: 3,
status: 'skipped',
existingPostId: 9 }] })
renderReviewPage ()
fireEvent.click (screen.getByRole ('button', { name: '再試行' }))
await waitFor (() => {
expect (api.apiPost.mock.calls[1]?.[1]?.rows).toEqual ([
expect.objectContaining ({ sourceRow: 3 })])
})
})
it ('navigates to /posts when the last failed row completes', async () => {
savePostImportSession ({
source: 'https://example.com/post',
repairMode: 'failed',
rows: [
buildPostImportRow ({
sourceRow: 1,
importStatus: 'created',
createdPostId: 1 }),
buildPostImportRow ({
sourceRow: 2,
importStatus: 'failed',
recoverable: true,
importErrors: { base: ['failed'] } })] })
api.apiPost
.mockResolvedValueOnce ({
rows: [buildPostImportRow ({
sourceRow: 2,
importStatus: 'pending',
recoverable: true })] })
.mockResolvedValueOnce ({
created: 1,
skipped: 0,
failed: 0,
rows: [{
sourceRow: 2,
status: 'created',
post: { id: 2 } }] })
renderReviewPage ()
fireEvent.click (screen.getByRole ('button', { name: '再試行' }))
await waitFor (() => {
expect (screen.getByText ('POSTS ROUTE')).toBeInTheDocument ()
})
})
it ('sorts recoverable pending validation rows to the top in repair mode', () => {
savePostImportSession ('review-repair-sort', {
savePostImportSession ({
source: '',
repairMode: 'failed',
rows: [
@@ -277,16 +360,7 @@ describe ('PostImportReviewPage', () => {
recoverable: true,
validationErrors: { title: ['invalid'] } })] })
const { container } = render (
<HelmetProvider>
<MemoryRouter initialEntries={['/posts/import/review-repair-sort/review']}>
<Routes>
<Route
path="/posts/import/:sessionId/review"
element={<PostImportReviewPage user={buildUser ()}/>}/>
</Routes>
</MemoryRouter>
</HelmetProvider>)
const { container } = renderReviewPage ()
const titles = Array.from (container.querySelectorAll ('.line-clamp-2')).map (
node => node.textContent)
+251 -107
ファイルの表示
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { Helmet } from 'react-helmet-async'
import { useNavigate, useParams } from 'react-router-dom'
import { useNavigate } from 'react-router-dom'
import PageTitle from '@/components/common/PageTitle'
import MainArea from '@/components/layout/MainArea'
@@ -11,10 +11,12 @@ import { toast } from '@/components/ui/use-toast'
import { SITE_TITLE } from '@/config'
import { apiPost } from '@/lib/api'
import useDialogue from '@/lib/dialogues/useDialogue'
import { canEditContent } from '@/lib/users'
import { loadPostImportSession,
import { clearPostImportSession,
clearPostImportSourceDraft,
loadPostImportSession,
buildNextEditedRow,
canEditReviewRow,
canRetryResultRow,
creatableImportRows,
hasExactSourceRows,
initialisePreviewRows,
@@ -24,8 +26,11 @@ import { loadPostImportSession,
processableImportRows,
replaceImportRow,
resultRepairMode,
resultRowMessages,
reviewSummaryCounts,
retryImportRow,
savePostImportSession } from '@/lib/postImportSession'
import { canEditContent } from '@/lib/users'
import Forbidden from '@/pages/Forbidden'
import type { FC } from 'react'
@@ -49,31 +54,23 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
const editable = canEditContent (user)
const dialogue = useDialogue ()
const navigate = useNavigate ()
const { sessionId } = useParams ()
const [session, setSession] = useState<PostImportSession | null> (null)
const [loading, setLoading] = useState (false)
const [missing, setMissing] = useState (false)
const [loadingRow, setLoadingRow] = useState<number | null> (null)
const [editingRow, setEditingRow] = useState<PostImportRow | null> (null)
const sessionRef = useRef<PostImportSession | null> (null)
useEffect (() => {
if (sessionId == null)
return
const loaded = loadPostImportSession (sessionId, message =>
const loaded = loadPostImportSession (message =>
toast ({ title: '取込状態を復元できませんでした', description: message }))
if (loaded == null)
{
navigate ('/posts/new', { replace: true })
return
}
setSession (loaded)
setMissing (loaded == null)
}, [sessionId])
useEffect (() => {
if (sessionId == null || session == null)
return
savePostImportSession (sessionId, session, message =>
toast ({ title: '取込状態を保存できませんでした', description: message }))
}, [session, sessionId])
}, [navigate])
useEffect (() => {
sessionRef.current = session
@@ -96,6 +93,22 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
return aRepair - bRepair || a.sourceRow - b.sourceRow
}))
: rows
const busy = loading || loadingRow != null
const persistSession = (nextSession: PostImportSession) => {
sessionRef.current = nextSession
setSession (nextSession)
savePostImportSession (nextSession, message =>
toast ({ title: '取込状態を保存できませんでした', description: message }))
}
const finishImport = () => {
clearPostImportSession (message =>
toast ({ title: '取込状態を削除できませんでした', description: message }))
clearPostImportSourceDraft (message =>
toast ({ title: '入力内容を削除できませんでした', description: message }))
navigate ('/posts')
}
useEffect (() => {
if (editingRow == null || session?.repairMode !== 'failed')
@@ -108,8 +121,8 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
const saveDraft = async (
row: PostImportRow,
{ draft, resetRequested }: {
draft: PostImportRowDraft
resetRequested: boolean },
draft: PostImportRowDraft
resetRequested: boolean },
): Promise<{ saved: boolean
row: PostImportRow | null }> => {
const currentSession = sessionRef.current
@@ -133,55 +146,52 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
: row
const urlChanged = draft.url !== baseRow.url
const nextRow = buildNextEditedRow (baseRow, draft, urlChanged)
const nextRows = currentSession.rows.map (row =>
row.sourceRow === baseRow.sourceRow
const nextRows = currentSession.rows.map (currentRow =>
currentRow.sourceRow === baseRow.sourceRow
? nextRow
: row)
: currentRow)
try
{
const validated = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/validate', {
rows:
nextRows
.filter (row => row.importStatus !== 'created')
.map (row => ({ sourceRow: row.sourceRow,
url: row.url,
attributes: row.attributes,
provenance: row.provenance,
tagSources: row.tagSources,
metadataUrl: row.metadataUrl })),
.filter (currentRow => currentRow.importStatus !== 'created')
.map (currentRow => ({
sourceRow: currentRow.sourceRow,
url: currentRow.url,
attributes: currentRow.attributes,
provenance: currentRow.provenance,
tagSources: currentRow.tagSources,
metadataUrl: currentRow.metadataUrl })),
changed_row: urlChanged ? baseRow.sourceRow : -1 })
const validatedRows = initialisePreviewRows (validated.rows)
const target = validatedRows.find (
validatedRow => validatedRow.sourceRow === baseRow.sourceRow)
validatedRow => validatedRow.sourceRow === baseRow.sourceRow)
const latestSession = sessionRef.current
if (latestSession == null)
return { saved: false, row: null }
return { saved: false, row: null }
if (target == null)
{
const restoredRows = replaceImportRow (latestSession.rows, row)
const restoredSession = {
{
const restoredRows = replaceImportRow (latestSession.rows, row)
persistSession ({
...latestSession,
rows: restoredRows,
repairMode: resultRepairMode (restoredRows) }
sessionRef.current = restoredSession
setSession (restoredSession)
toast ({ title: '行の再検証結果が不完全でした' })
return { saved: false, row: null }
}
repairMode: resultRepairMode (restoredRows) })
toast ({ title: '行の再検証結果が不完全でした' })
return { saved: false, row: null }
}
const editedRows = replaceImportRow (latestSession.rows, nextRow)
const rows = mergeValidatedImportRow (editedRows, target)
const mergedRows = mergeValidatedImportRow (editedRows, target)
const nextSession = {
...latestSession,
rows,
repairMode: resultRepairMode (rows) }
sessionRef.current = nextSession
setSession (nextSession)
rows: mergedRows,
repairMode: resultRepairMode (mergedRows) }
persistSession (nextSession)
const mergedTarget =
rows.find (mergedRow => mergedRow.sourceRow === baseRow.sourceRow)
?? target
mergedRows.find (mergedRow => mergedRow.sourceRow === baseRow.sourceRow)
?? target
if (Object.keys (mergedTarget.validationErrors).length > 0)
return { saved: false, row: mergedTarget }
return { saved: false, row: mergedTarget }
return { saved: true, row: null }
}
catch
@@ -220,49 +230,197 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
finally
{
setEditingRow (current =>
current?.sourceRow === row.sourceRow
? null
: current)
current?.sourceRow === row.sourceRow
? null
: current)
}
}
const retry = async (sourceRow: number) => {
if (busy)
return
const initialSession = sessionRef.current
if (initialSession == null)
return
setLoadingRow (sourceRow)
const originalRow = initialSession.rows.find (row => row.sourceRow === sourceRow)
if (originalRow == null)
{
setLoadingRow (null)
return
}
try
{
const pendingRows = retryImportRow (initialSession.rows, sourceRow)
const pendingSession = { ...initialSession, rows: pendingRows }
persistSession (pendingSession)
const requestedRows = pendingRows.filter (row => row.importStatus !== 'created')
const validated = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/validate', {
rows: requestedRows.map (row => ({
sourceRow: row.sourceRow,
url: row.url,
attributes: row.attributes,
provenance: row.provenance,
tagSources: row.tagSources,
metadataUrl: row.metadataUrl })),
changed_row: -1 })
const validatedRows = initialisePreviewRows (validated.rows)
if (!(hasExactSourceRows (requestedRows.map (row => row.sourceRow), validatedRows)))
{
const latest = sessionRef.current ?? pendingSession
const restoredRows = replaceImportRow (latest.rows, originalRow)
persistSession ({
...latest,
rows: restoredRows,
repairMode: resultRepairMode (restoredRows) })
toast ({ title: '再検証結果が不完全でした' })
return
}
const validatedTarget = validatedRows.find (row => row.sourceRow === sourceRow)
if (validatedTarget == null)
{
const latest = sessionRef.current ?? pendingSession
const restoredRows = replaceImportRow (latest.rows, originalRow)
persistSession ({
...latest,
rows: restoredRows,
repairMode: resultRepairMode (restoredRows) })
toast ({ title: '再検証結果が不完全でした' })
return
}
const latestAfterValidate = sessionRef.current ?? pendingSession
const mergedValidatedRows = mergeValidatedImportRow (
latestAfterValidate.rows,
validatedTarget)
const nextSession = {
...latestAfterValidate,
rows: mergedValidatedRows,
repairMode: resultRepairMode (mergedValidatedRows) }
persistSession (nextSession)
const target = mergedValidatedRows.find (row => row.sourceRow === sourceRow)
if (target == null)
{
const restoredRows = replaceImportRow (latestAfterValidate.rows, originalRow)
persistSession ({
...latestAfterValidate,
rows: restoredRows,
repairMode: resultRepairMode (restoredRows) })
toast ({ title: '再検証結果が不完全でした' })
return
}
if (Object.keys (target.validationErrors ?? { }).length > 0)
{
void editRow (target)
return
}
const result = await apiPost<{
created: number
skipped: number
failed: number
rows: PostImportResultRow[] }> ('/posts/import', {
rows: [{
sourceRow: target.sourceRow,
url: target.url,
attributes: target.attributes,
provenance: target.provenance,
tagSources: target.tagSources,
metadataUrl: target.metadataUrl }] })
if (!(hasExactSourceRows ([sourceRow], result.rows)))
{
const latest = sessionRef.current ?? nextSession
const restoredRows = replaceImportRow (latest.rows, originalRow)
persistSession ({
...latest,
rows: restoredRows,
repairMode: resultRepairMode (restoredRows) })
toast ({ title: '登録結果が不完全でした' })
return
}
const latestAfterImport = sessionRef.current ?? nextSession
const mergedRows = mergeImportResults (latestAfterImport.rows, result.rows)
const recoverableRows = result.rows.filter (row =>
row.status === 'failed'
&& row.recoverable
&& Object.keys (row.errors ?? { }).length > 0)
const nextRows = mergedRows.map ((row): PostImportRow => {
const recoverable = recoverableRows.find (
failedRow => failedRow.sourceRow === row.sourceRow)
if (recoverable == null)
return row
return {
...row,
importStatus: 'pending',
recoverable: true,
validationErrors: recoverable.errors ?? { },
importErrors: undefined }
})
const resultSession = {
...latestAfterImport,
rows: nextRows,
repairMode: resultRepairMode (nextRows) }
persistSession (resultSession)
if (nextRows.every (row =>
row.importStatus === 'created' || row.importStatus === 'skipped'))
{
finishImport ()
return
}
}
catch
{
const latest = sessionRef.current ?? initialSession
const restoredRows = replaceImportRow (latest.rows, originalRow)
persistSession ({
...latest,
rows: restoredRows,
repairMode: resultRepairMode (restoredRows) })
toast ({ title: '再試行に失敗しました' })
}
finally
{
setLoadingRow (null)
}
}
const submit = async () => {
const currentSession = sessionRef.current
if (sessionId == null || currentSession == null || processable.length === 0)
if (currentSession == null || processable.length === 0 || busy)
return
setLoading (true)
try
{
const validatableRows =
currentSession.rows.filter (row => row.importStatus !== 'created')
currentSession.rows.filter (row => row.importStatus !== 'created')
const validated = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/validate', {
rows:
validatableRows.map (row => ({ sourceRow: row.sourceRow,
url: row.url,
attributes: row.attributes,
provenance: row.provenance,
tagSources: row.tagSources,
metadataUrl: row.metadataUrl })),
validatableRows.map (row => ({
sourceRow: row.sourceRow,
url: row.url,
attributes: row.attributes,
provenance: row.provenance,
tagSources: row.tagSources,
metadataUrl: row.metadataUrl })),
changed_row: -1 })
const validatedRows = initialisePreviewRows (validated.rows)
const expectedSourceRows = validatableRows.map (row => row.sourceRow)
if (!(hasExactSourceRows (expectedSourceRows, validatedRows)))
{
toast ({ title: '再検証結果が不完全でした' })
return
}
{
toast ({ title: '再検証結果が不完全でした' })
return
}
const latestAfterValidate = sessionRef.current ?? currentSession
const mergedRows = mergeValidatedImportRows (latestAfterValidate.rows, validatedRows)
const firstInvalid = mergedRows.find (row => Object.keys (row.validationErrors).length > 0)
if (firstInvalid != null)
{
const nextSession = {
persistSession ({
...latestAfterValidate,
rows: mergedRows,
repairMode: resultRepairMode (mergedRows) }
sessionRef.current = nextSession
setSession (nextSession)
repairMode: resultRepairMode (mergedRows) })
void editRow (firstInvalid)
return
}
@@ -270,12 +428,7 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
...latestAfterValidate,
rows: mergedRows,
repairMode: resultRepairMode (mergedRows) }
sessionRef.current = validatedSession
setSession (validatedSession)
const savedValidated = savePostImportSession (sessionId, validatedSession, message =>
toast ({ title: '取込状態を保存できませんでした', description: message }))
if (!(savedValidated))
return
persistSession (validatedSession)
const result = await apiPost<{
created: number
@@ -302,7 +455,7 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
&& row.recoverable
&& Object.keys (row.errors ?? { }).length > 0)
const nextRows = mergedResults.map ((row): PostImportRow => {
const recoverable = recoverableRows.find (rr => rr.sourceRow === row.sourceRow)
const recoverable = recoverableRows.find (failedRow => failedRow.sourceRow === row.sourceRow)
if (recoverable == null)
return row
return {
@@ -316,13 +469,12 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
...latestAfterImport,
rows: nextRows,
repairMode: resultRepairMode (nextRows) }
sessionRef.current = nextSession
setSession (nextSession)
const saved = savePostImportSession (sessionId, nextSession, message =>
toast ({ title: '取込状態を保存できませんでした', description: message }))
if (!(saved))
return
navigate (`/posts/import/${ sessionId }/result`)
persistSession (nextSession)
if (nextRows.every (row =>
row.importStatus === 'created' || row.importStatus === 'skipped'))
{
finishImport ()
}
}
catch
{
@@ -337,19 +489,8 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
if (!(editable))
return <Forbidden/>
if (missing || sessionId == null || session == null)
{
return (
<MainArea>
<div className="mx-auto max-w-4xl space-y-4 p-4">
<PageTitle>稿</PageTitle>
<div className="text-red-700 dark:text-red-300"></div>
<Button type="button" onClick={() => navigate ('/posts/import')}>
URL
</Button>
</div>
</MainArea>)
}
if (session == null)
return null
return (
<>
@@ -366,19 +507,22 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
<div key={row.sourceRow} id={`post-import-row-${ row.sourceRow }`}>
<PostImportRowSummary
row={row}
editDisabled={loading}
onEdit={() => void editRow (row)}/>
editDisabled={busy}
retryDisabled={busy}
onEdit={() => void editRow (row)}
onRetry={canRetryResultRow (row) ? () => void retry (row.sourceRow) : undefined}
rowMessages={resultRowMessages (row)}/>
</div>))}
</div>
</div>
</MainArea>
<PostImportFooter
loading={loading}
loading={busy}
processableCount={processable.length}
creatableCount={creatable.length}
skipPlannedCount={counts.skipPlanned}
onBack={() => navigate ('/posts/import')}
onBack={() => navigate ('/posts/new')}
onSubmit={submit}/>
</>)
}
@@ -389,12 +533,12 @@ const PostImportFooter = (
creatableCount,
skipPlannedCount,
onBack,
onSubmit }: { loading: boolean
processableCount: number
creatableCount: number
skipPlannedCount: number
onBack: () => void
onSubmit: () => void },
onSubmit }: { loading: boolean
processableCount: number
creatableCount: number
skipPlannedCount: number
onBack: () => void
onSubmit: () => void },
) => (
<div
className="shrink-0 border-t bg-white/95 p-4 backdrop-blur
+2 -3
ファイルの表示
@@ -82,12 +82,11 @@ describe ('PostImportSourcePage', () => {
fireEvent.click (screen.getByRole ('button', { name: '次へ' }))
await waitFor (() => {
expect (router.navigate).toHaveBeenCalledWith (
expect.stringMatching (/^\/posts\/import\/[^/]+\/review$/))
expect (router.navigate).toHaveBeenCalledWith ('/posts/new/review')
})
const sessionKeys = Array.from ({ length: sessionStorage.length }, (_, index) =>
sessionStorage.key (index)).filter (
key => key?.startsWith ('post-import-session:'))
expect (sessionKeys).toHaveLength (1)
expect (sessionKeys).toEqual (['post-import-session:current'])
})
})
+13 -5
ファイルの表示
@@ -15,8 +15,9 @@ import { apiPost, isApiError } from '@/lib/api'
import { canEditContent } from '@/lib/users'
import { countImportSourceLines,
cleanupExpiredPostImportSessions,
createPostImportSessionId,
clearPostImportSession,
initialisePreviewRows,
loadPostImportSession,
loadPostImportSourceDraft,
savePostImportSession,
savePostImportSourceDraft,
@@ -67,11 +68,18 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
cleanupExpiredPostImportSessions (message =>
toast ({ title: '保存済みデータを整理できませんでした', description: message }))
const currentSession = loadPostImportSession (message =>
toast ({ title: '保存済み入力を復元できませんでした', description: message }))
const draft = loadPostImportSourceDraft (message =>
toast ({ title: '保存済み入力を復元できませんでした', description: message }))
if (!(editedRef.current))
setSource (current => current === '' ? draft.source : current)
{
setSource (current =>
current === ''
? (currentSession?.source || draft.source)
: current)
}
}, [])
useEffect (() => {
@@ -117,16 +125,16 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
setSourceIssues (urlIssues)
return
}
const sessionId = createPostImportSessionId ()
clearPostImportSession (message =>
toast ({ title: '取込状態を保存できませんでした', description: message }))
const saved = savePostImportSession (
sessionId,
{ source,
rows: initialisePreviewRows (data.rows),
repairMode: 'all' },
message => toast ({ title: '取込状態を保存できませんでした', description: message }))
if (!(saved))
return
navigate (`/posts/import/${ sessionId }/review`)
navigate ('/posts/new/review')
}
catch (requestError)
{
+45 -35
ファイルの表示
@@ -37,11 +37,14 @@ describe ('PostNewPage', () => {
renderWithProviders (<PostNewPage user={buildUser ({ role: 'member' })}/>)
const textboxes = screen.getAllByRole ('textbox')
fireEvent.change (textboxes[0], { target: { value: 'https://example.com/post' } })
fireEvent.change (textboxes[1], { target: { value: '投稿タイトル' } })
fireEvent.change (textboxes[3], { target: { value: '1 2' } })
fireEvent.change (textboxes[4], { target: { value: 'tag1 tag2' } })
fireEvent.change (screen.getByLabelText ('URL'), {
target: { value: 'https://example.com/post' } })
fireEvent.change (screen.getByLabelText ('タイトル'), {
target: { value: '投稿タイトル' } })
fireEvent.change (screen.getByLabelText ('親投稿'), {
target: { value: '1 2' } })
fireEvent.change (screen.getByLabelText ('タグ'), {
target: { value: 'tag1 tag2' } })
fireEvent.click (screen.getByRole ('button', { name: '追加' }))
await waitFor (() => {
@@ -59,24 +62,27 @@ describe ('PostNewPage', () => {
expect (toastApi.toast).toHaveBeenCalledWith ({ title: '投稿成功!' })
})
it ('preserves duration while the video tag is temporarily removed', () => {
it ('shows the shared creation field order without duration or unrequested helper copy', () => {
api.apiGet.mockResolvedValue ([])
renderWithProviders (<PostNewPage user={buildUser ({ role: 'member' })}/>)
const { container } =
renderWithProviders (<PostNewPage user={buildUser ({ role: 'member' })}/>)
const tags = screen.getAllByRole ('textbox')[4]
fireEvent.change (tags, { target: { value: '動画' } })
fireEvent.change (
screen.getByPlaceholderText ('例: 2 / 2.5 / 1:23'),
{ target: { value: '180.5' } })
const labels = Array.from (container.querySelectorAll ('label'))
.map (node => node.textContent?.trim ())
fireEvent.change (tags, { target: { value: 'general-tag' } })
expect (labels.slice (0, 6)).toEqual ([
'URL',
'サムネール',
'タイトル',
'オリジナルの作成日時',
'タグ',
'親投稿'])
expect (screen.queryByPlaceholderText ('例: 2 / 2.5 / 1:23')).not.toBeInTheDocument ()
fireEvent.change (tags, {
target: { value: '動画 general-tag' },
})
expect (screen.getByPlaceholderText ('例: 2 / 2.5 / 1:23')).toHaveValue ('180.5')
expect (screen.queryByText ('必要なタイミングで URL から取得できます.'))
.not.toBeInTheDocument ()
expect (screen.getAllByRole ('button', { name: '取得' })).toHaveLength (2)
expect (screen.queryByRole ('button', { name: '変更をリセット' })).not.toBeInTheDocument ()
})
it ('shows 422 validation errors for post fields', async () => {
@@ -96,30 +102,32 @@ describe ('PostNewPage', () => {
renderWithProviders (<PostNewPage user={buildUser ({ role: 'member' })}/>)
const textboxes = screen.getAllByRole ('textbox')
fireEvent.change (textboxes[0], { target: { value: 'https://example.com/post' } })
fireEvent.change (textboxes[1], { target: { value: '投稿タイトル' } })
fireEvent.change (textboxes[4], { target: { value: 'nico:nico_tag' } })
fireEvent.change (screen.getByLabelText ('URL'), {
target: { value: 'https://example.com/post' } })
fireEvent.change (screen.getByLabelText ('タイトル'), {
target: { value: '投稿タイトル' } })
fireEvent.change (screen.getByLabelText ('タグ'), {
target: { value: 'nico:nico_tag' } })
fireEvent.click (screen.getByRole ('button', { name: '追加' }))
expect (await screen.findByText ('投稿内容を確認してください.')).toBeInTheDocument ()
expect (screen.getByText ('ニコニコ・タグは直接指定できません.')).toBeInTheDocument ()
expect (screen.getAllByRole ('textbox')[4]).toHaveAttribute ('aria-invalid', 'true')
expect (screen.getByLabelText ('タグ')).toHaveAttribute ('aria-invalid', 'true')
})
it ('uses the shared duration, tags, and datetime value contract for post submission', async () => {
it ('keeps shared tags and datetime submission values without showing duration', async () => {
api.apiPost.mockResolvedValueOnce ({})
api.apiGet.mockResolvedValue ([])
const { container } = renderWithProviders (<PostNewPage user={buildUser ({ role: 'member' })}/>)
const { container } = renderWithProviders (
<PostNewPage user={buildUser ({ role: 'member' })}/>)
const textboxes = screen.getAllByRole ('textbox')
fireEvent.change (textboxes[0], { target: { value: 'https://example.com/post' } })
fireEvent.change (textboxes[1], { target: { value: '投稿タイトル' } })
fireEvent.change (textboxes[4], { target: { value: '動画 tag1 tag2' } })
fireEvent.change (
screen.getByPlaceholderText ('例: 2 / 2.5 / 1:23'),
{ target: { value: '2.5' } })
fireEvent.change (screen.getByLabelText ('URL'), {
target: { value: 'https://example.com/post' } })
fireEvent.change (screen.getByLabelText ('タイトル'), {
target: { value: '投稿タイトル' } })
fireEvent.change (screen.getByLabelText ('タグ'), {
target: { value: '動画 tag1 tag2' } })
const datetimeInputs = container.querySelectorAll ('input[type="datetime-local"]')
fireEvent.change (datetimeInputs[0] as HTMLInputElement, {
target: { value: '2024-01-01T12:34' } })
@@ -129,13 +137,15 @@ describe ('PostNewPage', () => {
const formData = api.apiPost.mock.calls[0]?.[1] as FormData
expect (container.querySelector ('input[type="file"]')).not.toBeNull ()
expect (formData.get ('duration')).toBe ('2.5')
expect (formData.get ('duration')).toBeNull ()
expect (formData.get ('tags')).toBe ('動画 tag1 tag2')
expect (formData.get ('original_created_from')).toBe (
toMinutePrecisionIsoUtc ('2024-01-01T12:34'))
})
it ('shows deduplicated original-created endpoint errors on the shared datetime field', async () => {
it (
'shows deduplicated original-created endpoint errors on the shared datetime field',
async () => {
api.apiGet.mockResolvedValue ([])
api.isApiError.mockReturnValue (true)
api.apiPost.mockRejectedValueOnce ({
@@ -158,5 +168,5 @@ describe ('PostNewPage', () => {
expect (await screen.findByText ('日時を確認してください.')).toBeInTheDocument ()
expect (screen.getByText ('終了を確認してください.')).toBeInTheDocument ()
expect (screen.getAllByText ('日時を確認してください.')).toHaveLength (1)
})
})
})
+73 -101
ファイルの表示
@@ -1,14 +1,12 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Helmet } from 'react-helmet-async'
import { useNavigate } from 'react-router-dom'
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
import FieldError from '@/components/common/FieldError'
import Form from '@/components/common/Form'
import PageTitle from '@/components/common/PageTitle'
import MainArea from '@/components/layout/MainArea'
import PostDurationField from '@/components/posts/PostDurationField'
import PostTagsField from '@/components/posts/PostTagsField'
import PostCreationDataFields from '@/components/posts/PostCreationDataFields'
import PostTextField from '@/components/posts/PostTextField'
import PostThumbnailPreview from '@/components/posts/PostThumbnailPreview'
import { Button } from '@/components/ui/button'
@@ -27,12 +25,9 @@ type Props = { user: User | null }
type PostFormField =
'url' | 'title' | 'tags' | 'parentPostIds'
| 'videoMs' | 'originalCreatedAt' | 'originalCreatedFrom'
| 'originalCreatedAt' | 'originalCreatedFrom'
| 'originalCreatedBefore' | 'thumbnail'
const groupedMessages = (...values: (string[] | undefined)[]): string[] =>
[...new Set (values.flatMap (value => value ?? []))]
const PostNewPage: FC<Props> = ({ user }) => {
const editable = canEditContent (user)
@@ -48,7 +43,6 @@ const PostNewPage: FC<Props> = ({ user }) => {
useState<string | null> (null)
const [parentPostIds, setParentPostIds] = useState ('')
const [tags, setTags] = useState ('')
const [duration, setDuration] = useState ('')
const [thumbnailFile, setThumbnailFile] = useState<File | null> (null)
const [thumbnailLoading, setThumbnailLoading] = useState (false)
const [thumbnailPreview, setThumbnailPreview] = useState<string> ('')
@@ -57,9 +51,6 @@ const PostNewPage: FC<Props> = ({ user }) => {
const [url, setURL] = useState ('')
const thumbnailPreviewRef = useRef ('')
const videoFlg =
useMemo (() => tags.split (/\s+/).some (tag => tag.replace (/\[.*\]$/, '') === '動画'),
[tags])
const handleSubmit = async () => {
clearValidationErrors ()
@@ -69,8 +60,6 @@ const PostNewPage: FC<Props> = ({ user }) => {
formData.append ('url', url)
formData.append ('tags', tags)
formData.append ('parent_post_ids', parentPostIds)
if (videoFlg && duration !== '')
formData.append ('duration', duration)
if (thumbnailFile)
formData.append ('thumbnail', thumbnailFile)
if (originalCreatedFrom)
@@ -143,93 +132,76 @@ const PostNewPage: FC<Props> = ({ user }) => {
<PageTitle>稿</PageTitle>
<FieldError messages={baseErrors}/>
<PostTextField
label="URL"
type="url"
value={url}
errors={fieldErrors.url}
placeholder="例:https://www.nicovideo.jp/watch/..."
onChange={setURL}/>
<PostTextField
label="タイトル"
value={title}
errors={fieldErrors.title}
disabled={titleLoading}
placeholder={titleLoading ? 'Loading...' : undefined}
onChange={setTitle}
after={
<div className="flex flex-wrap items-center gap-2 text-sm">
<span> URL </span>
<Button
type="button"
variant="outline"
onClick={() => void fetchTitle ()}
disabled={!(url) || titleLoading}>
</Button>
</div>}/>
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2 text-sm">
<span> URL </span>
<Button
type="button"
variant="outline"
onClick={() => void fetchThumbnail ()}
disabled={!(url) || thumbnailLoading}>
</Button>
</div>
{thumbnailLoading && (
<p className="text-gray-500 text-sm">Loading...</p>)}
<PostTextField
label="サムネール"
value={thumbnailFile?.name ?? ''}
errors={fieldErrors.thumbnail}
disabled
onChange={() => {}}/>
<input
type="file"
accept="image/*"
onChange={e => {
const file = e.target.files?.[0]
if (file)
{
setThumbnailFile (file)
setThumbnailPreview (URL.createObjectURL (file))
}
}}/>
<PostThumbnailPreview
url={thumbnailPreview}
alt="preview"
className="h-28 w-28"/>
</div>
<PostTextField
label="親投稿"
value={parentPostIds}
errors={fieldErrors.parentPostIds}
onChange={setParentPostIds}/>
<PostTagsField tags={tags} setTags={setTags} errors={fieldErrors.tags}/>
{/* オリジナルの作成日時 */}
<PostOriginalCreatedTimeField
originalCreatedFrom={originalCreatedFrom}
setOriginalCreatedFrom={setOriginalCreatedFrom}
originalCreatedBefore={originalCreatedBefore}
setOriginalCreatedBefore={setOriginalCreatedBefore}
errors={groupedMessages (
fieldErrors.originalCreatedAt,
fieldErrors.originalCreatedFrom,
fieldErrors.originalCreatedBefore)}/>
{videoFlg && (
<PostDurationField
value={duration}
errors={fieldErrors.videoMs}
onChange={setDuration}/>)}
<PostCreationDataFields
url={{
value: url,
onChange: setURL,
errors: fieldErrors.url,
type: 'url',
placeholder: '例:https://www.nicovideo.jp/watch/...' }}
thumbnailField={
<div className="space-y-2">
<PostTextField
label="サムネール"
value={thumbnailFile?.name ?? ''}
errors={fieldErrors.thumbnail}
disabled
onChange={() => {}}
after={
<Button
type="button"
variant="outline"
onClick={() => void fetchThumbnail ()}
disabled={!(url) || thumbnailLoading}>
</Button>}/>
<input
type="file"
accept="image/*"
onChange={e => {
const file = e.target.files?.[0]
if (file)
{
setThumbnailFile (file)
setThumbnailPreview (URL.createObjectURL (file))
}
}}/>
<PostThumbnailPreview
url={thumbnailPreview}
alt="preview"
className="h-28 w-28"/>
</div>}
core={{
title: {
value: title,
onChange: setTitle,
errors: fieldErrors.title,
disabled: titleLoading,
after: (
<Button
type="button"
variant="outline"
onClick={() => void fetchTitle ()}
disabled={!(url) || titleLoading}>
</Button>) },
originalCreated: {
originalCreatedFrom,
setOriginalCreatedFrom,
originalCreatedBefore,
setOriginalCreatedBefore,
errors: {
originalCreatedAt: fieldErrors.originalCreatedAt,
originalCreatedFrom: fieldErrors.originalCreatedFrom,
originalCreatedBefore: fieldErrors.originalCreatedBefore } },
tags: {
value: tags,
onChange: setTags,
errors: fieldErrors.tags },
parentPostIds: {
value: parentPostIds,
onChange: setParentPostIds,
errors: fieldErrors.parentPostIds } }}/>
{/* 送信 */}
<Button onClick={handleSubmit}