このコミットが含まれているのは:
2026-07-16 20:35:07 +09:00
コミット 43a3772976
20個のファイルの変更305行の追加157行の削除
+1 -1
ファイルの表示
@@ -263,7 +263,7 @@ describe ('PostImportResultPage', () => {
fireEvent.change (screen.getByDisplayValue ('old title'), {
target: { value: 'edited title' } })
await act (async () => {
await actions.find (_1 => _1.label === '編輯内容を保存')?.onSelect ()
await actions.find (action => action.label === '編輯内容を保存')?.onSelect ()
})
})
+14 -56
ファイルの表示
@@ -17,6 +17,7 @@ import { apiPost } from '@/lib/api'
import useDialogue from '@/lib/dialogues/useDialogue'
import { canEditContent } from '@/lib/users'
import { clearPostImportSourceDraft,
buildNextEditedRow,
canEditResultRow,
canRetryResultRow,
hasExactSourceRows,
@@ -43,54 +44,6 @@ import type { User } from '@/types'
type Props = { user: User | null }
const buildNextEditedRow = (
editingRow: PostImportRow,
draft: PostImportRowDraft,
urlChanged: boolean,
): PostImportRow => {
const nextProvenance = { ...editingRow.provenance }
const nextAttributes = { ...editingRow.attributes }
const nextTagSources = {
automatic: editingRow.tagSources?.automatic ?? '',
manual: editingRow.tagSources?.manual ?? '' }
const draftFields = [
['title', draft.title],
['thumbnailBase', draft.thumbnailBase],
['originalCreatedFrom', draft.originalCreatedFrom],
['originalCreatedBefore', draft.originalCreatedBefore],
['duration', draft.duration],
['parentPostIds', draft.parentPostIds]] as const
draftFields.forEach (([field, value]) => {
nextAttributes[field] = value
nextProvenance[field] =
value !== String (editingRow.attributes[field] ?? '')
? 'manual'
: (editingRow.provenance[field] ?? 'automatic')
})
nextAttributes.tags = draft.tags
if (draft.tags !== String (editingRow.attributes.tags ?? ''))
{
nextProvenance.tags = 'manual'
nextTagSources.manual = draft.tags
}
else
{
nextProvenance.tags = editingRow.provenance.tags ?? 'automatic'
nextTagSources.manual = editingRow.tagSources?.manual ?? ''
}
return {
...editingRow,
url: draft.url,
attributes: nextAttributes,
provenance: {
...nextProvenance,
url: urlChanged ? 'manual' : (editingRow.provenance.url ?? 'manual') },
tagSources: nextTagSources,
importStatus: editingRow.importStatus === 'created' ? 'created' : 'pending',
importErrors: undefined }
}
const PostImportResultPage: FC<Props> = ({ user }) => {
const editable = canEditContent (user)
@@ -186,7 +139,8 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
metadataUrl: row.metadataUrl })),
changed_row: urlChanged ? baseRow.sourceRow : -1 })
const validatedRows = initialisePreviewRows (validated.rows)
const target = validatedRows.find (_1 => _1.sourceRow === baseRow.sourceRow)
const target = validatedRows.find (
validatedRow => validatedRow.sourceRow === baseRow.sourceRow)
const latestSession = sessionRef.current
if (latestSession == null)
return { saved: false, row: null }
@@ -211,7 +165,9 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
sessionRef.current = nextSession
setSession (nextSession)
persistSession (nextSession)
const mergedTarget = rows.find (_1 => _1.sourceRow === baseRow.sourceRow) ?? target
const mergedTarget =
rows.find (mergedRow => mergedRow.sourceRow === baseRow.sourceRow)
?? target
if (Object.keys (mergedTarget.validationErrors).length > 0)
return { saved: false, row: mergedTarget }
return { saved: true, row: null }
@@ -267,7 +223,7 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
return
setLoadingRow (sourceRow)
const originalRow = initialSession.rows.find (_1 => _1.sourceRow === sourceRow)
const originalRow = initialSession.rows.find (row => row.sourceRow === sourceRow)
if (originalRow == null)
{
setLoadingRow (null)
@@ -305,7 +261,8 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
toast ({ title: '再検証結果が不完全でした' })
return
}
const validatedTarget = validatedRows.find (_1 => _1.sourceRow === sourceRow)
const validatedTarget = validatedRows.find (
row => row.sourceRow === sourceRow)
if (validatedTarget == null)
{
const latest = sessionRef.current ?? pendingSession
@@ -331,7 +288,7 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
sessionRef.current = nextSession
setSession (nextSession)
persistSession (nextSession)
const target = mergedValidatedRows.find (_1 => _1.sourceRow === sourceRow)
const target = mergedValidatedRows.find (row => row.sourceRow === sourceRow)
if (target == null)
{
const restoredRows = replaceImportRow (latestAfterValidate.rows, originalRow)
@@ -387,7 +344,8 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
&& row.recoverable
&& Object.keys (row.errors ?? { }).length > 0)
const nextRows = mergedRows.map ((row): PostImportRow => {
const recoverable = recoverableRows.find (_1 => _1.sourceRow === row.sourceRow)
const recoverable = recoverableRows.find (
recoverableRow => recoverableRow.sourceRow === row.sourceRow)
if (recoverable == null)
return row
return {
@@ -397,7 +355,7 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
validationErrors: recoverable.errors ?? { },
importErrors: undefined }
})
const recoverableTarget = nextRows.find (_1 => _1.sourceRow === sourceRow)
const recoverableTarget = nextRows.find (row => row.sourceRow === sourceRow)
const resultSession = {
...nextSession,
rows: nextRows,
@@ -449,7 +407,7 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
toast ({ title: '取込状態を保存できませんでした', description: message }))
if (!(saved))
return
const row = nextSession.rows.find (_1 => _1.sourceRow === sourceRow)
const row = nextSession.rows.find (rowValue => rowValue.sourceRow === sourceRow)
if (row != null)
void editRow (row)
}
+80 -1
ファイルの表示
@@ -3,11 +3,12 @@ import { HelmetProvider } from 'react-helmet-async'
import { MemoryRouter, Route, Routes } from 'react-router-dom'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { savePostImportSession } from '@/lib/postImportSession'
import { loadPostImportSession, savePostImportSession } from '@/lib/postImportSession'
import PostImportReviewPage from '@/pages/posts/PostImportReviewPage'
import { buildUser } from '@/test/factories'
import { buildPostImportRow } from '@/test/postImportFactories'
import type { DialogueFormAction, DialogueFormControls } from '@/lib/dialogues/useDialogue'
import type { PostImportRow } from '@/lib/postImportSession'
const api = vi.hoisted (() => ({
@@ -178,4 +179,82 @@ describe ('PostImportReviewPage', () => {
})
expect (screen.queryByText ('RESULT ROUTE')).not.toBeInTheDocument ()
})
it ('keeps edited recoverable rows in session before the next batch submit', async () => {
savePostImportSession ('review-edit-retry', {
source: 'https://example.com/post',
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: 0,
skipped: 0,
failed: 1,
rows: [{
sourceRow: 1,
status: 'failed',
recoverable: true,
errors: { title: ['invalid'] } }] })
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 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>)
fireEvent.click (screen.getByRole ('button', { name: '編輯' }))
await waitFor (() => {
const saved = loadPostImportSession ('review-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[1]?.[1]?.rows?.[0]?.attributes?.title)
.toBe ('edited title')
})
})
})
+8 -51
ファイルの表示
@@ -13,6 +13,7 @@ import { apiPost } from '@/lib/api'
import useDialogue from '@/lib/dialogues/useDialogue'
import { canEditContent } from '@/lib/users'
import { loadPostImportSession,
buildNextEditedRow,
canEditReviewRow,
creatableImportRows,
hasExactSourceRows,
@@ -145,7 +146,8 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
metadataUrl: row.metadataUrl })),
changed_row: urlChanged ? baseRow.sourceRow : -1 })
const validatedRows = initialisePreviewRows (validated.rows)
const target = validatedRows.find (_1 => _1.sourceRow === baseRow.sourceRow)
const target = validatedRows.find (
validatedRow => validatedRow.sourceRow === baseRow.sourceRow)
const latestSession = sessionRef.current
if (latestSession == null)
return { saved: false, row: null }
@@ -161,14 +163,17 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
toast ({ title: '行の再検証結果が不完全でした' })
return { saved: false, row: null }
}
const rows = mergeValidatedImportRow (latestSession.rows, target)
const editedRows = replaceImportRow (latestSession.rows, nextRow)
const rows = mergeValidatedImportRow (editedRows, target)
const nextSession = {
...latestSession,
rows,
repairMode: resultRepairMode (rows) }
sessionRef.current = nextSession
setSession (nextSession)
const mergedTarget = rows.find (_1 => _1.sourceRow === baseRow.sourceRow) ?? target
const mergedTarget =
rows.find (mergedRow => mergedRow.sourceRow === baseRow.sourceRow)
?? target
if (Object.keys (mergedTarget.validationErrors).length > 0)
return { saved: false, row: mergedTarget }
return { saved: true, row: null }
@@ -411,52 +416,4 @@ const PostImportFooter = (
</div>
</div>)
const buildNextEditedRow = (
editingRow: PostImportRow,
draft: PostImportRowDraft,
urlChanged: boolean,
): PostImportRow => {
const nextProvenance = { ...editingRow.provenance }
const nextAttributes = { ...editingRow.attributes }
const nextTagSources = {
automatic: editingRow.tagSources?.automatic ?? '',
manual: editingRow.tagSources?.manual ?? '' }
const draftFields = [
['title', draft.title],
['thumbnailBase', draft.thumbnailBase],
['originalCreatedFrom', draft.originalCreatedFrom],
['originalCreatedBefore', draft.originalCreatedBefore],
['duration', draft.duration],
['parentPostIds', draft.parentPostIds]] as const
draftFields.forEach (([field, value]) => {
nextAttributes[field] = value
nextProvenance[field] =
value !== String (editingRow.attributes[field] ?? '')
? 'manual'
: (editingRow.provenance[field] ?? 'automatic')
})
nextAttributes.tags = draft.tags
if (draft.tags !== String (editingRow.attributes.tags ?? ''))
{
nextProvenance.tags = 'manual'
nextTagSources.manual = draft.tags
}
else
{
nextProvenance.tags = editingRow.provenance.tags ?? 'automatic'
nextTagSources.manual = editingRow.tagSources?.manual ?? ''
}
return {
...editingRow,
url: draft.url,
attributes: nextAttributes,
provenance: {
...nextProvenance,
url: urlChanged ? 'manual' : (editingRow.provenance.url ?? 'manual') },
tagSources: nextTagSources,
importStatus: editingRow.importStatus === 'created' ? 'created' : 'pending',
importErrors: undefined }
}
export default PostImportReviewPage
+2 -1
ファイルの表示
@@ -86,7 +86,8 @@ describe ('PostImportSourcePage', () => {
expect.stringMatching (/^\/posts\/import\/[^/]+\/review$/))
})
const sessionKeys = Array.from ({ length: sessionStorage.length }, (_, index) =>
sessionStorage.key (index)).filter (_1 => _1?.startsWith ('post-import-session:'))
sessionStorage.key (index)).filter (
key => key?.startsWith ('post-import-session:'))
expect (sessionKeys).toHaveLength (1)
})
})
+1 -1
ファイルの表示
@@ -60,7 +60,7 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
const sourceDescribedBy =
[sourceError != null ? SOURCE_ERROR_ID : null,
sourceIssues.length > 0 ? SOURCE_ISSUES_ID : null]
.filter (_1 => _1 != null)
.filter (value => value != null)
.join (' ')
useEffect (() => {
+26 -11
ファイルの表示
@@ -1,8 +1,7 @@
import { readFileSync } from 'node:fs'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { toMinutePrecisionIsoUtc } from '@/components/common/DateTimeField'
import PostNewPage from '@/pages/posts/PostNewPage'
import { buildUser } from '@/test/factories'
import { renderWithProviders } from '@/test/render'
@@ -108,15 +107,31 @@ describe ('PostNewPage', () => {
expect (screen.getAllByRole ('textbox')[4]).toHaveAttribute ('aria-invalid', 'true')
})
it ('shares the common post field components with the import form', () => {
const newPage = readFileSync ('src/pages/posts/PostNewPage.tsx', 'utf8')
const importForm = readFileSync ('src/components/posts/import/PostImportRowForm.tsx', 'utf8')
it ('uses the shared duration, tags, and datetime value contract for post submission', async () => {
api.apiPost.mockResolvedValueOnce ({})
api.apiGet.mockResolvedValue ([])
expect (newPage).toContain ("@/components/posts/PostTextField")
expect (newPage).toContain ("@/components/posts/PostDurationField")
expect (newPage).toContain ("@/components/posts/PostTagsField")
expect (importForm).toContain ("@/components/posts/PostTextField")
expect (importForm).toContain ("@/components/posts/PostDurationField")
expect (importForm).toContain ("@/components/posts/PostTagsField")
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' } })
const datetimeInputs = container.querySelectorAll ('input[type="datetime-local"]')
fireEvent.change (datetimeInputs[0] as HTMLInputElement, {
target: { value: '2024-01-01T12:34' } })
fireEvent.click (screen.getByRole ('button', { name: '追加' }))
await waitFor (() => expect (api.apiPost).toHaveBeenCalled ())
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 ('tags')).toBe ('動画 tag1 tag2')
expect (formData.get ('original_created_from')).toBe (
toMinutePrecisionIsoUtc ('2024-01-01T12:34'))
})
})