このコミットが含まれているのは:
@@ -1,12 +1,25 @@
|
||||
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 { buildPostImportRow } from '@/test/postImportFactories'
|
||||
|
||||
import type { DialogueFormAction, DialogueFormControls } from '@/lib/dialogues/useDialogue'
|
||||
|
||||
const api = vi.hoisted (() => ({
|
||||
apiGet: vi.fn (),
|
||||
}))
|
||||
|
||||
vi.mock ('@/lib/api', () => api)
|
||||
|
||||
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 () => {
|
||||
const row = buildPostImportRow ()
|
||||
row.attributes.title = 'manual title'
|
||||
@@ -111,7 +124,7 @@ describe ('PostImportRowForm', () => {
|
||||
await waitFor (() => expect (actions.length).toBe (2))
|
||||
|
||||
await act (async () => {
|
||||
await actions.find (action => action.label === '編輯内容を保存')?.onSelect ()
|
||||
void actions.find (action => action.label === '編輯内容を保存')?.onSelect ()
|
||||
})
|
||||
|
||||
expect (onSave).toHaveBeenCalledWith ({
|
||||
@@ -153,4 +166,96 @@ describe ('PostImportRowForm', () => {
|
||||
tags: 'tag1' }),
|
||||
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 PostTagsField from '@/components/posts/PostTagsField'
|
||||
import PostTextField from '@/components/posts/PostTextField'
|
||||
import PostThumbnailPreview from '@/components/posts/PostThumbnailPreview'
|
||||
import PostImportThumbnailPreview from '@/components/posts/import/PostImportThumbnailPreview'
|
||||
|
||||
import type { FC } from 'react'
|
||||
|
||||
@@ -56,6 +56,19 @@ const sameDraft = (left: Draft, right: Draft): boolean =>
|
||||
&& left.tags === right.tags
|
||||
&& 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> = (
|
||||
{ row,
|
||||
@@ -78,7 +91,12 @@ const PostImportRowForm: FC<Props> = (
|
||||
const resetDraft = useMemo (
|
||||
() => buildResetDraft (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,> (
|
||||
key: Key,
|
||||
@@ -145,7 +163,7 @@ const PostImportRowForm: FC<Props> = (
|
||||
<div className="space-y-4">
|
||||
<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">
|
||||
<PostThumbnailPreview
|
||||
<PostImportThumbnailPreview
|
||||
url={draft.thumbnailBase}
|
||||
className="h-28 w-28"/>
|
||||
</div>
|
||||
@@ -154,6 +172,7 @@ const PostImportRowForm: FC<Props> = (
|
||||
<PostTextField
|
||||
label="URL"
|
||||
value={draft.url}
|
||||
disabled={saving}
|
||||
warnings={displayRow.fieldWarnings.url}
|
||||
errors={groupedMessages (
|
||||
displayRow.validationErrors.url,
|
||||
@@ -162,6 +181,7 @@ const PostImportRowForm: FC<Props> = (
|
||||
<PostTextField
|
||||
label="タイトル"
|
||||
value={draft.title}
|
||||
disabled={saving}
|
||||
warnings={displayRow.fieldWarnings.title}
|
||||
errors={groupedMessages (
|
||||
displayRow.validationErrors.title,
|
||||
@@ -170,12 +190,14 @@ const PostImportRowForm: FC<Props> = (
|
||||
<PostTextField
|
||||
label="サムネール基底 URL"
|
||||
value={draft.thumbnailBase}
|
||||
disabled={saving}
|
||||
warnings={displayRow.fieldWarnings.thumbnailBase}
|
||||
errors={groupedMessages (
|
||||
displayRow.validationErrors.thumbnailBase,
|
||||
displayRow.importErrors?.thumbnailBase)}
|
||||
onChange={value => update ('thumbnailBase', value)}/>
|
||||
<PostOriginalCreatedTimeField
|
||||
disabled={saving}
|
||||
originalCreatedFrom={draft.originalCreatedFrom || null}
|
||||
setOriginalCreatedFrom={value => update ('originalCreatedFrom', value ?? '')}
|
||||
originalCreatedBefore={draft.originalCreatedBefore || null}
|
||||
@@ -189,6 +211,7 @@ const PostImportRowForm: FC<Props> = (
|
||||
displayRow.importErrors?.originalCreatedBefore)}/>
|
||||
<PostDurationField
|
||||
value={draft.duration}
|
||||
disabled={saving}
|
||||
errors={groupedMessages (
|
||||
displayRow.validationErrors.duration,
|
||||
displayRow.validationErrors.videoMs,
|
||||
@@ -197,6 +220,7 @@ const PostImportRowForm: FC<Props> = (
|
||||
onChange={value => update ('duration', value)}/>
|
||||
<PostTagsField
|
||||
tags={draft.tags}
|
||||
disabled={saving}
|
||||
setTags={value => update ('tags', value)}
|
||||
warnings={displayRow.fieldWarnings.tags}
|
||||
errors={groupedMessages (
|
||||
@@ -206,6 +230,7 @@ const PostImportRowForm: FC<Props> = (
|
||||
<PostTextField
|
||||
label="親投稿"
|
||||
value={draft.parentPostIds}
|
||||
disabled={saving}
|
||||
errors={groupedMessages (
|
||||
displayRow.validationErrors.parentPostIds,
|
||||
displayRow.importErrors?.parentPostIds)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { displayPostImportStatus } from '@/components/posts/import/postImportRowStatus'
|
||||
import { canEditReviewRow } from '@/lib/postImportSession'
|
||||
@@ -39,7 +39,7 @@ const PostImportRowSummary: FC<Props> = ({ row, onEdit, editDisabled }) => {
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-medium">#{row.sourceRow}</div>
|
||||
</div>
|
||||
<PostThumbnailPreview
|
||||
<PostImportThumbnailPreview
|
||||
url={String (row.attributes.thumbnailBase ?? '')}
|
||||
className="h-16 w-16"/>
|
||||
<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',
|
||||
'transition-shadow hover:shadow-sm')}>
|
||||
<div className="flex items-start gap-3">
|
||||
<PostThumbnailPreview
|
||||
<PostImportThumbnailPreview
|
||||
url={String (row.attributes.thumbnailBase ?? '')}
|
||||
className="h-20 w-20 shrink-0"/>
|
||||
<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
|
||||
新しい課題から参照
ユーザをブロックする