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

Reviewed-on: #413
Co-authored-by: miteruzo <miteruzo@naver.com>
Co-committed-by: miteruzo <miteruzo@naver.com>
このコミットはPull リクエスト #413 でマージされました.
このコミットが含まれているのは:
2026-07-19 00:03:10 +09:00
committed by みてるぞ
コミット f1181e8510
99個のファイルの変更10242行の追加1126行の削除
+33 -5
ファイルの表示
@@ -19,8 +19,8 @@ const toastApi = vi.hoisted (() => ({
vi.mock ('@/lib/posts', () => postsApi)
vi.mock ('@/lib/api', () => api)
vi.mock ('@/components/ui/use-toast', () => toastApi)
vi.mock ('@/components/dialogues/DialogueProvider', () => ({
useDialogue: () => ({
vi.mock ('@/lib/dialogues/useDialogue', () => ({
default: () => ({
choice: vi.fn (),
}),
}))
@@ -78,15 +78,43 @@ describe ('PostEditForm', () => {
render (<PostEditForm post={post} onSave={vi.fn ()}/>)
expect (screen.getByRole ('spinbutton')).toHaveValue (180.5)
expect (screen.getByText ('動画時間').parentElement?.querySelector ('input'))
.toHaveValue ('180.5')
const tags = screen.getAllByRole ('textbox')[2]
fireEvent.change (tags, { target: { value: 'general-tag' } })
expect (screen.queryByRole ('spinbutton')).not.toBeInTheDocument ()
expect (screen.queryByText ('動画時間')).not.toBeInTheDocument ()
fireEvent.change (tags, {
target: { value: '動画 general-tag' },
})
expect (screen.getByRole ('spinbutton')).toHaveValue (180.5)
expect (screen.getByText ('動画時間').parentElement?.querySelector ('input'))
.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)
})
})
+31 -42
ファイルの表示
@@ -1,15 +1,16 @@
import { useEffect, useMemo, useState } from 'react'
import PostFormTagsArea from '@/components/PostFormTagsArea'
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
import FieldError from '@/components/common/FieldError'
import FormField from '@/components/common/FormField'
import { useDialogue } from '@/components/dialogues/DialogueProvider'
import PostDurationField from '@/components/posts/PostDurationField'
import PostTagsField from '@/components/posts/PostTagsField'
import PostTextField from '@/components/posts/PostTextField'
import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/use-toast'
import { isApiError } from '@/lib/api'
import useDialogue from '@/lib/dialogues/useDialogue'
import { updatePost } from '@/lib/posts'
import { inputClass, msToTime } from '@/lib/utils'
import { msToTime } from '@/lib/utils'
import { useValidationErrors } from '@/lib/useValidationErrors'
import type { FC, FormEvent } from 'react'
@@ -17,7 +18,11 @@ import type { FC, FormEvent } from 'react'
import type { Post, TagWithSections } from '@/types'
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 =>
videoMs == null ? '' : String (videoMs / 1_000)
@@ -157,32 +162,20 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
<form onSubmit={handleSubmit} className="max-w-xl pt-2 space-y-4">
<FieldError messages={baseErrors}/>
{/* タイトル */}
<FormField label="タイトル">
{({ invalid }) => (
<input
type="text"
disabled={disabled}
className={inputClass (invalid)}
value={title ?? ''}
onChange={e => setTitle (e.target.value)}/>)}
</FormField>
<PostTextField
label="タイトル"
value={title ?? ''}
disabled={disabled}
onChange={setTitle}/>
{/* 親投稿 */}
<FormField label="親投稿" messages={fieldErrors.parentPostIds}>
{({ describedBy, invalid }) => (
<input
type="text"
disabled={disabled}
value={parentPostIds}
onChange={e => setParentPostIds (e.target.value)}
aria-describedby={describedBy}
aria-invalid={invalid}
className={inputClass (invalid)}/>)}
</FormField>
<PostTextField
label="親投稿"
value={parentPostIds}
disabled={disabled}
errors={fieldErrors.parentPostIds}
onChange={setParentPostIds}/>
{/* タグ */}
<PostFormTagsArea
<PostTagsField
disabled={disabled}
tags={tags}
setTags={setTags}
@@ -195,21 +188,17 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
setOriginalCreatedFrom={setOriginalCreatedFrom}
originalCreatedBefore={originalCreatedBefore}
setOriginalCreatedBefore={setOriginalCreatedBefore}
errors={fieldErrors.originalCreatedAt}/>
errors={groupedMessages (
fieldErrors.originalCreatedAt,
fieldErrors.originalCreatedFrom,
fieldErrors.originalCreatedBefore)}/>
{/* 動画時間 */}
{videoFlg && (
<FormField label="動画時間" messages={fieldErrors.videoMs}>
{({ invalid }) => (
<input
type="number"
min="0.001"
step="0.001"
disabled={disabled}
className={inputClass (invalid)}
value={duration}
onChange={e => setDuration (e.target.value)}/>)}
</FormField>)}
<PostDurationField
value={duration}
disabled={disabled}
errors={fieldErrors.videoMs}
onChange={setDuration}/>)}
{/* 送信 */}
<Button type="submit" disabled={disabled}>
+41 -3
ファイルの表示
@@ -20,8 +20,10 @@ describe ('PostOriginalCreatedTimeField', () => {
fireEvent.change (inputs[0], { target: { value: '2026-01-02T03:04' } })
fireEvent.change (inputs[1], { target: { value: '2026-01-03T03:04' } })
expect (setFrom).toHaveBeenCalledWith (expect.any (String))
expect (setBefore).toHaveBeenCalledWith (expect.any (String))
expect (setFrom).toHaveBeenCalledWith (expect.stringMatching (
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}Z$/))
expect (setBefore).toHaveBeenCalledWith (expect.stringMatching (
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}Z$/))
})
it ('infers an exclusive before value on blur', () => {
@@ -38,7 +40,43 @@ describe ('PostOriginalCreatedTimeField', () => {
const input = screen.getAllByDisplayValue ('')[0]
fireEvent.blur (input, { target: { value: '2026-01-02T03:04' } })
expect (setBefore).toHaveBeenCalledWith (expect.any (String))
const value = setBefore.mock.calls.at (-1)?.[0]
expect (value).toMatch (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}Z$/)
expect (new Date (value).getTime () - new Date ('2026-01-02T03:04').getTime ())
.toBe (60_000)
})
it ('does not rewrite mounted values that only differ by offset notation', () => {
const setFrom = vi.fn ()
const setBefore = vi.fn ()
render (
<PostOriginalCreatedTimeField
originalCreatedFrom="2024-01-01T12:34+09:00"
setOriginalCreatedFrom={setFrom}
originalCreatedBefore="2024-01-01T12:35+09:00"
setOriginalCreatedBefore={setBefore}/>,
)
expect (setFrom).not.toHaveBeenCalled ()
expect (setBefore).not.toHaveBeenCalled ()
})
it ('emits minute-precision UTC values only when the user edits the input', () => {
const setFrom = vi.fn ()
render (
<PostOriginalCreatedTimeField
originalCreatedFrom="2024-01-01T12:34+09:00"
setOriginalCreatedFrom={setFrom}
originalCreatedBefore={null}
setOriginalCreatedBefore={vi.fn ()}/>,
)
const input = screen.getByDisplayValue ('2024-01-01T12:34')
fireEvent.change (input, { target: { value: '2024-01-01T12:35' } })
expect (setFrom).toHaveBeenCalledWith ('2024-01-01T03:35Z')
})
it ('resets both values', () => {
+68 -64
ファイルの表示
@@ -1,4 +1,4 @@
import DateTimeField from '@/components/common/DateTimeField'
import DateTimeField, { toMinutePrecisionIsoUtc } from '@/components/common/DateTimeField'
import FormField from '@/components/common/FormField'
import { Button } from '@/components/ui/button'
@@ -19,70 +19,74 @@ const PostOriginalCreatedTimeField: FC<Props> = (
setOriginalCreatedFrom,
originalCreatedBefore,
setOriginalCreatedBefore,
errors }: Props) => (
<FormField label="オリジナルの作成日時" messages={errors}>
{({ describedBy, invalid }) => (
<>
<div className="my-1 flex">
<div className="w-80">
<DateTimeField
className="mr-2"
disabled={disabled ?? false}
aria-describedby={describedBy}
aria-invalid={invalid}
invalid={invalid}
value={originalCreatedFrom ?? undefined}
onChange={setOriginalCreatedFrom}
onBlur={ev => {
const v = ev.target.value
if (!(v))
return
errors }: Props) => {
return (
<FormField label="オリジナルの作成日時" messages={errors}>
{({ describedBy, invalid }) => (
<>
<div className="my-1 flex">
<div className="w-80">
<DateTimeField
className="mr-2"
disabled={disabled ?? false}
aria-describedby={describedBy}
aria-invalid={invalid}
invalid={invalid}
value={originalCreatedFrom ?? undefined}
onChange={setOriginalCreatedFrom}
onBlur={ev => {
const v = ev.target.value
if (!(v))
return
const d = new Date (v)
if (d.getMinutes () === 0 && d.getHours () === 0)
d.setDate (d.getDate () + 1)
else
d.setMinutes (d.getMinutes () + 1)
setOriginalCreatedBefore (d.toISOString ())
}}/>
</div>
<div>
<Button
className="bg-gray-600 text-white rounded"
disabled={disabled}
onClick={() => {
setOriginalCreatedFrom (null)
}}>
</Button>
</div>
</div>
const d = new Date (v)
if (d.getMinutes () === 0 && d.getHours () === 0)
d.setDate (d.getDate () + 1)
else
d.setMinutes (d.getMinutes () + 1)
setOriginalCreatedBefore (toMinutePrecisionIsoUtc (d.toISOString ()))
}}/>
</div>
<div>
<Button
type="button"
className="bg-gray-600 text-white rounded"
disabled={disabled}
onClick={() => {
setOriginalCreatedFrom (null)
}}>
</Button>
</div>
</div>
<div className="my-1 flex">
<div className="w-80">
<DateTimeField
className="mr-2"
disabled={disabled}
aria-describedby={describedBy}
aria-invalid={invalid}
invalid={invalid}
value={originalCreatedBefore ?? undefined}
onChange={setOriginalCreatedBefore}/>
</div>
<div>
<Button
className="bg-gray-600 text-white rounded"
disabled={disabled}
onClick={() => {
setOriginalCreatedBefore (null)
}}>
</Button>
</div>
</div>
</>)}
</FormField>)
<div className="my-1 flex">
<div className="w-80">
<DateTimeField
className="mr-2"
disabled={disabled}
aria-describedby={describedBy}
aria-invalid={invalid}
invalid={invalid}
value={originalCreatedBefore ?? undefined}
onChange={setOriginalCreatedBefore}/>
</div>
<div>
<Button
type="button"
className="bg-gray-600 text-white rounded"
disabled={disabled}
onClick={() => {
setOriginalCreatedBefore (null)
}}>
</Button>
</div>
</div>
</>)}
</FormField>)
}
export default PostOriginalCreatedTimeField
-13
ファイルの表示
@@ -6,7 +6,6 @@ import { createPath, useNavigate } from 'react-router-dom'
import { useOverlayStore } from '@/components/RouteBlockerOverlay'
import { prefetchForURL } from '@/lib/prefetchers'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { useUnsavedChangesGuard } from '@/lib/useUnsavedChangesGuard'
import { cn } from '@/lib/utils'
import type { AnchorHTMLAttributes, MouseEvent, TouchEvent } from 'react'
@@ -36,7 +35,6 @@ export default forwardRef<HTMLAnchorElement, Props> (({
const navigate = useNavigate ()
const qc = useQueryClient ()
const behaviourSettings = useClientBehaviourSettings ()
const { confirmDiscardNavigation } = useUnsavedChangesGuard ()
const linkPreloadMode = behaviourSettings.linkPreload ?? 'intent'
const path = useMemo (
() => typeof to === 'string' ? to : createPath (to),
@@ -45,10 +43,6 @@ export default forwardRef<HTMLAnchorElement, Props> (({
const url = useMemo (() => {
return (new URL (path, window.location.origin)).toString ()
}, [path])
const nextPathname = useMemo (
() => (new URL (path, window.location.origin)).pathname,
[path],
)
const setOverlay = useOverlayStore (s => s.setActive)
const doPrefetch = async () => {
@@ -93,13 +87,6 @@ export default forwardRef<HTMLAnchorElement, Props> (({
ev.preventDefault ()
if (nextPathname !== window.location.pathname)
{
const confirmed = await confirmDiscardNavigation ()
if (!(confirmed))
return
}
flushSync (() => {
setOverlay (true)
})
+43 -12
ファイルの表示
@@ -4,9 +4,13 @@ import { cn } from '@/lib/utils'
import type { ComponentProps, CSSProperties, FC, HTMLAttributes } from 'react'
import type { Tag } from '@/types'
import type { Category, Tag } from '@/types'
type CommonProps = {
type LightweightTag = {
name: string
category: Category }
type FullCommonProps = {
tag: Tag
nestLevel?: number
truncateOnMobile?: boolean
@@ -14,18 +18,43 @@ type CommonProps = {
withCount?: boolean }
type PropsWithLink =
& CommonProps
& FullCommonProps
& { linkFlg?: true }
& Partial<ComponentProps<typeof PrefetchLink>>
type PropsWithoutLink =
& CommonProps
& FullCommonProps
& { linkFlg: false }
& Partial<HTMLAttributes<HTMLSpanElement>>
type LightweightPropsWithLink =
& {
tag: LightweightTag
nestLevel?: number
truncateOnMobile?: boolean
withWiki: false
withCount: false
linkFlg?: true }
& Partial<ComponentProps<typeof PrefetchLink>>
type LightweightPropsWithoutLink =
& {
tag: LightweightTag
nestLevel?: number
truncateOnMobile?: boolean
withWiki: false
withCount: false
linkFlg: false }
& Partial<HTMLAttributes<HTMLSpanElement>>
type Props =
| PropsWithLink
| PropsWithoutLink
| LightweightPropsWithLink
| LightweightPropsWithoutLink
const isFullTag = (tag: Tag | LightweightTag): tag is Tag =>
'id' in tag
const TagLink: FC<Props> = ({ tag,
@@ -46,16 +75,18 @@ const TagLink: FC<Props> = ({ tag,
const spanClass = 'tag-link-colour'
const linkClass = 'tag-link-colour tag-link-hover-colour'
const textClass = 'group min-w-0 max-w-full overflow-hidden align-bottom'
const rootClass =
'inline-flex min-w-0 max-w-full flex-nowrap items-stretch align-baseline gap-x-1 md:items-baseline'
const markerWrapClass = 'shrink-0 self-start md:self-auto'
const countClass = 'shrink-0 self-end md:self-auto'
const matchedAlias = isFullTag (tag) ? tag.matchedAlias : null
const textTitle = title
?? (tag.matchedAlias == null ? tag.name : `${ tag.matchedAlias }${ tag.name }`)
?? (matchedAlias == null ? tag.name : `${ matchedAlias }${ tag.name }`)
return (
<span className={rootClass}>
{(linkFlg && withWiki) && (
<span
className={cn (
'inline-flex min-w-0 max-w-full flex-nowrap items-stretch align-baseline',
'gap-x-1 md:items-baseline')}>
{(linkFlg && withWiki && isFullTag (tag)) && (
<span className={markerWrapClass}>
{(tag.materialId != null || tag.hasWiki || tag.hasDeerjikists)
? (
@@ -118,7 +149,7 @@ const TagLink: FC<Props> = ({ tag,
style={{ paddingLeft: `${ (nestLevel - 1) }rem` }}>
</span>)}
{tag.matchedAlias != null && (
{matchedAlias != null && (
<>
<span
title={textTitle}
@@ -126,7 +157,7 @@ const TagLink: FC<Props> = ({ tag,
style={colourStyle}
{...props}>
<ResponsiveMarqueeText
text={tag.matchedAlias}
text={matchedAlias}
title={textTitle}
truncateOnMobile={truncateOnMobile}/>
</span>
@@ -156,7 +187,7 @@ const TagLink: FC<Props> = ({ tag,
title={textTitle}
truncateOnMobile={truncateOnMobile}/>
</span>)}
{withCount && (
{(withCount && isFullTag (tag)) && (
<span className={countClass}>{tag.postCount}</span>)}
</span>)
}
+39
ファイルの表示
@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest'
import { menuOutline } from '@/components/TopNav'
import { buildUser } from '@/test/factories'
const submenuItem = (role: 'guest' | 'member' | 'admin', section: string, item: string) => {
const menu = menuOutline ({
user: buildUser ({ role }),
wikiId: section === 'Wiki' ? 10 : null,
pathName: section === 'Wiki' ? '/wiki/page' : '/posts' })
return menu.find (entry => entry.name === section)?.subMenu.find (
subMenuItem => subMenuItem.name === item)
}
describe ('menuOutline', () => {
it ('uses content-edit permission for post, material, and Wiki actions', () => {
for (const role of ['member', 'admin'] as const)
{
expect (submenuItem (role, '広場', '追加')?.visible).toBe (true)
expect (submenuItem (role, '素材', '追加')?.visible).toBe (true)
expect (submenuItem (role, 'Wiki', '新規')?.visible).toBe (true)
expect (submenuItem (role, 'Wiki', '編輯')?.visible).toBe (true)
}
expect (submenuItem ('guest', '広場', '追加')?.visible).toBe (false)
expect (submenuItem ('guest', '素材', '追加')?.visible).toBe (false)
expect (submenuItem ('guest', 'Wiki', '新規')?.visible).toBe (false)
expect (submenuItem ('guest', 'Wiki', '編輯')?.visible).toBe (false)
})
it ('uses /posts/new as the post creation 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)
})
})
+10 -6
ファイルの表示
@@ -9,6 +9,7 @@ import TopNavUser from '@/components/TopNavUser'
import { WikiIdBus } from '@/lib/eventBus/WikiIdBus'
import { materialsKeys, tagsKeys, wikiKeys } from '@/lib/queryKeys'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { canEditContent } from '@/lib/users'
import { fetchTag, fetchTagByName } from '@/lib/tags'
import { fetchMaterial } from '@/lib/materials'
import { cn } from '@/lib/utils'
@@ -26,10 +27,11 @@ export const menuOutline = (
tag?: Tag | null
material?: Material | null
wikiId: number | null
user: User | null,
user: User | null
pathName: string },
): Menu => {
const postCount = tag?.postCount ?? material?.tag?.postCount ?? 0
const editable = canEditContent (user)
const wikiPageFlg = Boolean (/^\/wiki\/(?!new|changes)[^/]+/.test (pathName) && wikiId)
const wikiTitle = pathName.split ('/')[2] ?? ''
@@ -42,7 +44,7 @@ export const menuOutline = (
{ name: '広場', to: '/posts', subMenu: [
{ name: '一覧', to: '/posts' },
{ name: '検索', to: '/posts/search' },
{ name: '追加', to: '/posts/new' },
{ name: '追加', to: '/posts/new', visible: editable },
{ name: '全体履歴', to: '/posts/changes' },
{ name: 'ヘルプ', to: '/wiki/ヘルプ:広場' }] },
{ name: 'タグ', to: '/tags', subMenu: [
@@ -58,8 +60,9 @@ export const menuOutline = (
visible: tagFlg && tag?.category !== 'nico' }] },
{ name: '素材', to: '/materials', visible: true, subMenu: [
{ name: '一覧', to: '/materials' },
{ name: '追加', to: '/materials/new' },
{ name: '抑止', to: '/materials/suppressions' },
{ name: '追加', to: '/materials/new', visible: editable },
{ name: '抑止', to: '/materials/suppressions',
visible: user?.role === 'admin' },
{ name: '全体履歴', to: '/materials/changes' },
{ name: 'ヘルプ', to: '/wiki/ヘルプ:素材管理' },
{ component: <Separator/>, visible: materialFlg },
@@ -70,14 +73,15 @@ export const menuOutline = (
visible: materialFlg }] },
{ name: 'Wiki', to: '/wiki/ヘルプ:ホーム', base: '/wiki', subMenu: [
{ name: '検索', to: '/wiki' },
{ name: '新規', to: '/wiki/new' },
{ name: '新規', to: '/wiki/new', visible: editable },
{ name: '全体履歴', to: '/wiki/changes' },
{ name: 'ヘルプ', to: '/wiki/ヘルプ:Wiki' },
{ component: <Separator/>, visible: wikiPageFlg },
{ name: `広場 (${ postCount || 0 })`, to: `/posts?tags=${ wikiTitle }`,
visible: wikiPageFlg },
{ name: '履歴', to: `/wiki/changes?id=${ wikiId }`, visible: wikiPageFlg },
{ name: '編輯', to: `/wiki/${ wikiId || wikiTitle }/edit`, visible: wikiPageFlg }] },
{ name: '編輯', to: `/wiki/${ wikiId || wikiTitle }/edit`,
visible: wikiPageFlg && editable }] },
{ name: 'おたのしみ', visible: false, subMenu: [
{ name: '上映会 (β)', to: '/theatres/1' },
{ name: 'グカネータ (β)', to: '/gekanator' }] },
+2 -1
ファイルの表示
@@ -21,7 +21,8 @@ describe ('DateTimeField', () => {
fireEvent.change (input, { target: { value: '' } })
const first = handleChange.mock.calls[0]?.[0]
expect (new Date (first).getFullYear ()).toBe (2026)
expect (first).toMatch (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}Z$/)
expect (new Date (first).getUTCSeconds ()).toBe (0)
expect (handleChange).toHaveBeenLastCalledWith (null)
})
})
+24 -8
ファイルの表示
@@ -8,13 +8,27 @@ import type { ComponentPropsWithoutRef, FC, FocusEvent } from 'react'
const pad = (n: number): string => n.toString ().padStart (2, '0')
const toDateTimeLocalValue = (d: Date) => {
const y = d.getFullYear ()
const m = pad (d.getMonth () + 1)
const day = pad (d.getDate ())
const h = pad (d.getHours ())
const min = pad (d.getMinutes ())
return `${ y }-${ m }-${ day }T${ h }:${ min }:00`
const toDateTimeLocalValue = (value: Date) => {
const y = value.getFullYear ()
const m = pad (value.getMonth () + 1)
const day = pad (value.getDate ())
const h = pad (value.getHours ())
const min = pad (value.getMinutes ())
return `${ y }-${ m }-${ day }T${ h }:${ min }`
}
const toMinutePrecisionIsoUtc = (value: string) => {
const date = new Date (value)
if (Number.isNaN (date.getTime ()))
return value
const y = date.getUTCFullYear ()
const m = pad (date.getUTCMonth () + 1)
const day = pad (date.getUTCDate ())
const h = pad (date.getUTCHours ())
const min = pad (date.getUTCMinutes ())
return `${ y }-${ m }-${ day }T${ h }:${ min }Z`
}
@@ -47,14 +61,16 @@ const DateTimeField: FC<Props> = ({ value, onChange, className, onBlur, invalid,
'focus:ring-2 focus:ring-blue-200']),
className)}
type="datetime-local"
step={60}
value={local}
aria-invalid={invalid}
onChange={ev => {
const v = ev.target.value
setLocal (v)
onChange?.(v ? (new Date (v)).toISOString () : null)
onChange?.(v ? toMinutePrecisionIsoUtc (v) : null)
}}
onBlur={onBlur}/>)
}
export default DateTimeField
export { toMinutePrecisionIsoUtc }
+18
ファイルの表示
@@ -0,0 +1,18 @@
import type { FC } from 'react'
type Props = { id?: string
messages?: string[] }
export const FieldWarning: FC<Props> = ({ id, messages }: Props) => {
if (messages == null || messages.length === 0)
return null
return (
<ul id={id} className="mt-1 space-y-1 text-amber-700 dark:text-amber-200">
{messages.map ((message, i) => <li key={i}>{message}</li>)}
</ul>)
}
export default FieldWarning
+8 -4
ファイルの表示
@@ -1,11 +1,15 @@
import { cn } from '@/lib/utils'
import type { FC, ReactNode } from 'react'
type Props = { children: ReactNode }
type Props = {
children: ReactNode
className?: string }
const Form: FC<Props> = ({ children }) => (
<div className="max-w-xl mx-auto p-4 space-y-4">
const Form: FC<Props> = ({ children, className }) => (
<div className={cn ('mx-auto max-w-xl space-y-4 p-4', className)}>
{children}
</div>)
export default Form
export default Form
+34
ファイルの表示
@@ -0,0 +1,34 @@
import { cn } from '@/lib/utils'
import type { FC, ReactNode } from 'react'
export type StatusBadgeTone =
'success'
| 'neutral'
| 'warning'
type Props = {
children: ReactNode
tone: StatusBadgeTone }
const TONES: Record<StatusBadgeTone, string[]> = {
success: [
'border-emerald-300 bg-emerald-50 text-emerald-700',
'dark:border-emerald-900 dark:bg-emerald-950 dark:text-emerald-200'],
warning: [
'border-amber-300 bg-amber-50 text-amber-700',
'dark:border-amber-900 dark:bg-amber-950 dark:text-amber-200'],
neutral: [
'border-stone-300 bg-stone-50 text-stone-700',
'dark:border-stone-700 dark:bg-stone-900 dark:text-stone-200'] }
const StatusBadge: FC<Props> = ({ children, tone }) => (
<span
className={cn (
'inline-flex items-center rounded-full border px-2.5 py-1 text-xs font-medium',
TONES[tone])}>
{children}
</span>)
export default StatusBadge
+116
ファイルの表示
@@ -0,0 +1,116 @@
import { useCallback, useEffect } from 'react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import DialogueProvider from '@/components/dialogues/DialogueProvider'
import useDialogue from '@/lib/dialogues/useDialogue'
import type { DialogueFormControls } from '@/lib/dialogues/useDialogue'
const FormBody = (
{ controls,
onSelect }: { controls: DialogueFormControls
onSelect: () => Promise<boolean> | boolean },
) => {
useEffect (() => {
controls.setActions ([{
label: '左操作',
placement: 'start',
onSelect }, {
label: '保存',
onSelect: () => true }])
}, [controls, onSelect])
return <div></div>
}
const NestedConfirmFormBody = ({ controls }: { controls: DialogueFormControls }) => {
const reset = useCallback (async () => {
await controls.confirm ({
title: '変更をリセットしますか?',
confirmText: 'リセット' })
return false
}, [controls])
return <FormBody controls={controls} onSelect={reset}/>
}
describe ('DialogueProvider', () => {
it ('keeps ordinary dialogues in FIFO order', async () => {
const Launcher = () => {
const dialogue = useDialogue ()
return (
<button
onClick={() => {
void dialogue.confirm ({ title: '一件目' })
void dialogue.confirm ({ title: '二件目' })
}}>
</button>)
}
render (<DialogueProvider><Launcher/></DialogueProvider>)
fireEvent.click (screen.getByRole ('button', { name: '開く' }))
expect (screen.getByText ('一件目')).toBeInTheDocument ()
expect (screen.queryByText ('二件目')).not.toBeInTheDocument ()
fireEvent.click (screen.getByRole ('button', { name: '確定' }))
expect (await screen.findByText ('二件目')).toBeInTheDocument ()
})
it ('keeps a large form open when an action returns false', async () => {
const action = vi.fn ().mockResolvedValue (false)
const Launcher = () => {
const dialogue = useDialogue ()
return (
<button
onClick={() => void dialogue.form ({
title: '投稿を編輯',
description: '説明',
size: 'large',
body: controls => <FormBody controls={controls} onSelect={action}/> })}>
</button>)
}
render (<DialogueProvider><Launcher/></DialogueProvider>)
fireEvent.click (screen.getByRole ('button', { name: '開く' }))
const dialogue = screen.getByRole ('dialog')
expect (dialogue).toHaveClass ('max-h-[calc(100dvh-1rem)]', 'flex-col', 'max-w-3xl')
await waitFor (() => expect (screen.getByRole ('button', { name: '左操作' }))
.toBeInTheDocument ())
expect (screen.getByRole ('button', { name: '左操作' })).toHaveClass (
'w-full',
'md:w-auto')
fireEvent.click (screen.getByRole ('button', { name: '左操作' }))
await waitFor (() => expect (action).toHaveBeenCalledTimes (1))
expect (screen.getByText ('投稿を編輯')).toBeInTheDocument ()
})
it ('opens a nested confirmation over a form and returns to the same form', async () => {
const Launcher = () => {
const dialogue = useDialogue ()
return (
<button
onClick={() => void dialogue.form ({
title: '投稿を編輯',
body: controls => <NestedConfirmFormBody controls={controls}/> })}>
</button>)
}
render (<DialogueProvider><Launcher/></DialogueProvider>)
fireEvent.click (screen.getByRole ('button', { name: '開く' }))
const action = await screen.findByRole ('button', { name: '左操作' })
fireEvent.click (action)
expect (await screen.findByText ('変更をリセットしますか?')).toBeInTheDocument ()
fireEvent.click (screen.getByRole ('button', { name: 'リセット' }))
await waitFor (() => {
expect (screen.queryByText ('変更をリセットしますか?')).not.toBeInTheDocument ()
})
expect (screen.getByText ('投稿を編輯')).toBeInTheDocument ()
})
})
+266 -99
ファイルの表示
@@ -1,4 +1,4 @@
import { createContext, useCallback, useContext, useMemo, useState } from 'react'
import { useCallback, useMemo, useRef, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Dialog,
@@ -7,29 +7,16 @@ import { Dialog,
DialogFooter,
DialogHeader,
DialogTitle } from '@/components/ui/dialog'
import { DialogueContext } from '@/lib/dialogues/useDialogue'
import type { FC, ReactNode } from 'react'
type DialogueVariant = 'default' | 'danger'
type ConfirmOptions = { title: string
description?: ReactNode
confirmText?: string
cancelText?: string
variant?: DialogueVariant }
type AlertOptions = { title: string
description?: ReactNode
okText?: string }
type Choice<T extends string> = { value: T
label: string
variant?: DialogueVariant }
type ChoiceOptions<T extends string> = { title: string
description?: ReactNode
choices: Choice<T>[]
cancelText?: string }
import type { AlertOptions,
ChoiceOptions,
ConfirmOptions,
DialogueAPI,
DialogueFormAction,
DialogueFormControls,
DialogueFormOptions } from '@/lib/dialogues/useDialogue'
type DialogueRequest =
| { id: number
@@ -44,13 +31,10 @@ type DialogueRequest =
kind: 'choice'
options: ChoiceOptions<string>
resolve: (value: string | null) => void }
type DialogueAPI =
{ confirm: (options: ConfirmOptions) => Promise<boolean>
alert: (options: AlertOptions) => Promise<void>
choice: <T extends string> (options: ChoiceOptions<T>) => Promise<T | null> }
const DialogueContext = createContext<DialogueAPI | null> (null)
| { id: number
kind: 'form'
options: DialogueFormOptions
resolve: () => void }
let nextDialogueId = 1
@@ -59,20 +43,27 @@ type Props = { children: ReactNode }
const DialogueProvider: FC<Props> = ({ children }) => {
const [queue, setQueue] = useState<DialogueRequest[]> ([])
const [pendingIds, setPendingIds] = useState<number[]> ([])
const [formActions, setFormActions] = useState<Record<number, DialogueFormAction[]>> ({ })
const formControls = useRef<Record<number, DialogueFormControls>> ({ })
const [nestedConfirm, setNestedConfirm] = useState<{
parentId: number
options: ConfirmOptions
resolve: (value: boolean) => void } | null> (null)
const push = useCallback ((request: Omit<DialogueRequest, 'id'>) => {
const id = nextDialogueId
++nextDialogueId
setQueue (q => [...q, { ...request, id } as DialogueRequest])
setQueue (current => [...current, { ...request, id } as DialogueRequest])
}, [])
const closeActive = useCallback ((result?: unknown) => {
setQueue (q => {
const [active, ...rest] = q
const closeRequest = useCallback ((id: number, result?: unknown) => {
setQueue (current => {
const active = current.find (request => request.id === id)
if (!(active))
return rest
if (active == null)
return current
switch (active.kind)
{
@@ -87,9 +78,42 @@ const DialogueProvider: FC<Props> = ({ children }) => {
case 'choice':
active.resolve ((result ?? null) as string | null)
break
case 'form':
active.resolve ()
break
}
return rest
return current.filter (request => request.id !== id)
})
setPendingIds (current => current.filter (pendingId => pendingId !== id))
setFormActions (current => {
const { [id]: _, ...rest } = current
return rest
})
delete formControls.current[id]
}, [])
const setRequestActions = useCallback (
(id: number, actions: DialogueFormAction[]) => {
setFormActions (current => ({ ...current, [id]: actions }))
},
[])
const openNestedConfirm = useCallback (
(parentId: number, options: ConfirmOptions) =>
new Promise<boolean> (resolve => {
setNestedConfirm ({ parentId, options, resolve })
}),
[])
const closeNestedConfirm = useCallback ((result: boolean) => {
setNestedConfirm (current => {
if (current == null)
return current
current.resolve (result)
return null
})
}, [])
@@ -103,86 +127,229 @@ const DialogueProvider: FC<Props> = ({ children }) => {
choice: options => new Promise (resolve => {
push ({ kind: 'choice',
options: options as ChoiceOptions<string>,
resolve: resolve as (value: string | null) => void })}) }), [push])
resolve: resolve as (value: string | null) => void })
}),
form: options => new Promise<void> (resolve => {
push ({ kind: 'form', options, resolve })
}) }), [push])
const handleFormAction = useCallback (
async (id: number, action: DialogueFormAction) => {
if (pendingIds.includes (id))
return
setPendingIds (current => [...current, id])
try
{
const shouldClose = await action.onSelect ()
if (shouldClose !== false)
closeRequest (id)
}
finally
{
setPendingIds (current => current.filter (pendingId => pendingId !== id))
}
},
[closeRequest, pendingIds])
const active = queue[0]
const startActions =
active?.kind === 'form'
? (formActions[active.id] ?? []).filter (action => action.placement === 'start')
: []
const endActions =
active?.kind === 'form'
? (formActions[active.id] ?? []).filter (action =>
action.placement == null || action.placement === 'end')
: []
return (
<DialogueContext.Provider value={api}>
{children}
<Dialog
open={Boolean (active)}
onOpenChange={open => {
if (!(open))
closeActive (active?.kind !== 'confirm' && null)
}}>
{active && (
{active && (
<Dialog
open
onOpenChange={open => {
const blocked =
nestedConfirm?.parentId === active.id
|| pendingIds.includes (active.id)
if (!(open) && !(blocked))
closeRequest (active.id, active.kind !== 'confirm' && null)
}}>
<DialogContent
className={
active.kind === 'form'
? `flex max-h-[calc(100dvh-1rem)] ${ active.options.size === 'large'
? 'max-w-3xl'
: 'max-w-lg' } flex-col overflow-hidden gap-0`
: 'px-6 pb-6 pt-7'
}
onEscapeKeyDown={event => {
if (nestedConfirm?.parentId === active.id || pendingIds.includes (active.id))
event.preventDefault ()
}}
onPointerDownOutside={event => {
if (nestedConfirm?.parentId === active.id || pendingIds.includes (active.id))
event.preventDefault ()
}}>
{active.kind === 'form'
? (
<>
<DialogHeader className="shrink-0 pb-4 pl-8 pr-0 pt-1 text-left">
<DialogTitle>{active.options.title}</DialogTitle>
{active.options.description && (
<DialogDescription asChild>
<div>{active.options.description}</div>
</DialogDescription>)}
</DialogHeader>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain">
{active.options.body (
formControls.current[active.id] ??= {
close: () => closeRequest (active.id),
setActions: actions => setRequestActions (active.id, actions),
confirm: options => openNestedConfirm (active.id, options) })}
</div>
<DialogFooter
className="shrink-0 flex-col gap-2 pt-4
md:flex-row md:justify-between md:gap-0 md:space-x-0">
<div className="flex w-full flex-col gap-2 md:w-auto md:flex-row">
{startActions.map (action => (
<Button
key={action.label}
className="w-full md:w-auto"
variant={action.variant === 'danger'
? 'destructive'
: 'default'}
onClick={() => void handleFormAction (active.id, action)}
disabled={pendingIds.includes (active.id)
|| nestedConfirm?.parentId === active.id
|| action.disabled}>
{action.label}
</Button>))}
</div>
<div className="flex w-full flex-col gap-2 md:w-auto md:flex-row">
<Button
className="w-full md:w-auto"
variant="outline"
onClick={() => closeRequest (active.id)}
disabled={pendingIds.includes (active.id)
|| nestedConfirm?.parentId === active.id}>
{active.options.cancelText ?? '取消'}
</Button>
{endActions.map (action => (
<Button
key={action.label}
className="w-full md:w-auto"
variant={action.variant === 'danger'
? 'destructive'
: 'default'}
onClick={() => void handleFormAction (active.id, action)}
disabled={pendingIds.includes (active.id)
|| nestedConfirm?.parentId === active.id
|| action.disabled}>
{action.label}
</Button>))}
</div>
</DialogFooter>
</>)
: (
<>
<DialogHeader className="pl-8">
<DialogTitle>{active.options.title}</DialogTitle>
{active.options.description && (
<DialogDescription asChild>
<div>{active.options.description}</div>
</DialogDescription>)}
</DialogHeader>
<DialogFooter>
{active.kind === 'confirm' && (
<>
<Button
variant="outline"
onClick={() => closeRequest (active.id, false)}>
{active.options.cancelText ?? '取消'}
</Button>
<Button
variant={(active.options.variant === 'danger')
? 'destructive'
: 'default'}
onClick={() => closeRequest (active.id, true)}>
{active.options.confirmText ?? '確定'}
</Button>
</>)}
{active.kind === 'alert' && (
<Button onClick={() => closeRequest (active.id)}>
{active.options.okText ?? '確定'}
</Button>)}
{active.kind === 'choice' && (
<>
<Button
variant="outline"
onClick={() => closeRequest (active.id, null)}>
{active.options.cancelText ?? '取消'}
</Button>
{active.options.choices.map (choice => (
<Button
key={choice.value}
variant={(choice.variant === 'danger')
? 'destructive'
: 'default'}
onClick={() => closeRequest (active.id, choice.value)}>
{choice.label}
</Button>))}
</>)}
</DialogFooter>
</>)}
</DialogContent>
</Dialog>)}
{nestedConfirm && (
<Dialog
open
onOpenChange={open => {
if (!(open))
closeNestedConfirm (false)
}}>
<DialogContent className="px-6 pb-6 pt-7">
<DialogHeader className="pl-8">
<DialogTitle>{active.options.title}</DialogTitle>
<DialogTitle>{nestedConfirm.options.title}</DialogTitle>
{active.options.description && (
{nestedConfirm.options.description && (
<DialogDescription asChild>
<div>{active.options.description}</div>
<div>{nestedConfirm.options.description}</div>
</DialogDescription>)}
</DialogHeader>
<DialogFooter>
{active.kind === 'confirm' && (
<>
<Button
variant="outline"
onClick={() => closeActive (false)}>
{active.options.cancelText ?? '取消'}
</Button>
<Button
variant="outline"
onClick={() => closeNestedConfirm (false)}>
{nestedConfirm.options.cancelText ?? '取消'}
</Button>
<Button
variant={(active.options.variant === 'danger')
? 'destructive'
: 'default'}
onClick={() => closeActive (true)}>
{active.options.confirmText ?? '確定'}
</Button>
</>)}
{active.kind === 'alert' && (
<Button onClick={() => closeActive ()}>
{active.options.okText ?? '確定'}
</Button>)}
{active.kind === 'choice' && (
<>
<Button
variant="outline"
onClick={() => closeActive (null)}>
{active.options.cancelText ?? '取消'}
</Button>
{active.options.choices.map (choice => (
<Button
key={choice.value}
variant={(choice.variant === 'danger')
? 'destructive'
: 'default'}
onClick={() => closeActive (choice.value)}>
{choice.label}
</Button>))}
</>)}
<Button
variant={nestedConfirm.options.variant === 'danger'
? 'destructive'
: 'default'}
onClick={() => closeNestedConfirm (true)}>
{nestedConfirm.options.confirmText ?? '確定'}
</Button>
</DialogFooter>
</DialogContent>)}
</Dialog>
</DialogContent>
</Dialog>)}
</DialogueContext.Provider>)
}
export const useDialogue = () => {
const dialogue = useContext (DialogueContext)
if (!(dialogue))
throw new Error ('useDialogue must be used inside DialogueProvider')
return dialogue
}
export { useDialogue } from '@/lib/dialogues/useDialogue'
export default DialogueProvider
+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')
+95 -97
ファイルの表示
@@ -1,10 +1,10 @@
"use client"
'use client'
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import * as DialogPrimitive from '@radix-ui/react-dialog'
import { X } from 'lucide-react'
import * as React from 'react'
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils'
const Dialog = DialogPrimitive.Root
@@ -15,111 +15,109 @@ const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className)}
{...props}
/>))
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(
({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn (
'fixed inset-0 z-50 bg-black/80',
'data-[state=open]:animate-in data-[state=closed]:animate-out',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className)}
{...props}/>))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn (
'fixed left-[50%] top-[50%] z-50 grid w-[calc(100%-2rem)] max-w-lg',
'translate-x-[-50%] translate-y-[-50%]',
'gap-5 rounded-2xl border border-border',
'bg-background p-6 text-foreground shadow-2xl',
'duration-200',
'data-[state=open]:animate-in data-[state=closed]:animate-out',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
className)}
{...props}
>
{children}
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(
({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn (
'fixed left-[50%] top-[50%] z-50 grid w-[calc(100%-2rem)] max-w-lg',
'translate-x-[-50%] translate-y-[-50%]',
'gap-5 rounded-2xl border border-border',
'bg-background p-6 text-foreground shadow-2xl',
'duration-200',
'data-[state=open]:animate-in data-[state=closed]:animate-out',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
className)}
{...props}>
{children}
<DialogPrimitive.Close
className={cn (
'absolute left-4 top-4 rounded-full p-1',
'text-slate-500 transition-colors',
'hover:bg-slate-200 hover:text-slate-900',
'dark:text-slate-400 dark:hover:bg-slate-700 dark:hover:text-slate-50',
'focus:outline-none focus:ring-2 focus:ring-slate-400')}>
<X className="h-4 w-4"/>
<span className="sr-only"></span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>))
<DialogPrimitive.Close
className={cn (
'absolute left-4 top-4 rounded-full p-1',
'text-slate-500 transition-colors',
'hover:bg-slate-200 hover:text-slate-900',
'dark:text-slate-400 dark:hover:bg-slate-700 dark:hover:text-slate-50',
'focus:outline-none focus:ring-2 focus:ring-slate-400')}>
<X className="h-4 w-4"/>
<span className="sr-only"></span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className)}
{...props}
/>)
DialogHeader.displayName = "DialogHeader"
const DialogHeader = (
{ className, ...props }: React.HTMLAttributes<HTMLDivElement>,
) => (
<div
className={cn (
'flex flex-col space-y-1.5 text-center md:text-left',
className)}
{...props}/>)
DialogHeader.displayName = 'DialogHeader'
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className)}
{...props}
/>)
DialogFooter.displayName = "DialogFooter"
const DialogFooter = (
{ className, ...props }: React.HTMLAttributes<HTMLDivElement>,
) => (
<div
className={cn (
'flex flex-col-reverse md:flex-row md:justify-end md:space-x-2',
className)}
{...props}/>)
DialogFooter.displayName = 'DialogFooter'
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className)}
{...props}
/>))
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(
({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn (
'text-lg font-semibold leading-none tracking-tight',
className)}
{...props}/>))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>))
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(
({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn ('text-sm text-muted-foreground', className)}
{...props}/>))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}