Reviewed-on: #413 Co-authored-by: miteruzo <miteruzo@naver.com> Co-committed-by: miteruzo <miteruzo@naver.com>
このコミットはPull リクエスト #413 でマージされました.
このコミットが含まれているのは:
@@ -13,7 +13,7 @@ import MainArea from '@/components/layout/MainArea'
|
||||
import { SITE_TITLE } from '@/config'
|
||||
import { fetchMaterials, parseMaterialFilter } from '@/lib/materials'
|
||||
import { materialsKeys } from '@/lib/queryKeys'
|
||||
import { dateString, inputClass } from '@/lib/utils'
|
||||
import { cn, dateString, inputClass } from '@/lib/utils'
|
||||
|
||||
import type { FC, FormEvent } from 'react'
|
||||
|
||||
@@ -113,10 +113,11 @@ const clearedTagSelectionPath = (
|
||||
|
||||
const MaterialThumb: FC<{ material: Material }> = ({ material }) => (
|
||||
<div
|
||||
className={`flex aspect-square h-[180px] w-[180px] items-center justify-center
|
||||
overflow-hidden rounded-lg border border-stone-200 bg-white text-center
|
||||
text-stone-900 shadow-sm dark:border-stone-700 dark:bg-stone-900
|
||||
dark:text-stone-100`}>
|
||||
className={cn (
|
||||
'flex aspect-square h-[180px] w-[180px] items-center justify-center',
|
||||
'overflow-hidden rounded-lg border border-stone-200 bg-white text-center',
|
||||
'text-stone-900 shadow-sm dark:border-stone-700 dark:bg-stone-900',
|
||||
'dark:text-stone-100')}>
|
||||
{material.thumbnail
|
||||
? <img src={material.thumbnail} alt="" className="block h-full w-full object-cover"/>
|
||||
: (
|
||||
@@ -482,7 +483,7 @@ const MaterialListPage: FC = () => {
|
||||
className={inputClass (invalid)}>
|
||||
<option value="all">すべて</option>
|
||||
<option value="tagged">タグあり</option>
|
||||
<option value="untagged">タグなし</option>
|
||||
<option value="untagged"></option>
|
||||
</select>)}
|
||||
</FormField>
|
||||
|
||||
@@ -531,30 +532,32 @@ const MaterialListPage: FC = () => {
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => updateQuery ({ view: 'card' })}
|
||||
className={`rounded-full border px-4 py-2 text-sm ${
|
||||
view === 'card'
|
||||
? [
|
||||
'border-sky-500 bg-sky-50 text-sky-700 dark:border-sky-400',
|
||||
'dark:bg-sky-950 dark:text-sky-100'].join (' ')
|
||||
: [
|
||||
'border-stone-300 bg-white text-stone-900 dark:border-stone-700',
|
||||
'dark:bg-stone-900 dark:text-stone-100'].join (' ') }`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => updateQuery ({ view: 'card' })}
|
||||
className={cn (
|
||||
'rounded-full border px-4 py-2 text-sm',
|
||||
view === 'card'
|
||||
? [
|
||||
'border-sky-500 bg-sky-50 text-sky-700 dark:border-sky-400',
|
||||
'dark:bg-sky-950 dark:text-sky-100']
|
||||
: [
|
||||
'border-stone-300 bg-white text-stone-900 dark:border-stone-700',
|
||||
'dark:bg-stone-900 dark:text-stone-100'])}>
|
||||
アイコン
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => updateQuery ({ view: 'list' })}
|
||||
className={`rounded-full border px-4 py-2 text-sm ${
|
||||
view === 'list'
|
||||
? [
|
||||
'border-sky-500 bg-sky-50 text-sky-700 dark:border-sky-400',
|
||||
'dark:bg-sky-950 dark:text-sky-100'].join (' ')
|
||||
: [
|
||||
'border-stone-300 bg-white text-stone-900 dark:border-stone-700',
|
||||
'dark:bg-stone-900 dark:text-stone-100'].join (' ') }`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => updateQuery ({ view: 'list' })}
|
||||
className={cn (
|
||||
'rounded-full border px-4 py-2 text-sm',
|
||||
view === 'list'
|
||||
? [
|
||||
'border-sky-500 bg-sky-50 text-sky-700 dark:border-sky-400',
|
||||
'dark:bg-sky-950 dark:text-sky-100']
|
||||
: [
|
||||
'border-stone-300 bg-white text-stone-900 dark:border-stone-700',
|
||||
'dark:bg-stone-900 dark:text-stone-100'])}>
|
||||
詳細
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import PostImportReviewPage from '@/pages/posts/PostImportReviewPage'
|
||||
import { buildUser } from '@/test/factories'
|
||||
import { renderWithProviders } from '@/test/render'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
const api = vi.hoisted (() => ({
|
||||
apiGet: vi.fn (),
|
||||
apiPost: vi.fn (),
|
||||
isApiError: vi.fn (() => false) }))
|
||||
|
||||
vi.mock ('@/lib/api', () => api)
|
||||
vi.mock ('framer-motion', () => ({
|
||||
AnimatePresence: ({ children }: { children?: ReactNode }) => <>{children}</>,
|
||||
motion: {
|
||||
div: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
|
||||
main: ({ children }: { children?: ReactNode }) => <main>{children}</main> } }))
|
||||
|
||||
const metadata = (url: string, title: string) => ({
|
||||
url,
|
||||
title,
|
||||
thumbnailBase: 'https://example.com/thumbnail.jpg',
|
||||
originalCreatedFrom: '',
|
||||
originalCreatedBefore: '',
|
||||
duration: '',
|
||||
tags: '',
|
||||
parentPostIds: [],
|
||||
fieldWarnings: { },
|
||||
baseWarnings: [],
|
||||
displayTags: [] })
|
||||
|
||||
|
||||
const LocationProbe = () => {
|
||||
const location = useLocation ()
|
||||
return <output aria-label="current-location">{location.pathname}{location.search}</output>
|
||||
}
|
||||
|
||||
|
||||
const renderReviewPage = (urls: string[]) => {
|
||||
const search = urls.map (url => encodeURIComponent (url)).join ('+')
|
||||
return renderWithProviders (
|
||||
<>
|
||||
<PostImportReviewPage user={buildUser ()}/>
|
||||
<LocationProbe/>
|
||||
</>,
|
||||
{ route: `/posts/new?urls=${ search }` })
|
||||
}
|
||||
|
||||
describe ('PostImportReviewPage', () => {
|
||||
beforeEach (() => {
|
||||
vi.clearAllMocks ()
|
||||
api.isApiError.mockReturnValue (false)
|
||||
})
|
||||
|
||||
it ('fetches metadata with at most four concurrent requests', async () => {
|
||||
const resolvers: Array<(value: ReturnType<typeof metadata>) => void> = []
|
||||
api.apiGet.mockImplementation ((_path, options) =>
|
||||
new Promise (resolve => {
|
||||
resolvers.push (resolve)
|
||||
const url = String (options.params.url)
|
||||
void url
|
||||
}))
|
||||
const urls = Array.from (
|
||||
{ length: 6 },
|
||||
(_, index) => `https://example.com/${ index + 1 }`)
|
||||
|
||||
renderReviewPage (urls)
|
||||
|
||||
await waitFor (() => expect (api.apiGet).toHaveBeenCalledTimes (4))
|
||||
|
||||
resolvers[0]?.(metadata (urls[0]!, 'first'))
|
||||
|
||||
expect (await screen.findAllByText ('first')).toHaveLength (2)
|
||||
await waitFor (() => expect (api.apiGet).toHaveBeenCalledTimes (5))
|
||||
expect (screen.getByRole ('button', { name: '追加' })).toBeDisabled ()
|
||||
|
||||
for (let index = 1; index < resolvers.length; ++index)
|
||||
resolvers[index]?.(metadata (urls[index]!, `post ${ index + 1 }`))
|
||||
})
|
||||
|
||||
it ('keeps successful rows when another metadata request fails', async () => {
|
||||
api.apiGet
|
||||
.mockResolvedValueOnce (metadata ('https://example.com/one', 'first'))
|
||||
.mockRejectedValueOnce (new TypeError ('network'))
|
||||
|
||||
renderReviewPage (['https://example.com/one', 'https://example.com/two'])
|
||||
|
||||
expect (await screen.findAllByText ('first')).toHaveLength (2)
|
||||
await waitFor (() => {
|
||||
expect (screen.getAllByText ('登録不可')).toHaveLength (2)
|
||||
})
|
||||
expect (screen.getAllByRole ('button', { name: '編輯' })[0]).toBeEnabled ()
|
||||
})
|
||||
|
||||
it ('shows existing posts with Active Storage thumbnail precedence', async () => {
|
||||
api.apiGet.mockResolvedValue ({
|
||||
...metadata ('https://example.com/post', ''),
|
||||
existingPostId: 24,
|
||||
existingPost: {
|
||||
id: 24,
|
||||
title: 'existing post',
|
||||
url: 'https://example.com/post',
|
||||
thumbnail: 'https://example.com/storage.jpg',
|
||||
thumbnailBase: 'https://example.com/base.jpg' } })
|
||||
|
||||
renderReviewPage (['https://example.com/post'])
|
||||
|
||||
const disclosure = await screen.findByRole ('button', {
|
||||
name: '既存投稿による自動スキップ 1件' })
|
||||
expect (screen.getByRole ('button', { name: '追加' })).toBeDisabled ()
|
||||
|
||||
fireEvent.click (disclosure)
|
||||
|
||||
expect ((await screen.findAllByRole ('img', { name: 'サムネール' }))[0])
|
||||
.toHaveAttribute ('src', 'https://example.com/storage.jpg')
|
||||
})
|
||||
|
||||
it ('keeps a recoverable bulk failure on the review screen', async () => {
|
||||
api.apiGet.mockResolvedValue (
|
||||
metadata ('https://example.com/post', 'new post'))
|
||||
api.apiPost.mockResolvedValue ({
|
||||
results: [{
|
||||
status: 'failed',
|
||||
recoverable: true,
|
||||
errors: { title: ['invalid title'] } }] })
|
||||
|
||||
renderReviewPage (['https://example.com/post'])
|
||||
|
||||
fireEvent.click (await screen.findByRole ('button', { name: '追加' }))
|
||||
|
||||
expect (await screen.findAllByText ('登録失敗')).toHaveLength (2)
|
||||
expect (screen.getAllByText ('invalid title')).toHaveLength (2)
|
||||
expect (screen.getAllByRole ('button', { name: '編輯' })[0]).toBeEnabled ()
|
||||
expect (screen.getAllByRole ('button', { name: '再試行' })[0]).toBeEnabled ()
|
||||
expect (screen.getByLabelText ('current-location')).toHaveTextContent ('/posts/new?')
|
||||
})
|
||||
|
||||
it ('navigates to /posts after every submitted row is created', async () => {
|
||||
api.apiGet.mockResolvedValue (
|
||||
metadata ('https://example.com/post', 'new post'))
|
||||
api.apiPost.mockResolvedValue ({
|
||||
results: [{ status: 'created', post: { id: 42 } }] })
|
||||
|
||||
renderReviewPage (['https://example.com/post'])
|
||||
|
||||
fireEvent.click (await screen.findByRole ('button', { name: '追加' }))
|
||||
|
||||
await waitFor (() => {
|
||||
expect (screen.getByLabelText ('current-location')).toHaveTextContent ('/posts')
|
||||
})
|
||||
expect (api.apiPost).toHaveBeenCalledWith ('/posts/bulk', expect.any (FormData))
|
||||
})
|
||||
})
|
||||
ファイル差分が大きすぎるため省略します
差分を読込み
@@ -0,0 +1,67 @@
|
||||
import { fireEvent, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import PostImportSourcePage from '@/pages/posts/PostImportSourcePage'
|
||||
import { buildUser } from '@/test/factories'
|
||||
import { renderWithProviders } from '@/test/render'
|
||||
|
||||
const router = vi.hoisted (() => ({ navigate: vi.fn () }))
|
||||
|
||||
vi.mock ('react-router-dom', async importOriginal => ({
|
||||
...await importOriginal<typeof import('react-router-dom')> (),
|
||||
useNavigate: () => router.navigate }))
|
||||
|
||||
describe ('PostImportSourcePage', () => {
|
||||
beforeEach (() => {
|
||||
sessionStorage.clear ()
|
||||
vi.clearAllMocks ()
|
||||
})
|
||||
|
||||
it ('validates an empty source only after Next is pressed', () => {
|
||||
renderWithProviders (<PostImportSourcePage user={buildUser ()}/>)
|
||||
|
||||
expect (screen.queryByText ('URL を入力してください.')).not.toBeInTheDocument ()
|
||||
|
||||
fireEvent.click (screen.getByRole ('button', { name: '次へ' }))
|
||||
|
||||
expect (screen.getByText ('URL を入力してください.')).toBeInTheDocument ()
|
||||
expect (router.navigate).not.toHaveBeenCalled ()
|
||||
})
|
||||
|
||||
it ('reports frontend URL issues with original line numbers', () => {
|
||||
renderWithProviders (<PostImportSourcePage user={buildUser ()}/>)
|
||||
const input = screen.getByRole ('textbox', { name: '' })
|
||||
|
||||
fireEvent.change (input, {
|
||||
target: { value: '\nftp://example.com/file\nhttps://example.com/valid' } })
|
||||
fireEvent.click (screen.getByRole ('button', { name: '次へ' }))
|
||||
|
||||
expect (screen.getByText (/2 行目: HTTP または HTTPS/)).toBeInTheDocument ()
|
||||
expect (screen.getByText ('ftp://example.com/file')).toBeInTheDocument ()
|
||||
expect (input).toHaveAttribute ('aria-invalid', 'true')
|
||||
expect (router.navigate).not.toHaveBeenCalled ()
|
||||
})
|
||||
|
||||
it ('navigates with individually encoded URLs without calling an API', () => {
|
||||
renderWithProviders (<PostImportSourcePage user={buildUser ()}/>)
|
||||
|
||||
fireEvent.change (screen.getByRole ('textbox', { name: '' }), {
|
||||
target: {
|
||||
value: 'https://example.com/one+a\nhttps://example.com/two?value=b+c' } })
|
||||
fireEvent.click (screen.getByRole ('button', { name: '次へ' }))
|
||||
|
||||
expect (router.navigate).toHaveBeenCalledWith (
|
||||
'/posts/new?urls=https%3A%2F%2Fexample.com%2Fone%2Ba'
|
||||
+ '+https%3A%2F%2Fexample.com%2Ftwo%3Fvalue%3Db%2Bc')
|
||||
})
|
||||
|
||||
it ('disables Next when the encoded request target reaches 4096 bytes', () => {
|
||||
renderWithProviders (<PostImportSourcePage user={buildUser ()}/>)
|
||||
const input = screen.getByRole ('textbox', { name: '' })
|
||||
|
||||
fireEvent.change (input, {
|
||||
target: { value: `https://example.com/${ 'a'.repeat (6_200) }` } })
|
||||
|
||||
expect (screen.getByRole ('button', { name: '次へ' })).toBeDisabled ()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,181 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import FieldError from '@/components/common/FieldError'
|
||||
import Form from '@/components/common/Form'
|
||||
import FormField from '@/components/common/FormField'
|
||||
import PageTitle from '@/components/common/PageTitle'
|
||||
import TextArea from '@/components/common/TextArea'
|
||||
import MainArea from '@/components/layout/MainArea'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { toast } from '@/components/ui/use-toast'
|
||||
import { SITE_TITLE } from '@/config'
|
||||
import {
|
||||
buildPostNewReviewPath,
|
||||
isPostNewReviewPathWithinLimit,
|
||||
} from '@/lib/postNewQueryState'
|
||||
import {
|
||||
countImportSourceLines,
|
||||
extractImportSourceUrls,
|
||||
validateImportSource,
|
||||
} from '@/lib/postImportSourceValidation'
|
||||
import {
|
||||
loadPostImportSourceDraft,
|
||||
savePostImportSourceDraft,
|
||||
} from '@/lib/postImportStorage'
|
||||
import { canEditContent } from '@/lib/users'
|
||||
import Forbidden from '@/pages/Forbidden'
|
||||
|
||||
import type { FC } from 'react'
|
||||
|
||||
import type { User } from '@/types'
|
||||
|
||||
type Props = { user: User | null }
|
||||
|
||||
const MAX_ROWS = 100
|
||||
const SOURCE_ERROR_ID = 'post-import-source-error'
|
||||
const SOURCE_ISSUES_ID = 'post-import-source-issues'
|
||||
|
||||
|
||||
const PostImportSourcePage: FC<Props> = ({ user }) => {
|
||||
const editable = canEditContent (user)
|
||||
const navigate = useNavigate ()
|
||||
const [source, setSource] = useState ('')
|
||||
const [loading, setLoading] = useState (false)
|
||||
const [sourceIssues, setSourceIssues] = useState<ReturnType<typeof validateImportSource>> ([])
|
||||
const [sourceError, setSourceError] = useState<string | null> (null)
|
||||
const saveTimer = useRef<number | null> (null)
|
||||
const editedRef = useRef (false)
|
||||
|
||||
const lineCount = countImportSourceLines (source)
|
||||
const sourceUrls = extractImportSourceUrls (source)
|
||||
const withinPathLimit = isPostNewReviewPathWithinLimit (sourceUrls)
|
||||
const messages = sourceError != null ? [sourceError] : []
|
||||
const sourceDescribedBy =
|
||||
[sourceError != null ? SOURCE_ERROR_ID : null,
|
||||
sourceIssues.length > 0 ? SOURCE_ISSUES_ID : null]
|
||||
.filter (value => value != null)
|
||||
.join (' ')
|
||||
|
||||
useEffect (() => {
|
||||
const draft = loadPostImportSourceDraft (message =>
|
||||
toast ({ title: '保存済み入力を復元できませんでした', description: message }))
|
||||
|
||||
if (!(editedRef.current))
|
||||
{
|
||||
setSource (current =>
|
||||
current === ''
|
||||
? draft.source
|
||||
: current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect (() => {
|
||||
if (saveTimer.current != null)
|
||||
window.clearTimeout (saveTimer.current)
|
||||
|
||||
saveTimer.current = window.setTimeout (() => {
|
||||
savePostImportSourceDraft (source, message =>
|
||||
toast ({ title: '入力内容を保存できませんでした', description: message }))
|
||||
}, 300)
|
||||
|
||||
return () => {
|
||||
if (saveTimer.current != null)
|
||||
window.clearTimeout (saveTimer.current)
|
||||
}
|
||||
}, [source])
|
||||
|
||||
const preview = async () => {
|
||||
if (lineCount === 0)
|
||||
{
|
||||
setSourceIssues ([])
|
||||
setSourceError ('URL を入力してください.')
|
||||
return
|
||||
}
|
||||
|
||||
const issues = validateImportSource (source)
|
||||
if (issues.length > 0)
|
||||
{
|
||||
setSourceIssues (issues)
|
||||
setSourceError (null)
|
||||
return
|
||||
}
|
||||
if (!(withinPathLimit))
|
||||
return
|
||||
|
||||
setLoading (true)
|
||||
setSourceIssues ([])
|
||||
setSourceError (null)
|
||||
try
|
||||
{
|
||||
navigate (buildPostNewReviewPath (sourceUrls))
|
||||
}
|
||||
finally
|
||||
{
|
||||
setLoading (false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!(editable))
|
||||
return <Forbidden/>
|
||||
|
||||
return (
|
||||
<MainArea>
|
||||
<Helmet>
|
||||
<title>{`広場に投稿を追加 | ${ SITE_TITLE }`}</title>
|
||||
</Helmet>
|
||||
|
||||
<Form className="max-w-4xl">
|
||||
<PageTitle>広場に投稿を追加</PageTitle>
|
||||
<FormField label="URL リスト(1 行 1 URL)">
|
||||
{() => (
|
||||
<TextArea
|
||||
value={source}
|
||||
rows={14}
|
||||
invalid={messages.length > 0 || sourceIssues.length > 0}
|
||||
aria-describedby={sourceDescribedBy || undefined}
|
||||
className="h-80 font-mono text-sm"
|
||||
onBlur={() => {
|
||||
savePostImportSourceDraft (source, message =>
|
||||
toast ({
|
||||
title: '入力内容を保存できませんでした',
|
||||
description: message }))
|
||||
}}
|
||||
onChange={ev => {
|
||||
editedRef.current = true
|
||||
setSource (ev.target.value)
|
||||
setSourceError (null)
|
||||
setSourceIssues ([])
|
||||
}}/>)}
|
||||
</FormField>
|
||||
<div id={messages.length > 0 ? SOURCE_ERROR_ID : undefined}>
|
||||
<FieldError messages={messages}/>
|
||||
</div>
|
||||
<ul
|
||||
id={SOURCE_ISSUES_ID}
|
||||
className="space-y-2 text-sm text-red-700 dark:text-red-300">
|
||||
{sourceIssues.map (issue => (
|
||||
<li key={`${ issue.sourceRow }-${ issue.message }-${ issue.url }`}>
|
||||
<div>{issue.sourceRow} 行目: {issue.message}</div>
|
||||
<div className="break-all font-mono text-xs">{issue.url}</div>
|
||||
</li>))}
|
||||
</ul>
|
||||
|
||||
<div className="relative z-10 flex items-center justify-between gap-3">
|
||||
<div className="text-sm text-neutral-600 dark:text-neutral-300">
|
||||
入力行数 {lineCount} / {MAX_ROWS}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
className="pointer-events-auto shrink-0"
|
||||
onClick={preview}
|
||||
disabled={loading || !(withinPathLimit)}>
|
||||
次へ
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</MainArea>)
|
||||
}
|
||||
|
||||
export default PostImportSourcePage
|
||||
@@ -1,4 +1,4 @@
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import PostNewPage from '@/pages/posts/PostNewPage'
|
||||
@@ -6,101 +6,35 @@ import { buildUser } from '@/test/factories'
|
||||
import { renderWithProviders } from '@/test/render'
|
||||
|
||||
const api = vi.hoisted (() => ({
|
||||
apiGet: vi.fn (),
|
||||
apiPost: vi.fn (),
|
||||
isApiError: vi.fn (),
|
||||
}))
|
||||
|
||||
const toastApi = vi.hoisted (() => ({
|
||||
toast: vi.fn (),
|
||||
}))
|
||||
apiGet: vi.fn (),
|
||||
apiPost: vi.fn (),
|
||||
isApiError: vi.fn () }))
|
||||
|
||||
vi.mock ('@/lib/api', () => api)
|
||||
vi.mock ('@/components/ui/use-toast', () => toastApi)
|
||||
|
||||
describe ('PostNewPage', () => {
|
||||
beforeEach (() => {
|
||||
vi.clearAllMocks ()
|
||||
api.isApiError.mockReturnValue (false)
|
||||
api.apiGet.mockResolvedValue ({
|
||||
url: 'https://example.com/post',
|
||||
title: 'post',
|
||||
tags: '' })
|
||||
sessionStorage.clear ()
|
||||
})
|
||||
|
||||
it ('blocks guests', () => {
|
||||
renderWithProviders (<PostNewPage user={buildUser ({ role: 'guest' })}/>)
|
||||
it ('shows the source page on /posts/new', () => {
|
||||
renderWithProviders (<PostNewPage user={buildUser ()}/>, {
|
||||
route: '/posts/new' })
|
||||
|
||||
expect (screen.getByText ('403')).toBeInTheDocument ()
|
||||
expect (screen.getByRole ('heading', { name: '広場に投稿を追加' })).toBeInTheDocument ()
|
||||
})
|
||||
|
||||
it ('submits a new post with manual title and thumbnail fetch UI', async () => {
|
||||
api.apiPost.mockResolvedValueOnce ({})
|
||||
api.apiGet.mockResolvedValue ([])
|
||||
it ('shows the review page when query state is present', () => {
|
||||
renderWithProviders (<PostNewPage user={buildUser ()}/>, {
|
||||
route: '/posts/new?urls=https%3A%2F%2Fexample.com%2Fpost' })
|
||||
|
||||
renderWithProviders (<PostNewPage user={buildUser ({ role: 'member' })}/>)
|
||||
|
||||
const textboxes = screen.getAllByRole ('textbox')
|
||||
fireEvent.change (textboxes[0], { target: { value: 'https://example.com/post' } })
|
||||
fireEvent.change (textboxes[1], { target: { value: '投稿タイトル' } })
|
||||
fireEvent.change (textboxes[2], { target: { value: '1 2' } })
|
||||
fireEvent.change (textboxes[3], { target: { value: 'tag1 tag2' } })
|
||||
fireEvent.click (screen.getByRole ('button', { name: '追加' }))
|
||||
|
||||
await waitFor (() => {
|
||||
expect (api.apiPost).toHaveBeenCalledWith (
|
||||
'/posts',
|
||||
expect.any (FormData),
|
||||
{ headers: { 'Content-Type': 'multipart/form-data' } },
|
||||
)
|
||||
})
|
||||
const formData = api.apiPost.mock.calls[0]?.[1] as FormData
|
||||
expect (formData.get ('url')).toBe ('https://example.com/post')
|
||||
expect (formData.get ('title')).toBe ('投稿タイトル')
|
||||
expect (formData.get ('parent_post_ids')).toBe ('1 2')
|
||||
expect (formData.get ('tags')).toBe ('tag1 tag2')
|
||||
expect (toastApi.toast).toHaveBeenCalledWith ({ title: '投稿成功!' })
|
||||
})
|
||||
|
||||
it ('preserves duration while the video tag is temporarily removed', () => {
|
||||
api.apiGet.mockResolvedValue ([])
|
||||
|
||||
renderWithProviders (<PostNewPage user={buildUser ({ role: 'member' })}/>)
|
||||
|
||||
const tags = screen.getAllByRole ('textbox')[3]
|
||||
fireEvent.change (tags, { target: { value: '動画' } })
|
||||
fireEvent.change (screen.getByRole ('spinbutton'), { target: { value: '180.5' } })
|
||||
|
||||
fireEvent.change (tags, { target: { value: 'general-tag' } })
|
||||
expect (screen.queryByRole ('spinbutton')).not.toBeInTheDocument ()
|
||||
|
||||
fireEvent.change (tags, {
|
||||
target: { value: '動画 general-tag' },
|
||||
})
|
||||
expect (screen.getByRole ('spinbutton')).toHaveValue (180.5)
|
||||
})
|
||||
|
||||
it ('shows 422 validation errors for post fields', async () => {
|
||||
api.apiGet.mockResolvedValue ([])
|
||||
api.isApiError.mockReturnValue (true)
|
||||
api.apiPost.mockRejectedValueOnce ({
|
||||
response: {
|
||||
status: 422,
|
||||
data: {
|
||||
type: 'validation_error',
|
||||
message: '入力内容を確認してください.',
|
||||
errors: { tags: ['ニコニコ・タグは直接指定できません.'] },
|
||||
base_errors: ['投稿内容を確認してください.'],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
renderWithProviders (<PostNewPage user={buildUser ({ role: 'member' })}/>)
|
||||
|
||||
const textboxes = screen.getAllByRole ('textbox')
|
||||
fireEvent.change (textboxes[0], { target: { value: 'https://example.com/post' } })
|
||||
fireEvent.change (textboxes[1], { target: { value: '投稿タイトル' } })
|
||||
fireEvent.change (textboxes[3], { target: { value: 'nico:nico_tag' } })
|
||||
fireEvent.click (screen.getByRole ('button', { name: '追加' }))
|
||||
|
||||
expect (await screen.findByText ('投稿内容を確認してください.')).toBeInTheDocument ()
|
||||
expect (screen.getByText ('ニコニコ・タグは直接指定できません.')).toBeInTheDocument ()
|
||||
expect (screen.getAllByRole ('textbox')[3]).toHaveAttribute ('aria-invalid', 'true')
|
||||
expect (screen.getByRole ('heading', { name: '追加内容確認' })).toBeInTheDocument ()
|
||||
expect (screen.queryByText ('広場に投稿を追加')).not.toBeInTheDocument ()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,22 +1,9 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useMemo } from 'react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
|
||||
import PostFormTagsArea from '@/components/PostFormTagsArea'
|
||||
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
|
||||
import FieldError from '@/components/common/FieldError'
|
||||
import Form from '@/components/common/Form'
|
||||
import FormField from '@/components/common/FormField'
|
||||
import PageTitle from '@/components/common/PageTitle'
|
||||
import MainArea from '@/components/layout/MainArea'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { toast } from '@/components/ui/use-toast'
|
||||
import { SITE_TITLE } from '@/config'
|
||||
import { apiGet, apiPost } from '@/lib/api'
|
||||
import { canEditContent } from '@/lib/users'
|
||||
import { inputClass } from '@/lib/utils'
|
||||
import { useValidationErrors } from '@/lib/useValidationErrors'
|
||||
import Forbidden from '@/pages/Forbidden'
|
||||
import PostImportReviewPage from '@/pages/posts/PostImportReviewPage'
|
||||
import PostImportSourcePage from '@/pages/posts/PostImportSourcePage'
|
||||
import { hasPostNewReviewState } from '@/lib/postNewQueryState'
|
||||
|
||||
import type { FC } from 'react'
|
||||
|
||||
@@ -24,233 +11,16 @@ import type { User } from '@/types'
|
||||
|
||||
type Props = { user: User | null }
|
||||
|
||||
type PostFormField =
|
||||
'url' | 'title' | 'tags' | 'parentPostIds' | 'videoMs' | 'originalCreatedAt' | 'thumbnail'
|
||||
|
||||
|
||||
const PostNewPage: FC<Props> = ({ user }) => {
|
||||
const editable = canEditContent (user)
|
||||
const location = useLocation ()
|
||||
const reviewMode = useMemo (
|
||||
() => hasPostNewReviewState (location.search),
|
||||
[location.search])
|
||||
|
||||
const navigate = useNavigate ()
|
||||
|
||||
const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } =
|
||||
useValidationErrors<PostFormField> ()
|
||||
|
||||
const [originalCreatedBefore, setOriginalCreatedBefore] = useState<string | null> (null)
|
||||
const [originalCreatedFrom, setOriginalCreatedFrom] = useState<string | null> (null)
|
||||
const [parentPostIds, setParentPostIds] = useState ('')
|
||||
const [tags, setTags] = useState ('')
|
||||
const [duration, setDuration] = useState ('')
|
||||
const [thumbnailFile, setThumbnailFile] = useState<File | null> (null)
|
||||
const [thumbnailLoading, setThumbnailLoading] = useState (false)
|
||||
const [thumbnailPreview, setThumbnailPreview] = useState<string> ('')
|
||||
const [title, setTitle] = useState ('')
|
||||
const [titleLoading, setTitleLoading] = useState (false)
|
||||
const [url, setURL] = useState ('')
|
||||
|
||||
const thumbnailPreviewRef = useRef ('')
|
||||
const videoFlg =
|
||||
useMemo (() => tags.split (/\s+/).some (tag => tag.replace (/\[.*\]$/, '') === '動画'),
|
||||
[tags])
|
||||
|
||||
const handleSubmit = async () => {
|
||||
clearValidationErrors ()
|
||||
|
||||
const formData = new FormData
|
||||
formData.append ('title', title)
|
||||
formData.append ('url', url)
|
||||
formData.append ('tags', tags)
|
||||
formData.append ('parent_post_ids', parentPostIds)
|
||||
if (videoFlg && duration !== '')
|
||||
formData.append ('duration', duration)
|
||||
if (thumbnailFile)
|
||||
formData.append ('thumbnail', thumbnailFile)
|
||||
if (originalCreatedFrom)
|
||||
formData.append ('original_created_from', originalCreatedFrom)
|
||||
if (originalCreatedBefore)
|
||||
formData.append ('original_created_before', originalCreatedBefore)
|
||||
|
||||
try
|
||||
{
|
||||
await apiPost ('/posts', formData, { headers: { 'Content-Type': 'multipart/form-data' } })
|
||||
toast ({ title: '投稿成功!' })
|
||||
navigate ('/posts')
|
||||
}
|
||||
catch (e)
|
||||
{
|
||||
applyValidationError (e)
|
||||
toast ({ title: '投稿失敗', description: '入力を確認してください。' })
|
||||
}
|
||||
}
|
||||
|
||||
const fetchTitle = useCallback (async () => {
|
||||
setTitleLoading (true)
|
||||
try
|
||||
{
|
||||
const data = await apiGet<{ title: string }> ('/preview/title', { params: { url } })
|
||||
setTitle (data.title || '')
|
||||
}
|
||||
finally
|
||||
{
|
||||
setTitleLoading (false)
|
||||
}
|
||||
}, [url])
|
||||
|
||||
const fetchThumbnail = useCallback (async () => {
|
||||
setThumbnailPreview ('')
|
||||
setThumbnailFile (null)
|
||||
setThumbnailLoading (true)
|
||||
if (thumbnailPreviewRef.current)
|
||||
URL.revokeObjectURL (thumbnailPreviewRef.current)
|
||||
try
|
||||
{
|
||||
const data = await apiGet<Blob> ('/preview/thumbnail',
|
||||
{ params: { url }, responseType: 'blob' })
|
||||
const imageURL = URL.createObjectURL (data)
|
||||
setThumbnailPreview (imageURL)
|
||||
setThumbnailFile (new File ([data],
|
||||
'thumbnail.png',
|
||||
{ type: data.type || 'image/png' }))
|
||||
}
|
||||
finally
|
||||
{
|
||||
setThumbnailLoading (false)
|
||||
}
|
||||
}, [url])
|
||||
|
||||
useEffect (() => {
|
||||
thumbnailPreviewRef.current = thumbnailPreview
|
||||
}, [thumbnailPreview])
|
||||
|
||||
if (!(editable))
|
||||
return <Forbidden/>
|
||||
|
||||
return (
|
||||
<MainArea>
|
||||
<Helmet>
|
||||
<title>{`広場に投稿を追加 | ${ SITE_TITLE }`}</title>
|
||||
</Helmet>
|
||||
<Form>
|
||||
<PageTitle>広場に投稿を追加する</PageTitle>
|
||||
<FieldError messages={baseErrors}/>
|
||||
|
||||
{/* URL */}
|
||||
<FormField label="URL" messages={fieldErrors.url}>
|
||||
{({ describedBy, invalid }) => (
|
||||
<input type="url"
|
||||
placeholder="例:https://www.nicovideo.jp/watch/..."
|
||||
value={url}
|
||||
onChange={e => setURL (e.target.value)}
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={invalid}
|
||||
className={inputClass (invalid)}/>)}
|
||||
</FormField>
|
||||
|
||||
{/* タイトル */}
|
||||
<FormField label="タイトル" messages={fieldErrors.title}>
|
||||
{({ describedBy, invalid }) => (
|
||||
<div className="space-y-2">
|
||||
<input type="text"
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={invalid}
|
||||
className={inputClass (invalid)}
|
||||
value={title}
|
||||
placeholder={titleLoading ? 'Loading...' : ''}
|
||||
onChange={ev => setTitle (ev.target.value)}
|
||||
disabled={titleLoading}/>
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span>必要なタイミングで URL から取得できます.</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => void fetchTitle ()}
|
||||
disabled={!(url) || titleLoading}>
|
||||
取得
|
||||
</Button>
|
||||
</div>
|
||||
</div>)}
|
||||
</FormField>
|
||||
|
||||
{/* サムネール */}
|
||||
<FormField label="サムネール" messages={fieldErrors.thumbnail}>
|
||||
{({ describedBy, invalid }) => (
|
||||
<>
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2 text-sm">
|
||||
<span>必要なタイミングで URL から取得できます.</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => void fetchThumbnail ()}
|
||||
disabled={!(url) || thumbnailLoading}>
|
||||
取得
|
||||
</Button>
|
||||
</div>
|
||||
{thumbnailLoading && (
|
||||
<p className="text-gray-500 text-sm">Loading...</p>)}
|
||||
<input type="file"
|
||||
accept="image/*"
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={invalid}
|
||||
onChange={e => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file)
|
||||
{
|
||||
setThumbnailFile (file)
|
||||
setThumbnailPreview (URL.createObjectURL (file))
|
||||
}
|
||||
}}/>
|
||||
{thumbnailPreview && (
|
||||
<img src={thumbnailPreview}
|
||||
alt="preview"
|
||||
className="mt-2 max-h-48 rounded border"/>)}
|
||||
</>)}
|
||||
</FormField>
|
||||
|
||||
{/* 親投稿 */}
|
||||
<FormField label="親投稿" messages={fieldErrors.parentPostIds}>
|
||||
{({ describedBy, invalid }) => (
|
||||
<input
|
||||
type="text"
|
||||
value={parentPostIds}
|
||||
onChange={e => setParentPostIds (e.target.value)}
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={invalid}
|
||||
className={inputClass (invalid)}/>)}
|
||||
</FormField>
|
||||
|
||||
{/* タグ */}
|
||||
<PostFormTagsArea tags={tags} setTags={setTags} errors={fieldErrors.tags}/>
|
||||
|
||||
{/* オリジナルの作成日時 */}
|
||||
<PostOriginalCreatedTimeField
|
||||
originalCreatedFrom={originalCreatedFrom}
|
||||
setOriginalCreatedFrom={setOriginalCreatedFrom}
|
||||
originalCreatedBefore={originalCreatedBefore}
|
||||
setOriginalCreatedBefore={setOriginalCreatedBefore}
|
||||
errors={fieldErrors.originalCreatedAt}/>
|
||||
|
||||
{/* 動画時間 */}
|
||||
{(videoFlg &&
|
||||
<FormField label="動画時間" messages={fieldErrors.videoMs}>
|
||||
{({ invalid }) => (
|
||||
<input
|
||||
type="number"
|
||||
min="0.001"
|
||||
step="0.001"
|
||||
value={duration}
|
||||
onChange={e => setDuration (e.target.value)}
|
||||
aria-invalid={invalid}
|
||||
className={inputClass (invalid)}/>)}
|
||||
</FormField>)}
|
||||
|
||||
{/* 送信 */}
|
||||
<Button onClick={handleSubmit}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400"
|
||||
disabled={titleLoading || thumbnailLoading}>
|
||||
追加
|
||||
</Button>
|
||||
</Form>
|
||||
</MainArea>)
|
||||
return reviewMode
|
||||
? <PostImportReviewPage user={user}/>
|
||||
: <PostImportSourcePage user={user}/>
|
||||
}
|
||||
|
||||
export default PostNewPage
|
||||
|
||||
@@ -6,7 +6,6 @@ import { dateString } from '@/lib/utils'
|
||||
import { buildTag, buildUser } from '@/test/factories'
|
||||
import { renderWithProviders } from '@/test/render'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import type { NicoTag } from '@/types'
|
||||
|
||||
const api = vi.hoisted (() => ({
|
||||
@@ -27,10 +26,10 @@ const scrollIntoView = vi.fn ()
|
||||
|
||||
vi.mock ('@/lib/api', () => api)
|
||||
vi.mock ('@/components/ui/use-toast', () => toastApi)
|
||||
vi.mock ('@/components/dialogues/DialogueProvider', () => ({
|
||||
default: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
useDialogue: () => dialogue,
|
||||
}))
|
||||
vi.mock ('@/components/dialogues/DialogueProvider', async importOriginal => ({
|
||||
...await importOriginal<
|
||||
typeof import('@/components/dialogues/DialogueProvider')> (),
|
||||
useDialogue: () => dialogue }))
|
||||
|
||||
const buildNicoTag = (values: Partial<NicoTag> = {}): NicoTag => ({
|
||||
...buildTag (),
|
||||
|
||||
@@ -40,10 +40,10 @@ const postEmbed = vi.hoisted (() => ({
|
||||
|
||||
vi.mock ('@/lib/api', () => api)
|
||||
vi.mock ('@/lib/posts', () => postsApi)
|
||||
vi.mock ('@/components/dialogues/DialogueProvider', () => ({
|
||||
default: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
useDialogue: () => dialogue,
|
||||
}))
|
||||
vi.mock ('@/components/dialogues/DialogueProvider', async importOriginal => ({
|
||||
...await importOriginal<
|
||||
typeof import('@/components/dialogues/DialogueProvider')> (),
|
||||
useDialogue: () => dialogue }))
|
||||
vi.mock ('@/components/PostEmbed', () => ({
|
||||
default: (props: {
|
||||
ref?: { current: unknown }
|
||||
@@ -262,7 +262,9 @@ describe ('TheatreDetailPage', () => {
|
||||
expect (postEmbed.seek).not.toHaveBeenCalledWith (0)
|
||||
})
|
||||
|
||||
it ('shows child tags from the post tag tree in both vertical and horizontal layouts', async () => {
|
||||
it (
|
||||
'shows child tags from the post tag tree in both vertical and horizontal layouts',
|
||||
async () => {
|
||||
const childTag = buildTag ({ id: 12, name: '子タグ', category: 'general' })
|
||||
const parentTag = buildTag ({
|
||||
id: 11,
|
||||
@@ -307,7 +309,7 @@ describe ('TheatreDetailPage', () => {
|
||||
expect (within (tagSection ()).getAllByRole ('link', { name: '子タグ' }))
|
||||
.toHaveLength (1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it ('does not advance host post while video length is unknown', async () => {
|
||||
api.apiPut.mockImplementation ((path: string) => {
|
||||
|
||||
@@ -1197,13 +1197,9 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
||||
}, [hasUnsavedThemeChanges, savedThemeSlots])
|
||||
|
||||
useEffect (() => {
|
||||
registerUnsavedChangesSource ({
|
||||
dirty: hasPageUnsavedChanges,
|
||||
discard: discardAllDirtyChanges })
|
||||
|
||||
return () => {
|
||||
registerUnsavedChangesSource (null)
|
||||
}
|
||||
return registerUnsavedChangesSource ({
|
||||
dirty: hasPageUnsavedChanges,
|
||||
discard: discardAllDirtyChanges })
|
||||
}, [discardAllDirtyChanges, hasPageUnsavedChanges, registerUnsavedChangesSource])
|
||||
|
||||
useKeyboardShortcuts ({
|
||||
|
||||
新しい課題から参照
ユーザをブロックする