このコミットが含まれているのは:
2026-07-16 20:35:07 +09:00
コミット 43a3772976
20個のファイルの変更305行の追加157行の削除
+4
ファイルの表示
@@ -281,6 +281,10 @@ case 'no':
- In TypeScript and TSX, use `value == null` and `value != null` as the
default nullish checks. Do not use `=== null`, `=== undefined`,
`!== null`, or `!== undefined`.
- In JavaScript, JSX, TypeScript, and TSX, never use `_1`, `_2`, or similar
Ruby-style numbered parameter names. Reserve numbered parameters for Ruby.
Use a meaningful callback parameter name such as `row`, `item`, `value`,
`entry`, or `result`.
- If code appears to need a distinction between `null` and `undefined`, treat
that as a design smell and revise the logic to avoid the distinction.
External library APIs that explicitly require distinguishing the two are the
+15 -5
ファイルの表示
@@ -253,7 +253,7 @@ class Post < ApplicationRecord
value.match(
/\A(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/ \
'(?::(\d{2})(?:\.(\d+))?)?' \
'(Z|[+-]\d{2}:?\d{2})\z/')
'(Z|[+-]\d{2}:?\d{2})?\z/')
return nil if match.nil?
year = match[1].to_i
@@ -265,16 +265,26 @@ class Post < ApplicationRecord
fraction = match[7]
offset = match[8]
return nil unless valid_original_created_components?(year, month, day, hour, minute, second)
return nil unless valid_original_created_offset?(offset)
return nil if offset.present? && !(valid_original_created_offset?(offset))
Time.new(
if offset.present?
return Time.new(
year,
month,
day,
hour,
minute,
second + Rational(parse_original_created_nanoseconds(fraction), 1_000_000_000),
normalise_original_created_offset(offset)).in_time_zone
end
Time.zone.local(
year,
month,
day,
hour,
minute,
second + Rational(parse_original_created_nanoseconds(fraction), 1_000_000_000),
normalise_original_created_offset(offset)).in_time_zone
second).change(nsec: parse_original_created_nanoseconds(fraction))
rescue ArgumentError, TypeError
nil
end
+5 -1
ファイルの表示
@@ -173,6 +173,10 @@ pass or the remaining failure is clearly blocked.
`?` / `:` pairing.
- Keep short inline props types local when they remain readable and within the
line limit; do not mechanically extract a named type with no reuse benefit.
- In JavaScript, JSX, TypeScript, and TSX, never use `_1`, `_2`, or similar
Ruby-style numbered parameter names. Reserve numbered parameters for Ruby.
Use a meaningful callback parameter name such as `row`, `item`, `value`,
`entry`, or `result`.
- In multi-line object literals, keep the opening `{` with the first pair when
the line length allows it; do not mechanically explode short objects into
Prettier-style vertical blocks.
@@ -258,7 +262,7 @@ finally
```ts
const editingRow =
Number.isFinite (editingSourceRow)
? rows.find (_1 => _1.sourceRow === editingSourceRow) ?? null
? rows.find (row => row.sourceRow === editingSourceRow) ?? null
: null
```
+2 -1
ファイルの表示
@@ -8,7 +8,8 @@ const submenuItem = (role: 'guest' | 'member' | 'admin', section: string, item:
user: buildUser ({ role }),
wikiId: section === 'Wiki' ? 10 : null,
pathName: section === 'Wiki' ? '/wiki/page' : '/posts' })
return menu.find (_1 => _1.name === section)?.subMenu.find (_1 => _1.name === item)
return menu.find (entry => entry.name === section)?.subMenu.find (
subMenuItem => subMenuItem.name === item)
}
describe ('menuOutline', () => {
+2 -2
ファイルの表示
@@ -86,7 +86,7 @@ const DialogueProvider: FC<Props> = ({ children }) => {
return current.filter (request => request.id !== id)
})
setPendingIds (current => current.filter (_1 => _1 !== id))
setPendingIds (current => current.filter (pendingId => pendingId !== id))
setFormActions (current => {
const { [id]: _, ...rest } = current
return rest
@@ -147,7 +147,7 @@ const DialogueProvider: FC<Props> = ({ children }) => {
}
finally
{
setPendingIds (current => current.filter (_1 => _1 !== id))
setPendingIds (current => current.filter (pendingId => pendingId !== id))
}
},
[closeRequest, pendingIds])
+38 -5
ファイルの表示
@@ -24,7 +24,7 @@ describe ('PostImportRowForm', () => {
const titleInput = screen.getByDisplayValue ('manual title')
await waitFor (() => expect (actions.at (-1)?.length).toBe (2))
const reset = actions.at (-1)?.find (_1 => _1.label === '変更をリセット')
const reset = actions.at (-1)?.find (action => action.label === '変更をリセット')
expect (reset).toMatchObject ({ placement: 'start', variant: 'danger', disabled: false })
await act (async () => {
await reset?.onSelect ()
@@ -34,7 +34,7 @@ describe ('PostImportRowForm', () => {
expect (titleInput).toHaveValue ('')
expect (onSave).not.toHaveBeenCalled ()
const save = actions.at (-1)?.find (_1 => _1.label === '編輯内容を保存')
const save = actions.at (-1)?.find (action => action.label === '編輯内容を保存')
await act (async () => {
await save?.onSelect ()
})
@@ -63,7 +63,7 @@ describe ('PostImportRowForm', () => {
await waitFor (() => expect (actions.length).toBe (2))
await act (async () => {
await actions.find (_1 => _1.label === '変更をリセット')?.onSelect ()
await actions.find (action => action.label === '変更をリセット')?.onSelect ()
})
expect (screen.getByDisplayValue ('manual title')).toBeInTheDocument ()
@@ -86,7 +86,7 @@ describe ('PostImportRowForm', () => {
expect (screen.getByText ('duration error')).toBeInTheDocument ()
expect (screen.getByText ('title warning')).toBeInTheDocument ()
expect (screen.getAllByRole ('textbox').filter (
_1 => _1.getAttribute ('aria-invalid') === 'true')).toHaveLength (3)
textbox => textbox.getAttribute ('aria-invalid') === 'true')).toHaveLength (3)
})
it ('keeps untouched original created values unchanged in the save payload', async () => {
@@ -111,7 +111,7 @@ describe ('PostImportRowForm', () => {
await waitFor (() => expect (actions.length).toBe (2))
await act (async () => {
await actions.find (_1 => _1.label === '編輯内容を保存')?.onSelect ()
await actions.find (action => action.label === '編輯内容を保存')?.onSelect ()
})
expect (onSave).toHaveBeenCalledWith ({
@@ -120,4 +120,37 @@ describe ('PostImportRowForm', () => {
originalCreatedBefore: '2024-01-01T12:35+09:00' }),
resetRequested: false })
})
it ('keeps the shared duration and tags string contract without leaking file upload UI', async () => {
let actions: DialogueFormAction[] = []
const controls: DialogueFormControls = {
close: vi.fn (),
confirm: vi.fn (),
setActions: next => {
actions = next
} }
const onSave = vi.fn ().mockResolvedValue ({ saved: true, row: null })
const { container } = render (
<PostImportRowForm
row={buildPostImportRow ({
attributes: { duration: '2', tags: 'tag1' } })}
controls={controls}
onSave={onSave}/>)
await waitFor (() => expect (actions.length).toBe (2))
expect (container.querySelector ('input[type="file"]')).toBeNull ()
expect (screen.getByPlaceholderText ('例: 2 / 2.5 / 1:23')).toHaveValue ('2')
expect (screen.getByDisplayValue ('tag1')).toBeInTheDocument ()
await act (async () => {
await actions.find (action => action.label === '編輯内容を保存')?.onSelect ()
})
expect (onSave).toHaveBeenCalledWith ({
draft: expect.objectContaining ({
duration: '2',
tags: 'tag1' }),
resetRequested: false })
})
})
+2 -10
ファイルの表示
@@ -10,18 +10,10 @@ import PostThumbnailPreview from '@/components/posts/PostThumbnailPreview'
import type { FC } from 'react'
import type { PostImportRow } from '@/lib/postImportSession'
import type { PostImportEditableDraft, PostImportRow } from '@/lib/postImportSession'
import type { DialogueFormControls } from '@/lib/dialogues/useDialogue'
type Draft = {
url: string
title: string
thumbnailBase: string
originalCreatedFrom: string
originalCreatedBefore: string
duration: string
tags: string
parentPostIds: string }
type Draft = PostImportEditableDraft
type Props = {
row: PostImportRow
+1 -1
ファイルの表示
@@ -5,7 +5,7 @@ export type PostImportDisplayStatus = 'ready' | 'skipped' | 'warning'
export type PostImportBadgeValue = PostImportDisplayStatus
const hasWarnings = (row: PostImportRow): boolean =>
Object.values (row.fieldWarnings ?? { }).some (_1 => _1.length > 0)
Object.values (row.fieldWarnings ?? { }).some (messages => messages.length > 0)
|| row.baseWarnings.length > 0
export const displayPostImportStatus = (
+35
ファイルの表示
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'
import { creatableImportRows,
buildNextEditedRow,
canEditResultRow,
canEditReviewRow,
canRetryResultRow,
@@ -250,4 +251,38 @@ describe ('post import row state', () => {
expect (initialised.resetSnapshot.attributes.title).toBe ('')
expect (initialised.resetSnapshot.fieldWarnings.title).toEqual (['warning'])
})
it ('builds the next edited row with shared repair semantics', () => {
const row = buildPostImportRow ({
url: 'https://example.com/original',
importStatus: 'failed',
recoverable: true,
importErrors: { base: ['failed'] },
attributes: { title: 'old title', tags: 'old-tag', duration: '2' },
provenance: { title: 'automatic', tags: 'automatic', url: 'manual' },
tagSources: { automatic: 'old-tag', manual: '' } })
const nextRow = buildNextEditedRow (
row,
{
url: 'https://example.com/edited',
title: 'edited title',
thumbnailBase: '',
originalCreatedFrom: '',
originalCreatedBefore: '',
duration: '2.5',
tags: 'edited-tag',
parentPostIds: '' },
true)
expect (nextRow.importStatus).toBe ('pending')
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.tags).toBe ('edited-tag')
expect (nextRow.provenance.title).toBe ('manual')
expect (nextRow.provenance.url).toBe ('manual')
expect (nextRow.tagSources?.manual).toBe ('edited-tag')
})
})
+51 -2
ファイルの表示
@@ -1,4 +1,5 @@
import type { PostImportResultRow,
import type { PostImportEditableDraft,
PostImportResultRow,
PostImportRow } from '@/lib/postImportTypes'
const hasSkipReason = (row: PostImportRow): boolean =>
@@ -112,6 +113,54 @@ export const resultRowWarnings = (row: PostImportRow): string[] =>
...Object.values (row.fieldWarnings ?? { }).flat (),
...row.baseWarnings])]
export const buildNextEditedRow = (
editingRow: PostImportRow,
draft: PostImportEditableDraft,
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 const hasExactSourceRows = (
expected: number[],
@@ -121,7 +170,7 @@ export const hasExactSourceRows = (
return false
const expectedSorted = [...expected].sort ((a, b) => a - b)
const actualSorted = actual.map (_1 => _1.sourceRow).sort ((a, b) => a - b)
const actualSorted = actual.map (row => row.sourceRow).sort ((a, b) => a - b)
return expectedSorted.every ((value, index) => value === actualSorted[index])
}
+2 -2
ファイルの表示
@@ -40,8 +40,8 @@ const normaliseImportUrl = (value: string): string | null => {
export const countImportSourceLines = (source: string): number =>
source
.split (/\r\n|\n|\r/)
.map (_1 => _1.trim ())
.filter (_1 => _1 !== '')
.map (line => line.trim ())
.filter (line => line !== '')
.length
+6 -6
ファイルの表示
@@ -96,7 +96,7 @@ const ensureStringListRecord = (value: unknown): Record<string, string[]> | null
const result: Record<string, string[]> = { }
for (const [key, entry] of Object.entries (value))
{
if (!(Array.isArray (entry)) || !(entry.every (_1 => typeof _1 === 'string')))
if (!(Array.isArray (entry)) || !(entry.every (item => typeof item === 'string')))
return null
result[key] = entry
}
@@ -161,7 +161,7 @@ const sanitiseResetSnapshot = (value: unknown): PostImportResetSnapshot | null =
return null
if (!(hasOnlyKeys (value.tagSources, TAG_SOURCE_KEYS)))
return null
if (!(Object.values (value.tagSources).every (_1 => typeof _1 === 'string')))
if (!(Object.values (value.tagSources).every (entry => typeof entry === 'string')))
return null
const fieldWarnings = ensureStringListRecord (value.fieldWarnings)
if (fieldWarnings == null)
@@ -169,7 +169,7 @@ const sanitiseResetSnapshot = (value: unknown): PostImportResetSnapshot | null =
if (!(hasOnlyKeys (fieldWarnings, WARNING_KEYS)))
return null
if (!(Array.isArray (value.baseWarnings))
|| !(value.baseWarnings.every (_1 => typeof _1 === 'string')))
|| !(value.baseWarnings.every (warning => typeof warning === 'string')))
return null
if (value.metadataUrl != null && typeof value.metadataUrl !== 'string')
return null
@@ -235,7 +235,7 @@ const sanitiseRow = (value: unknown): PostImportRow | null => {
if (importErrors === null)
return null
if (!(Array.isArray (value.baseWarnings))
|| !(value.baseWarnings.every (_1 => typeof _1 === 'string')))
|| !(value.baseWarnings.every (warning => typeof warning === 'string')))
return null
const provenanceEntries = Object.entries (value.provenance)
@@ -255,7 +255,7 @@ const sanitiseRow = (value: unknown): PostImportRow | null => {
return null
if (!(hasOnlyKeys (value.tagSources, TAG_SOURCE_KEYS)))
return null
if (!(Object.values (value.tagSources).every (_1 => typeof _1 === 'string')))
if (!(Object.values (value.tagSources).every (entry => typeof entry === 'string')))
return null
}
@@ -401,7 +401,7 @@ export const loadPostImportSession = (
}
const rows = value.rows.map (sanitiseRow)
if (rows.some (_1 => _1 == null))
if (rows.some (row => row == null))
return null
return {
+10
ファイルの表示
@@ -72,4 +72,14 @@ export type PostImportSourceIssue = {
message: string
url: string }
export type PostImportEditableDraft = {
url: string
title: string
thumbnailBase: string
originalCreatedFrom: string
originalCreatedBefore: string
duration: string
tags: string
parentPostIds: string }
export type StorageErrorHandler = (message: string) => void
+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'))
})
})