このコミットが含まれているのは:
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 - In TypeScript and TSX, use `value == null` and `value != null` as the
default nullish checks. Do not use `=== null`, `=== undefined`, default nullish checks. Do not use `=== null`, `=== undefined`,
`!== null`, or `!== 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 - 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. that as a design smell and revise the logic to avoid the distinction.
External library APIs that explicitly require distinguishing the two are the External library APIs that explicitly require distinguishing the two are the
+15 -5
ファイルの表示
@@ -253,7 +253,7 @@ class Post < ApplicationRecord
value.match( value.match(
/\A(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/ \ /\A(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/ \
'(?::(\d{2})(?:\.(\d+))?)?' \ '(?::(\d{2})(?:\.(\d+))?)?' \
'(Z|[+-]\d{2}:?\d{2})\z/') '(Z|[+-]\d{2}:?\d{2})?\z/')
return nil if match.nil? return nil if match.nil?
year = match[1].to_i year = match[1].to_i
@@ -265,16 +265,26 @@ class Post < ApplicationRecord
fraction = match[7] fraction = match[7]
offset = match[8] offset = match[8]
return nil unless valid_original_created_components?(year, month, day, hour, minute, second) 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, year,
month, month,
day, day,
hour, hour,
minute, minute,
second + Rational(parse_original_created_nanoseconds(fraction), 1_000_000_000), second).change(nsec: parse_original_created_nanoseconds(fraction))
normalise_original_created_offset(offset)).in_time_zone
rescue ArgumentError, TypeError rescue ArgumentError, TypeError
nil nil
end end
+5 -1
ファイルの表示
@@ -173,6 +173,10 @@ pass or the remaining failure is clearly blocked.
`?` / `:` pairing. `?` / `:` pairing.
- Keep short inline props types local when they remain readable and within the - 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. 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 - 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 the line length allows it; do not mechanically explode short objects into
Prettier-style vertical blocks. Prettier-style vertical blocks.
@@ -258,7 +262,7 @@ finally
```ts ```ts
const editingRow = const editingRow =
Number.isFinite (editingSourceRow) Number.isFinite (editingSourceRow)
? rows.find (_1 => _1.sourceRow === editingSourceRow) ?? null ? rows.find (row => row.sourceRow === editingSourceRow) ?? null
: null : null
``` ```
+2 -1
ファイルの表示
@@ -8,7 +8,8 @@ const submenuItem = (role: 'guest' | 'member' | 'admin', section: string, item:
user: buildUser ({ role }), user: buildUser ({ role }),
wikiId: section === 'Wiki' ? 10 : null, wikiId: section === 'Wiki' ? 10 : null,
pathName: section === 'Wiki' ? '/wiki/page' : '/posts' }) 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', () => { describe ('menuOutline', () => {
+2 -2
ファイルの表示
@@ -86,7 +86,7 @@ const DialogueProvider: FC<Props> = ({ children }) => {
return current.filter (request => request.id !== id) return current.filter (request => request.id !== id)
}) })
setPendingIds (current => current.filter (_1 => _1 !== id)) setPendingIds (current => current.filter (pendingId => pendingId !== id))
setFormActions (current => { setFormActions (current => {
const { [id]: _, ...rest } = current const { [id]: _, ...rest } = current
return rest return rest
@@ -147,7 +147,7 @@ const DialogueProvider: FC<Props> = ({ children }) => {
} }
finally finally
{ {
setPendingIds (current => current.filter (_1 => _1 !== id)) setPendingIds (current => current.filter (pendingId => pendingId !== id))
} }
}, },
[closeRequest, pendingIds]) [closeRequest, pendingIds])
+38 -5
ファイルの表示
@@ -24,7 +24,7 @@ describe ('PostImportRowForm', () => {
const titleInput = screen.getByDisplayValue ('manual title') const titleInput = screen.getByDisplayValue ('manual title')
await waitFor (() => expect (actions.at (-1)?.length).toBe (2)) 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 }) expect (reset).toMatchObject ({ placement: 'start', variant: 'danger', disabled: false })
await act (async () => { await act (async () => {
await reset?.onSelect () await reset?.onSelect ()
@@ -34,7 +34,7 @@ describe ('PostImportRowForm', () => {
expect (titleInput).toHaveValue ('') expect (titleInput).toHaveValue ('')
expect (onSave).not.toHaveBeenCalled () 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 act (async () => {
await save?.onSelect () await save?.onSelect ()
}) })
@@ -63,7 +63,7 @@ describe ('PostImportRowForm', () => {
await waitFor (() => expect (actions.length).toBe (2)) await waitFor (() => expect (actions.length).toBe (2))
await act (async () => { await act (async () => {
await actions.find (_1 => _1.label === '変更をリセット')?.onSelect () await actions.find (action => action.label === '変更をリセット')?.onSelect ()
}) })
expect (screen.getByDisplayValue ('manual title')).toBeInTheDocument () expect (screen.getByDisplayValue ('manual title')).toBeInTheDocument ()
@@ -86,7 +86,7 @@ describe ('PostImportRowForm', () => {
expect (screen.getByText ('duration error')).toBeInTheDocument () expect (screen.getByText ('duration error')).toBeInTheDocument ()
expect (screen.getByText ('title warning')).toBeInTheDocument () expect (screen.getByText ('title warning')).toBeInTheDocument ()
expect (screen.getAllByRole ('textbox').filter ( 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 () => { 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 waitFor (() => expect (actions.length).toBe (2))
await act (async () => { await act (async () => {
await actions.find (_1 => _1.label === '編輯内容を保存')?.onSelect () await actions.find (action => action.label === '編輯内容を保存')?.onSelect ()
}) })
expect (onSave).toHaveBeenCalledWith ({ expect (onSave).toHaveBeenCalledWith ({
@@ -120,4 +120,37 @@ describe ('PostImportRowForm', () => {
originalCreatedBefore: '2024-01-01T12:35+09:00' }), originalCreatedBefore: '2024-01-01T12:35+09:00' }),
resetRequested: false }) 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 { FC } from 'react'
import type { PostImportRow } from '@/lib/postImportSession' import type { PostImportEditableDraft, PostImportRow } from '@/lib/postImportSession'
import type { DialogueFormControls } from '@/lib/dialogues/useDialogue' import type { DialogueFormControls } from '@/lib/dialogues/useDialogue'
type Draft = { type Draft = PostImportEditableDraft
url: string
title: string
thumbnailBase: string
originalCreatedFrom: string
originalCreatedBefore: string
duration: string
tags: string
parentPostIds: string }
type Props = { type Props = {
row: PostImportRow row: PostImportRow
+1 -1
ファイルの表示
@@ -5,7 +5,7 @@ export type PostImportDisplayStatus = 'ready' | 'skipped' | 'warning'
export type PostImportBadgeValue = PostImportDisplayStatus export type PostImportBadgeValue = PostImportDisplayStatus
const hasWarnings = (row: PostImportRow): boolean => 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 || row.baseWarnings.length > 0
export const displayPostImportStatus = ( export const displayPostImportStatus = (
+35
ファイルの表示
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { creatableImportRows, import { creatableImportRows,
buildNextEditedRow,
canEditResultRow, canEditResultRow,
canEditReviewRow, canEditReviewRow,
canRetryResultRow, canRetryResultRow,
@@ -250,4 +251,38 @@ describe ('post import row state', () => {
expect (initialised.resetSnapshot.attributes.title).toBe ('') expect (initialised.resetSnapshot.attributes.title).toBe ('')
expect (initialised.resetSnapshot.fieldWarnings.title).toEqual (['warning']) 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' PostImportRow } from '@/lib/postImportTypes'
const hasSkipReason = (row: PostImportRow): boolean => const hasSkipReason = (row: PostImportRow): boolean =>
@@ -112,6 +113,54 @@ export const resultRowWarnings = (row: PostImportRow): string[] =>
...Object.values (row.fieldWarnings ?? { }).flat (), ...Object.values (row.fieldWarnings ?? { }).flat (),
...row.baseWarnings])] ...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 = ( export const hasExactSourceRows = (
expected: number[], expected: number[],
@@ -121,7 +170,7 @@ export const hasExactSourceRows = (
return false return false
const expectedSorted = [...expected].sort ((a, b) => a - b) 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]) 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 => export const countImportSourceLines = (source: string): number =>
source source
.split (/\r\n|\n|\r/) .split (/\r\n|\n|\r/)
.map (_1 => _1.trim ()) .map (line => line.trim ())
.filter (_1 => _1 !== '') .filter (line => line !== '')
.length .length
+6 -6
ファイルの表示
@@ -96,7 +96,7 @@ const ensureStringListRecord = (value: unknown): Record<string, string[]> | null
const result: Record<string, string[]> = { } const result: Record<string, string[]> = { }
for (const [key, entry] of Object.entries (value)) 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 return null
result[key] = entry result[key] = entry
} }
@@ -161,7 +161,7 @@ const sanitiseResetSnapshot = (value: unknown): PostImportResetSnapshot | null =
return null return null
if (!(hasOnlyKeys (value.tagSources, TAG_SOURCE_KEYS))) if (!(hasOnlyKeys (value.tagSources, TAG_SOURCE_KEYS)))
return null return null
if (!(Object.values (value.tagSources).every (_1 => typeof _1 === 'string'))) if (!(Object.values (value.tagSources).every (entry => typeof entry === 'string')))
return null return null
const fieldWarnings = ensureStringListRecord (value.fieldWarnings) const fieldWarnings = ensureStringListRecord (value.fieldWarnings)
if (fieldWarnings == null) if (fieldWarnings == null)
@@ -169,7 +169,7 @@ const sanitiseResetSnapshot = (value: unknown): PostImportResetSnapshot | null =
if (!(hasOnlyKeys (fieldWarnings, WARNING_KEYS))) if (!(hasOnlyKeys (fieldWarnings, WARNING_KEYS)))
return null return null
if (!(Array.isArray (value.baseWarnings)) if (!(Array.isArray (value.baseWarnings))
|| !(value.baseWarnings.every (_1 => typeof _1 === 'string'))) || !(value.baseWarnings.every (warning => typeof warning === 'string')))
return null return null
if (value.metadataUrl != null && typeof value.metadataUrl !== 'string') if (value.metadataUrl != null && typeof value.metadataUrl !== 'string')
return null return null
@@ -235,7 +235,7 @@ const sanitiseRow = (value: unknown): PostImportRow | null => {
if (importErrors === null) if (importErrors === null)
return null return null
if (!(Array.isArray (value.baseWarnings)) if (!(Array.isArray (value.baseWarnings))
|| !(value.baseWarnings.every (_1 => typeof _1 === 'string'))) || !(value.baseWarnings.every (warning => typeof warning === 'string')))
return null return null
const provenanceEntries = Object.entries (value.provenance) const provenanceEntries = Object.entries (value.provenance)
@@ -255,7 +255,7 @@ const sanitiseRow = (value: unknown): PostImportRow | null => {
return null return null
if (!(hasOnlyKeys (value.tagSources, TAG_SOURCE_KEYS))) if (!(hasOnlyKeys (value.tagSources, TAG_SOURCE_KEYS)))
return null return null
if (!(Object.values (value.tagSources).every (_1 => typeof _1 === 'string'))) if (!(Object.values (value.tagSources).every (entry => typeof entry === 'string')))
return null return null
} }
@@ -401,7 +401,7 @@ export const loadPostImportSession = (
} }
const rows = value.rows.map (sanitiseRow) const rows = value.rows.map (sanitiseRow)
if (rows.some (_1 => _1 == null)) if (rows.some (row => row == null))
return null return null
return { return {
+10
ファイルの表示
@@ -72,4 +72,14 @@ export type PostImportSourceIssue = {
message: string message: string
url: 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 export type StorageErrorHandler = (message: string) => void
+1 -1
ファイルの表示
@@ -263,7 +263,7 @@ describe ('PostImportResultPage', () => {
fireEvent.change (screen.getByDisplayValue ('old title'), { fireEvent.change (screen.getByDisplayValue ('old title'), {
target: { value: 'edited title' } }) target: { value: 'edited title' } })
await act (async () => { 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 useDialogue from '@/lib/dialogues/useDialogue'
import { canEditContent } from '@/lib/users' import { canEditContent } from '@/lib/users'
import { clearPostImportSourceDraft, import { clearPostImportSourceDraft,
buildNextEditedRow,
canEditResultRow, canEditResultRow,
canRetryResultRow, canRetryResultRow,
hasExactSourceRows, hasExactSourceRows,
@@ -43,54 +44,6 @@ import type { User } from '@/types'
type Props = { user: User | null } 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 PostImportResultPage: FC<Props> = ({ user }) => {
const editable = canEditContent (user) const editable = canEditContent (user)
@@ -186,7 +139,8 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
metadataUrl: row.metadataUrl })), metadataUrl: row.metadataUrl })),
changed_row: urlChanged ? baseRow.sourceRow : -1 }) changed_row: urlChanged ? baseRow.sourceRow : -1 })
const validatedRows = initialisePreviewRows (validated.rows) 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 const latestSession = sessionRef.current
if (latestSession == null) if (latestSession == null)
return { saved: false, row: null } return { saved: false, row: null }
@@ -211,7 +165,9 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
sessionRef.current = nextSession sessionRef.current = nextSession
setSession (nextSession) setSession (nextSession)
persistSession (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) if (Object.keys (mergedTarget.validationErrors).length > 0)
return { saved: false, row: mergedTarget } return { saved: false, row: mergedTarget }
return { saved: true, row: null } return { saved: true, row: null }
@@ -267,7 +223,7 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
return return
setLoadingRow (sourceRow) setLoadingRow (sourceRow)
const originalRow = initialSession.rows.find (_1 => _1.sourceRow === sourceRow) const originalRow = initialSession.rows.find (row => row.sourceRow === sourceRow)
if (originalRow == null) if (originalRow == null)
{ {
setLoadingRow (null) setLoadingRow (null)
@@ -305,7 +261,8 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
toast ({ title: '再検証結果が不完全でした' }) toast ({ title: '再検証結果が不完全でした' })
return return
} }
const validatedTarget = validatedRows.find (_1 => _1.sourceRow === sourceRow) const validatedTarget = validatedRows.find (
row => row.sourceRow === sourceRow)
if (validatedTarget == null) if (validatedTarget == null)
{ {
const latest = sessionRef.current ?? pendingSession const latest = sessionRef.current ?? pendingSession
@@ -331,7 +288,7 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
sessionRef.current = nextSession sessionRef.current = nextSession
setSession (nextSession) setSession (nextSession)
persistSession (nextSession) persistSession (nextSession)
const target = mergedValidatedRows.find (_1 => _1.sourceRow === sourceRow) const target = mergedValidatedRows.find (row => row.sourceRow === sourceRow)
if (target == null) if (target == null)
{ {
const restoredRows = replaceImportRow (latestAfterValidate.rows, originalRow) const restoredRows = replaceImportRow (latestAfterValidate.rows, originalRow)
@@ -387,7 +344,8 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
&& row.recoverable && row.recoverable
&& Object.keys (row.errors ?? { }).length > 0) && Object.keys (row.errors ?? { }).length > 0)
const nextRows = mergedRows.map ((row): PostImportRow => { 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) if (recoverable == null)
return row return row
return { return {
@@ -397,7 +355,7 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
validationErrors: recoverable.errors ?? { }, validationErrors: recoverable.errors ?? { },
importErrors: undefined } importErrors: undefined }
}) })
const recoverableTarget = nextRows.find (_1 => _1.sourceRow === sourceRow) const recoverableTarget = nextRows.find (row => row.sourceRow === sourceRow)
const resultSession = { const resultSession = {
...nextSession, ...nextSession,
rows: nextRows, rows: nextRows,
@@ -449,7 +407,7 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
toast ({ title: '取込状態を保存できませんでした', description: message })) toast ({ title: '取込状態を保存できませんでした', description: message }))
if (!(saved)) if (!(saved))
return return
const row = nextSession.rows.find (_1 => _1.sourceRow === sourceRow) const row = nextSession.rows.find (rowValue => rowValue.sourceRow === sourceRow)
if (row != null) if (row != null)
void editRow (row) void editRow (row)
} }
+80 -1
ファイルの表示
@@ -3,11 +3,12 @@ import { HelmetProvider } from 'react-helmet-async'
import { MemoryRouter, Route, Routes } from 'react-router-dom' import { MemoryRouter, Route, Routes } from 'react-router-dom'
import { beforeEach, describe, expect, it, vi } from 'vitest' 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 PostImportReviewPage from '@/pages/posts/PostImportReviewPage'
import { buildUser } from '@/test/factories' import { buildUser } from '@/test/factories'
import { buildPostImportRow } from '@/test/postImportFactories' import { buildPostImportRow } from '@/test/postImportFactories'
import type { DialogueFormAction, DialogueFormControls } from '@/lib/dialogues/useDialogue'
import type { PostImportRow } from '@/lib/postImportSession' import type { PostImportRow } from '@/lib/postImportSession'
const api = vi.hoisted (() => ({ const api = vi.hoisted (() => ({
@@ -178,4 +179,82 @@ describe ('PostImportReviewPage', () => {
}) })
expect (screen.queryByText ('RESULT ROUTE')).not.toBeInTheDocument () 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 useDialogue from '@/lib/dialogues/useDialogue'
import { canEditContent } from '@/lib/users' import { canEditContent } from '@/lib/users'
import { loadPostImportSession, import { loadPostImportSession,
buildNextEditedRow,
canEditReviewRow, canEditReviewRow,
creatableImportRows, creatableImportRows,
hasExactSourceRows, hasExactSourceRows,
@@ -145,7 +146,8 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
metadataUrl: row.metadataUrl })), metadataUrl: row.metadataUrl })),
changed_row: urlChanged ? baseRow.sourceRow : -1 }) changed_row: urlChanged ? baseRow.sourceRow : -1 })
const validatedRows = initialisePreviewRows (validated.rows) 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 const latestSession = sessionRef.current
if (latestSession == null) if (latestSession == null)
return { saved: false, row: null } return { saved: false, row: null }
@@ -161,14 +163,17 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
toast ({ title: '行の再検証結果が不完全でした' }) toast ({ title: '行の再検証結果が不完全でした' })
return { saved: false, row: null } return { saved: false, row: null }
} }
const rows = mergeValidatedImportRow (latestSession.rows, target) const editedRows = replaceImportRow (latestSession.rows, nextRow)
const rows = mergeValidatedImportRow (editedRows, target)
const nextSession = { const nextSession = {
...latestSession, ...latestSession,
rows, rows,
repairMode: resultRepairMode (rows) } repairMode: resultRepairMode (rows) }
sessionRef.current = nextSession sessionRef.current = nextSession
setSession (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) if (Object.keys (mergedTarget.validationErrors).length > 0)
return { saved: false, row: mergedTarget } return { saved: false, row: mergedTarget }
return { saved: true, row: null } return { saved: true, row: null }
@@ -411,52 +416,4 @@ const PostImportFooter = (
</div> </div>
</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 export default PostImportReviewPage
+2 -1
ファイルの表示
@@ -86,7 +86,8 @@ describe ('PostImportSourcePage', () => {
expect.stringMatching (/^\/posts\/import\/[^/]+\/review$/)) expect.stringMatching (/^\/posts\/import\/[^/]+\/review$/))
}) })
const sessionKeys = Array.from ({ length: sessionStorage.length }, (_, index) => 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) expect (sessionKeys).toHaveLength (1)
}) })
}) })
+1 -1
ファイルの表示
@@ -60,7 +60,7 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
const sourceDescribedBy = const sourceDescribedBy =
[sourceError != null ? SOURCE_ERROR_ID : null, [sourceError != null ? SOURCE_ERROR_ID : null,
sourceIssues.length > 0 ? SOURCE_ISSUES_ID : null] sourceIssues.length > 0 ? SOURCE_ISSUES_ID : null]
.filter (_1 => _1 != null) .filter (value => value != null)
.join (' ') .join (' ')
useEffect (() => { useEffect (() => {
+26 -11
ファイルの表示
@@ -1,8 +1,7 @@
import { readFileSync } from 'node:fs'
import { fireEvent, screen, waitFor } from '@testing-library/react' import { fireEvent, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import { toMinutePrecisionIsoUtc } from '@/components/common/DateTimeField'
import PostNewPage from '@/pages/posts/PostNewPage' import PostNewPage from '@/pages/posts/PostNewPage'
import { buildUser } from '@/test/factories' import { buildUser } from '@/test/factories'
import { renderWithProviders } from '@/test/render' import { renderWithProviders } from '@/test/render'
@@ -108,15 +107,31 @@ describe ('PostNewPage', () => {
expect (screen.getAllByRole ('textbox')[4]).toHaveAttribute ('aria-invalid', 'true') expect (screen.getAllByRole ('textbox')[4]).toHaveAttribute ('aria-invalid', 'true')
}) })
it ('shares the common post field components with the import form', () => { it ('uses the shared duration, tags, and datetime value contract for post submission', async () => {
const newPage = readFileSync ('src/pages/posts/PostNewPage.tsx', 'utf8') api.apiPost.mockResolvedValueOnce ({})
const importForm = readFileSync ('src/components/posts/import/PostImportRowForm.tsx', 'utf8') api.apiGet.mockResolvedValue ([])
expect (newPage).toContain ("@/components/posts/PostTextField") const { container } = renderWithProviders (<PostNewPage user={buildUser ({ role: 'member' })}/>)
expect (newPage).toContain ("@/components/posts/PostDurationField")
expect (newPage).toContain ("@/components/posts/PostTagsField") const textboxes = screen.getAllByRole ('textbox')
expect (importForm).toContain ("@/components/posts/PostTextField") fireEvent.change (textboxes[0], { target: { value: 'https://example.com/post' } })
expect (importForm).toContain ("@/components/posts/PostDurationField") fireEvent.change (textboxes[1], { target: { value: '投稿タイトル' } })
expect (importForm).toContain ("@/components/posts/PostTagsField") 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'))
}) })
}) })