このコミットが含まれているのは:
@@ -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
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 (() => {
|
||||
|
||||
@@ -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'))
|
||||
})
|
||||
})
|
||||
|
||||
新しい課題から参照
ユーザをブロックする