このコミットが含まれているのは:
@@ -108,7 +108,12 @@ class PostMetadataFetcher
|
|||||||
end
|
end
|
||||||
|
|
||||||
from = timestamp.change(sec: 0, nsec: 0)
|
from = timestamp.change(sec: 0, nsec: 0)
|
||||||
before = from + 1.minute
|
before =
|
||||||
|
if match[5].nil?
|
||||||
|
from + 1.hour
|
||||||
|
else
|
||||||
|
from + 1.minute
|
||||||
|
end
|
||||||
[from, before]
|
[from, before]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,20 @@ RSpec.describe PostMetadataFetcher do
|
|||||||
expect(result.fetch(:original_created_from)).not_to include('.')
|
expect(result.fetch(:original_created_from)).not_to include('.')
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'treats hour-precision timestamps as one-hour ranges with and without offsets' do
|
||||||
|
offset_result = fetch_with_published_time('2024-02-03T12+02:30')
|
||||||
|
local_result = fetch_with_published_time('2024-02-03T12')
|
||||||
|
|
||||||
|
expect(Time.iso8601(offset_result.fetch(:original_created_from)))
|
||||||
|
.to eq(Time.iso8601('2024-02-03T12:00:00+02:30'))
|
||||||
|
expect(
|
||||||
|
Time.iso8601(offset_result.fetch(:original_created_before)) -
|
||||||
|
Time.iso8601(offset_result.fetch(:original_created_from))
|
||||||
|
).to eq(3600)
|
||||||
|
expect(local_result.fetch(:original_created_from)).to eq('2024-02-03T12:00:00Z')
|
||||||
|
expect(local_result.fetch(:original_created_before)).to eq('2024-02-03T13:00:00Z')
|
||||||
|
end
|
||||||
|
|
||||||
it 'adds platform tags for known video URLs' do
|
it 'adds platform tags for known video URLs' do
|
||||||
result = fetch_with_published_time('2024', url: 'https://youtu.be/abc123')
|
result = fetch_with_published_time('2024', url: 'https://youtu.be/abc123')
|
||||||
|
|
||||||
|
|||||||
@@ -89,4 +89,28 @@ describe ('PostEditForm', () => {
|
|||||||
})
|
})
|
||||||
expect (screen.getByPlaceholderText ('例: 2 / 2.5 / 1:23')).toHaveValue ('180.5')
|
expect (screen.getByPlaceholderText ('例: 2 / 2.5 / 1:23')).toHaveValue ('180.5')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it ('shows deduplicated original-created endpoint errors on the shared datetime field', async () => {
|
||||||
|
const post = buildPost ()
|
||||||
|
api.isApiError.mockReturnValue (true)
|
||||||
|
postsApi.updatePost.mockRejectedValueOnce ({
|
||||||
|
response: {
|
||||||
|
status: 422,
|
||||||
|
data: {
|
||||||
|
type: 'validation_error',
|
||||||
|
errors: {
|
||||||
|
original_created_at: ['日時を確認してください.'],
|
||||||
|
original_created_from: ['日時を確認してください.'],
|
||||||
|
original_created_before: ['終了を確認してください.'] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
render (<PostEditForm post={post} onSave={vi.fn ()}/>)
|
||||||
|
fireEvent.submit (screen.getByRole ('button', { name: '更新' }).closest ('form')!)
|
||||||
|
|
||||||
|
expect (await screen.findByText ('日時を確認してください.')).toBeInTheDocument ()
|
||||||
|
expect (screen.getByText ('終了を確認してください.')).toBeInTheDocument ()
|
||||||
|
expect (screen.getAllByText ('日時を確認してください.')).toHaveLength (1)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -18,7 +18,11 @@ import type { FC, FormEvent } from 'react'
|
|||||||
import type { Post, TagWithSections } from '@/types'
|
import type { Post, TagWithSections } from '@/types'
|
||||||
|
|
||||||
type PostFormField =
|
type PostFormField =
|
||||||
'parentPostIds' | 'tags' | 'videoMs' | 'originalCreatedAt'
|
'parentPostIds' | 'tags' | 'videoMs'
|
||||||
|
| 'originalCreatedAt' | 'originalCreatedFrom' | 'originalCreatedBefore'
|
||||||
|
|
||||||
|
const groupedMessages = (...values: (string[] | undefined)[]): string[] =>
|
||||||
|
[...new Set (values.flatMap (value => value ?? []))]
|
||||||
|
|
||||||
const videoMsToDurationValue = (videoMs: number | null): string =>
|
const videoMsToDurationValue = (videoMs: number | null): string =>
|
||||||
videoMs == null ? '' : String (videoMs / 1_000)
|
videoMs == null ? '' : String (videoMs / 1_000)
|
||||||
@@ -184,7 +188,10 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
|
|||||||
setOriginalCreatedFrom={setOriginalCreatedFrom}
|
setOriginalCreatedFrom={setOriginalCreatedFrom}
|
||||||
originalCreatedBefore={originalCreatedBefore}
|
originalCreatedBefore={originalCreatedBefore}
|
||||||
setOriginalCreatedBefore={setOriginalCreatedBefore}
|
setOriginalCreatedBefore={setOriginalCreatedBefore}
|
||||||
errors={fieldErrors.originalCreatedAt}/>
|
errors={groupedMessages (
|
||||||
|
fieldErrors.originalCreatedAt,
|
||||||
|
fieldErrors.originalCreatedFrom,
|
||||||
|
fieldErrors.originalCreatedBefore)}/>
|
||||||
|
|
||||||
{videoFlg && (
|
{videoFlg && (
|
||||||
<PostDurationField
|
<PostDurationField
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import PostThumbnailPreview from '@/components/posts/PostThumbnailPreview'
|
||||||
|
|
||||||
|
describe ('PostThumbnailPreview', () => {
|
||||||
|
it ('keeps an existing blob preview URL unchanged for normal post forms', () => {
|
||||||
|
render (<PostThumbnailPreview url="blob:preview" className="h-10 w-10"/>)
|
||||||
|
|
||||||
|
expect (screen.getByRole ('img')).toHaveAttribute ('src', 'blob:preview')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,12 +1,25 @@
|
|||||||
import { act, render, screen, waitFor } from '@testing-library/react'
|
import { act, render, screen, waitFor } from '@testing-library/react'
|
||||||
import { describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
import PostImportRowForm from '@/components/posts/import/PostImportRowForm'
|
import PostImportRowForm from '@/components/posts/import/PostImportRowForm'
|
||||||
import { buildPostImportRow } from '@/test/postImportFactories'
|
import { buildPostImportRow } from '@/test/postImportFactories'
|
||||||
|
|
||||||
import type { DialogueFormAction, DialogueFormControls } from '@/lib/dialogues/useDialogue'
|
import type { DialogueFormAction, DialogueFormControls } from '@/lib/dialogues/useDialogue'
|
||||||
|
|
||||||
|
const api = vi.hoisted (() => ({
|
||||||
|
apiGet: vi.fn (),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock ('@/lib/api', () => api)
|
||||||
|
|
||||||
describe ('PostImportRowForm', () => {
|
describe ('PostImportRowForm', () => {
|
||||||
|
beforeEach (() => {
|
||||||
|
vi.clearAllMocks ()
|
||||||
|
globalThis.URL.createObjectURL = vi.fn (() => 'blob:preview')
|
||||||
|
globalThis.URL.revokeObjectURL = vi.fn ()
|
||||||
|
api.apiGet.mockResolvedValue (new Blob (['img'], { type: 'image/png' }))
|
||||||
|
})
|
||||||
|
|
||||||
it ('resets only the draft, then saves with resetRequested', async () => {
|
it ('resets only the draft, then saves with resetRequested', async () => {
|
||||||
const row = buildPostImportRow ()
|
const row = buildPostImportRow ()
|
||||||
row.attributes.title = 'manual title'
|
row.attributes.title = 'manual title'
|
||||||
@@ -111,7 +124,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 (action => action.label === '編輯内容を保存')?.onSelect ()
|
void actions.find (action => action.label === '編輯内容を保存')?.onSelect ()
|
||||||
})
|
})
|
||||||
|
|
||||||
expect (onSave).toHaveBeenCalledWith ({
|
expect (onSave).toHaveBeenCalledWith ({
|
||||||
@@ -153,4 +166,96 @@ describe ('PostImportRowForm', () => {
|
|||||||
tags: 'tag1' }),
|
tags: 'tag1' }),
|
||||||
resetRequested: false })
|
resetRequested: false })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it ('keeps reset enabled when the value matches but provenance still differs', async () => {
|
||||||
|
const row = buildPostImportRow ({
|
||||||
|
attributes: { title: 'same title' },
|
||||||
|
provenance: { title: 'manual' },
|
||||||
|
resetSnapshot: {
|
||||||
|
url: 'https://example.com/post',
|
||||||
|
attributes: {
|
||||||
|
title: 'same title',
|
||||||
|
thumbnailBase: '',
|
||||||
|
originalCreatedFrom: '',
|
||||||
|
originalCreatedBefore: '',
|
||||||
|
duration: '',
|
||||||
|
tags: '',
|
||||||
|
parentPostIds: '' },
|
||||||
|
provenance: {
|
||||||
|
url: 'manual',
|
||||||
|
title: 'automatic',
|
||||||
|
thumbnailBase: 'automatic',
|
||||||
|
originalCreatedFrom: 'automatic',
|
||||||
|
originalCreatedBefore: 'automatic',
|
||||||
|
duration: 'automatic',
|
||||||
|
tags: 'automatic',
|
||||||
|
parentPostIds: 'automatic' },
|
||||||
|
tagSources: { automatic: '', manual: '' },
|
||||||
|
fieldWarnings: { },
|
||||||
|
baseWarnings: [] } })
|
||||||
|
let actions: DialogueFormAction[] = []
|
||||||
|
|
||||||
|
render (
|
||||||
|
<PostImportRowForm
|
||||||
|
row={row}
|
||||||
|
controls={{
|
||||||
|
close: vi.fn (),
|
||||||
|
confirm: vi.fn (),
|
||||||
|
setActions: next => {
|
||||||
|
actions = next
|
||||||
|
} }}
|
||||||
|
onSave={vi.fn ()}/>)
|
||||||
|
await waitFor (() => expect (actions.length).toBe (2))
|
||||||
|
|
||||||
|
expect (actions.find (action => action.label === '変更をリセット')?.disabled).toBe (false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it ('disables every field while save validation is pending and re-enables them afterwards', async () => {
|
||||||
|
let actions: DialogueFormAction[] = []
|
||||||
|
let resolveSave:
|
||||||
|
((value: { saved: boolean
|
||||||
|
row: ReturnType<typeof buildPostImportRow> | null }) => void) | null
|
||||||
|
= null
|
||||||
|
const onSave = vi.fn (() =>
|
||||||
|
new Promise<{ saved: boolean
|
||||||
|
row: ReturnType<typeof buildPostImportRow> | null }> (resolve => {
|
||||||
|
resolveSave = resolve
|
||||||
|
}))
|
||||||
|
|
||||||
|
render (
|
||||||
|
<PostImportRowForm
|
||||||
|
row={buildPostImportRow ({ attributes: { title: 'draft title' } })}
|
||||||
|
controls={{
|
||||||
|
close: vi.fn (),
|
||||||
|
confirm: vi.fn (),
|
||||||
|
setActions: next => {
|
||||||
|
actions = next
|
||||||
|
} }}
|
||||||
|
onSave={onSave}/>)
|
||||||
|
await waitFor (() => expect (actions.length).toBe (2))
|
||||||
|
|
||||||
|
await act (async () => {
|
||||||
|
await actions.find (action => action.label === '編輯内容を保存')?.onSelect ()
|
||||||
|
})
|
||||||
|
|
||||||
|
await waitFor (() => {
|
||||||
|
screen.getAllByRole ('textbox').forEach (textbox => {
|
||||||
|
expect (textbox).toBeDisabled ()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
resolveSave?.({
|
||||||
|
saved: false,
|
||||||
|
row: buildPostImportRow ({
|
||||||
|
attributes: { title: 'draft title' },
|
||||||
|
validationErrors: { title: ['タイトルを確認してください.'] } }) })
|
||||||
|
|
||||||
|
await waitFor (() => {
|
||||||
|
screen.getAllByRole ('textbox').forEach (textbox => {
|
||||||
|
expect (textbox).not.toBeDisabled ()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
expect (screen.getByDisplayValue ('draft title')).toBeInTheDocument ()
|
||||||
|
expect (screen.getByText ('タイトルを確認してください.')).toBeInTheDocument ()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import FieldWarning from '@/components/common/FieldWarning'
|
|||||||
import PostDurationField from '@/components/posts/PostDurationField'
|
import PostDurationField from '@/components/posts/PostDurationField'
|
||||||
import PostTagsField from '@/components/posts/PostTagsField'
|
import PostTagsField from '@/components/posts/PostTagsField'
|
||||||
import PostTextField from '@/components/posts/PostTextField'
|
import PostTextField from '@/components/posts/PostTextField'
|
||||||
import PostThumbnailPreview from '@/components/posts/PostThumbnailPreview'
|
import PostImportThumbnailPreview from '@/components/posts/import/PostImportThumbnailPreview'
|
||||||
|
|
||||||
import type { FC } from 'react'
|
import type { FC } from 'react'
|
||||||
|
|
||||||
@@ -56,6 +56,19 @@ const sameDraft = (left: Draft, right: Draft): boolean =>
|
|||||||
&& left.tags === right.tags
|
&& left.tags === right.tags
|
||||||
&& left.parentPostIds === right.parentPostIds
|
&& left.parentPostIds === right.parentPostIds
|
||||||
|
|
||||||
|
const sameProvenance = (
|
||||||
|
current: PostImportRow['provenance'],
|
||||||
|
reset: PostImportRow['resetSnapshot']['provenance'],
|
||||||
|
): boolean =>
|
||||||
|
Object.keys (reset).every (field => current[field] === reset[field])
|
||||||
|
|
||||||
|
const sameTagSources = (
|
||||||
|
current: PostImportRow['tagSources'],
|
||||||
|
reset: PostImportRow['resetSnapshot']['tagSources'],
|
||||||
|
): boolean =>
|
||||||
|
(current?.automatic ?? '') === reset.automatic
|
||||||
|
&& (current?.manual ?? '') === reset.manual
|
||||||
|
|
||||||
|
|
||||||
const PostImportRowForm: FC<Props> = (
|
const PostImportRowForm: FC<Props> = (
|
||||||
{ row,
|
{ row,
|
||||||
@@ -78,7 +91,12 @@ const PostImportRowForm: FC<Props> = (
|
|||||||
const resetDraft = useMemo (
|
const resetDraft = useMemo (
|
||||||
() => buildResetDraft (row),
|
() => buildResetDraft (row),
|
||||||
[row])
|
[row])
|
||||||
const resetDisabled = saving || sameDraft (draft, resetDraft)
|
const resetDisabled =
|
||||||
|
saving
|
||||||
|
|| (sameDraft (draft, resetDraft)
|
||||||
|
&& sameProvenance (row.provenance, row.resetSnapshot.provenance)
|
||||||
|
&& sameTagSources (row.tagSources, row.resetSnapshot.tagSources)
|
||||||
|
&& row.metadataUrl === row.resetSnapshot.metadataUrl)
|
||||||
|
|
||||||
const update = <Key extends keyof Draft,> (
|
const update = <Key extends keyof Draft,> (
|
||||||
key: Key,
|
key: Key,
|
||||||
@@ -145,7 +163,7 @@ const PostImportRowForm: FC<Props> = (
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="grid gap-6 md:grid-cols-[7rem_minmax(0,1fr)]">
|
<div className="grid gap-6 md:grid-cols-[7rem_minmax(0,1fr)]">
|
||||||
<div className="space-y-3 md:sticky md:top-0 md:self-start">
|
<div className="space-y-3 md:sticky md:top-0 md:self-start">
|
||||||
<PostThumbnailPreview
|
<PostImportThumbnailPreview
|
||||||
url={draft.thumbnailBase}
|
url={draft.thumbnailBase}
|
||||||
className="h-28 w-28"/>
|
className="h-28 w-28"/>
|
||||||
</div>
|
</div>
|
||||||
@@ -154,6 +172,7 @@ const PostImportRowForm: FC<Props> = (
|
|||||||
<PostTextField
|
<PostTextField
|
||||||
label="URL"
|
label="URL"
|
||||||
value={draft.url}
|
value={draft.url}
|
||||||
|
disabled={saving}
|
||||||
warnings={displayRow.fieldWarnings.url}
|
warnings={displayRow.fieldWarnings.url}
|
||||||
errors={groupedMessages (
|
errors={groupedMessages (
|
||||||
displayRow.validationErrors.url,
|
displayRow.validationErrors.url,
|
||||||
@@ -162,6 +181,7 @@ const PostImportRowForm: FC<Props> = (
|
|||||||
<PostTextField
|
<PostTextField
|
||||||
label="タイトル"
|
label="タイトル"
|
||||||
value={draft.title}
|
value={draft.title}
|
||||||
|
disabled={saving}
|
||||||
warnings={displayRow.fieldWarnings.title}
|
warnings={displayRow.fieldWarnings.title}
|
||||||
errors={groupedMessages (
|
errors={groupedMessages (
|
||||||
displayRow.validationErrors.title,
|
displayRow.validationErrors.title,
|
||||||
@@ -170,12 +190,14 @@ const PostImportRowForm: FC<Props> = (
|
|||||||
<PostTextField
|
<PostTextField
|
||||||
label="サムネール基底 URL"
|
label="サムネール基底 URL"
|
||||||
value={draft.thumbnailBase}
|
value={draft.thumbnailBase}
|
||||||
|
disabled={saving}
|
||||||
warnings={displayRow.fieldWarnings.thumbnailBase}
|
warnings={displayRow.fieldWarnings.thumbnailBase}
|
||||||
errors={groupedMessages (
|
errors={groupedMessages (
|
||||||
displayRow.validationErrors.thumbnailBase,
|
displayRow.validationErrors.thumbnailBase,
|
||||||
displayRow.importErrors?.thumbnailBase)}
|
displayRow.importErrors?.thumbnailBase)}
|
||||||
onChange={value => update ('thumbnailBase', value)}/>
|
onChange={value => update ('thumbnailBase', value)}/>
|
||||||
<PostOriginalCreatedTimeField
|
<PostOriginalCreatedTimeField
|
||||||
|
disabled={saving}
|
||||||
originalCreatedFrom={draft.originalCreatedFrom || null}
|
originalCreatedFrom={draft.originalCreatedFrom || null}
|
||||||
setOriginalCreatedFrom={value => update ('originalCreatedFrom', value ?? '')}
|
setOriginalCreatedFrom={value => update ('originalCreatedFrom', value ?? '')}
|
||||||
originalCreatedBefore={draft.originalCreatedBefore || null}
|
originalCreatedBefore={draft.originalCreatedBefore || null}
|
||||||
@@ -189,6 +211,7 @@ const PostImportRowForm: FC<Props> = (
|
|||||||
displayRow.importErrors?.originalCreatedBefore)}/>
|
displayRow.importErrors?.originalCreatedBefore)}/>
|
||||||
<PostDurationField
|
<PostDurationField
|
||||||
value={draft.duration}
|
value={draft.duration}
|
||||||
|
disabled={saving}
|
||||||
errors={groupedMessages (
|
errors={groupedMessages (
|
||||||
displayRow.validationErrors.duration,
|
displayRow.validationErrors.duration,
|
||||||
displayRow.validationErrors.videoMs,
|
displayRow.validationErrors.videoMs,
|
||||||
@@ -197,6 +220,7 @@ const PostImportRowForm: FC<Props> = (
|
|||||||
onChange={value => update ('duration', value)}/>
|
onChange={value => update ('duration', value)}/>
|
||||||
<PostTagsField
|
<PostTagsField
|
||||||
tags={draft.tags}
|
tags={draft.tags}
|
||||||
|
disabled={saving}
|
||||||
setTags={value => update ('tags', value)}
|
setTags={value => update ('tags', value)}
|
||||||
warnings={displayRow.fieldWarnings.tags}
|
warnings={displayRow.fieldWarnings.tags}
|
||||||
errors={groupedMessages (
|
errors={groupedMessages (
|
||||||
@@ -206,6 +230,7 @@ const PostImportRowForm: FC<Props> = (
|
|||||||
<PostTextField
|
<PostTextField
|
||||||
label="親投稿"
|
label="親投稿"
|
||||||
value={draft.parentPostIds}
|
value={draft.parentPostIds}
|
||||||
|
disabled={saving}
|
||||||
errors={groupedMessages (
|
errors={groupedMessages (
|
||||||
displayRow.validationErrors.parentPostIds,
|
displayRow.validationErrors.parentPostIds,
|
||||||
displayRow.importErrors?.parentPostIds)}
|
displayRow.importErrors?.parentPostIds)}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import PostThumbnailPreview from '@/components/posts/PostThumbnailPreview'
|
import PostImportThumbnailPreview from '@/components/posts/import/PostImportThumbnailPreview'
|
||||||
import PostImportStatusBadge from '@/components/posts/import/PostImportStatusBadge'
|
import PostImportStatusBadge from '@/components/posts/import/PostImportStatusBadge'
|
||||||
import { displayPostImportStatus } from '@/components/posts/import/postImportRowStatus'
|
import { displayPostImportStatus } from '@/components/posts/import/postImportRowStatus'
|
||||||
import { canEditReviewRow } from '@/lib/postImportSession'
|
import { canEditReviewRow } from '@/lib/postImportSession'
|
||||||
@@ -39,7 +39,7 @@ const PostImportRowSummary: FC<Props> = ({ row, onEdit, editDisabled }) => {
|
|||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="text-sm font-medium">#{row.sourceRow}</div>
|
<div className="text-sm font-medium">#{row.sourceRow}</div>
|
||||||
</div>
|
</div>
|
||||||
<PostThumbnailPreview
|
<PostImportThumbnailPreview
|
||||||
url={String (row.attributes.thumbnailBase ?? '')}
|
url={String (row.attributes.thumbnailBase ?? '')}
|
||||||
className="h-16 w-16"/>
|
className="h-16 w-16"/>
|
||||||
<div className="min-w-0 space-y-1">
|
<div className="min-w-0 space-y-1">
|
||||||
@@ -80,7 +80,7 @@ const PostImportRowSummary: FC<Props> = ({ row, onEdit, editDisabled }) => {
|
|||||||
'space-y-3 rounded-lg border p-4 md:hidden',
|
'space-y-3 rounded-lg border p-4 md:hidden',
|
||||||
'transition-shadow hover:shadow-sm')}>
|
'transition-shadow hover:shadow-sm')}>
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<PostThumbnailPreview
|
<PostImportThumbnailPreview
|
||||||
url={String (row.attributes.thumbnailBase ?? '')}
|
url={String (row.attributes.thumbnailBase ?? '')}
|
||||||
className="h-20 w-20 shrink-0"/>
|
className="h-20 w-20 shrink-0"/>
|
||||||
<div className="min-w-0 flex-1 space-y-2">
|
<div className="min-w-0 flex-1 space-y-2">
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { render, screen, waitFor } from '@testing-library/react'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import PostImportThumbnailPreview from '@/components/posts/import/PostImportThumbnailPreview'
|
||||||
|
|
||||||
|
const api = vi.hoisted (() => ({
|
||||||
|
apiGet: vi.fn (),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock ('@/lib/api', () => api)
|
||||||
|
|
||||||
|
describe ('PostImportThumbnailPreview', () => {
|
||||||
|
beforeEach (() => {
|
||||||
|
vi.clearAllMocks ()
|
||||||
|
globalThis.URL.createObjectURL = vi.fn (() => 'blob:preview')
|
||||||
|
globalThis.URL.revokeObjectURL = vi.fn ()
|
||||||
|
})
|
||||||
|
|
||||||
|
it ('uses a backend-fetched blob URL instead of the external thumbnail URL directly', async () => {
|
||||||
|
api.apiGet.mockResolvedValueOnce (new Blob (['img'], { type: 'image/png' }))
|
||||||
|
|
||||||
|
render (
|
||||||
|
<PostImportThumbnailPreview
|
||||||
|
url="https://example.com/thumbnail.jpg"
|
||||||
|
className="h-10 w-10"/>)
|
||||||
|
|
||||||
|
await waitFor (() => {
|
||||||
|
expect (screen.getByRole ('img')).toHaveAttribute ('src', 'blob:preview')
|
||||||
|
})
|
||||||
|
expect (screen.getByRole ('img')).not.toHaveAttribute (
|
||||||
|
'src',
|
||||||
|
'https://example.com/thumbnail.jpg')
|
||||||
|
expect (api.apiGet).toHaveBeenCalledWith ('/preview/thumbnail', {
|
||||||
|
params: { url: 'https://example.com/thumbnail.jpg' },
|
||||||
|
responseType: 'blob' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it ('does not render the unsafe URL directly when preview fetching fails', async () => {
|
||||||
|
api.apiGet.mockRejectedValueOnce (new Error ('unsafe'))
|
||||||
|
|
||||||
|
render (
|
||||||
|
<PostImportThumbnailPreview
|
||||||
|
url="http://127.0.0.1/private.png"
|
||||||
|
className="h-10 w-10"/>)
|
||||||
|
|
||||||
|
expect (await screen.findByText ('サムネールを表示できません')).toBeInTheDocument ()
|
||||||
|
expect (screen.queryByRole ('img')).toBeNull ()
|
||||||
|
})
|
||||||
|
|
||||||
|
it ('revokes the old object URL when the source URL changes', async () => {
|
||||||
|
const createObjectUrlMock =
|
||||||
|
globalThis.URL.createObjectURL as unknown as ReturnType<typeof vi.fn>
|
||||||
|
createObjectUrlMock
|
||||||
|
.mockReturnValueOnce ('blob:first')
|
||||||
|
.mockReturnValueOnce ('blob:second')
|
||||||
|
api.apiGet
|
||||||
|
.mockResolvedValueOnce (new Blob (['first'], { type: 'image/png' }))
|
||||||
|
.mockResolvedValueOnce (new Blob (['second'], { type: 'image/png' }))
|
||||||
|
|
||||||
|
const { rerender } = render (
|
||||||
|
<PostImportThumbnailPreview
|
||||||
|
url="https://example.com/first.jpg"
|
||||||
|
className="h-10 w-10"/>)
|
||||||
|
await waitFor (() => {
|
||||||
|
expect (screen.getByRole ('img')).toHaveAttribute ('src', 'blob:first')
|
||||||
|
})
|
||||||
|
|
||||||
|
rerender (
|
||||||
|
<PostImportThumbnailPreview
|
||||||
|
url="https://example.com/second.jpg"
|
||||||
|
className="h-10 w-10"/>)
|
||||||
|
|
||||||
|
await waitFor (() => {
|
||||||
|
expect (screen.getByRole ('img')).toHaveAttribute ('src', 'blob:second')
|
||||||
|
})
|
||||||
|
expect (globalThis.URL.revokeObjectURL).toHaveBeenCalledWith ('blob:first')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
import PostThumbnailPreview from '@/components/posts/PostThumbnailPreview'
|
||||||
|
import { apiGet } from '@/lib/api'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
import type { FC } from 'react'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
url: string
|
||||||
|
alt?: string
|
||||||
|
className?: string }
|
||||||
|
|
||||||
|
|
||||||
|
const PostImportThumbnailPreview: FC<Props> = (
|
||||||
|
{ url,
|
||||||
|
alt = 'サムネール',
|
||||||
|
className = 'h-16 w-16' },
|
||||||
|
) => {
|
||||||
|
const [previewUrl, setPreviewUrl] = useState ('')
|
||||||
|
const [unavailable, setUnavailable] = useState (false)
|
||||||
|
const previewUrlRef = useRef ('')
|
||||||
|
|
||||||
|
useEffect (() => {
|
||||||
|
if (previewUrlRef.current)
|
||||||
|
{
|
||||||
|
URL.revokeObjectURL (previewUrlRef.current)
|
||||||
|
previewUrlRef.current = ''
|
||||||
|
}
|
||||||
|
setPreviewUrl ('')
|
||||||
|
setUnavailable (false)
|
||||||
|
|
||||||
|
if (!(url))
|
||||||
|
return
|
||||||
|
|
||||||
|
let active = true
|
||||||
|
|
||||||
|
const loadPreview = async () => {
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const blob = await apiGet<Blob> ('/preview/thumbnail', {
|
||||||
|
params: { url },
|
||||||
|
responseType: 'blob' })
|
||||||
|
if (!(active))
|
||||||
|
return
|
||||||
|
|
||||||
|
const nextPreviewUrl = URL.createObjectURL (blob)
|
||||||
|
previewUrlRef.current = nextPreviewUrl
|
||||||
|
setPreviewUrl (nextPreviewUrl)
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
if (active)
|
||||||
|
setUnavailable (true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void loadPreview ()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
active = false
|
||||||
|
if (previewUrlRef.current)
|
||||||
|
{
|
||||||
|
URL.revokeObjectURL (previewUrlRef.current)
|
||||||
|
previewUrlRef.current = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [url])
|
||||||
|
|
||||||
|
if (unavailable)
|
||||||
|
{
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`${ className } flex items-center justify-center rounded border
|
||||||
|
border-amber-300 bg-amber-50 p-2 text-center text-xs
|
||||||
|
text-amber-700 dark:border-amber-900 dark:bg-amber-950
|
||||||
|
dark:text-amber-200`}>
|
||||||
|
サムネールを表示できません
|
||||||
|
</div>)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PostThumbnailPreview
|
||||||
|
url={previewUrl}
|
||||||
|
alt={alt}
|
||||||
|
className={className}/>)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PostImportThumbnailPreview
|
||||||
@@ -16,6 +16,7 @@ import type { PostImportRow } from '@/lib/postImportSession'
|
|||||||
import type { DialogueFormAction, DialogueFormControls } from '@/lib/dialogues/useDialogue'
|
import type { DialogueFormAction, DialogueFormControls } from '@/lib/dialogues/useDialogue'
|
||||||
|
|
||||||
const api = vi.hoisted (() => ({
|
const api = vi.hoisted (() => ({
|
||||||
|
apiGet: vi.fn (),
|
||||||
apiPost: vi.fn (),
|
apiPost: vi.fn (),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -37,6 +38,9 @@ describe ('PostImportResultPage', () => {
|
|||||||
beforeEach (() => {
|
beforeEach (() => {
|
||||||
sessionStorage.clear ()
|
sessionStorage.clear ()
|
||||||
vi.clearAllMocks ()
|
vi.clearAllMocks ()
|
||||||
|
globalThis.URL.createObjectURL = vi.fn (() => 'blob:preview')
|
||||||
|
globalThis.URL.revokeObjectURL = vi.fn ()
|
||||||
|
api.apiGet.mockResolvedValue (new Blob (['img'], { type: 'image/png' }))
|
||||||
})
|
})
|
||||||
|
|
||||||
it ('shows edit and retry only for recoverable failures and shows warnings', () => {
|
it ('shows edit and retry only for recoverable failures and shows warnings', () => {
|
||||||
@@ -86,6 +90,35 @@ describe ('PostImportResultPage', () => {
|
|||||||
)).toBeInTheDocument ()
|
)).toBeInTheDocument ()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it ('shows hour-precision original-created ranges with the shared utility output', () => {
|
||||||
|
const from = '2024-01-01T12:00:00Z'
|
||||||
|
const before = '2024-01-01T13:00:00Z'
|
||||||
|
savePostImportSession ('result-hour-range', {
|
||||||
|
source: '',
|
||||||
|
repairMode: 'all',
|
||||||
|
rows: [buildPostImportRow ({
|
||||||
|
sourceRow: 1,
|
||||||
|
importStatus: 'created',
|
||||||
|
createdPostId: 1,
|
||||||
|
attributes: {
|
||||||
|
title: 'created row',
|
||||||
|
originalCreatedFrom: from,
|
||||||
|
originalCreatedBefore: before } })] })
|
||||||
|
|
||||||
|
render (
|
||||||
|
<HelmetProvider>
|
||||||
|
<MemoryRouter initialEntries={['/posts/import/result-hour-range/result']}>
|
||||||
|
<Routes>
|
||||||
|
<Route
|
||||||
|
path="/posts/import/:sessionId/result"
|
||||||
|
element={<PostImportResultPage user={buildUser ()}/>}/>
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>
|
||||||
|
</HelmetProvider>)
|
||||||
|
|
||||||
|
expect (screen.getByText (originalCreatedAtString (from, before))).toBeInTheDocument ()
|
||||||
|
})
|
||||||
|
|
||||||
it ('keeps recoverable pending rows actionable and hides actions for hard failures', () => {
|
it ('keeps recoverable pending rows actionable and hides actions for hard failures', () => {
|
||||||
savePostImportSession ('result-pending', {
|
savePostImportSession ('result-pending', {
|
||||||
source: '',
|
source: '',
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import type { DialogueFormAction, DialogueFormControls } from '@/lib/dialogues/u
|
|||||||
import type { PostImportRow } from '@/lib/postImportSession'
|
import type { PostImportRow } from '@/lib/postImportSession'
|
||||||
|
|
||||||
const api = vi.hoisted (() => ({
|
const api = vi.hoisted (() => ({
|
||||||
|
apiGet: vi.fn (),
|
||||||
apiPost: vi.fn (),
|
apiPost: vi.fn (),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -33,6 +34,9 @@ describe ('PostImportReviewPage', () => {
|
|||||||
beforeEach (() => {
|
beforeEach (() => {
|
||||||
sessionStorage.clear ()
|
sessionStorage.clear ()
|
||||||
vi.clearAllMocks ()
|
vi.clearAllMocks ()
|
||||||
|
globalThis.URL.createObjectURL = vi.fn (() => 'blob:preview')
|
||||||
|
globalThis.URL.revokeObjectURL = vi.fn ()
|
||||||
|
api.apiGet.mockResolvedValue (new Blob (['img'], { type: 'image/png' }))
|
||||||
})
|
})
|
||||||
|
|
||||||
it ('navigates to the result route after an initial recoverable failure', async () => {
|
it ('navigates to the result route after an initial recoverable failure', async () => {
|
||||||
@@ -257,4 +261,35 @@ describe ('PostImportReviewPage', () => {
|
|||||||
.toBe ('edited title')
|
.toBe ('edited title')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it ('sorts recoverable pending validation rows to the top in repair mode', () => {
|
||||||
|
savePostImportSession ('review-repair-sort', {
|
||||||
|
source: '',
|
||||||
|
repairMode: 'failed',
|
||||||
|
rows: [
|
||||||
|
buildPostImportRow ({
|
||||||
|
sourceRow: 2,
|
||||||
|
attributes: { title: 'normal row' } }),
|
||||||
|
buildPostImportRow ({
|
||||||
|
sourceRow: 1,
|
||||||
|
attributes: { title: 'repair row' },
|
||||||
|
importStatus: 'pending',
|
||||||
|
recoverable: true,
|
||||||
|
validationErrors: { title: ['invalid'] } })] })
|
||||||
|
|
||||||
|
const { container } = render (
|
||||||
|
<HelmetProvider>
|
||||||
|
<MemoryRouter initialEntries={['/posts/import/review-repair-sort/review']}>
|
||||||
|
<Routes>
|
||||||
|
<Route
|
||||||
|
path="/posts/import/:sessionId/review"
|
||||||
|
element={<PostImportReviewPage user={buildUser ()}/>}/>
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>
|
||||||
|
</HelmetProvider>)
|
||||||
|
|
||||||
|
const titles = Array.from (container.querySelectorAll ('.line-clamp-2')).map (
|
||||||
|
node => node.textContent)
|
||||||
|
expect (titles[0]).toBe ('repair row')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -38,6 +38,12 @@ import type { User } from '@/types'
|
|||||||
|
|
||||||
type Props = { user: User | null }
|
type Props = { user: User | null }
|
||||||
|
|
||||||
|
const isRepairRow = (row: PostImportRow): boolean =>
|
||||||
|
row.recoverable === true
|
||||||
|
&& (row.importStatus === 'failed'
|
||||||
|
|| (row.importStatus === 'pending'
|
||||||
|
&& Object.keys (row.validationErrors).length > 0))
|
||||||
|
|
||||||
|
|
||||||
const PostImportReviewPage: FC<Props> = ({ user }) => {
|
const PostImportReviewPage: FC<Props> = ({ user }) => {
|
||||||
const editable = canEditContent (user)
|
const editable = canEditContent (user)
|
||||||
@@ -85,9 +91,9 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
|||||||
session?.repairMode === 'failed'
|
session?.repairMode === 'failed'
|
||||||
? (
|
? (
|
||||||
[...rows].sort ((a, b) => {
|
[...rows].sort ((a, b) => {
|
||||||
const aFailed = a.importStatus === 'failed' ? 0 : 1
|
const aRepair = isRepairRow (a) ? 0 : 1
|
||||||
const bFailed = b.importStatus === 'failed' ? 0 : 1
|
const bRepair = isRepairRow (b) ? 0 : 1
|
||||||
return aFailed - bFailed || a.sourceRow - b.sourceRow
|
return aRepair - bRepair || a.sourceRow - b.sourceRow
|
||||||
}))
|
}))
|
||||||
: rows
|
: rows
|
||||||
|
|
||||||
@@ -228,11 +234,11 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
|||||||
setLoading (true)
|
setLoading (true)
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
const validatableRows =
|
||||||
|
currentSession.rows.filter (row => row.importStatus !== 'created')
|
||||||
const validated = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/validate', {
|
const validated = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/validate', {
|
||||||
rows:
|
rows:
|
||||||
currentSession.rows
|
validatableRows.map (row => ({ sourceRow: row.sourceRow,
|
||||||
.filter (row => row.importStatus !== 'created')
|
|
||||||
.map (row => ({ sourceRow: row.sourceRow,
|
|
||||||
url: row.url,
|
url: row.url,
|
||||||
attributes: row.attributes,
|
attributes: row.attributes,
|
||||||
provenance: row.provenance,
|
provenance: row.provenance,
|
||||||
@@ -240,9 +246,7 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
|||||||
metadataUrl: row.metadataUrl })),
|
metadataUrl: row.metadataUrl })),
|
||||||
changed_row: -1 })
|
changed_row: -1 })
|
||||||
const validatedRows = initialisePreviewRows (validated.rows)
|
const validatedRows = initialisePreviewRows (validated.rows)
|
||||||
const expectedSourceRows = currentSession.rows
|
const expectedSourceRows = validatableRows.map (row => row.sourceRow)
|
||||||
.filter (row => row.importStatus !== 'created')
|
|
||||||
.map (row => row.sourceRow)
|
|
||||||
if (!(hasExactSourceRows (expectedSourceRows, validatedRows)))
|
if (!(hasExactSourceRows (expectedSourceRows, validatedRows)))
|
||||||
{
|
{
|
||||||
toast ({ title: '再検証結果が不完全でした' })
|
toast ({ title: '再検証結果が不完全でした' })
|
||||||
|
|||||||
@@ -134,4 +134,29 @@ describe ('PostNewPage', () => {
|
|||||||
expect (formData.get ('original_created_from')).toBe (
|
expect (formData.get ('original_created_from')).toBe (
|
||||||
toMinutePrecisionIsoUtc ('2024-01-01T12:34'))
|
toMinutePrecisionIsoUtc ('2024-01-01T12:34'))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it ('shows deduplicated original-created endpoint errors on the shared datetime field', async () => {
|
||||||
|
api.apiGet.mockResolvedValue ([])
|
||||||
|
api.isApiError.mockReturnValue (true)
|
||||||
|
api.apiPost.mockRejectedValueOnce ({
|
||||||
|
response: {
|
||||||
|
status: 422,
|
||||||
|
data: {
|
||||||
|
type: 'validation_error',
|
||||||
|
errors: {
|
||||||
|
original_created_at: ['日時を確認してください.'],
|
||||||
|
original_created_from: ['日時を確認してください.'],
|
||||||
|
original_created_before: ['終了を確認してください.'] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
renderWithProviders (<PostNewPage user={buildUser ({ role: 'member' })}/>)
|
||||||
|
|
||||||
|
fireEvent.click (screen.getByRole ('button', { name: '追加' }))
|
||||||
|
|
||||||
|
expect (await screen.findByText ('日時を確認してください.')).toBeInTheDocument ()
|
||||||
|
expect (screen.getByText ('終了を確認してください.')).toBeInTheDocument ()
|
||||||
|
expect (screen.getAllByText ('日時を確認してください.')).toHaveLength (1)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -27,7 +27,11 @@ type Props = { user: User | null }
|
|||||||
|
|
||||||
type PostFormField =
|
type PostFormField =
|
||||||
'url' | 'title' | 'tags' | 'parentPostIds'
|
'url' | 'title' | 'tags' | 'parentPostIds'
|
||||||
| 'videoMs' | 'originalCreatedAt' | 'thumbnail'
|
| 'videoMs' | 'originalCreatedAt' | 'originalCreatedFrom'
|
||||||
|
| 'originalCreatedBefore' | 'thumbnail'
|
||||||
|
|
||||||
|
const groupedMessages = (...values: (string[] | undefined)[]): string[] =>
|
||||||
|
[...new Set (values.flatMap (value => value ?? []))]
|
||||||
|
|
||||||
|
|
||||||
const PostNewPage: FC<Props> = ({ user }) => {
|
const PostNewPage: FC<Props> = ({ user }) => {
|
||||||
@@ -216,7 +220,10 @@ const PostNewPage: FC<Props> = ({ user }) => {
|
|||||||
setOriginalCreatedFrom={setOriginalCreatedFrom}
|
setOriginalCreatedFrom={setOriginalCreatedFrom}
|
||||||
originalCreatedBefore={originalCreatedBefore}
|
originalCreatedBefore={originalCreatedBefore}
|
||||||
setOriginalCreatedBefore={setOriginalCreatedBefore}
|
setOriginalCreatedBefore={setOriginalCreatedBefore}
|
||||||
errors={fieldErrors.originalCreatedAt}/>
|
errors={groupedMessages (
|
||||||
|
fieldErrors.originalCreatedAt,
|
||||||
|
fieldErrors.originalCreatedFrom,
|
||||||
|
fieldErrors.originalCreatedBefore)}/>
|
||||||
|
|
||||||
{videoFlg && (
|
{videoFlg && (
|
||||||
<PostDurationField
|
<PostDurationField
|
||||||
|
|||||||
新しい課題から参照
ユーザをブロックする