このコミットが含まれているのは:
2026-07-16 21:45:02 +09:00
コミット 66c738cbff
26個のファイルの変更1083行の追加951行の削除
+4
ファイルの表示
@@ -30,6 +30,10 @@ describe ('menuOutline', () => {
expect (submenuItem ('guest', 'Wiki', '編輯')?.visible).toBe (false)
})
it ('uses /posts/new as the import entrypoint', () => {
expect (submenuItem ('member', '広場', '取込')?.to).toBe ('/posts/new')
})
it ('keeps material suppression admin-only', () => {
expect (submenuItem ('member', '素材', '抑止')?.visible).toBe (false)
expect (submenuItem ('admin', '素材', '抑止')?.visible).toBe (true)
+1 -1
ファイルの表示
@@ -45,7 +45,7 @@ export const menuOutline = (
{ name: '一覧', to: '/posts' },
{ name: '検索', to: '/posts/search' },
{ name: '追加', to: '/posts/new', visible: editable },
{ name: '取込', to: '/posts/import', visible: editable },
{ name: '取込', to: '/posts/new', visible: editable },
{ name: '全体履歴', to: '/posts/changes' },
{ name: 'ヘルプ', to: '/wiki/ヘルプ:広場' }] },
{ name: 'タグ', to: '/tags', subMenu: [
+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 }
+44
ファイルの表示
@@ -0,0 +1,44 @@
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 }
const PostCreationDataFields: FC<Props> = (
{ url,
thumbnailField,
core },
) => (
<>
<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}/>
</>)
export default PostCreationDataFields
+41
ファイルの表示
@@ -0,0 +1,41 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { buildPostImportRow } from '@/test/postImportFactories'
import { buildUser } from '@/test/factories'
import { renderWithProviders } from '@/test/render'
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 PostNewPage', async () => {
const { default: PostNewPage } = await import ('@/pages/posts/PostNewPage')
renderWithProviders (<PostNewPage user={buildUser ({ role: 'member' })}/>)
expect (screen.getByTestId ('shared-fields')).toBeInTheDocument ()
})
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 ()
})
})
+13 -7
ファイルの表示
@@ -1,5 +1,7 @@
import { useEffect, useState } from 'react'
import { cn } from '@/lib/utils'
import type { FC } from 'react'
type Props = {
@@ -21,8 +23,10 @@ const PostThumbnailPreview: FC<Props> = (
{
return (
<div
className={`${ className } flex items-center justify-center rounded border
border-border bg-muted text-xs text-muted-foreground`}>
className={cn (
className,
'flex items-center justify-center rounded border',
'border-border bg-muted text-xs text-muted-foreground')}>
</div>)
}
@@ -31,10 +35,12 @@ const PostThumbnailPreview: FC<Props> = (
{
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`}>
className={cn (
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>)
}
@@ -43,7 +49,7 @@ const PostThumbnailPreview: FC<Props> = (
<img
src={url}
alt={alt}
className={`${ className } rounded border border-border object-cover`}
className={cn (className, 'rounded border border-border object-cover')}
onError={() => setFailed (true)}/>)
}
+26 -11
ファイルの表示
@@ -85,7 +85,7 @@ describe ('PostImportRowForm', () => {
it ('marks edited fields and areas invalid from field errors', () => {
const row = buildPostImportRow ({
validationErrors: { url: ['URL error'], tags: ['tag error'] },
importErrors: { duration: ['duration error'] },
importErrors: { title: ['title error'] },
fieldWarnings: { title: ['title warning'] } })
render (
@@ -96,7 +96,7 @@ describe ('PostImportRowForm', () => {
expect (screen.getByText ('URL error')).toBeInTheDocument ()
expect (screen.getByText ('tag error')).toBeInTheDocument ()
expect (screen.getByText ('duration 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)
@@ -134,7 +134,9 @@ describe ('PostImportRowForm', () => {
resetRequested: false })
})
it ('keeps the shared duration and tags string contract without leaking file upload UI', async () => {
it (
'shows the shared creation field order without duration and without file upload UI',
async () => {
let actions: DialogueFormAction[] = []
const controls: DialogueFormControls = {
close: vi.fn (),
@@ -152,8 +154,18 @@ describe ('PostImportRowForm', () => {
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"]')).toBeNull ()
expect (screen.getByPlaceholderText ('例: 2 / 2.5 / 1:23')).toHaveValue ('2')
expect (screen.queryByPlaceholderText ('例: 2 / 2.5 / 1:23')).not.toBeInTheDocument ()
expect (screen.getByDisplayValue ('tag1')).toBeInTheDocument ()
await act (async () => {
@@ -162,10 +174,9 @@ describe ('PostImportRowForm', () => {
expect (onSave).toHaveBeenCalledWith ({
draft: expect.objectContaining ({
duration: '2',
tags: 'tag1' }),
resetRequested: false })
})
})
it ('keeps reset enabled when the value matches but provenance still differs', async () => {
const row = buildPostImportRow ({
@@ -178,7 +189,6 @@ describe ('PostImportRowForm', () => {
thumbnailBase: '',
originalCreatedFrom: '',
originalCreatedBefore: '',
duration: '',
tags: '',
parentPostIds: '' },
provenance: {
@@ -187,7 +197,6 @@ describe ('PostImportRowForm', () => {
thumbnailBase: 'automatic',
originalCreatedFrom: 'automatic',
originalCreatedBefore: 'automatic',
duration: 'automatic',
tags: 'automatic',
parentPostIds: 'automatic' },
tagSources: { automatic: '', manual: '' },
@@ -210,7 +219,9 @@ describe ('PostImportRowForm', () => {
expect (actions.find (action => action.label === '変更をリセット')?.disabled).toBe (false)
})
it ('disables every field while save validation is pending and re-enables them afterwards', async () => {
it (
'disables every field while save validation is pending and re-enables them afterwards',
async () => {
let actions: DialogueFormAction[] = []
let resolveSave:
((value: { saved: boolean
@@ -234,8 +245,9 @@ describe ('PostImportRowForm', () => {
onSave={onSave}/>)
await waitFor (() => expect (actions.length).toBe (2))
let savePromise: Promise<boolean | void> | undefined
await act (async () => {
await actions.find (action => action.label === '編輯内容を保存')?.onSelect ()
savePromise = actions.find (action => action.label === '編輯内容を保存')?.onSelect ()
})
await waitFor (() => {
@@ -249,6 +261,9 @@ describe ('PostImportRowForm', () => {
row: buildPostImportRow ({
attributes: { title: 'draft title' },
validationErrors: { title: ['タイトルを確認してください.'] } }) })
await act (async () => {
await savePromise
})
await waitFor (() => {
screen.getAllByRole ('textbox').forEach (textbox => {
@@ -257,5 +272,5 @@ describe ('PostImportRowForm', () => {
})
expect (screen.getByDisplayValue ('draft title')).toBeInTheDocument ()
expect (screen.getByText ('タイトルを確認してください.')).toBeInTheDocument ()
})
})
})
+65 -72
ファイルの表示
@@ -1,10 +1,8 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
import FieldError from '@/components/common/FieldError'
import FieldWarning from '@/components/common/FieldWarning'
import PostDurationField from '@/components/posts/PostDurationField'
import PostTagsField from '@/components/posts/PostTagsField'
import PostCreationDataFields from '@/components/posts/PostCreationDataFields'
import PostTextField from '@/components/posts/PostTextField'
import PostImportThumbnailPreview from '@/components/posts/import/PostImportThumbnailPreview'
@@ -29,7 +27,6 @@ const buildDraft = (row: PostImportRow): Draft => ({
thumbnailBase: String (row.attributes.thumbnailBase ?? ''),
originalCreatedFrom: String (row.attributes.originalCreatedFrom ?? ''),
originalCreatedBefore: String (row.attributes.originalCreatedBefore ?? ''),
duration: String (row.attributes.duration ?? ''),
tags: String (row.attributes.tags ?? ''),
parentPostIds: String (row.attributes.parentPostIds ?? '') })
@@ -39,7 +36,6 @@ const buildResetDraft = (row: PostImportRow): Draft => ({
thumbnailBase: String (row.resetSnapshot.attributes.thumbnailBase ?? ''),
originalCreatedFrom: String (row.resetSnapshot.attributes.originalCreatedFrom ?? ''),
originalCreatedBefore: String (row.resetSnapshot.attributes.originalCreatedBefore ?? ''),
duration: String (row.resetSnapshot.attributes.duration ?? ''),
tags: String (row.resetSnapshot.attributes.tags ?? ''),
parentPostIds: String (row.resetSnapshot.attributes.parentPostIds ?? '') })
@@ -52,7 +48,6 @@ const sameDraft = (left: Draft, right: Draft): boolean =>
&& left.thumbnailBase === right.thumbnailBase
&& left.originalCreatedFrom === right.originalCreatedFrom
&& left.originalCreatedBefore === right.originalCreatedBefore
&& left.duration === right.duration
&& left.tags === right.tags
&& left.parentPostIds === right.parentPostIds
@@ -169,72 +164,70 @@ const PostImportRowForm: FC<Props> = (
</div>
<div className="space-y-4">
<PostTextField
label="URL"
value={draft.url}
disabled={saving}
warnings={displayRow.fieldWarnings.url}
errors={groupedMessages (
displayRow.validationErrors.url,
displayRow.importErrors?.url)}
onChange={value => update ('url', value)}/>
<PostTextField
label="タイトル"
value={draft.title}
disabled={saving}
warnings={displayRow.fieldWarnings.title}
errors={groupedMessages (
displayRow.validationErrors.title,
displayRow.importErrors?.title)}
onChange={value => update ('title', value)}/>
<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}
setOriginalCreatedBefore={value => update ('originalCreatedBefore', value ?? '')}
errors={groupedMessages (
displayRow.validationErrors.originalCreatedAt,
displayRow.validationErrors.originalCreatedFrom,
displayRow.validationErrors.originalCreatedBefore,
displayRow.importErrors?.originalCreatedAt,
displayRow.importErrors?.originalCreatedFrom,
displayRow.importErrors?.originalCreatedBefore)}/>
<PostDurationField
value={draft.duration}
disabled={saving}
errors={groupedMessages (
displayRow.validationErrors.duration,
displayRow.validationErrors.videoMs,
displayRow.importErrors?.duration,
displayRow.importErrors?.videoMs)}
onChange={value => update ('duration', value)}/>
<PostTagsField
tags={draft.tags}
disabled={saving}
setTags={value => update ('tags', value)}
warnings={displayRow.fieldWarnings.tags}
errors={groupedMessages (
displayRow.validationErrors.tags,
displayRow.importErrors?.tags)}
rows={4}/>
<PostTextField
label="親投稿"
value={draft.parentPostIds}
disabled={saving}
errors={groupedMessages (
displayRow.validationErrors.parentPostIds,
displayRow.importErrors?.parentPostIds)}
onChange={value => update ('parentPostIds', value)}/>
<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={displayRow.fieldWarnings.thumbnailBase}
errors={groupedMessages (
displayRow.validationErrors.thumbnailBase,
displayRow.importErrors?.thumbnailBase)}
onChange={value => update ('thumbnailBase', value)}/>
</>}
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) } }}/>
<FieldWarning messages={displayRow.baseWarnings}/>
<FieldError messages={displayRow.validationErrors.base}/>
<FieldError messages={displayRow.importErrors?.base}/>
+50 -19
ファイルの表示
@@ -1,8 +1,9 @@
import FieldError from '@/components/common/FieldError'
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 } from '@/lib/postImportSession'
import { canEditReviewRow, canRetryResultRow } from '@/lib/postImportSession'
import { cn, originalCreatedAtString } from '@/lib/utils'
import type { FC } from 'react'
@@ -12,7 +13,10 @@ import type { PostImportRow } from '@/lib/postImportSession'
type Props = {
row: PostImportRow
onEdit: () => void
editDisabled?: boolean }
onRetry?: () => void
rowMessages?: string[]
editDisabled?: boolean
retryDisabled?: boolean }
const summaryWarning = (row: PostImportRow): string | null =>
Object.values (row.fieldWarnings ?? { }).flat ()[0]
@@ -25,7 +29,14 @@ const summaryDate = (row: PostImportRow): string =>
row.attributes.originalCreatedBefore?.toString () ?? null)
const PostImportRowSummary: FC<Props> = ({ row, onEdit, editDisabled }) => {
const PostImportRowSummary: FC<Props> = (
{ row,
onEdit,
onRetry,
rowMessages,
editDisabled,
retryDisabled },
) => {
const warning = summaryWarning (row)
const displayStatus = displayPostImportStatus (row)
@@ -54,24 +65,34 @@ const PostImportRowSummary: FC<Props> = ({ row, onEdit, editDisabled }) => {
</div>
<div className="text-xs text-neutral-500 dark:text-neutral-400">
{summaryDate (row)}
{row.attributes.duration ? ` / ${ row.attributes.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">
<Button
type="button"
variant="outline"
onClick={onEdit}
disabled={!(canEditReviewRow (row)) || editDisabled === true}>
</Button>
<div className="flex gap-2">
<Button
type="button"
variant="outline"
onClick={onEdit}
disabled={!(canEditReviewRow (row)) || editDisabled === true}>
</Button>
{onRetry != null && (
<Button
type="button"
variant="outline"
onClick={onRetry}
disabled={!(canRetryResultRow (row)) || retryDisabled === true}>
</Button>)}
</div>
</div>
</div>
@@ -98,21 +119,31 @@ const PostImportRowSummary: FC<Props> = ({ row, onEdit, editDisabled }) => {
</div>
<div className="text-xs text-neutral-500 dark:text-neutral-400">
{summaryDate (row)}
{row.attributes.duration ? ` / ${ row.attributes.duration }` : ''}
</div>
{warning && (
<div className="text-xs text-amber-700 dark:text-amber-200">
{warning}
</div>)}
<FieldError messages={rowMessages}/>
</div>
</div>
<Button
type="button"
variant="outline"
onClick={onEdit}
disabled={!(canEditReviewRow (row)) || editDisabled === true}>
</Button>
<div className="flex flex-col gap-2 sm:flex-row">
<Button
type="button"
variant="outline"
onClick={onEdit}
disabled={!(canEditReviewRow (row)) || editDisabled === true}>
</Button>
{onRetry != null && (
<Button
type="button"
variant="outline"
onClick={onRetry}
disabled={!(canRetryResultRow (row)) || retryDisabled === true}>
</Button>)}
</div>
</div>
</>)
}
+6 -4
ファイルの表示
@@ -71,10 +71,12 @@ const PostImportThumbnailPreview: FC<Props> = (
{
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`}>
className={cn (
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>)
}