広場投稿追加画面の刷新 (#399) #413
@@ -172,6 +172,8 @@ class Post < ApplicationRecord
|
||||
private
|
||||
|
||||
def validate_original_created_range
|
||||
return if skip_original_created_validation?
|
||||
|
||||
f = parse_original_created_value(:original_created_from)
|
||||
b = parse_original_created_value(:original_created_before)
|
||||
return if f.nil? || b.nil?
|
||||
@@ -251,4 +253,12 @@ class Post < ApplicationRecord
|
||||
def minute_precision_time? value
|
||||
value.sec.zero? && value.nsec.zero?
|
||||
end
|
||||
|
||||
def skip_original_created_validation?
|
||||
return false if new_record?
|
||||
return false if will_save_change_to_original_created_from?
|
||||
return false if will_save_change_to_original_created_before?
|
||||
|
||||
true
|
||||
end
|
||||
end
|
||||
|
||||
@@ -244,6 +244,44 @@ RSpec.describe Post, type: :model do
|
||||
end
|
||||
|
||||
describe 'original created datetime validation' do
|
||||
it 'allows unrelated updates on persisted posts with second-bearing datetimes' do
|
||||
post = described_class.create!(title: 'title', url: 'https://example.com/post')
|
||||
post.update_columns(
|
||||
original_created_from: Time.zone.parse('2024-01-01T12:34:30Z'),
|
||||
original_created_before: Time.zone.parse('2024-01-01T12:35:30Z')
|
||||
)
|
||||
|
||||
post.title = 'updated title'
|
||||
|
||||
expect(post).to be_valid
|
||||
expect { post.save! }.not_to raise_error
|
||||
end
|
||||
|
||||
it 'rejects second-bearing updates when the datetime field changes' do
|
||||
post = described_class.create!(title: 'title', url: 'https://example.com/post')
|
||||
|
||||
post.original_created_from = '2024-01-01T12:34:30Z'
|
||||
|
||||
expect(post).to be_invalid
|
||||
expect(post.errors[:original_created_from]).to eq(
|
||||
[described_class::ORIGINAL_CREATED_MINUTE_PRECISION_MESSAGE]
|
||||
)
|
||||
end
|
||||
|
||||
it 'accepts fixing persisted datetimes to minute precision' do
|
||||
post = described_class.create!(title: 'title', url: 'https://example.com/post')
|
||||
post.update_columns(
|
||||
original_created_from: Time.zone.parse('2024-01-01T12:34:30Z'),
|
||||
original_created_before: Time.zone.parse('2024-01-01T12:35:30Z')
|
||||
)
|
||||
|
||||
post.original_created_from = '2024-01-01T12:34Z'
|
||||
post.original_created_before = '2024-01-01T12:35Z'
|
||||
|
||||
expect(post).to be_valid
|
||||
expect { post.save! }.not_to raise_error
|
||||
end
|
||||
|
||||
it 'adds only the minute-precision error for second precision values' do
|
||||
post = described_class.new(
|
||||
title: 'title',
|
||||
|
||||
@@ -109,6 +109,8 @@ RSpec.describe 'Post imports API', type: :request do
|
||||
expect(json.fetch('rows').first.fetch('validation_errors')).to include(
|
||||
'original_created_from' => ['オリジナルの作成日時は分単位で入力してください.']
|
||||
)
|
||||
expect(json.fetch('rows').first.fetch('validation_errors'))
|
||||
.not_to have_key('original_created_at')
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -2221,6 +2221,7 @@ RSpec.describe 'Posts API', type: :request do
|
||||
expect(json.fetch('errors')).to include(
|
||||
'original_created_from' => ['オリジナルの作成日時は分単位で入力してください.']
|
||||
)
|
||||
expect(json.fetch('errors')).not_to have_key('original_created_at')
|
||||
end
|
||||
|
||||
it 'rejects fractional-second original created timestamps on POST /posts' do
|
||||
@@ -2237,22 +2238,23 @@ RSpec.describe 'Posts API', type: :request do
|
||||
expect(json.fetch('errors')).to include(
|
||||
'original_created_before' => ['オリジナルの作成日時は分単位で入力してください.']
|
||||
)
|
||||
expect(json.fetch('errors')).not_to have_key('original_created_at')
|
||||
end
|
||||
|
||||
it 'rejects original created ranges shorter than one minute on POST /posts' do
|
||||
it 'rejects non-increasing original created ranges on POST /posts' do
|
||||
sign_in_as(member)
|
||||
|
||||
post '/posts', params: post_write_params(
|
||||
title: 'too short original created range',
|
||||
url: 'https://example.com/too-short-original-created-range',
|
||||
title: 'non-increasing original created range',
|
||||
url: 'https://example.com/non-increasing-original-created-range',
|
||||
tags: 'spec_tag',
|
||||
thumbnail: dummy_upload,
|
||||
original_created_from: '2020-01-01T00:00Z',
|
||||
original_created_before: '2020-01-01T00:00:30Z')
|
||||
original_created_before: '2020-01-01T00:00Z')
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(json.fetch('errors')).to include(
|
||||
'original_created_at' => ['オリジナルの作成日時の範囲は1分以上必要です.']
|
||||
'original_created_at' => ['オリジナルの作成日時の順番がをかしぃです.']
|
||||
)
|
||||
end
|
||||
|
||||
|
||||
@@ -46,20 +46,37 @@ describe ('PostOriginalCreatedTimeField', () => {
|
||||
.toBe (60_000)
|
||||
})
|
||||
|
||||
it ('normalises second-bearing values to minute precision', () => {
|
||||
it ('does not rewrite mounted values that only differ by offset notation', () => {
|
||||
const setFrom = vi.fn ()
|
||||
const setBefore = vi.fn ()
|
||||
|
||||
render (
|
||||
<PostOriginalCreatedTimeField
|
||||
originalCreatedFrom="2026-01-01T00:00:05Z"
|
||||
originalCreatedFrom="2024-01-01T12:34+09:00"
|
||||
setOriginalCreatedFrom={setFrom}
|
||||
originalCreatedBefore="2026-01-02T00:00:00.123Z"
|
||||
originalCreatedBefore="2024-01-01T12:35+09:00"
|
||||
setOriginalCreatedBefore={setBefore}/>,
|
||||
)
|
||||
|
||||
expect (setFrom).toHaveBeenCalledWith ('2026-01-01T00:00Z')
|
||||
expect (setBefore).toHaveBeenCalledWith ('2026-01-02T00:00Z')
|
||||
expect (setFrom).not.toHaveBeenCalled ()
|
||||
expect (setBefore).not.toHaveBeenCalled ()
|
||||
})
|
||||
|
||||
it ('emits minute-precision UTC values only when the user edits the input', () => {
|
||||
const setFrom = vi.fn ()
|
||||
|
||||
render (
|
||||
<PostOriginalCreatedTimeField
|
||||
originalCreatedFrom="2024-01-01T12:34+09:00"
|
||||
setOriginalCreatedFrom={setFrom}
|
||||
originalCreatedBefore={null}
|
||||
setOriginalCreatedBefore={vi.fn ()}/>,
|
||||
)
|
||||
|
||||
const input = screen.getDisplayValue ('2024-01-01T12:34')
|
||||
fireEvent.change (input, { target: { value: '2024-01-01T12:35' } })
|
||||
|
||||
expect (setFrom).toHaveBeenCalledWith ('2024-01-01T03:35Z')
|
||||
})
|
||||
|
||||
it ('resets both values', () => {
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { useEffect } from 'react'
|
||||
|
||||
import DateTimeField, { toMinutePrecisionIsoUtc } from '@/components/common/DateTimeField'
|
||||
import FormField from '@/components/common/FormField'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -22,24 +20,6 @@ const PostOriginalCreatedTimeField: FC<Props> = (
|
||||
originalCreatedBefore,
|
||||
setOriginalCreatedBefore,
|
||||
errors }: Props) => {
|
||||
useEffect (() => {
|
||||
if (originalCreatedFrom == null)
|
||||
return
|
||||
|
||||
const normalised = toMinutePrecisionIsoUtc (originalCreatedFrom)
|
||||
if (normalised !== originalCreatedFrom)
|
||||
setOriginalCreatedFrom (normalised)
|
||||
}, [originalCreatedFrom, setOriginalCreatedFrom])
|
||||
|
||||
useEffect (() => {
|
||||
if (originalCreatedBefore == null)
|
||||
return
|
||||
|
||||
const normalised = toMinutePrecisionIsoUtc (originalCreatedBefore)
|
||||
if (normalised !== originalCreatedBefore)
|
||||
setOriginalCreatedBefore (normalised)
|
||||
}, [originalCreatedBefore, setOriginalCreatedBefore])
|
||||
|
||||
return (
|
||||
<FormField label="オリジナルの作成日時" messages={errors}>
|
||||
{({ describedBy, invalid }) => (
|
||||
|
||||
@@ -88,4 +88,36 @@ describe ('PostImportRowForm', () => {
|
||||
expect (screen.getAllByRole ('textbox').filter (
|
||||
_1 => _1.getAttribute ('aria-invalid') === 'true')).toHaveLength (3)
|
||||
})
|
||||
|
||||
it ('keeps untouched original created values unchanged in the save payload', async () => {
|
||||
let actions: DialogueFormAction[] = []
|
||||
const row = buildPostImportRow ({
|
||||
attributes: {
|
||||
originalCreatedFrom: '2024-01-01T12:34+09:00',
|
||||
originalCreatedBefore: '2024-01-01T12:35+09:00' } })
|
||||
const controls: DialogueFormControls = {
|
||||
close: vi.fn (),
|
||||
confirm: vi.fn (),
|
||||
setActions: next => {
|
||||
actions = next
|
||||
} }
|
||||
const onSave = vi.fn ().mockResolvedValue ({ saved: true, row: null })
|
||||
|
||||
render (
|
||||
<PostImportRowForm
|
||||
row={row}
|
||||
controls={controls}
|
||||
onSave={onSave}/>)
|
||||
await waitFor (() => expect (actions.length).toBe (2))
|
||||
|
||||
await act (async () => {
|
||||
await actions.find (_1 => _1.label === '編輯内容を保存')?.onSelect ()
|
||||
})
|
||||
|
||||
expect (onSave).toHaveBeenCalledWith ({
|
||||
draft: expect.objectContaining ({
|
||||
originalCreatedFrom: '2024-01-01T12:34+09:00',
|
||||
originalCreatedBefore: '2024-01-01T12:35+09:00' }),
|
||||
resetRequested: false })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,13 +1,23 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
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 { savePostImportSession } from '@/lib/postImportSession'
|
||||
import {
|
||||
loadPostImportSession,
|
||||
savePostImportSession,
|
||||
} from '@/lib/postImportSession'
|
||||
import PostImportResultPage from '@/pages/posts/PostImportResultPage'
|
||||
import { buildUser } from '@/test/factories'
|
||||
import { buildPostImportRow } from '@/test/postImportFactories'
|
||||
|
||||
import type { PostImportRow } from '@/lib/postImportSession'
|
||||
import type { DialogueFormAction, DialogueFormControls } from '@/lib/dialogues/useDialogue'
|
||||
|
||||
const api = vi.hoisted (() => ({
|
||||
apiPost: vi.fn (),
|
||||
}))
|
||||
|
||||
const toastApi = vi.hoisted (() => ({
|
||||
toast: vi.fn (),
|
||||
}))
|
||||
@@ -17,6 +27,7 @@ const dialogue = vi.hoisted (() => ({
|
||||
}))
|
||||
|
||||
vi.mock ('@/components/ui/use-toast', () => toastApi)
|
||||
vi.mock ('@/lib/api', () => api)
|
||||
vi.mock ('@/lib/dialogues/useDialogue', () => ({
|
||||
default: () => dialogue,
|
||||
}))
|
||||
@@ -103,4 +114,207 @@ describe ('PostImportResultPage', () => {
|
||||
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 (_1 => _1.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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -125,6 +125,15 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
|
||||
const counts = useMemo (
|
||||
() => resultSummaryCounts (session?.rows ?? []),
|
||||
[session])
|
||||
const busy = loadingRow != null
|
||||
|
||||
const persistSession = (nextSession: PostImportSession) => {
|
||||
if (sessionId == null)
|
||||
return
|
||||
|
||||
savePostImportSession (sessionId, nextSession, message =>
|
||||
toast ({ title: '取込状態を保存できませんでした', description: message }))
|
||||
}
|
||||
|
||||
const saveDraft = async (
|
||||
row: PostImportRow,
|
||||
@@ -187,13 +196,15 @@ const PostImportResultPage: 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)
|
||||
persistSession (nextSession)
|
||||
const mergedTarget = rows.find (_1 => _1.sourceRow === baseRow.sourceRow) ?? target
|
||||
if (Object.keys (mergedTarget.validationErrors).length > 0)
|
||||
return { saved: false, row: mergedTarget }
|
||||
@@ -242,7 +253,7 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
|
||||
}
|
||||
|
||||
const retry = async (sourceRow: number) => {
|
||||
if (session == null || sessionId == null)
|
||||
if (session == null || sessionId == null || loadingRow != null)
|
||||
return
|
||||
|
||||
const initialSession = sessionRef.current
|
||||
@@ -262,6 +273,7 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
|
||||
const pendingSession = { ...initialSession, rows: pendingRows }
|
||||
sessionRef.current = pendingSession
|
||||
setSession (pendingSession)
|
||||
persistSession (pendingSession)
|
||||
const validated = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/validate', {
|
||||
rows: pendingRows
|
||||
.filter (row => row.importStatus !== 'created')
|
||||
@@ -285,6 +297,7 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
|
||||
repairMode: resultRepairMode (restoredRows) }
|
||||
sessionRef.current = restoredSession
|
||||
setSession (restoredSession)
|
||||
persistSession (restoredSession)
|
||||
toast ({ title: '再検証結果が不完全でした' })
|
||||
return
|
||||
}
|
||||
@@ -298,6 +311,7 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
|
||||
repairMode: resultRepairMode (validatedRows) }
|
||||
sessionRef.current = nextSession
|
||||
setSession (nextSession)
|
||||
persistSession (nextSession)
|
||||
const target = validatedRows.find (_1 => _1.sourceRow === sourceRow)
|
||||
if (target == null)
|
||||
{
|
||||
@@ -308,6 +322,7 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
|
||||
repairMode: resultRepairMode (restoredRows) }
|
||||
sessionRef.current = restoredSession
|
||||
setSession (restoredSession)
|
||||
persistSession (restoredSession)
|
||||
toast ({ title: '再検証結果が不完全でした' })
|
||||
return
|
||||
}
|
||||
@@ -356,6 +371,7 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
|
||||
repairMode: resultRepairMode (nextRows) }
|
||||
sessionRef.current = resultSession
|
||||
setSession (resultSession)
|
||||
persistSession (resultSession)
|
||||
if (recoverableTarget != null
|
||||
&& Object.keys (recoverableTarget.validationErrors).length > 0)
|
||||
{
|
||||
@@ -378,6 +394,7 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
|
||||
repairMode: resultRepairMode (restoredRows) }
|
||||
sessionRef.current = restoredSession
|
||||
setSession (restoredSession)
|
||||
persistSession (restoredSession)
|
||||
}
|
||||
toast ({ title: '再試行に失敗しました' })
|
||||
}
|
||||
@@ -389,7 +406,7 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
|
||||
|
||||
const openRepair = (sourceRow: number) => {
|
||||
const currentSession = sessionRef.current
|
||||
if (currentSession == null || sessionId == null)
|
||||
if (currentSession == null || sessionId == null || loadingRow != null)
|
||||
return
|
||||
|
||||
const nextSession = { ...currentSession, repairMode: 'failed' as const }
|
||||
@@ -479,14 +496,15 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => openRepair (row.sourceRow)}>
|
||||
onClick={() => openRepair (row.sourceRow)}
|
||||
disabled={busy}>
|
||||
編輯
|
||||
</Button>)}
|
||||
{canRetry && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => retry (row.sourceRow)}
|
||||
disabled={loadingRow === row.sourceRow}>
|
||||
disabled={busy}>
|
||||
再試行
|
||||
</Button>)}
|
||||
</div>
|
||||
@@ -498,6 +516,7 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
const currentSession = sessionRef.current
|
||||
if (currentSession == null)
|
||||
@@ -516,6 +535,7 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
clearPostImportSourceDraft (message =>
|
||||
toast ({ title: '入力内容を削除できませんでした', description: message }))
|
||||
|
||||
@@ -84,6 +84,11 @@ describe ('PostImportReviewPage', () => {
|
||||
new Promise<{ rows: PostImportRow[] }> (resolve => {
|
||||
resolveValidation = resolve
|
||||
}))
|
||||
api.apiPost.mockResolvedValueOnce ({
|
||||
created: 0,
|
||||
skipped: 0,
|
||||
failed: 0,
|
||||
rows: [] })
|
||||
|
||||
render (
|
||||
<HelmetProvider>
|
||||
@@ -106,4 +111,34 @@ describe ('PostImportReviewPage', () => {
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -231,6 +231,15 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
||||
metadataUrl: row.metadataUrl })),
|
||||
changed_row: -1 })
|
||||
const validatedRows = initialisePreviewRows (validated.rows)
|
||||
const expectedSourceRows = currentSession.rows
|
||||
.filter (row => row.importStatus !== 'created')
|
||||
.map (row => row.sourceRow)
|
||||
const validatedSourceRows = new Set (validatedRows.map (_1 => _1.sourceRow))
|
||||
if (expectedSourceRows.some (_1 => !(validatedSourceRows.has (_1))))
|
||||
{
|
||||
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)
|
||||
|
||||
新しい課題から参照
ユーザをブロックする