広場投稿追加画面の刷新 (#399) (#413)

Reviewed-on: http://git.miteruzo.com/miteruzo/btrc-hub/pulls/413
Co-authored-by: miteruzo <miteruzo@naver.com>
Co-committed-by: miteruzo <miteruzo@naver.com>
このコミットはプルリクエスト #413 でマージされました。
このコミットが含まれているのは:
2026-07-19 00:03:10 +09:00
committed by みてるぞ
コミット f1181e8510
99個のファイルの変更10242行の追加1126行の削除
+94
ファイルの表示
@@ -0,0 +1,94 @@
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
import PostTagsField from '@/components/posts/PostTagsField'
import PostTextField from '@/components/posts/PostTextField'
import type { FC, ReactNode } from 'react'
type TextMessages = string[] | undefined
type CoreField = {
value: string
onChange: (value: string) => void
errors?: TextMessages
warnings?: TextMessages
disabled?: boolean }
type OriginalCreatedField = {
originalCreatedAt?: TextMessages
originalCreatedFrom?: TextMessages
originalCreatedBefore?: TextMessages }
type PostCoreDataFieldsProps = {
title: {
value: string
onChange: (value: string) => void
errors?: TextMessages
warnings?: TextMessages
disabled?: boolean
after?: ReactNode }
originalCreated: {
disabled?: boolean
originalCreatedFrom: string | null
setOriginalCreatedFrom: (value: string | null) => void
originalCreatedBefore: string | null
setOriginalCreatedBefore: (value: string | null) => void
errors?: OriginalCreatedField }
tags: {
value: string
onChange: (value: string) => void
errors?: TextMessages
warnings?: TextMessages
disabled?: boolean
rows?: number }
parentPostIds: CoreField }
const groupedMessages = (...values: (TextMessages | null | undefined)[]): string[] =>
[...new Set (values.flatMap (value => value ?? []))]
const PostCoreDataFields: FC<PostCoreDataFieldsProps> = (
{ title,
originalCreated,
tags,
parentPostIds },
) => (
<>
<PostTextField
label="タイトル"
value={title.value}
disabled={title.disabled}
warnings={title.warnings}
errors={title.errors}
after={title.after}
onChange={title.onChange}/>
<PostOriginalCreatedTimeField
disabled={originalCreated.disabled}
originalCreatedFrom={originalCreated.originalCreatedFrom}
setOriginalCreatedFrom={originalCreated.setOriginalCreatedFrom}
originalCreatedBefore={originalCreated.originalCreatedBefore}
setOriginalCreatedBefore={originalCreated.setOriginalCreatedBefore}
errors={groupedMessages (
originalCreated.errors?.originalCreatedAt,
originalCreated.errors?.originalCreatedFrom,
originalCreated.errors?.originalCreatedBefore)}/>
<PostTagsField
tags={tags.value}
disabled={tags.disabled}
setTags={tags.onChange}
warnings={tags.warnings}
errors={tags.errors}
rows={tags.rows}/>
<PostTextField
label="親投稿"
value={parentPostIds.value}
disabled={parentPostIds.disabled}
warnings={parentPostIds.warnings}
errors={parentPostIds.errors}
onChange={parentPostIds.onChange}/>
</>)
export default PostCoreDataFields
export type { PostCoreDataFieldsProps }
+48
ファイルの表示
@@ -0,0 +1,48 @@
import PostCoreDataFields from '@/components/posts/PostCoreDataFields'
import PostTextField from '@/components/posts/PostTextField'
import type { FC, ReactNode } from 'react'
import type { PostCoreDataFieldsProps } from '@/components/posts/PostCoreDataFields'
type TextMessages = string[] | undefined
type Props = {
url: {
value: string
onChange: (value: string) => void
errors?: TextMessages
warnings?: TextMessages
disabled?: boolean
type?: string
placeholder?: string }
thumbnailField: ReactNode
core: PostCoreDataFieldsProps
extraFields?: ReactNode }
const PostCreationDataFields: FC<Props> = (
{ url,
thumbnailField,
core,
extraFields },
) => (
<>
<PostTextField
label="URL"
type={url.type}
value={url.value}
disabled={url.disabled}
warnings={url.warnings}
errors={url.errors}
placeholder={url.placeholder}
onChange={url.onChange}/>
{thumbnailField}
<PostCoreDataFields {...core}/>
{extraFields}
</>)
export default PostCreationDataFields
+31
ファイルの表示
@@ -0,0 +1,31 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { buildPostImportRow } from '@/test/postImportFactories'
import type { DialogueFormControls } from '@/lib/dialogues/useDialogue'
const sharedFieldsSpy = vi.hoisted (() => vi.fn (() => <div data-testid="shared-fields"/>))
vi.mock ('@/components/posts/PostCreationDataFields', () => ({
default: sharedFieldsSpy,
}))
describe ('PostCreationDataFields usage', () => {
it ('is used by PostImportRowForm', async () => {
const { default: PostImportRowForm } = await import (
'@/components/posts/import/PostImportRowForm')
render (
<PostImportRowForm
row={buildPostImportRow ()}
controls={{
close: vi.fn (),
confirm: vi.fn (),
setActions: vi.fn (),
} as DialogueFormControls}
onSave={vi.fn ()}/>)
expect (screen.getByTestId ('shared-fields')).toBeInTheDocument ()
})
})
+27
ファイルの表示
@@ -0,0 +1,27 @@
import PostTextField from '@/components/posts/PostTextField'
import type { FC } from 'react'
type Props = {
value: string
onChange: (value: string) => void
errors?: string[]
disabled?: boolean }
const PostDurationField: FC<Props> = (
{ value,
onChange,
errors,
disabled },
) => (
<PostTextField
label="動画時間"
value={value}
onChange={onChange}
errors={errors}
disabled={disabled}
type="text"/>
)
export default PostDurationField
+29
ファイルの表示
@@ -0,0 +1,29 @@
import PostFormTagsArea from '@/components/PostFormTagsArea'
import FieldWarning from '@/components/common/FieldWarning'
import type { ComponentPropsWithoutRef, FC } from 'react'
type Props = Omit<ComponentPropsWithoutRef<'textarea'>, 'value' | 'onChange'> & {
tags: string
setTags: (tags: string) => void
warnings?: string[]
errors?: string[] }
const PostTagsField: FC<Props> = (
{ tags,
setTags,
warnings,
errors,
...rest },
) => (
<div className="space-y-2">
<PostFormTagsArea
{...rest}
tags={tags}
setTags={setTags}
errors={errors}/>
<FieldWarning messages={warnings}/>
</div>)
export default PostTagsField
+52
ファイルの表示
@@ -0,0 +1,52 @@
import FieldWarning from '@/components/common/FieldWarning'
import FormField from '@/components/common/FormField'
import { inputClass } from '@/lib/utils'
import type { FC, ReactNode } from 'react'
type Props = {
label: string
value: string
onChange: (value: string) => void
warnings?: string[]
errors?: string[]
disabled?: boolean
type?: string
placeholder?: string
className?: string
after?: ReactNode
onBlur?: () => void }
const PostTextField: FC<Props> = (
{ label,
value,
onChange,
warnings,
errors,
disabled,
type = 'text',
placeholder,
className,
after,
onBlur },
) => (
<FormField label={label} messages={errors}>
{({ describedBy, invalid }) => (
<>
<input
type={type}
value={value}
disabled={disabled}
placeholder={placeholder}
onBlur={onBlur}
onChange={ev => onChange (ev.target.value)}
aria-describedby={describedBy}
aria-invalid={invalid}
className={inputClass (invalid, className)}/>
<FieldWarning messages={warnings}/>
{after}
</>)}
</FormField>)
export default PostTextField
+36
ファイルの表示
@@ -0,0 +1,36 @@
import { fireEvent, 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')
})
it ('renders an empty thumbnail frame without text when the URL is empty', () => {
const { container } = render (
<PostThumbnailPreview url="" className="h-10 w-10"/>)
expect (screen.queryByRole ('img')).toBeNull ()
expect (screen.queryByText ('サムネールを表示できません')).toBeNull ()
expect (screen.queryByText ('なし')).toBeNull ()
expect (container.querySelector ('div.rounded.border.bg-muted')).not.toBeNull ()
expect (container.textContent).toBe ('')
})
it ('renders an empty thumbnail frame without text when image loading fails', () => {
const { container } = render (
<PostThumbnailPreview url="blob:preview" className="h-10 w-10"/>)
fireEvent.error (screen.getByRole ('img'))
expect (screen.queryByRole ('img')).toBeNull ()
expect (screen.queryByText ('サムネールを表示できません')).toBeNull ()
expect (screen.queryByText ('なし')).toBeNull ()
expect (container.querySelector ('div.rounded.border.bg-muted')).not.toBeNull ()
expect (container.textContent).toBe ('')
})
})
+64
ファイルの表示
@@ -0,0 +1,64 @@
import { useEffect, useState } from 'react'
import { cn } from '@/lib/utils'
import type { FC } from 'react'
type Props = {
url: string
file?: File
alt?: string
className?: string
referrerPolicy?: 'no-referrer' }
const PostThumbnailPreview: FC<Props> = (
{ url,
file,
alt = 'サムネール',
className = 'h-16 w-16',
referrerPolicy },
) => {
const [failed, setFailed] = useState (false)
const [fileUrl, setFileUrl] = useState<string | null> (null)
useEffect (() => {
setFailed (false)
}, [file, url])
useEffect (() => {
if (file == null)
{
setFileUrl (null)
return
}
const nextUrl = URL.createObjectURL (file)
setFileUrl (nextUrl)
return () => {
URL.revokeObjectURL (nextUrl)
}
}, [file])
const resolvedUrl = url.trim () !== '' ? url : (fileUrl ?? '')
if (resolvedUrl === '' || failed)
{
return (
<div
className={cn (
className,
'rounded border border-border bg-muted')}/>)
}
return (
<img
src={resolvedUrl}
alt={alt}
referrerPolicy={referrerPolicy}
className={cn (className, 'rounded border border-border object-cover')}
onError={() => setFailed (true)}/>)
}
export default PostThumbnailPreview
+278
ファイルの表示
@@ -0,0 +1,278 @@
import { act, render, screen, waitFor } from '@testing-library/react'
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'
row.provenance.title = 'manual'
const actions: DialogueFormAction[][] = []
const controls: DialogueFormControls = {
close: vi.fn (),
confirm: vi.fn ().mockResolvedValue (true),
setActions: next => actions.push (next) }
const invalidRow = buildPostImportRow ({
validationErrors: { title: ['タイトルを確認してください.'] } })
const onSave = vi.fn ().mockResolvedValue ({ saved: false, row: invalidRow })
render (<PostImportRowForm row={row} controls={controls} onSave={onSave}/>)
const titleInput = screen.getByDisplayValue ('manual title')
await waitFor (() => expect (actions.at (-1)?.length).toBe (2))
const reset = actions.at (-1)?.find (action => action.label === '変更をリセット')
expect (reset).toMatchObject ({ placement: 'start', variant: 'danger', disabled: false })
await act (async () => {
await reset?.onSelect ()
})
expect (controls.confirm).toHaveBeenCalled ()
expect (titleInput).toHaveValue ('')
expect (onSave).not.toHaveBeenCalled ()
const save = actions.at (-1)?.find (action => action.label === '編輯内容を保存')
await act (async () => {
await save?.onSelect ()
})
expect (onSave).toHaveBeenCalledWith ({
draft: expect.objectContaining ({ title: '' }),
resetRequested: true })
expect (screen.getByText ('タイトルを確認してください.')).toBeInTheDocument ()
})
it ('does not reset the draft when confirmation is cancelled', async () => {
const row = buildPostImportRow ({ attributes: { title: 'manual title' } })
let actions: DialogueFormAction[] = []
const controls: DialogueFormControls = {
close: vi.fn (),
confirm: vi.fn ().mockResolvedValue (false),
setActions: next => {
actions = next
} }
render (
<PostImportRowForm
row={row}
controls={controls}
onSave={vi.fn ()}/>)
await waitFor (() => expect (actions.length).toBe (2))
await act (async () => {
await actions.find (action => action.label === '変更をリセット')?.onSelect ()
})
expect (screen.getByDisplayValue ('manual title')).toBeInTheDocument ()
})
it ('marks edited fields and areas invalid from field errors', () => {
const row = buildPostImportRow ({
validationErrors: { url: ['URL error'], tags: ['tag error'] },
importErrors: { title: ['title error'] },
fieldWarnings: { title: ['title warning'] } })
render (
<PostImportRowForm
row={row}
controls={{ close: vi.fn (), confirm: vi.fn (), setActions: vi.fn () }}
onSave={vi.fn ()}/>)
expect (screen.getByText ('URL error')).toBeInTheDocument ()
expect (screen.getByText ('tag error')).toBeInTheDocument ()
expect (screen.getByText ('title error')).toBeInTheDocument ()
expect (screen.getByText ('title warning')).toBeInTheDocument ()
expect (screen.getAllByRole ('textbox').filter (
textbox => textbox.getAttribute ('aria-invalid') === 'true')).toHaveLength (3)
})
it ('keeps untouched original created values unchanged in the save payload', async () => {
let actions: DialogueFormAction[] = []
const row = buildPostImportRow ({
attributes: {
originalCreatedFrom: '2024-01-01T12:34+09:00',
originalCreatedBefore: '2024-01-01T12:35+09:00' } })
const controls: DialogueFormControls = {
close: vi.fn (),
confirm: vi.fn (),
setActions: next => {
actions = next
} }
const onSave = vi.fn ().mockResolvedValue ({ saved: true, row: null })
render (
<PostImportRowForm
row={row}
controls={controls}
onSave={onSave}/>)
await waitFor (() => expect (actions.length).toBe (2))
await act (async () => {
void actions.find (action => action.label === '編輯内容を保存')?.onSelect ()
})
expect (onSave).toHaveBeenCalledWith ({
draft: expect.objectContaining ({
originalCreatedFrom: '2024-01-01T12:34+09:00',
originalCreatedBefore: '2024-01-01T12:35+09:00' }),
resetRequested: false })
})
it (
'shows upload input only when the thumbnail URL is blank',
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))
const labels = Array.from (container.querySelectorAll ('label'))
.map (node => node.textContent?.trim ())
expect (labels.slice (0, 6)).toEqual ([
'URL',
'サムネール',
'タイトル',
'オリジナルの作成日時',
'タグ',
'親投稿'])
expect (container.querySelector ('input[type="file"]')).toHaveAttribute (
'accept',
'image/*')
expect (screen.queryByPlaceholderText ('例: 2 / 2.5 / 1:23')).not.toBeInTheDocument ()
expect (screen.getByDisplayValue ('tag1')).toBeInTheDocument ()
await act (async () => {
await actions.find (action => action.label === '編輯内容を保存')?.onSelect ()
})
expect (onSave).toHaveBeenCalledWith ({
draft: expect.objectContaining ({
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: '',
tags: '',
parentPostIds: '' },
provenance: {
url: 'manual',
title: 'automatic',
thumbnailBase: 'automatic',
originalCreatedFrom: 'automatic',
originalCreatedBefore: '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))
let savePromise: Promise<boolean | void> | undefined
await act (async () => {
savePromise = 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 act (async () => {
await savePromise
})
await waitFor (() => {
screen.getAllByRole ('textbox').forEach (textbox => {
expect (textbox).not.toBeDisabled ()
})
})
expect (screen.getByDisplayValue ('draft title')).toBeInTheDocument ()
expect (screen.getByText ('タイトルを確認してください.')).toBeInTheDocument ()
})
})
+308
ファイルの表示
@@ -0,0 +1,308 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import FieldError from '@/components/common/FieldError'
import FieldWarning from '@/components/common/FieldWarning'
import PostCreationDataFields from '@/components/posts/PostCreationDataFields'
import PostDurationField from '@/components/posts/PostDurationField'
import PostTextField from '@/components/posts/PostTextField'
import PostImportThumbnailPreview from '@/components/posts/import/PostImportThumbnailPreview'
import { hasThumbnailBaseValue, hasVideoTag } from '@/lib/postImportRows'
import type { FC } from 'react'
import type { DialogueFormControls } from '@/lib/dialogues/useDialogue'
import type { PostImportEditableDraft, PostImportRow } from '@/lib/postImportTypes'
type Draft = PostImportEditableDraft
type Props = {
row: PostImportRow
controls: DialogueFormControls
onSave: (args: { draft: Draft
resetRequested: boolean }) => Promise<{
saved: boolean
row: PostImportRow | null }> }
const THUMBNAIL_MISSING_WARNING = 'サムネールなし'
const buildDraft = (row: PostImportRow): Draft => ({
url: row.url,
title: String (row.attributes.title ?? ''),
thumbnailBase: String (row.attributes.thumbnailBase ?? ''),
originalCreatedFrom: String (row.attributes.originalCreatedFrom ?? ''),
originalCreatedBefore: String (row.attributes.originalCreatedBefore ?? ''),
tags: String (row.attributes.tags ?? ''),
parentPostIds: String (row.attributes.parentPostIds ?? ''),
duration: String (row.attributes.duration ?? ''),
thumbnailFile: row.thumbnailFile })
const buildResetDraft = (row: PostImportRow): Draft => ({
url: row.resetSnapshot.url,
title: String (row.resetSnapshot.attributes.title ?? ''),
thumbnailBase: String (row.resetSnapshot.attributes.thumbnailBase ?? ''),
originalCreatedFrom: String (row.resetSnapshot.attributes.originalCreatedFrom ?? ''),
originalCreatedBefore: String (row.resetSnapshot.attributes.originalCreatedBefore ?? ''),
tags: String (row.resetSnapshot.attributes.tags ?? ''),
parentPostIds: String (row.resetSnapshot.attributes.parentPostIds ?? ''),
duration: String (row.resetSnapshot.attributes.duration ?? ''),
thumbnailFile: undefined })
const groupedMessages = (...values: (string[] | undefined)[]): string[] =>
[...new Set (values.flatMap (value => value ?? []))]
const sameDraft = (left: Draft, right: Draft): boolean =>
left.url === right.url
&& left.title === right.title
&& left.thumbnailBase === right.thumbnailBase
&& left.originalCreatedFrom === right.originalCreatedFrom
&& left.originalCreatedBefore === right.originalCreatedBefore
&& left.tags === right.tags
&& left.parentPostIds === right.parentPostIds
&& left.duration === right.duration
&& left.thumbnailFile === right.thumbnailFile
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 sameWarnings = (
current: PostImportRow,
reset: PostImportRow['resetSnapshot'],
): boolean =>
JSON.stringify (current.fieldWarnings) === JSON.stringify (reset.fieldWarnings)
&& JSON.stringify (current.baseWarnings) === JSON.stringify (reset.baseWarnings)
const thumbnailWarnings = (
messages: string[] | undefined,
thumbnailBase: string,
thumbnailFile: File | undefined,
): string[] => {
const others = (messages ?? []).filter (message => message !== THUMBNAIL_MISSING_WARNING)
return hasThumbnailBaseValue (thumbnailBase) || thumbnailFile != null
? others
: [...new Set ([...others, THUMBNAIL_MISSING_WARNING])]
}
const PostImportRowForm: FC<Props> = (
{ row,
controls,
onSave },
) => {
const [draft, setDraft] = useState<Draft> (() => buildDraft (row))
const [messageRow, setMessageRow] = useState<PostImportRow | null> (null)
const [saving, setSaving] = useState (false)
const [resetRequested, setResetRequested] = useState (false)
const [committedThumbnailBase, setCommittedThumbnailBase] = useState (
() => String (row.attributes.thumbnailBase ?? ''))
useEffect (() => {
const nextDraft = buildDraft (row)
setDraft (nextDraft)
setMessageRow (null)
setResetRequested (false)
setCommittedThumbnailBase (String (row.attributes.thumbnailBase ?? ''))
}, [row])
const displayRow = messageRow ?? row
const resetDraft = useMemo (
() => buildResetDraft (row),
[row])
const durationVisible = hasVideoTag (draft.tags)
const currentThumbnailWarnings = thumbnailWarnings (
displayRow.fieldWarnings.thumbnailBase,
draft.thumbnailBase,
draft.thumbnailFile)
const resetDisabled =
saving
|| (sameDraft (draft, resetDraft)
&& sameProvenance (row.provenance, row.resetSnapshot.provenance)
&& sameTagSources (row.tagSources, row.resetSnapshot.tagSources)
&& row.metadataUrl === row.resetSnapshot.metadataUrl
&& sameWarnings (displayRow, row.resetSnapshot))
const update = <Key extends keyof Draft,> (
key: Key,
value: Draft[Key],
) => {
if (messageRow != null)
setMessageRow (null)
setDraft (current => ({ ...current, [key]: value }))
}
const reset = useCallback (async (): Promise<boolean> => {
if (resetDisabled)
return false
const confirmed = await controls.confirm ({
title: '変更をリセットしますか?',
confirmText: 'リセット',
cancelText: '取消',
variant: 'danger' })
if (!(confirmed))
return false
setDraft (resetDraft)
setResetRequested (true)
setMessageRow (null)
setCommittedThumbnailBase (resetDraft.thumbnailBase)
return false
}, [controls, resetDisabled, resetDraft])
const save = useCallback (async (): Promise<boolean> => {
setSaving (true)
try
{
const result = await onSave ({ draft, resetRequested })
if (result.saved)
return true
if (result.row != null)
setMessageRow (result.row)
return false
}
finally
{
setSaving (false)
}
}, [draft, onSave, resetRequested])
useEffect (() => {
controls.setActions ([{
label: '変更をリセット',
placement: 'start',
variant: 'danger',
disabled: resetDisabled,
onSelect: reset },
{
label: '編輯内容を保存',
disabled: saving,
onSelect: save }])
}, [controls, resetDisabled, reset, save, saving])
return (
<>
<div className="px-6 pb-6">
<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">
<PostImportThumbnailPreview
url={committedThumbnailBase}
file={
hasThumbnailBaseValue (committedThumbnailBase)
? undefined
: draft.thumbnailFile}
className="h-28 w-28"/>
</div>
<div className="space-y-4">
<PostCreationDataFields
url={{
value: draft.url,
onChange: value => update ('url', value),
disabled: saving,
warnings: displayRow.fieldWarnings.url,
errors: groupedMessages (
displayRow.validationErrors.url,
displayRow.importErrors?.url) }}
thumbnailField={
<>
<PostTextField
label="サムネール"
value={draft.thumbnailBase}
disabled={saving}
warnings={currentThumbnailWarnings}
errors={groupedMessages (
displayRow.validationErrors.thumbnailBase,
displayRow.importErrors?.thumbnailBase)}
onBlur={() => {
if (draft.thumbnailBase.trim () !== committedThumbnailBase.trim ())
setCommittedThumbnailBase (draft.thumbnailBase)
}}
onChange={value => update ('thumbnailBase', value)}/>
{!(hasThumbnailBaseValue (draft.thumbnailBase)) && (
<input
type="file"
accept="image/*"
disabled={saving}
onChange={event => {
const file = event.target.files?.[0]
update ('thumbnailFile', file)
}}/>)}
</>}
core={{
title: {
value: draft.title,
onChange: value => update ('title', value),
disabled: saving,
warnings: displayRow.fieldWarnings.title,
errors: groupedMessages (
displayRow.validationErrors.title,
displayRow.importErrors?.title) },
originalCreated: {
disabled: saving,
originalCreatedFrom: draft.originalCreatedFrom || null,
setOriginalCreatedFrom: value =>
update ('originalCreatedFrom', value ?? ''),
originalCreatedBefore: draft.originalCreatedBefore || null,
setOriginalCreatedBefore: value =>
update ('originalCreatedBefore', value ?? ''),
errors: {
originalCreatedAt: groupedMessages (
displayRow.validationErrors.originalCreatedAt,
displayRow.importErrors?.originalCreatedAt),
originalCreatedFrom: groupedMessages (
displayRow.validationErrors.originalCreatedFrom,
displayRow.importErrors?.originalCreatedFrom),
originalCreatedBefore: groupedMessages (
displayRow.validationErrors.originalCreatedBefore,
displayRow.importErrors?.originalCreatedBefore) } },
tags: {
value: draft.tags,
onChange: value => update ('tags', value),
disabled: saving,
warnings: displayRow.fieldWarnings.tags,
errors: groupedMessages (
displayRow.validationErrors.tags,
displayRow.importErrors?.tags),
rows: 4 },
parentPostIds: {
value: draft.parentPostIds,
onChange: value => update ('parentPostIds', value),
disabled: saving,
errors: groupedMessages (
displayRow.validationErrors.parentPostIds,
displayRow.importErrors?.parentPostIds) } }}
extraFields={
durationVisible
? (
<PostDurationField
value={draft.duration}
onChange={value => update ('duration', value)}
disabled={saving}
errors={groupedMessages (
displayRow.validationErrors.videoMs,
displayRow.importErrors?.videoMs)}/>)
: null}/>
<FieldWarning messages={displayRow.baseWarnings}/>
<FieldError messages={displayRow.validationErrors.base}/>
<FieldError messages={displayRow.importErrors?.base}/>
</div>
</div>
</div>
</div>
</>)
}
export default PostImportRowForm
export { buildDraft }
export type { Draft as PostImportRowDraft }
+198
ファイルの表示
@@ -0,0 +1,198 @@
import FieldError from '@/components/common/FieldError'
import PostImportTagLinks from '@/components/posts/import/PostImportTagLinks'
import { Button } from '@/components/ui/button'
import PostImportThumbnailPreview from '@/components/posts/import/PostImportThumbnailPreview'
import PostImportStatusBadge from '@/components/posts/import/PostImportStatusBadge'
import { displayPostImportStatus } from '@/components/posts/import/postImportRowStatus'
import {
canEditReviewRow,
canRetryResultRow,
hasVideoTag,
} from '@/lib/postImportRows'
import { cn, originalCreatedAtString } from '@/lib/utils'
import type { FC } from 'react'
import type { PostImportRow } from '@/lib/postImportTypes'
type Props = {
row: PostImportRow
displayNumber?: number
onEdit?: () => void
onRetry?: () => void
onToggleSkip?: (checked: boolean) => void
rowMessages?: string[]
editDisabled?: boolean
retryDisabled?: boolean
skipDisabled?: boolean
showActions?: boolean
showSkipToggle?: boolean }
const summaryWarning = (row: PostImportRow): string | null =>
Object.values (row.fieldWarnings ?? { }).flat ()[0]
?? row.baseWarnings?.[0]
?? null
const summaryDate = (row: PostImportRow): string =>
originalCreatedAtString (
row.attributes.originalCreatedFrom?.toString () ?? null,
row.attributes.originalCreatedBefore?.toString () ?? null)
const PostImportRowSummary: FC<Props> = (
{ row,
displayNumber,
onEdit,
onRetry,
onToggleSkip,
rowMessages,
editDisabled,
retryDisabled,
skipDisabled,
showActions = true,
showSkipToggle = false },
) => {
const warning = summaryWarning (row)
const displayStatus = displayPostImportStatus (row)
const editVisible = onEdit != null
const editAllowed = editVisible && canEditReviewRow (row)
const retryAllowed = onRetry != null && canRetryResultRow (row)
const skipChecked = row.skipReason === 'manual'
const rowNumber = displayNumber ?? row.sourceRow
const duration = String (row.attributes.duration ?? '')
const showDuration = hasVideoTag (row.attributes.tags) && duration !== ''
const skipControl = showSkipToggle
? (
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={skipChecked}
onChange={event => onToggleSkip?.(event.target.checked)}
disabled={skipDisabled === true}/>
<span></span>
</label>)
: null
return (
<>
<div
className={cn (
'hidden items-center gap-4 rounded-lg border p-4 md:grid',
'md:grid-cols-[4rem_5rem_minmax(0,1fr)_auto_auto]',
'transition-shadow hover:shadow-sm')}>
<div className="space-y-1">
<div className="text-sm font-medium">#{rowNumber}</div>
</div>
<PostImportThumbnailPreview
url={String (row.attributes.thumbnailBase ?? '')}
file={row.thumbnailFile}
className="h-16 w-16"/>
<div className="min-w-0 space-y-1">
<div className="line-clamp-2 text-sm font-medium">
{String (row.attributes.title ?? '')}
</div>
<div className="truncate text-xs text-neutral-600 dark:text-neutral-300">
{row.url}
</div>
<PostImportTagLinks tags={row.displayTags}/>
<div className="text-xs text-neutral-500 dark:text-neutral-400">
{summaryDate (row)}
</div>
{showDuration && (
<div className="text-xs text-neutral-500 dark:text-neutral-400">
{duration}
</div>)}
{warning && (
<div className="text-xs text-amber-700 dark:text-amber-200">
{warning}
</div>)}
<FieldError messages={rowMessages}/>
</div>
<div className="space-y-1">
{displayStatus != null && <PostImportStatusBadge value={displayStatus}/>}
</div>
<div className="flex justify-end">
<div className="flex items-center gap-2">
{skipControl}
{showActions && editVisible && (
<Button
type="button"
variant="outline"
onClick={onEdit}
disabled={editDisabled === true || !(editAllowed)}>
</Button>)}
{showActions && retryAllowed && (
<Button
type="button"
variant="outline"
onClick={onRetry}
disabled={retryDisabled === true}>
</Button>)}
</div>
</div>
</div>
<div
className={cn (
'space-y-3 rounded-lg border p-4 md:hidden',
'transition-shadow hover:shadow-sm')}>
<div className="text-sm font-medium">#{rowNumber}</div>
<div className="flex items-start gap-3">
<PostImportThumbnailPreview
url={String (row.attributes.thumbnailBase ?? '')}
file={row.thumbnailFile}
className="h-20 w-20 shrink-0"/>
<div className="min-w-0 flex-1 space-y-2">
<div className="line-clamp-2 text-sm font-medium">
{String (row.attributes.title ?? '')}
</div>
<div className="truncate text-xs text-neutral-600 dark:text-neutral-300">
{row.url}
</div>
<div className="flex flex-wrap gap-2">
{displayStatus != null && <PostImportStatusBadge value={displayStatus}/>}
</div>
<PostImportTagLinks tags={row.displayTags}/>
<div className="text-xs text-neutral-500 dark:text-neutral-400">
{summaryDate (row)}
</div>
{showDuration && (
<div className="text-xs text-neutral-500 dark:text-neutral-400">
{duration}
</div>)}
{warning && (
<div className="text-xs text-amber-700 dark:text-amber-200">
{warning}
</div>)}
<FieldError messages={rowMessages}/>
{skipControl}
</div>
</div>
{showActions && (editVisible || retryAllowed) && (
<div className="flex flex-col gap-2 md:flex-row">
{editVisible && (
<Button
type="button"
className="w-full md:w-auto"
variant="outline"
onClick={onEdit}
disabled={editDisabled === true || !(editAllowed)}>
</Button>)}
{retryAllowed && (
<Button
type="button"
className="w-full md:w-auto"
variant="outline"
onClick={onRetry}
disabled={retryDisabled === true}>
</Button>)}
</div>)}
</div>
</>)
}
export default PostImportRowSummary
+33
ファイルの表示
@@ -0,0 +1,33 @@
import StatusBadge from '@/components/common/StatusBadge'
import type { FC } from 'react'
import type { StatusBadgeTone } from '@/components/common/StatusBadge'
import type { PostImportBadgeValue } from '@/components/posts/import/postImportRowStatus'
type Props = {
value: PostImportBadgeValue }
const LABELS: Record<PostImportBadgeValue, string> = {
ready: '登録可能',
error: '登録不可',
warning: '警告',
skipped: 'スキップ',
created: '登録済み',
failed: '登録失敗' }
const TONES: Record<PostImportBadgeValue, StatusBadgeTone> = {
ready: 'success',
error: 'warning',
warning: 'warning',
skipped: 'neutral',
created: 'success',
failed: 'warning' }
const PostImportStatusBadge: FC<Props> = ({ value }) => (
<StatusBadge tone={TONES[value]}>
{LABELS[value]}
</StatusBadge>)
export default PostImportStatusBadge
+36
ファイルの表示
@@ -0,0 +1,36 @@
import TagLink from '@/components/TagLink'
import type { FC } from 'react'
import type { PostImportDisplayTag } from '@/lib/postImportTypes'
type Props = {
tags: PostImportDisplayTag[] | undefined }
const PostImportTagLinks: FC<Props> = ({ tags }) => {
if (tags == null || tags.length === 0)
return null
return (
<div className="flex flex-wrap text-xs gap-x-1">
{tags.map (tag => {
const key = `${ tag.category }:${ tag.name }:${ tag.sectionLiterals?.join ('|') ?? '' }`
return (
<span key={key} className="inline-flex flex-nowrap items-baseline gap-1">
<TagLink
tag={{
name: tag.name,
category: tag.category }}
linkFlg={false}
withWiki={false}
withCount={false}/>
{tag.sectionLiterals?.map (literal => (
<span key={literal} className="text-xs text-neutral-500 dark:text-neutral-400">
{literal}
</span>))}
</span>)
})}
</div>)
}
export default PostImportTagLinks
+59
ファイルの表示
@@ -0,0 +1,59 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import PostImportThumbnailPreview from '@/components/posts/import/PostImportThumbnailPreview'
describe ('PostImportThumbnailPreview', () => {
beforeEach (() => {
vi.clearAllMocks ()
globalThis.URL.createObjectURL = vi.fn (() => 'blob:preview')
globalThis.URL.revokeObjectURL = vi.fn ()
})
it ('renders the remote URL directly without a backend proxy', () => {
render (
<PostImportThumbnailPreview
url="https://example.com/thumbnail.jpg"
className="h-10 w-10"/>)
expect (screen.getByRole ('img')).toHaveAttribute (
'src',
'https://example.com/thumbnail.jpg')
expect (screen.getByRole ('img')).toHaveAttribute (
'referrerpolicy',
'no-referrer')
})
it ('shows the empty frame after the remote image fails', () => {
const { container } = render (
<PostImportThumbnailPreview
url="https://example.com/missing.jpg"
className="h-10 w-10"/>)
fireEvent.error (screen.getByRole ('img'))
expect (screen.queryByRole ('img')).toBeNull ()
expect (container.querySelector ('div.rounded.border.bg-muted')).not.toBeNull ()
expect (container.textContent).toBe ('')
})
it ('uses and revokes an object URL only when the remote URL is blank', () => {
const file = new File (['image'], 'thumbnail.png', { type: 'image/png' })
const { rerender, unmount } = render (
<PostImportThumbnailPreview url="" file={file} className="h-10 w-10"/>)
expect (screen.getByRole ('img')).toHaveAttribute ('src', 'blob:preview')
rerender (
<PostImportThumbnailPreview
url="https://example.com/remote.jpg"
file={file}
className="h-10 w-10"/>)
expect (screen.getByRole ('img')).toHaveAttribute (
'src',
'https://example.com/remote.jpg')
unmount ()
expect (globalThis.URL.revokeObjectURL).toHaveBeenCalledWith ('blob:preview')
})
})
+25
ファイルの表示
@@ -0,0 +1,25 @@
import PostThumbnailPreview from '@/components/posts/PostThumbnailPreview'
import type { FC } from 'react'
type Props = {
url: string
file?: File
alt?: string
className?: string }
const PostImportThumbnailPreview: FC<Props> = (
{ url,
file,
alt = 'サムネール',
className = 'h-16 w-16' },
) => (
<PostThumbnailPreview
url={url}
file={file}
alt={alt}
className={className}
referrerPolicy="no-referrer"/>)
export default PostImportThumbnailPreview
+29
ファイルの表示
@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest'
import { displayPostImportStatus } from '@/components/posts/import/postImportRowStatus'
import { buildPostImportRow } from '@/test/postImportFactories'
describe ('displayPostImportStatus', () => {
it ('shows only ready, warning, and skipped states', () => {
expect (displayPostImportStatus (buildPostImportRow ())).toBe ('ready')
expect (displayPostImportStatus (buildPostImportRow ({
status: 'warning',
fieldWarnings: { title: ['warning'] } }))).toBe ('warning')
expect (displayPostImportStatus (buildPostImportRow ({
skipReason: 'existing',
existingPostId: 2 }))).toBe ('skipped')
expect (displayPostImportStatus (buildPostImportRow ({
skipReason: 'manual' }))).toBe ('skipped')
})
it ('distinguishes validation, failure, and created states', () => {
expect (displayPostImportStatus (buildPostImportRow ({
status: 'error',
validationErrors: { title: ['invalid'] } }))).toBe ('error')
expect (displayPostImportStatus (buildPostImportRow ({
importStatus: 'failed' }))).toBe ('failed')
expect (displayPostImportStatus (buildPostImportRow ({
importStatus: 'created',
createdPostId: 3 }))).toBe ('created')
})
})
+34
ファイルの表示
@@ -0,0 +1,34 @@
import type { PostImportRow } from '@/lib/postImportTypes'
export type PostImportDisplayStatus =
'ready'
| 'error'
| 'skipped'
| 'warning'
| 'created'
| 'failed'
export type PostImportBadgeValue = PostImportDisplayStatus
const hasWarnings = (row: PostImportRow): boolean =>
Object.values (row.fieldWarnings ?? { }).some (messages => messages.length > 0)
|| row.baseWarnings.length > 0
export const displayPostImportStatus = (
row: PostImportRow,
): PostImportDisplayStatus | null =>
row.status === 'pending'
? null
: (row.importStatus === 'failed')
? 'failed'
: (row.skipReason != null || row.importStatus === 'skipped')
? 'skipped'
: (row.importStatus === 'created')
? 'created'
: (row.status === 'error')
? 'error'
: (Object.values (row.validationErrors ?? { }).some (messages => messages.length > 0))
? 'error'
: ((hasWarnings (row) || row.status === 'warning')
? 'warning'
: 'ready')