このコミットが含まれているのは:
@@ -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
|
||||||
|
|||||||
@@ -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,9 +265,10 @@ 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,
|
year,
|
||||||
month,
|
month,
|
||||||
day,
|
day,
|
||||||
@@ -275,6 +276,15 @@ class Post < ApplicationRecord
|
|||||||
minute,
|
minute,
|
||||||
second + Rational(parse_original_created_nanoseconds(fraction), 1_000_000_000),
|
second + Rational(parse_original_created_nanoseconds(fraction), 1_000_000_000),
|
||||||
normalise_original_created_offset(offset)).in_time_zone
|
normalise_original_created_offset(offset)).in_time_zone
|
||||||
|
end
|
||||||
|
|
||||||
|
Time.zone.local(
|
||||||
|
year,
|
||||||
|
month,
|
||||||
|
day,
|
||||||
|
hour,
|
||||||
|
minute,
|
||||||
|
second).change(nsec: parse_original_created_nanoseconds(fraction))
|
||||||
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
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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])
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 = (
|
||||||
|
|||||||
@@ -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')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 ()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 (() => {
|
||||||
|
|||||||
@@ -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'))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
新しい課題から参照
ユーザをブロックする