このコミットが含まれているのは:
2026-07-16 21:45:02 +09:00
コミット 66c738cbff
26個のファイルの変更1083行の追加951行の削除
+13
ファイルの表示
@@ -285,6 +285,13 @@ case 'no':
Ruby-style numbered parameter names. Reserve numbered parameters for Ruby. Ruby-style numbered parameter names. Reserve numbered parameters for Ruby.
Use a meaningful callback parameter name such as `row`, `item`, `value`, Use a meaningful callback parameter name such as `row`, `item`, `value`,
`entry`, or `result`. `entry`, or `result`.
- In JavaScript, JSX, TypeScript, and TSX, use `cn` from `@/lib/utils`
whenever `className` combines multiple values, conditional classes, or a
caller-provided `className` prop.
- Do not construct `className` with template literals, `${ ... }`, string
concatenation, arrays joined with spaces, or feature-local class-merging
helpers.
- A static `className="..."` containing only fixed classes does not need `cn`.
- If code appears to need a distinction between `null` and `undefined`, treat - If code appears to need a distinction between `null` and `undefined`, treat
that as a design smell and revise the logic to avoid the distinction. that as a design smell and revise the logic to avoid the distinction.
External library APIs that explicitly require distinguishing the two are the External library APIs that explicitly require distinguishing the two are the
@@ -460,6 +467,12 @@ and layout reuse, follow `frontend/AGENTS.md`.
structure, control flow, or variable mutability unless the requested style structure, control flow, or variable mutability unless the requested style
explicitly requires it. explicitly requires it.
- Do not add production dependencies without explicit approval. - Do not add production dependencies without explicit approval.
- Do not add user-facing copy, helper text, descriptions, notes, tooltips,
placeholders, empty-state messages, loading messages, or explanatory text
unless the user explicitly specified the wording.
- When new user-facing wording appears necessary, ask the user for the exact
wording and placement before implementing it.
- Do not invent replacement copy when removing unrequested wording.
- Do not create, modify, or run tests unless the user explicitly asks for - Do not create, modify, or run tests unless the user explicitly asks for
test work. When the user asks for tests, keep working and rerun them until test work. When the user asks for tests, keep working and rerun them until
they pass or the remaining failure is clearly blocked. they pass or the remaining failure is clearly blocked.
+3 -4
ファイルの表示
@@ -238,7 +238,8 @@ class Post < ApplicationRecord
value = raw_value.to_s.strip value = raw_value.to_s.strip
return nil if value.blank? return nil if value.blank?
if (match = value.match(/\A(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})\z/)) match = value.match(/\A(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})\z/)
if match
year = match[1].to_i year = match[1].to_i
month = match[2].to_i month = match[2].to_i
day = match[3].to_i day = match[3].to_i
@@ -251,9 +252,7 @@ class Post < ApplicationRecord
match = match =
value.match( value.match(
/\A(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/ \ /\A(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d+))?)?(Z|[+-]\d{2}:?\d{2})?\z/)
'(?::(\d{2})(?:\.(\d+))?)?' \
'(Z|[+-]\d{2}:?\d{2})?\z/')
return nil if match.nil? return nil if match.nil?
year = match[1].to_i year = match[1].to_i
+13
ファイルの表示
@@ -130,6 +130,13 @@ pass or the remaining failure is clearly blocked.
- Tailwind scans `src/**/*.{html,js,ts,jsx,tsx,mdx}`. - Tailwind scans `src/**/*.{html,js,ts,jsx,tsx,mdx}`.
- Use `cn` from `src/lib/utils.ts` for conditional class names and class merging. - Use `cn` from `src/lib/utils.ts` for conditional class names and class merging.
- In JavaScript, JSX, TypeScript, and TSX, use `cn` from `@/lib/utils`
whenever `className` combines multiple values, conditional classes, or a
caller-provided `className` prop.
- Do not construct `className` with template literals, `${ ... }`, string
concatenation, arrays joined with spaces, or feature-local class-merging
helpers.
- A static `className="..."` containing only fixed classes does not need `cn`.
- Reuse components from `src/components/common`, `src/components/layout`, and - Reuse components from `src/components/common`, `src/components/layout`, and
`src/components/ui` before adding new primitives. `src/components/ui` before adding new primitives.
- Keep Tailwind classes consistent with nearby components. - Keep Tailwind classes consistent with nearby components.
@@ -140,6 +147,12 @@ pass or the remaining failure is clearly blocked.
short Japanese labels that fit the control. short Japanese labels that fit the control.
- Preserve existing Japanese tone and orthography in nearby UI text, including - Preserve existing Japanese tone and orthography in nearby UI text, including
old-kana wording where the file already uses it. old-kana wording where the file already uses it.
- Do not add user-facing copy, helper text, descriptions, notes, tooltips,
placeholders, empty-state messages, loading messages, or explanatory text
unless the user explicitly specified the wording.
- When new user-facing wording appears necessary, ask the user for the exact
wording and placement before implementing it.
- Do not invent replacement copy when removing unrequested wording.
- When adding dynamic tag colour classes, update `tailwind.config.js` safelist - When adding dynamic tag colour classes, update `tailwind.config.js` safelist
if the class cannot be statically detected. if the class cannot be statically detected.
- Do not introduce new UI libraries or production dependencies without approval. - Do not introduce new UI libraries or production dependencies without approval.
+12 -10
ファイルの表示
@@ -41,11 +41,9 @@ import NotFound from '@/pages/NotFound'
import TOSPage from '@/pages/TOSPage.mdx' import TOSPage from '@/pages/TOSPage.mdx'
import PostDetailPage from '@/pages/posts/PostDetailPage' import PostDetailPage from '@/pages/posts/PostDetailPage'
import PostHistoryPage from '@/pages/posts/PostHistoryPage' import PostHistoryPage from '@/pages/posts/PostHistoryPage'
import PostImportResultPage from '@/pages/posts/PostImportResultPage'
import PostImportReviewPage from '@/pages/posts/PostImportReviewPage' import PostImportReviewPage from '@/pages/posts/PostImportReviewPage'
import PostImportSourcePage from '@/pages/posts/PostImportSourcePage' import PostImportSourcePage from '@/pages/posts/PostImportSourcePage'
import PostListPage from '@/pages/posts/PostListPage' import PostListPage from '@/pages/posts/PostListPage'
import PostNewPage from '@/pages/posts/PostNewPage'
import PostSearchPage from '@/pages/posts/PostSearchPage' import PostSearchPage from '@/pages/posts/PostSearchPage'
import ServiceUnavailable from '@/pages/ServiceUnavailable' import ServiceUnavailable from '@/pages/ServiceUnavailable'
import SettingPage from '@/pages/users/SettingPage' import SettingPage from '@/pages/users/SettingPage'
@@ -77,10 +75,12 @@ const RouteTransitionWrapper = ({ animationMode, user, setUser }: {
<Routes location={location}> <Routes location={location}>
<Route path="/" element={<Navigate to="/posts" replace/>}/> <Route path="/" element={<Navigate to="/posts" replace/>}/>
<Route path="/posts" element={<PostListPage/>}/> <Route path="/posts" element={<PostListPage/>}/>
<Route path="/posts/new" element={<PostNewPage user={user}/>}/> <Route path="/posts/new" element={<PostImportSourcePage user={user}/>}/>
<Route path="/posts/import" element={<PostImportSourcePage user={user}/>}/> <Route path="/posts/new/review" element={<PostImportReviewPage user={user}/>}/>
<Route path="/posts/import/:sessionId/review" element={<PostImportReviewPage user={user}/>}/> <Route path="/posts/import" element={<Navigate to="/posts/new" replace/>}/>
<Route path="/posts/import/:sessionId/result" element={<PostImportResultPage user={user}/>}/> <Route path="/posts/import/:sessionId/review" element={<Navigate to="/posts/new" replace/>}/>
<Route path="/posts/import/:sessionId/result" element={<Navigate to="/posts" replace/>}/>
<Route path="/posts/new/:sessionId/result" element={<Navigate to="/posts" replace/>}/>
<Route path="/posts/search" element={<PostSearchPage/>}/> <Route path="/posts/search" element={<PostSearchPage/>}/>
<Route path="/posts/:id" element={<PostDetailRoute user={user}/>}/> <Route path="/posts/:id" element={<PostDetailRoute user={user}/>}/>
<Route path="/posts/changes" element={<PostHistoryPage/>}/> <Route path="/posts/changes" element={<PostHistoryPage/>}/>
@@ -118,10 +118,12 @@ const RouteTransitionWrapper = ({ animationMode, user, setUser }: {
<Routes location={location}> <Routes location={location}>
<Route path="/" element={<Navigate to="/posts" replace/>}/> <Route path="/" element={<Navigate to="/posts" replace/>}/>
<Route path="/posts" element={<PostListPage/>}/> <Route path="/posts" element={<PostListPage/>}/>
<Route path="/posts/new" element={<PostNewPage user={user}/>}/> <Route path="/posts/new" element={<PostImportSourcePage user={user}/>}/>
<Route path="/posts/import" element={<PostImportSourcePage user={user}/>}/> <Route path="/posts/new/review" element={<PostImportReviewPage user={user}/>}/>
<Route path="/posts/import/:sessionId/review" element={<PostImportReviewPage user={user}/>}/> <Route path="/posts/import" element={<Navigate to="/posts/new" replace/>}/>
<Route path="/posts/import/:sessionId/result" element={<PostImportResultPage user={user}/>}/> <Route path="/posts/import/:sessionId/review" element={<Navigate to="/posts/new" replace/>}/>
<Route path="/posts/import/:sessionId/result" element={<Navigate to="/posts" replace/>}/>
<Route path="/posts/new/:sessionId/result" element={<Navigate to="/posts" replace/>}/>
<Route path="/posts/search" element={<PostSearchPage/>}/> <Route path="/posts/search" element={<PostSearchPage/>}/>
<Route path="/posts/:id" element={<PostDetailRoute user={user}/>}/> <Route path="/posts/:id" element={<PostDetailRoute user={user}/>}/>
<Route path="/posts/changes" element={<PostHistoryPage/>}/> <Route path="/posts/changes" element={<PostHistoryPage/>}/>
+4
ファイルの表示
@@ -30,6 +30,10 @@ describe ('menuOutline', () => {
expect (submenuItem ('guest', 'Wiki', '編輯')?.visible).toBe (false) expect (submenuItem ('guest', 'Wiki', '編輯')?.visible).toBe (false)
}) })
it ('uses /posts/new as the import entrypoint', () => {
expect (submenuItem ('member', '広場', '取込')?.to).toBe ('/posts/new')
})
it ('keeps material suppression admin-only', () => { it ('keeps material suppression admin-only', () => {
expect (submenuItem ('member', '素材', '抑止')?.visible).toBe (false) expect (submenuItem ('member', '素材', '抑止')?.visible).toBe (false)
expect (submenuItem ('admin', '素材', '抑止')?.visible).toBe (true) expect (submenuItem ('admin', '素材', '抑止')?.visible).toBe (true)
+1 -1
ファイルの表示
@@ -45,7 +45,7 @@ export const menuOutline = (
{ name: '一覧', to: '/posts' }, { name: '一覧', to: '/posts' },
{ name: '検索', to: '/posts/search' }, { name: '検索', to: '/posts/search' },
{ name: '追加', to: '/posts/new', visible: editable }, { name: '追加', to: '/posts/new', visible: editable },
{ name: '取込', to: '/posts/import', visible: editable }, { name: '取込', to: '/posts/new', visible: editable },
{ name: '全体履歴', to: '/posts/changes' }, { name: '全体履歴', to: '/posts/changes' },
{ name: 'ヘルプ', to: '/wiki/ヘルプ:広場' }] }, { name: 'ヘルプ', to: '/wiki/ヘルプ:広場' }] },
{ name: 'タグ', to: '/tags', subMenu: [ { name: 'タグ', to: '/tags', subMenu: [
+94
ファイルの表示
@@ -0,0 +1,94 @@
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
import PostTagsField from '@/components/posts/PostTagsField'
import PostTextField from '@/components/posts/PostTextField'
import type { FC, ReactNode } from 'react'
type TextMessages = string[] | undefined
type CoreField = {
value: string
onChange: (value: string) => void
errors?: TextMessages
warnings?: TextMessages
disabled?: boolean }
type OriginalCreatedField = {
originalCreatedAt?: TextMessages
originalCreatedFrom?: TextMessages
originalCreatedBefore?: TextMessages }
type PostCoreDataFieldsProps = {
title: {
value: string
onChange: (value: string) => void
errors?: TextMessages
warnings?: TextMessages
disabled?: boolean
after?: ReactNode }
originalCreated: {
disabled?: boolean
originalCreatedFrom: string | null
setOriginalCreatedFrom: (value: string | null) => void
originalCreatedBefore: string | null
setOriginalCreatedBefore: (value: string | null) => void
errors?: OriginalCreatedField }
tags: {
value: string
onChange: (value: string) => void
errors?: TextMessages
warnings?: TextMessages
disabled?: boolean
rows?: number }
parentPostIds: CoreField }
const groupedMessages = (...values: (TextMessages | null | undefined)[]): string[] =>
[...new Set (values.flatMap (value => value ?? []))]
const PostCoreDataFields: FC<PostCoreDataFieldsProps> = (
{ title,
originalCreated,
tags,
parentPostIds },
) => (
<>
<PostTextField
label="タイトル"
value={title.value}
disabled={title.disabled}
warnings={title.warnings}
errors={title.errors}
after={title.after}
onChange={title.onChange}/>
<PostOriginalCreatedTimeField
disabled={originalCreated.disabled}
originalCreatedFrom={originalCreated.originalCreatedFrom}
setOriginalCreatedFrom={originalCreated.setOriginalCreatedFrom}
originalCreatedBefore={originalCreated.originalCreatedBefore}
setOriginalCreatedBefore={originalCreated.setOriginalCreatedBefore}
errors={groupedMessages (
originalCreated.errors?.originalCreatedAt,
originalCreated.errors?.originalCreatedFrom,
originalCreated.errors?.originalCreatedBefore)}/>
<PostTagsField
tags={tags.value}
disabled={tags.disabled}
setTags={tags.onChange}
warnings={tags.warnings}
errors={tags.errors}
rows={tags.rows}/>
<PostTextField
label="親投稿"
value={parentPostIds.value}
disabled={parentPostIds.disabled}
warnings={parentPostIds.warnings}
errors={parentPostIds.errors}
onChange={parentPostIds.onChange}/>
</>)
export default PostCoreDataFields
export type { PostCoreDataFieldsProps }
+44
ファイルの表示
@@ -0,0 +1,44 @@
import PostCoreDataFields from '@/components/posts/PostCoreDataFields'
import PostTextField from '@/components/posts/PostTextField'
import type { FC, ReactNode } from 'react'
import type { PostCoreDataFieldsProps } from '@/components/posts/PostCoreDataFields'
type TextMessages = string[] | undefined
type Props = {
url: {
value: string
onChange: (value: string) => void
errors?: TextMessages
warnings?: TextMessages
disabled?: boolean
type?: string
placeholder?: string }
thumbnailField: ReactNode
core: PostCoreDataFieldsProps }
const PostCreationDataFields: FC<Props> = (
{ url,
thumbnailField,
core },
) => (
<>
<PostTextField
label="URL"
type={url.type}
value={url.value}
disabled={url.disabled}
warnings={url.warnings}
errors={url.errors}
placeholder={url.placeholder}
onChange={url.onChange}/>
{thumbnailField}
<PostCoreDataFields {...core}/>
</>)
export default PostCreationDataFields
+41
ファイルの表示
@@ -0,0 +1,41 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { buildPostImportRow } from '@/test/postImportFactories'
import { buildUser } from '@/test/factories'
import { renderWithProviders } from '@/test/render'
import type { DialogueFormControls } from '@/lib/dialogues/useDialogue'
const sharedFieldsSpy = vi.hoisted (() => vi.fn (() => <div data-testid="shared-fields"/>))
vi.mock ('@/components/posts/PostCreationDataFields', () => ({
default: sharedFieldsSpy,
}))
describe ('PostCreationDataFields usage', () => {
it ('is used by PostNewPage', async () => {
const { default: PostNewPage } = await import ('@/pages/posts/PostNewPage')
renderWithProviders (<PostNewPage user={buildUser ({ role: 'member' })}/>)
expect (screen.getByTestId ('shared-fields')).toBeInTheDocument ()
})
it ('is used by PostImportRowForm', async () => {
const { default: PostImportRowForm } = await import (
'@/components/posts/import/PostImportRowForm')
render (
<PostImportRowForm
row={buildPostImportRow ()}
controls={{
close: vi.fn (),
confirm: vi.fn (),
setActions: vi.fn (),
} as DialogueFormControls}
onSave={vi.fn ()}/>)
expect (screen.getByTestId ('shared-fields')).toBeInTheDocument ()
})
})
+13 -7
ファイルの表示
@@ -1,5 +1,7 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { cn } from '@/lib/utils'
import type { FC } from 'react' import type { FC } from 'react'
type Props = { type Props = {
@@ -21,8 +23,10 @@ const PostThumbnailPreview: FC<Props> = (
{ {
return ( return (
<div <div
className={`${ className } flex items-center justify-center rounded border className={cn (
border-border bg-muted text-xs text-muted-foreground`}> className,
'flex items-center justify-center rounded border',
'border-border bg-muted text-xs text-muted-foreground')}>
</div>) </div>)
} }
@@ -31,10 +35,12 @@ const PostThumbnailPreview: FC<Props> = (
{ {
return ( return (
<div <div
className={`${ className } flex items-center justify-center rounded border className={cn (
border-amber-300 bg-amber-50 p-2 text-center text-xs className,
text-amber-700 dark:border-amber-900 dark:bg-amber-950 'flex items-center justify-center rounded border',
dark:text-amber-200`}> 'border-amber-300 bg-amber-50 p-2 text-center text-xs',
'text-amber-700 dark:border-amber-900 dark:bg-amber-950',
'dark:text-amber-200')}>
</div>) </div>)
} }
@@ -43,7 +49,7 @@ const PostThumbnailPreview: FC<Props> = (
<img <img
src={url} src={url}
alt={alt} alt={alt}
className={`${ className } rounded border border-border object-cover`} className={cn (className, 'rounded border border-border object-cover')}
onError={() => setFailed (true)}/>) onError={() => setFailed (true)}/>)
} }
+26 -11
ファイルの表示
@@ -85,7 +85,7 @@ describe ('PostImportRowForm', () => {
it ('marks edited fields and areas invalid from field errors', () => { it ('marks edited fields and areas invalid from field errors', () => {
const row = buildPostImportRow ({ const row = buildPostImportRow ({
validationErrors: { url: ['URL error'], tags: ['tag error'] }, validationErrors: { url: ['URL error'], tags: ['tag error'] },
importErrors: { duration: ['duration error'] }, importErrors: { title: ['title error'] },
fieldWarnings: { title: ['title warning'] } }) fieldWarnings: { title: ['title warning'] } })
render ( render (
@@ -96,7 +96,7 @@ describe ('PostImportRowForm', () => {
expect (screen.getByText ('URL error')).toBeInTheDocument () expect (screen.getByText ('URL error')).toBeInTheDocument ()
expect (screen.getByText ('tag error')).toBeInTheDocument () expect (screen.getByText ('tag error')).toBeInTheDocument ()
expect (screen.getByText ('duration error')).toBeInTheDocument () expect (screen.getByText ('title error')).toBeInTheDocument ()
expect (screen.getByText ('title warning')).toBeInTheDocument () expect (screen.getByText ('title warning')).toBeInTheDocument ()
expect (screen.getAllByRole ('textbox').filter ( expect (screen.getAllByRole ('textbox').filter (
textbox => textbox.getAttribute ('aria-invalid') === 'true')).toHaveLength (3) textbox => textbox.getAttribute ('aria-invalid') === 'true')).toHaveLength (3)
@@ -134,7 +134,9 @@ describe ('PostImportRowForm', () => {
resetRequested: false }) resetRequested: false })
}) })
it ('keeps the shared duration and tags string contract without leaking file upload UI', async () => { it (
'shows the shared creation field order without duration and without file upload UI',
async () => {
let actions: DialogueFormAction[] = [] let actions: DialogueFormAction[] = []
const controls: DialogueFormControls = { const controls: DialogueFormControls = {
close: vi.fn (), close: vi.fn (),
@@ -152,8 +154,18 @@ describe ('PostImportRowForm', () => {
onSave={onSave}/>) onSave={onSave}/>)
await waitFor (() => expect (actions.length).toBe (2)) await waitFor (() => expect (actions.length).toBe (2))
const labels = Array.from (container.querySelectorAll ('label'))
.map (node => node.textContent?.trim ())
expect (labels.slice (0, 6)).toEqual ([
'URL',
'サムネール',
'タイトル',
'オリジナルの作成日時',
'タグ',
'親投稿'])
expect (container.querySelector ('input[type="file"]')).toBeNull () expect (container.querySelector ('input[type="file"]')).toBeNull ()
expect (screen.getByPlaceholderText ('例: 2 / 2.5 / 1:23')).toHaveValue ('2') expect (screen.queryByPlaceholderText ('例: 2 / 2.5 / 1:23')).not.toBeInTheDocument ()
expect (screen.getByDisplayValue ('tag1')).toBeInTheDocument () expect (screen.getByDisplayValue ('tag1')).toBeInTheDocument ()
await act (async () => { await act (async () => {
@@ -162,10 +174,9 @@ describe ('PostImportRowForm', () => {
expect (onSave).toHaveBeenCalledWith ({ expect (onSave).toHaveBeenCalledWith ({
draft: expect.objectContaining ({ draft: expect.objectContaining ({
duration: '2',
tags: 'tag1' }), tags: 'tag1' }),
resetRequested: false }) resetRequested: false })
}) })
it ('keeps reset enabled when the value matches but provenance still differs', async () => { it ('keeps reset enabled when the value matches but provenance still differs', async () => {
const row = buildPostImportRow ({ const row = buildPostImportRow ({
@@ -178,7 +189,6 @@ describe ('PostImportRowForm', () => {
thumbnailBase: '', thumbnailBase: '',
originalCreatedFrom: '', originalCreatedFrom: '',
originalCreatedBefore: '', originalCreatedBefore: '',
duration: '',
tags: '', tags: '',
parentPostIds: '' }, parentPostIds: '' },
provenance: { provenance: {
@@ -187,7 +197,6 @@ describe ('PostImportRowForm', () => {
thumbnailBase: 'automatic', thumbnailBase: 'automatic',
originalCreatedFrom: 'automatic', originalCreatedFrom: 'automatic',
originalCreatedBefore: 'automatic', originalCreatedBefore: 'automatic',
duration: 'automatic',
tags: 'automatic', tags: 'automatic',
parentPostIds: 'automatic' }, parentPostIds: 'automatic' },
tagSources: { automatic: '', manual: '' }, tagSources: { automatic: '', manual: '' },
@@ -210,7 +219,9 @@ describe ('PostImportRowForm', () => {
expect (actions.find (action => action.label === '変更をリセット')?.disabled).toBe (false) expect (actions.find (action => action.label === '変更をリセット')?.disabled).toBe (false)
}) })
it ('disables every field while save validation is pending and re-enables them afterwards', async () => { it (
'disables every field while save validation is pending and re-enables them afterwards',
async () => {
let actions: DialogueFormAction[] = [] let actions: DialogueFormAction[] = []
let resolveSave: let resolveSave:
((value: { saved: boolean ((value: { saved: boolean
@@ -234,8 +245,9 @@ describe ('PostImportRowForm', () => {
onSave={onSave}/>) onSave={onSave}/>)
await waitFor (() => expect (actions.length).toBe (2)) await waitFor (() => expect (actions.length).toBe (2))
let savePromise: Promise<boolean | void> | undefined
await act (async () => { await act (async () => {
await actions.find (action => action.label === '編輯内容を保存')?.onSelect () savePromise = actions.find (action => action.label === '編輯内容を保存')?.onSelect ()
}) })
await waitFor (() => { await waitFor (() => {
@@ -249,6 +261,9 @@ describe ('PostImportRowForm', () => {
row: buildPostImportRow ({ row: buildPostImportRow ({
attributes: { title: 'draft title' }, attributes: { title: 'draft title' },
validationErrors: { title: ['タイトルを確認してください.'] } }) }) validationErrors: { title: ['タイトルを確認してください.'] } }) })
await act (async () => {
await savePromise
})
await waitFor (() => { await waitFor (() => {
screen.getAllByRole ('textbox').forEach (textbox => { screen.getAllByRole ('textbox').forEach (textbox => {
@@ -257,5 +272,5 @@ describe ('PostImportRowForm', () => {
}) })
expect (screen.getByDisplayValue ('draft title')).toBeInTheDocument () expect (screen.getByDisplayValue ('draft title')).toBeInTheDocument ()
expect (screen.getByText ('タイトルを確認してください.')).toBeInTheDocument () expect (screen.getByText ('タイトルを確認してください.')).toBeInTheDocument ()
}) })
}) })
+65 -72
ファイルの表示
@@ -1,10 +1,8 @@
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
import FieldError from '@/components/common/FieldError' import FieldError from '@/components/common/FieldError'
import FieldWarning from '@/components/common/FieldWarning' import FieldWarning from '@/components/common/FieldWarning'
import PostDurationField from '@/components/posts/PostDurationField' import PostCreationDataFields from '@/components/posts/PostCreationDataFields'
import PostTagsField from '@/components/posts/PostTagsField'
import PostTextField from '@/components/posts/PostTextField' import PostTextField from '@/components/posts/PostTextField'
import PostImportThumbnailPreview from '@/components/posts/import/PostImportThumbnailPreview' import PostImportThumbnailPreview from '@/components/posts/import/PostImportThumbnailPreview'
@@ -29,7 +27,6 @@ const buildDraft = (row: PostImportRow): Draft => ({
thumbnailBase: String (row.attributes.thumbnailBase ?? ''), thumbnailBase: String (row.attributes.thumbnailBase ?? ''),
originalCreatedFrom: String (row.attributes.originalCreatedFrom ?? ''), originalCreatedFrom: String (row.attributes.originalCreatedFrom ?? ''),
originalCreatedBefore: String (row.attributes.originalCreatedBefore ?? ''), originalCreatedBefore: String (row.attributes.originalCreatedBefore ?? ''),
duration: String (row.attributes.duration ?? ''),
tags: String (row.attributes.tags ?? ''), tags: String (row.attributes.tags ?? ''),
parentPostIds: String (row.attributes.parentPostIds ?? '') }) parentPostIds: String (row.attributes.parentPostIds ?? '') })
@@ -39,7 +36,6 @@ const buildResetDraft = (row: PostImportRow): Draft => ({
thumbnailBase: String (row.resetSnapshot.attributes.thumbnailBase ?? ''), thumbnailBase: String (row.resetSnapshot.attributes.thumbnailBase ?? ''),
originalCreatedFrom: String (row.resetSnapshot.attributes.originalCreatedFrom ?? ''), originalCreatedFrom: String (row.resetSnapshot.attributes.originalCreatedFrom ?? ''),
originalCreatedBefore: String (row.resetSnapshot.attributes.originalCreatedBefore ?? ''), originalCreatedBefore: String (row.resetSnapshot.attributes.originalCreatedBefore ?? ''),
duration: String (row.resetSnapshot.attributes.duration ?? ''),
tags: String (row.resetSnapshot.attributes.tags ?? ''), tags: String (row.resetSnapshot.attributes.tags ?? ''),
parentPostIds: String (row.resetSnapshot.attributes.parentPostIds ?? '') }) parentPostIds: String (row.resetSnapshot.attributes.parentPostIds ?? '') })
@@ -52,7 +48,6 @@ const sameDraft = (left: Draft, right: Draft): boolean =>
&& left.thumbnailBase === right.thumbnailBase && left.thumbnailBase === right.thumbnailBase
&& left.originalCreatedFrom === right.originalCreatedFrom && left.originalCreatedFrom === right.originalCreatedFrom
&& left.originalCreatedBefore === right.originalCreatedBefore && left.originalCreatedBefore === right.originalCreatedBefore
&& left.duration === right.duration
&& left.tags === right.tags && left.tags === right.tags
&& left.parentPostIds === right.parentPostIds && left.parentPostIds === right.parentPostIds
@@ -169,72 +164,70 @@ const PostImportRowForm: FC<Props> = (
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
<PostTextField <PostCreationDataFields
label="URL" url={{
value={draft.url} value: draft.url,
disabled={saving} onChange: value => update ('url', value),
warnings={displayRow.fieldWarnings.url} disabled: saving,
errors={groupedMessages ( warnings: displayRow.fieldWarnings.url,
displayRow.validationErrors.url, errors: groupedMessages (
displayRow.importErrors?.url)} displayRow.validationErrors.url,
onChange={value => update ('url', value)}/> displayRow.importErrors?.url) }}
<PostTextField thumbnailField={
label="タイトル" <>
value={draft.title} <PostTextField
disabled={saving} label="サムネール"
warnings={displayRow.fieldWarnings.title} value={draft.thumbnailBase}
errors={groupedMessages ( disabled={saving}
displayRow.validationErrors.title, warnings={displayRow.fieldWarnings.thumbnailBase}
displayRow.importErrors?.title)} errors={groupedMessages (
onChange={value => update ('title', value)}/> displayRow.validationErrors.thumbnailBase,
<PostTextField displayRow.importErrors?.thumbnailBase)}
label="サムネール基底 URL" onChange={value => update ('thumbnailBase', value)}/>
value={draft.thumbnailBase} </>}
disabled={saving} core={{
warnings={displayRow.fieldWarnings.thumbnailBase} title: {
errors={groupedMessages ( value: draft.title,
displayRow.validationErrors.thumbnailBase, onChange: value => update ('title', value),
displayRow.importErrors?.thumbnailBase)} disabled: saving,
onChange={value => update ('thumbnailBase', value)}/> warnings: displayRow.fieldWarnings.title,
<PostOriginalCreatedTimeField errors: groupedMessages (
disabled={saving} displayRow.validationErrors.title,
originalCreatedFrom={draft.originalCreatedFrom || null} displayRow.importErrors?.title) },
setOriginalCreatedFrom={value => update ('originalCreatedFrom', value ?? '')} originalCreated: {
originalCreatedBefore={draft.originalCreatedBefore || null} disabled: saving,
setOriginalCreatedBefore={value => update ('originalCreatedBefore', value ?? '')} originalCreatedFrom: draft.originalCreatedFrom || null,
errors={groupedMessages ( setOriginalCreatedFrom: value =>
displayRow.validationErrors.originalCreatedAt, update ('originalCreatedFrom', value ?? ''),
displayRow.validationErrors.originalCreatedFrom, originalCreatedBefore: draft.originalCreatedBefore || null,
displayRow.validationErrors.originalCreatedBefore, setOriginalCreatedBefore: value =>
displayRow.importErrors?.originalCreatedAt, update ('originalCreatedBefore', value ?? ''),
displayRow.importErrors?.originalCreatedFrom, errors: {
displayRow.importErrors?.originalCreatedBefore)}/> originalCreatedAt: groupedMessages (
<PostDurationField displayRow.validationErrors.originalCreatedAt,
value={draft.duration} displayRow.importErrors?.originalCreatedAt),
disabled={saving} originalCreatedFrom: groupedMessages (
errors={groupedMessages ( displayRow.validationErrors.originalCreatedFrom,
displayRow.validationErrors.duration, displayRow.importErrors?.originalCreatedFrom),
displayRow.validationErrors.videoMs, originalCreatedBefore: groupedMessages (
displayRow.importErrors?.duration, displayRow.validationErrors.originalCreatedBefore,
displayRow.importErrors?.videoMs)} displayRow.importErrors?.originalCreatedBefore) } },
onChange={value => update ('duration', value)}/> tags: {
<PostTagsField value: draft.tags,
tags={draft.tags} onChange: value => update ('tags', value),
disabled={saving} disabled: saving,
setTags={value => update ('tags', value)} warnings: displayRow.fieldWarnings.tags,
warnings={displayRow.fieldWarnings.tags} errors: groupedMessages (
errors={groupedMessages ( displayRow.validationErrors.tags,
displayRow.validationErrors.tags, displayRow.importErrors?.tags),
displayRow.importErrors?.tags)} rows: 4 },
rows={4}/> parentPostIds: {
<PostTextField value: draft.parentPostIds,
label="親投稿" onChange: value => update ('parentPostIds', value),
value={draft.parentPostIds} disabled: saving,
disabled={saving} errors: groupedMessages (
errors={groupedMessages ( displayRow.validationErrors.parentPostIds,
displayRow.validationErrors.parentPostIds, displayRow.importErrors?.parentPostIds) } }}/>
displayRow.importErrors?.parentPostIds)}
onChange={value => update ('parentPostIds', value)}/>
<FieldWarning messages={displayRow.baseWarnings}/> <FieldWarning messages={displayRow.baseWarnings}/>
<FieldError messages={displayRow.validationErrors.base}/> <FieldError messages={displayRow.validationErrors.base}/>
<FieldError messages={displayRow.importErrors?.base}/> <FieldError messages={displayRow.importErrors?.base}/>
+50 -19
ファイルの表示
@@ -1,8 +1,9 @@
import FieldError from '@/components/common/FieldError'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import PostImportThumbnailPreview from '@/components/posts/import/PostImportThumbnailPreview' import PostImportThumbnailPreview from '@/components/posts/import/PostImportThumbnailPreview'
import PostImportStatusBadge from '@/components/posts/import/PostImportStatusBadge' import PostImportStatusBadge from '@/components/posts/import/PostImportStatusBadge'
import { displayPostImportStatus } from '@/components/posts/import/postImportRowStatus' import { displayPostImportStatus } from '@/components/posts/import/postImportRowStatus'
import { canEditReviewRow } from '@/lib/postImportSession' import { canEditReviewRow, canRetryResultRow } from '@/lib/postImportSession'
import { cn, originalCreatedAtString } from '@/lib/utils' import { cn, originalCreatedAtString } from '@/lib/utils'
import type { FC } from 'react' import type { FC } from 'react'
@@ -12,7 +13,10 @@ import type { PostImportRow } from '@/lib/postImportSession'
type Props = { type Props = {
row: PostImportRow row: PostImportRow
onEdit: () => void onEdit: () => void
editDisabled?: boolean } onRetry?: () => void
rowMessages?: string[]
editDisabled?: boolean
retryDisabled?: boolean }
const summaryWarning = (row: PostImportRow): string | null => const summaryWarning = (row: PostImportRow): string | null =>
Object.values (row.fieldWarnings ?? { }).flat ()[0] Object.values (row.fieldWarnings ?? { }).flat ()[0]
@@ -25,7 +29,14 @@ const summaryDate = (row: PostImportRow): string =>
row.attributes.originalCreatedBefore?.toString () ?? null) row.attributes.originalCreatedBefore?.toString () ?? null)
const PostImportRowSummary: FC<Props> = ({ row, onEdit, editDisabled }) => { const PostImportRowSummary: FC<Props> = (
{ row,
onEdit,
onRetry,
rowMessages,
editDisabled,
retryDisabled },
) => {
const warning = summaryWarning (row) const warning = summaryWarning (row)
const displayStatus = displayPostImportStatus (row) const displayStatus = displayPostImportStatus (row)
@@ -54,24 +65,34 @@ const PostImportRowSummary: FC<Props> = ({ row, onEdit, editDisabled }) => {
</div> </div>
<div className="text-xs text-neutral-500 dark:text-neutral-400"> <div className="text-xs text-neutral-500 dark:text-neutral-400">
{summaryDate (row)} {summaryDate (row)}
{row.attributes.duration ? ` / ${ row.attributes.duration }` : ''}
</div> </div>
{warning && ( {warning && (
<div className="text-xs text-amber-700 dark:text-amber-200"> <div className="text-xs text-amber-700 dark:text-amber-200">
{warning} {warning}
</div>)} </div>)}
<FieldError messages={rowMessages}/>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
{displayStatus != null && <PostImportStatusBadge value={displayStatus}/>} {displayStatus != null && <PostImportStatusBadge value={displayStatus}/>}
</div> </div>
<div className="flex justify-end"> <div className="flex justify-end">
<Button <div className="flex gap-2">
type="button" <Button
variant="outline" type="button"
onClick={onEdit} variant="outline"
disabled={!(canEditReviewRow (row)) || editDisabled === true}> onClick={onEdit}
disabled={!(canEditReviewRow (row)) || editDisabled === true}>
</Button>
</Button>
{onRetry != null && (
<Button
type="button"
variant="outline"
onClick={onRetry}
disabled={!(canRetryResultRow (row)) || retryDisabled === true}>
</Button>)}
</div>
</div> </div>
</div> </div>
@@ -98,21 +119,31 @@ const PostImportRowSummary: FC<Props> = ({ row, onEdit, editDisabled }) => {
</div> </div>
<div className="text-xs text-neutral-500 dark:text-neutral-400"> <div className="text-xs text-neutral-500 dark:text-neutral-400">
{summaryDate (row)} {summaryDate (row)}
{row.attributes.duration ? ` / ${ row.attributes.duration }` : ''}
</div> </div>
{warning && ( {warning && (
<div className="text-xs text-amber-700 dark:text-amber-200"> <div className="text-xs text-amber-700 dark:text-amber-200">
{warning} {warning}
</div>)} </div>)}
<FieldError messages={rowMessages}/>
</div> </div>
</div> </div>
<Button <div className="flex flex-col gap-2 sm:flex-row">
type="button" <Button
variant="outline" type="button"
onClick={onEdit} variant="outline"
disabled={!(canEditReviewRow (row)) || editDisabled === true}> onClick={onEdit}
disabled={!(canEditReviewRow (row)) || editDisabled === true}>
</Button>
</Button>
{onRetry != null && (
<Button
type="button"
variant="outline"
onClick={onRetry}
disabled={!(canRetryResultRow (row)) || retryDisabled === true}>
</Button>)}
</div>
</div> </div>
</>) </>)
} }
+6 -4
ファイルの表示
@@ -71,10 +71,12 @@ const PostImportThumbnailPreview: FC<Props> = (
{ {
return ( return (
<div <div
className={`${ className } flex items-center justify-center rounded border className={cn (
border-amber-300 bg-amber-50 p-2 text-center text-xs className,
text-amber-700 dark:border-amber-900 dark:bg-amber-950 'flex items-center justify-center rounded border',
dark:text-amber-200`}> 'border-amber-300 bg-amber-50 p-2 text-center text-xs',
'text-amber-700 dark:border-amber-900 dark:bg-amber-950',
'dark:text-amber-200')}>
</div>) </div>)
} }
+1 -2
ファイルの表示
@@ -270,7 +270,6 @@ describe ('post import row state', () => {
thumbnailBase: '', thumbnailBase: '',
originalCreatedFrom: '', originalCreatedFrom: '',
originalCreatedBefore: '', originalCreatedBefore: '',
duration: '2.5',
tags: 'edited-tag', tags: 'edited-tag',
parentPostIds: '' }, parentPostIds: '' },
true) true)
@@ -279,7 +278,7 @@ describe ('post import row state', () => {
expect (nextRow.importErrors).toBeUndefined () expect (nextRow.importErrors).toBeUndefined ()
expect (nextRow.url).toBe ('https://example.com/edited') expect (nextRow.url).toBe ('https://example.com/edited')
expect (nextRow.attributes.title).toBe ('edited title') expect (nextRow.attributes.title).toBe ('edited title')
expect (nextRow.attributes.duration).toBe ('2.5') expect (nextRow.attributes.duration).toBe ('2')
expect (nextRow.attributes.tags).toBe ('edited-tag') expect (nextRow.attributes.tags).toBe ('edited-tag')
expect (nextRow.provenance.title).toBe ('manual') expect (nextRow.provenance.title).toBe ('manual')
expect (nextRow.provenance.url).toBe ('manual') expect (nextRow.provenance.url).toBe ('manual')
-1
ファイルの表示
@@ -128,7 +128,6 @@ export const buildNextEditedRow = (
['thumbnailBase', draft.thumbnailBase], ['thumbnailBase', draft.thumbnailBase],
['originalCreatedFrom', draft.originalCreatedFrom], ['originalCreatedFrom', draft.originalCreatedFrom],
['originalCreatedBefore', draft.originalCreatedBefore], ['originalCreatedBefore', draft.originalCreatedBefore],
['duration', draft.duration],
['parentPostIds', draft.parentPostIds]] as const ['parentPostIds', draft.parentPostIds]] as const
draftFields.forEach (([field, value]) => { draftFields.forEach (([field, value]) => {
nextAttributes[field] = value nextAttributes[field] = value
+11 -9
ファイルの表示
@@ -25,11 +25,11 @@ describe ('post import storage', () => {
skipReason: 'existing', skipReason: 'existing',
existingPostId: 10 }) existingPostId: 10 })
expect (savePostImportSession ('session', { expect (savePostImportSession ({
source: skipped.url, source: skipped.url,
rows: [row, skipped], rows: [row, skipped],
repairMode: 'all' })).toBe (true) repairMode: 'all' })).toBe (true)
expect (loadPostImportSession ('session')).toMatchObject ({ expect (loadPostImportSession ()).toMatchObject ({
version: 2, version: 2,
source: skipped.url, source: skipped.url,
rows: [{ rows: [{
@@ -63,12 +63,13 @@ describe ('post import storage', () => {
{ ...session.rows[0], importStatus: 'skipped', recoverable: true }, { ...session.rows[0], importStatus: 'skipped', recoverable: true },
{ ...session.rows[0], recoverable: true }] { ...session.rows[0], recoverable: true }]
for (const [index, row] of invalidRows.entries ()) for (const row of invalidRows)
{ {
sessionStorage.setItem (`post-import-session:invalid-${ index }`, JSON.stringify ({ sessionStorage.setItem ('post-import-session:current', JSON.stringify ({
...session, ...session,
rows: [row] })) rows: [row] }))
expect (loadPostImportSession (`invalid-${ index }`)).toBeNull () expect (loadPostImportSession ()).toBeNull ()
sessionStorage.clear ()
} }
}) })
@@ -82,18 +83,19 @@ describe ('post import storage', () => {
{ ...row, resetSnapshot: { ...row.resetSnapshot, { ...row, resetSnapshot: { ...row.resetSnapshot,
fieldWarnings: { title: 'warning' } } }] fieldWarnings: { title: 'warning' } } }]
invalidRows.forEach ((invalidRow, index) => { invalidRows.forEach (invalidRow => {
sessionStorage.setItem (`post-import-session:shape-${ index }`, JSON.stringify ({ sessionStorage.setItem ('post-import-session:current', JSON.stringify ({
version: 2, version: 2,
savedAt: new Date ().toISOString (), savedAt: new Date ().toISOString (),
source: '', source: '',
rows: [invalidRow], rows: [invalidRow],
repairMode: 'all' })) repairMode: 'all' }))
expect (loadPostImportSession (`shape-${ index }`)).toBeNull () expect (loadPostImportSession ()).toBeNull ()
sessionStorage.clear ()
}) })
}) })
it ('removes expired and malformed sessions without touching current sessions', () => { it ('keeps only the current fixed-key session and removes legacy prefixed entries', () => {
const current = { const current = {
version: 2, version: 2,
savedAt: new Date ().toISOString (), savedAt: new Date ().toISOString (),
+39 -16
ファイルの表示
@@ -8,6 +8,7 @@ import type { PostImportOrigin,
const SESSION_VERSION = 2 const SESSION_VERSION = 2
const SESSION_PREFIX = 'post-import-session:' const SESSION_PREFIX = 'post-import-session:'
const CURRENT_SESSION_KEY = `${ SESSION_PREFIX }current`
const SOURCE_DRAFT_KEY = 'post-import-source-draft' const SOURCE_DRAFT_KEY = 'post-import-source-draft'
const SESSION_MAX_AGE_MS = 24 * 60 * 60 * 1000 const SESSION_MAX_AGE_MS = 24 * 60 * 60 * 1000
const ATTRIBUTE_KEYS = [ const ATTRIBUTE_KEYS = [
@@ -28,7 +29,7 @@ const isPlainObject = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value != null && !(Array.isArray (value)) typeof value === 'object' && value != null && !(Array.isArray (value))
const sessionKey = (sessionId: string): string => `${ SESSION_PREFIX }${ sessionId }` const sessionKey = (): string => CURRENT_SESSION_KEY
const readStorage = ( const readStorage = (
@@ -288,12 +289,6 @@ const isExpiredSession = (savedAt: string): boolean => {
} }
export const createPostImportSessionId = (): string =>
typeof crypto !== 'undefined' && 'randomUUID' in crypto
? crypto.randomUUID ()
: `${ Date.now () }-${ Math.random ().toString (36).slice (2) }`
export const cleanupExpiredPostImportSessions = ( export const cleanupExpiredPostImportSessions = (
onError?: StorageErrorHandler, onError?: StorageErrorHandler,
) => { ) => {
@@ -311,6 +306,13 @@ export const cleanupExpiredPostImportSessions = (
const raw = sessionStorage.getItem (key) const raw = sessionStorage.getItem (key)
if (raw == null) if (raw == null)
continue continue
if (key !== CURRENT_SESSION_KEY)
{
sessionStorage.removeItem (key)
--i
continue
}
try try
{ {
const value = JSON.parse (raw) as { savedAt?: string } const value = JSON.parse (raw) as { savedAt?: string }
@@ -368,24 +370,38 @@ export const clearPostImportSourceDraft = (
export const savePostImportSession = ( export const savePostImportSession = (
sessionId: string, sessionOrLegacyId: string | Omit<PostImportSession, 'version' | 'savedAt'>,
session: Omit<PostImportSession, 'version' | 'savedAt'>, sessionOrOnError?: Omit<PostImportSession, 'version' | 'savedAt'> | StorageErrorHandler,
onError?: StorageErrorHandler, onError?: StorageErrorHandler,
): boolean => ): boolean => {
writeStorage ( const session =
sessionKey (sessionId), typeof sessionOrLegacyId === 'string'
? sessionOrOnError as Omit<PostImportSession, 'version' | 'savedAt'>
: sessionOrLegacyId
const errorHandler =
typeof sessionOrLegacyId === 'string'
? onError
: sessionOrOnError as StorageErrorHandler | undefined
return writeStorage (
sessionKey (),
JSON.stringify ({ JSON.stringify ({
...session, ...session,
version: SESSION_VERSION, version: SESSION_VERSION,
savedAt: new Date ().toISOString () }), savedAt: new Date ().toISOString () }),
onError) errorHandler)
}
export const loadPostImportSession = ( export const loadPostImportSession = (
sessionId: string, legacyIdOrOnError?: string | StorageErrorHandler,
onError?: StorageErrorHandler, onError?: StorageErrorHandler,
): PostImportSession | null => { ): PostImportSession | null => {
const raw = readStorage (sessionKey (sessionId), onError) const errorHandler =
typeof legacyIdOrOnError === 'string'
? onError
: legacyIdOrOnError
const raw = readStorage (sessionKey (), errorHandler)
if (raw == null) if (raw == null)
return null return null
@@ -396,7 +412,7 @@ export const loadPostImportSession = (
return null return null
if (typeof value.savedAt !== 'string' || isExpiredSession (value.savedAt)) if (typeof value.savedAt !== 'string' || isExpiredSession (value.savedAt))
{ {
removeStorage (sessionKey (sessionId), onError) removeStorage (sessionKey (), errorHandler)
return null return null
} }
@@ -416,3 +432,10 @@ export const loadPostImportSession = (
return null return null
} }
} }
export const clearPostImportSession = (
onError?: StorageErrorHandler,
) => {
removeStorage (sessionKey (), onError)
}
-1
ファイルの表示
@@ -78,7 +78,6 @@ export type PostImportEditableDraft = {
thumbnailBase: string thumbnailBase: string
originalCreatedFrom: string originalCreatedFrom: string
originalCreatedBefore: string originalCreatedBefore: string
duration: string
tags: string tags: string
parentPostIds: string } parentPostIds: string }
+36 -390
ファイルの表示
@@ -1,402 +1,48 @@
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import { render, screen } from '@testing-library/react'
import { HelmetProvider } from 'react-helmet-async' import { MemoryRouter, Navigate, Route, Routes } from 'react-router-dom'
import { MemoryRouter, Route, Routes } from 'react-router-dom' import { describe, expect, it } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { describe ('legacy post import routes', () => {
loadPostImportSession, it ('redirects /posts/import to /posts/new', () => {
savePostImportSession, render (
} from '@/lib/postImportSession' <MemoryRouter initialEntries={['/posts/import']}>
import PostImportResultPage from '@/pages/posts/PostImportResultPage' <Routes>
import { buildUser } from '@/test/factories' <Route path="/posts/import" element={<Navigate to="/posts/new" replace/>}/>
import { buildPostImportRow } from '@/test/postImportFactories' <Route path="/posts/new" element={<div>SOURCE ROUTE</div>}/>
import { originalCreatedAtString } from '@/lib/utils' </Routes>
</MemoryRouter>)
import type { PostImportRow } from '@/lib/postImportSession' expect (screen.getByText ('SOURCE ROUTE')).toBeInTheDocument ()
import type { DialogueFormAction, DialogueFormControls } from '@/lib/dialogues/useDialogue'
const api = vi.hoisted (() => ({
apiGet: vi.fn (),
apiPost: vi.fn (),
}))
const toastApi = vi.hoisted (() => ({
toast: vi.fn (),
}))
const dialogue = vi.hoisted (() => ({
form: vi.fn (() => Promise.resolve ()),
}))
vi.mock ('@/components/ui/use-toast', () => toastApi)
vi.mock ('@/lib/api', () => api)
vi.mock ('@/lib/dialogues/useDialogue', () => ({
default: () => dialogue,
}))
describe ('PostImportResultPage', () => {
beforeEach (() => {
sessionStorage.clear ()
vi.clearAllMocks ()
globalThis.URL.createObjectURL = vi.fn (() => 'blob:preview')
globalThis.URL.revokeObjectURL = vi.fn ()
api.apiGet.mockResolvedValue (new Blob (['img'], { type: 'image/png' }))
}) })
it ('shows edit and retry only for recoverable failures and shows warnings', () => { it ('redirects legacy review routes to /posts/new', () => {
savePostImportSession ('result-session', {
source: '',
repairMode: 'failed',
rows: [
buildPostImportRow ({
sourceRow: 1,
attributes: { title: 'recoverable row' },
importStatus: 'failed',
recoverable: true,
importErrors: { base: ['recoverable failed'] } }),
buildPostImportRow ({
sourceRow: 2,
attributes: { title: 'hard failed row' },
importStatus: 'failed',
importErrors: { base: ['hard failed'] } }),
buildPostImportRow ({
sourceRow: 3,
attributes: {
title: 'created row',
originalCreatedFrom: '2024-01-01T00:00:00Z',
originalCreatedBefore: '2024-01-02T00:00:00Z' },
importStatus: 'created',
createdPostId: 3,
fieldWarnings: {
thumbnailBase: ['サムネール画像を取得できませんでした.'] } })] })
render ( render (
<HelmetProvider> <MemoryRouter initialEntries={['/posts/import/legacy/review']}>
<MemoryRouter initialEntries={['/posts/import/result-session/result']}> <Routes>
<Routes> <Route
<Route path="/posts/import/:sessionId/review"
path="/posts/import/:sessionId/result" element={<Navigate to="/posts/new" replace/>}/>
element={<PostImportResultPage user={buildUser ()}/>}/> <Route path="/posts/new" element={<div>SOURCE ROUTE</div>}/>
</Routes> </Routes>
</MemoryRouter> </MemoryRouter>)
</HelmetProvider>)
expect (screen.getAllByRole ('button', { name: '編輯' })).toHaveLength (1) expect (screen.getByText ('SOURCE ROUTE')).toBeInTheDocument ()
expect (screen.getAllByRole ('button', { name: '再試行' })).toHaveLength (1)
expect (screen.getByText ('サムネール画像を取得できませんでした.'))
.toBeInTheDocument ()
expect (screen.getByText (
originalCreatedAtString ('2024-01-01T00:00:00Z', '2024-01-02T00:00:00Z'),
)).toBeInTheDocument ()
}) })
it ('shows hour-precision original-created ranges with the shared utility output', () => { it ('redirects legacy result routes to /posts', () => {
const from = '2024-01-01T12:00:00Z'
const before = '2024-01-01T13:00:00Z'
savePostImportSession ('result-hour-range', {
source: '',
repairMode: 'all',
rows: [buildPostImportRow ({
sourceRow: 1,
importStatus: 'created',
createdPostId: 1,
attributes: {
title: 'created row',
originalCreatedFrom: from,
originalCreatedBefore: before } })] })
render ( render (
<HelmetProvider> <MemoryRouter initialEntries={['/posts/import/legacy/result']}>
<MemoryRouter initialEntries={['/posts/import/result-hour-range/result']}> <Routes>
<Routes> <Route
<Route path="/posts/import/:sessionId/result"
path="/posts/import/:sessionId/result" element={<Navigate to="/posts" replace/>}/>
element={<PostImportResultPage user={buildUser ()}/>}/> <Route
</Routes> path="/posts/new/:sessionId/result"
</MemoryRouter> element={<Navigate to="/posts" replace/>}/>
</HelmetProvider>) <Route path="/posts" element={<div>POSTS ROUTE</div>}/>
</Routes>
</MemoryRouter>)
expect (screen.getByText (originalCreatedAtString (from, before))).toBeInTheDocument () expect (screen.getByText ('POSTS ROUTE')).toBeInTheDocument ()
})
it ('keeps recoverable pending rows actionable and hides actions for hard failures', () => {
savePostImportSession ('result-pending', {
source: '',
repairMode: 'failed',
rows: [
buildPostImportRow ({
sourceRow: 1,
importStatus: 'pending',
recoverable: true,
validationErrors: { title: ['invalid'] } }),
buildPostImportRow ({
sourceRow: 2,
importStatus: 'pending',
recoverable: true }),
buildPostImportRow ({
sourceRow: 3,
importStatus: 'failed',
importErrors: { base: ['hard failed'] } })] })
render (
<HelmetProvider>
<MemoryRouter initialEntries={['/posts/import/result-pending/result']}>
<Routes>
<Route
path="/posts/import/:sessionId/result"
element={<PostImportResultPage user={buildUser ()}/>}/>
</Routes>
</MemoryRouter>
</HelmetProvider>)
expect (screen.getAllByRole ('button', { name: '編輯' })).toHaveLength (1)
expect (screen.getAllByRole ('button', { name: '再試行' })).toHaveLength (1)
expect (screen.getByText ('invalid')).toBeInTheDocument ()
expect (screen.getByText ('hard failed')).toBeInTheDocument ()
})
it ('disables editing, retry, and navigation while retry is running', async () => {
let resolveValidation: ((value: { rows: PostImportRow[] }) => void) | null = null
savePostImportSession ('result-retry-busy', {
source: '',
repairMode: 'failed',
rows: [
buildPostImportRow ({
sourceRow: 1,
importStatus: 'failed',
recoverable: true,
importErrors: { base: ['failed'] } }),
buildPostImportRow ({
sourceRow: 2,
importStatus: 'failed',
recoverable: true,
importErrors: { base: ['failed'] } })] })
api.apiPost.mockImplementationOnce (() =>
new Promise<{ rows: PostImportRow[] }> (resolve => {
resolveValidation = resolve
}))
api.apiPost.mockResolvedValueOnce ({
created: 0,
skipped: 0,
failed: 0,
rows: [] })
render (
<HelmetProvider>
<MemoryRouter initialEntries={['/posts/import/result-retry-busy/result']}>
<Routes>
<Route
path="/posts/import/:sessionId/result"
element={<PostImportResultPage user={buildUser ()}/>}/>
</Routes>
</MemoryRouter>
</HelmetProvider>)
fireEvent.click (screen.getAllByRole ('button', { name: '再試行' })[0])
await waitFor (() => {
screen.getAllByRole ('button', { name: '編輯' }).forEach (button => {
expect (button).toBeDisabled ()
})
screen.getAllByRole ('button', { name: '再試行' }).forEach (button => {
expect (button).toBeDisabled ()
})
expect (screen.getByRole ('button', { name: '確認画面へ戻る' })).toBeDisabled ()
expect (screen.getByRole ('button', { name: '新しい URL リストを入力' })).toBeDisabled ()
})
resolveValidation?.({ rows: [buildPostImportRow ({ sourceRow: 1 })] })
})
it ('persists retry success to sessionStorage', async () => {
savePostImportSession ('result-retry-success', {
source: '',
repairMode: 'failed',
rows: [buildPostImportRow ({
sourceRow: 1,
importStatus: 'failed',
recoverable: true,
importErrors: { base: ['failed'] } })] })
api.apiPost
.mockResolvedValueOnce ({
rows: [buildPostImportRow ({
sourceRow: 1,
importStatus: 'pending',
recoverable: true })] })
.mockResolvedValueOnce ({
created: 1,
skipped: 0,
failed: 0,
rows: [{
sourceRow: 1,
status: 'created',
post: { id: 10 } }] })
render (
<HelmetProvider>
<MemoryRouter initialEntries={['/posts/import/result-retry-success/result']}>
<Routes>
<Route
path="/posts/import/:sessionId/result"
element={<PostImportResultPage user={buildUser ()}/>}/>
</Routes>
</MemoryRouter>
</HelmetProvider>)
fireEvent.click (screen.getByRole ('button', { name: '再試行' }))
await waitFor (() => {
expect (loadPostImportSession ('result-retry-success')?.rows[0]?.importStatus)
.toBe ('created')
})
})
it ('keeps edited recoverable rows in session and retries with the edited values', async () => {
savePostImportSession ('result-edit-retry', {
source: '',
repairMode: 'failed',
rows: [buildPostImportRow ({
sourceRow: 1,
attributes: { title: 'old title' },
importStatus: 'failed',
recoverable: true,
importErrors: { base: ['failed'] } })] })
api.apiPost
.mockResolvedValueOnce ({
rows: [buildPostImportRow ({
sourceRow: 1,
attributes: { title: 'edited title' },
importStatus: 'pending',
recoverable: true })] })
.mockResolvedValueOnce ({
rows: [buildPostImportRow ({
sourceRow: 1,
attributes: { title: 'edited title' },
importStatus: 'pending',
recoverable: true })] })
.mockResolvedValueOnce ({
created: 1,
skipped: 0,
failed: 0,
rows: [{
sourceRow: 1,
status: 'created',
post: { id: 11 } }] })
dialogue.form.mockImplementationOnce (async options => {
let actions: DialogueFormAction[] = []
const controls: DialogueFormControls = {
close: vi.fn (),
confirm: vi.fn (),
setActions: next => {
actions = next
} }
render (options.body (controls))
await waitFor (() => expect (actions.length).toBe (2))
fireEvent.change (screen.getByDisplayValue ('old title'), {
target: { value: 'edited title' } })
await act (async () => {
await actions.find (action => action.label === '編輯内容を保存')?.onSelect ()
})
})
render (
<HelmetProvider>
<MemoryRouter initialEntries={['/posts/import/result-edit-retry/result']}>
<Routes>
<Route
path="/posts/import/:sessionId/result"
element={<PostImportResultPage user={buildUser ()}/>}/>
</Routes>
</MemoryRouter>
</HelmetProvider>)
fireEvent.click (screen.getByRole ('button', { name: '編輯' }))
await waitFor (() => {
const saved = loadPostImportSession ('result-edit-retry')
expect (saved?.rows[0]?.attributes.title).toBe ('edited title')
expect (saved?.rows[0]?.importStatus).toBe ('pending')
})
fireEvent.click (screen.getByRole ('button', { name: '再試行' }))
await waitFor (() => {
expect (api.apiPost.mock.calls[2]?.[1]?.rows?.[0]?.attributes?.title)
.toBe ('edited title')
})
})
it ('persists restored failed state when retry fails', async () => {
savePostImportSession ('result-retry-failure', {
source: '',
repairMode: 'failed',
rows: [buildPostImportRow ({
sourceRow: 1,
importStatus: 'failed',
recoverable: true,
importErrors: { base: ['failed'] } })] })
api.apiPost.mockRejectedValueOnce (new Error ('network error'))
render (
<HelmetProvider>
<MemoryRouter initialEntries={['/posts/import/result-retry-failure/result']}>
<Routes>
<Route
path="/posts/import/:sessionId/result"
element={<PostImportResultPage user={buildUser ()}/>}/>
</Routes>
</MemoryRouter>
</HelmetProvider>)
fireEvent.click (screen.getByRole ('button', { name: '再試行' }))
await waitFor (() => {
const saved = loadPostImportSession ('result-retry-failure')
expect (saved?.rows[0]?.importStatus).toBe ('failed')
expect (saved?.rows[0]?.recoverable).toBe (true)
})
})
it ('restores the failed row when the retry import response is incomplete', async () => {
savePostImportSession ('result-retry-missing-row', {
source: '',
repairMode: 'failed',
rows: [buildPostImportRow ({
sourceRow: 1,
importStatus: 'failed',
recoverable: true,
importErrors: { base: ['failed'] } })] })
api.apiPost
.mockResolvedValueOnce ({
rows: [buildPostImportRow ({
sourceRow: 1,
importStatus: 'pending',
recoverable: true })] })
.mockResolvedValueOnce ({
created: 1,
skipped: 0,
failed: 0,
rows: [] })
render (
<HelmetProvider>
<MemoryRouter initialEntries={['/posts/import/result-retry-missing-row/result']}>
<Routes>
<Route
path="/posts/import/:sessionId/result"
element={<PostImportResultPage user={buildUser ()}/>}/>
</Routes>
</MemoryRouter>
</HelmetProvider>)
fireEvent.click (screen.getByRole ('button', { name: '再試行' }))
await waitFor (() => {
const saved = loadPostImportSession ('result-retry-missing-row')
expect (saved?.rows[0]?.importStatus).toBe ('failed')
expect (toastApi.toast).toHaveBeenCalledWith (
expect.objectContaining ({ title: '登録結果が不完全でした' }))
})
}) })
}) })
+227 -153
ファイルの表示
@@ -30,6 +30,20 @@ vi.mock ('@/lib/dialogues/useDialogue', () => ({
default: () => dialogue, default: () => dialogue,
})) }))
const renderReviewPage = (route = '/posts/new/review') =>
render (
<HelmetProvider>
<MemoryRouter initialEntries={[route]}>
<Routes>
<Route path="/posts/new" element={<div>SOURCE ROUTE</div>}/>
<Route
path="/posts/new/review"
element={<PostImportReviewPage user={buildUser ()}/>}/>
<Route path="/posts" element={<div>POSTS ROUTE</div>}/>
</Routes>
</MemoryRouter>
</HelmetProvider>)
describe ('PostImportReviewPage', () => { describe ('PostImportReviewPage', () => {
beforeEach (() => { beforeEach (() => {
sessionStorage.clear () sessionStorage.clear ()
@@ -39,8 +53,124 @@ describe ('PostImportReviewPage', () => {
api.apiGet.mockResolvedValue (new Blob (['img'], { type: 'image/png' })) api.apiGet.mockResolvedValue (new Blob (['img'], { type: 'image/png' }))
}) })
it ('navigates to the result route after an initial recoverable failure', async () => { it ('redirects to /posts/new when no current work exists', async () => {
savePostImportSession ('review-session', { renderReviewPage ()
await waitFor (() => {
expect (screen.getByText ('SOURCE ROUTE')).toBeInTheDocument ()
})
})
it ('restores the current work on review reload', async () => {
savePostImportSession ({
source: 'https://example.com/post',
repairMode: 'all',
rows: [buildPostImportRow ({
sourceRow: 1,
attributes: { title: 'restored row' } })] })
renderReviewPage ()
expect (await screen.findByText ('restored row')).toBeInTheDocument ()
})
it ('returns to /posts/new while keeping the current work', async () => {
savePostImportSession ({
source: 'https://example.com/post',
repairMode: 'all',
rows: [buildPostImportRow ({
sourceRow: 1,
attributes: { title: 'restored row' } })] })
renderReviewPage ()
fireEvent.click (await screen.findByRole ('button', { name: 'URL リスト入力へ戻る' }))
await waitFor (() => {
expect (screen.getByText ('SOURCE ROUTE')).toBeInTheDocument ()
})
expect (loadPostImportSession ()?.rows[0]?.attributes.title).toBe ('restored row')
})
it ('disables row editing and navigation while batch import is running', async () => {
let resolveValidation: ((value: { rows: PostImportRow[] }) => void) | null = null
savePostImportSession ({
source: 'https://example.com/post',
repairMode: 'all',
rows: [buildPostImportRow ({ sourceRow: 1 })] })
api.apiPost.mockImplementationOnce (() =>
new Promise<{ rows: PostImportRow[] }> (resolve => {
resolveValidation = resolve
}))
api.apiPost.mockResolvedValueOnce ({
created: 0,
skipped: 0,
failed: 0,
rows: [] })
renderReviewPage ()
fireEvent.click (screen.getByRole ('button', { name: '取込実行' }))
await waitFor (() => {
expect (screen.getByRole ('button', { name: '編輯' })).toBeDisabled ()
expect (screen.getByRole ('button', { name: 'URL リスト入力へ戻る' })).toBeDisabled ()
expect (screen.getByRole ('button', { name: '取込実行' })).toBeDisabled ()
})
resolveValidation?.({ rows: [buildPostImportRow ({ sourceRow: 1 })] })
})
it ('does not call import when validation omits a requested source row', async () => {
savePostImportSession ({
source: 'https://example.com/post-1\nhttps://example.com/post-2',
repairMode: 'all',
rows: [
buildPostImportRow ({ sourceRow: 1 }),
buildPostImportRow ({ sourceRow: 2 })] })
api.apiPost.mockResolvedValueOnce ({
rows: [buildPostImportRow ({ sourceRow: 1 })] })
renderReviewPage ()
fireEvent.click (screen.getByRole ('button', { name: '取込実行' }))
await waitFor (() => {
expect (toastApi.toast).toHaveBeenCalledWith (
expect.objectContaining ({ title: '再検証結果が不完全でした' }))
})
expect (api.apiPost).toHaveBeenCalledTimes (1)
})
it ('navigates to /posts when all rows finish as created or skipped', async () => {
savePostImportSession ({
source: 'https://example.com/post',
repairMode: 'all',
rows: [buildPostImportRow ({ sourceRow: 1 })] })
api.apiPost
.mockResolvedValueOnce ({
rows: [buildPostImportRow ({ sourceRow: 1 })] })
.mockResolvedValueOnce ({
created: 1,
skipped: 0,
failed: 0,
rows: [{
sourceRow: 1,
status: 'created',
post: { id: 1 } }] })
renderReviewPage ()
fireEvent.click (screen.getByRole ('button', { name: '取込実行' }))
await waitFor (() => {
expect (screen.getByText ('POSTS ROUTE')).toBeInTheDocument ()
})
expect (loadPostImportSession ()).toBeNull ()
})
it ('stays on review when a recoverable failed row remains', async () => {
savePostImportSession ({
source: 'https://example.com/post', source: 'https://example.com/post',
repairMode: 'all', repairMode: 'all',
rows: [buildPostImportRow ({ sourceRow: 1 })] }) rows: [buildPostImportRow ({ sourceRow: 1 })] })
@@ -57,140 +187,21 @@ describe ('PostImportReviewPage', () => {
recoverable: true, recoverable: true,
errors: { title: ['invalid'] } }] }) errors: { title: ['invalid'] } }] })
render ( renderReviewPage ()
<HelmetProvider>
<MemoryRouter initialEntries={['/posts/import/review-session/review']}>
<Routes>
<Route
path="/posts/import/:sessionId/review"
element={<PostImportReviewPage user={buildUser ()}/>}/>
<Route
path="/posts/import/:sessionId/result"
element={<div>RESULT ROUTE</div>}/>
</Routes>
</MemoryRouter>
</HelmetProvider>)
fireEvent.click (screen.getByRole ('button', { name: '取込実行' })) fireEvent.click (screen.getByRole ('button', { name: '取込実行' }))
await waitFor (() => { expect (await screen.findByText ('invalid')).toBeInTheDocument ()
expect (screen.getByText ('RESULT ROUTE')).toBeInTheDocument () expect (screen.queryByText ('POSTS ROUTE')).not.toBeInTheDocument ()
})
expect (dialogue.form).not.toHaveBeenCalled ()
})
it ('disables row editing and navigation while batch import is running', async () => {
let resolveValidation: ((value: { rows: PostImportRow[] }) => void) | null = null
savePostImportSession ('review-loading', {
source: 'https://example.com/post',
repairMode: 'all',
rows: [buildPostImportRow ({ sourceRow: 1 })] })
api.apiPost.mockImplementationOnce (() =>
new Promise<{ rows: PostImportRow[] }> (resolve => {
resolveValidation = resolve
}))
api.apiPost.mockResolvedValueOnce ({
created: 0,
skipped: 0,
failed: 0,
rows: [] })
render (
<HelmetProvider>
<MemoryRouter initialEntries={['/posts/import/review-loading/review']}>
<Routes>
<Route
path="/posts/import/:sessionId/review"
element={<PostImportReviewPage user={buildUser ()}/>}/>
</Routes>
</MemoryRouter>
</HelmetProvider>)
fireEvent.click (screen.getByRole ('button', { name: '取込実行' }))
await waitFor (() => {
expect (screen.getByRole ('button', { name: '編輯' })).toBeDisabled ()
expect (screen.getByRole ('button', { name: 'URL リスト入力へ戻る' })).toBeDisabled ()
expect (screen.getByRole ('button', { name: '取込実行' })).toBeDisabled ()
})
resolveValidation?.({ rows: [buildPostImportRow ({ sourceRow: 1 })] })
})
it ('does not call import when validation omits a requested source row', async () => {
savePostImportSession ('review-missing-row', {
source: 'https://example.com/post-1\nhttps://example.com/post-2',
repairMode: 'all',
rows: [
buildPostImportRow ({ sourceRow: 1 }),
buildPostImportRow ({ sourceRow: 2 })] })
api.apiPost.mockResolvedValueOnce ({
rows: [buildPostImportRow ({ sourceRow: 1 })] })
render (
<HelmetProvider>
<MemoryRouter initialEntries={['/posts/import/review-missing-row/review']}>
<Routes>
<Route
path="/posts/import/:sessionId/review"
element={<PostImportReviewPage user={buildUser ()}/>}/>
</Routes>
</MemoryRouter>
</HelmetProvider>)
fireEvent.click (screen.getByRole ('button', { name: '取込実行' }))
await waitFor (() => {
expect (toastApi.toast).toHaveBeenCalledWith (
expect.objectContaining ({ title: '再検証結果が不完全でした' }))
})
expect (api.apiPost).toHaveBeenCalledTimes (1)
})
it ('does not navigate to result when import omits a requested source row', async () => {
savePostImportSession ('review-missing-import-row', {
source: 'https://example.com/post',
repairMode: 'all',
rows: [buildPostImportRow ({ sourceRow: 1 })] })
api.apiPost
.mockResolvedValueOnce ({
rows: [buildPostImportRow ({ sourceRow: 1 })] })
.mockResolvedValueOnce ({
created: 1,
skipped: 0,
failed: 0,
rows: [] })
render (
<HelmetProvider>
<MemoryRouter initialEntries={['/posts/import/review-missing-import-row/review']}>
<Routes>
<Route
path="/posts/import/:sessionId/review"
element={<PostImportReviewPage user={buildUser ()}/>}/>
<Route
path="/posts/import/:sessionId/result"
element={<div>RESULT ROUTE</div>}/>
</Routes>
</MemoryRouter>
</HelmetProvider>)
fireEvent.click (screen.getByRole ('button', { name: '取込実行' }))
await waitFor (() => {
expect (toastApi.toast).toHaveBeenCalledWith (
expect.objectContaining ({ title: '登録結果が不完全でした' }))
})
expect (screen.queryByText ('RESULT ROUTE')).not.toBeInTheDocument ()
}) })
it ('keeps edited recoverable rows in session before the next batch submit', async () => { it ('keeps edited recoverable rows in session before the next batch submit', async () => {
savePostImportSession ('review-edit-retry', { savePostImportSession ({
source: 'https://example.com/post', source: 'https://example.com/post',
repairMode: 'failed', repairMode: 'failed',
rows: [buildPostImportRow ({ rows: [buildPostImportRow ({
sourceRow: 1, sourceRow: 1,
attributes: { title: 'old title' }, attributes: { title: 'old title', duration: '2' },
importStatus: 'failed', importStatus: 'failed',
recoverable: true, recoverable: true,
importErrors: { base: ['failed'] } })] }) importErrors: { base: ['failed'] } })] })
@@ -198,13 +209,13 @@ describe ('PostImportReviewPage', () => {
.mockResolvedValueOnce ({ .mockResolvedValueOnce ({
rows: [buildPostImportRow ({ rows: [buildPostImportRow ({
sourceRow: 1, sourceRow: 1,
attributes: { title: 'edited title' }, attributes: { title: 'edited title', duration: '2' },
importStatus: 'pending', importStatus: 'pending',
recoverable: true })] }) recoverable: true })] })
.mockResolvedValueOnce ({ .mockResolvedValueOnce ({
rows: [buildPostImportRow ({ rows: [buildPostImportRow ({
sourceRow: 1, sourceRow: 1,
attributes: { title: 'edited title' }, attributes: { title: 'edited title', duration: '2' },
importStatus: 'pending', importStatus: 'pending',
recoverable: true })] }) recoverable: true })] })
.mockResolvedValueOnce ({ .mockResolvedValueOnce ({
@@ -232,24 +243,12 @@ describe ('PostImportReviewPage', () => {
await actions.find (action => action.label === '編輯内容を保存')?.onSelect () await actions.find (action => action.label === '編輯内容を保存')?.onSelect ()
}) })
render ( renderReviewPage ()
<HelmetProvider>
<MemoryRouter initialEntries={['/posts/import/review-edit-retry/review']}>
<Routes>
<Route
path="/posts/import/:sessionId/review"
element={<PostImportReviewPage user={buildUser ()}/>}/>
<Route
path="/posts/import/:sessionId/result"
element={<div>RESULT ROUTE</div>}/>
</Routes>
</MemoryRouter>
</HelmetProvider>)
fireEvent.click (screen.getByRole ('button', { name: '編輯' })) fireEvent.click (screen.getByRole ('button', { name: '編輯' }))
await waitFor (() => { await waitFor (() => {
const saved = loadPostImportSession ('review-edit-retry') const saved = loadPostImportSession ()
expect (saved?.rows[0]?.attributes.title).toBe ('edited title') expect (saved?.rows[0]?.attributes.title).toBe ('edited title')
expect (saved?.rows[0]?.importStatus).toBe ('pending') expect (saved?.rows[0]?.importStatus).toBe ('pending')
}) })
@@ -259,11 +258,95 @@ describe ('PostImportReviewPage', () => {
await waitFor (() => { await waitFor (() => {
expect (api.apiPost.mock.calls[1]?.[1]?.rows?.[0]?.attributes?.title) expect (api.apiPost.mock.calls[1]?.[1]?.rows?.[0]?.attributes?.title)
.toBe ('edited title') .toBe ('edited title')
expect (api.apiPost.mock.calls[1]?.[1]?.rows?.[0]?.attributes?.duration)
.toBe ('2')
})
})
it ('retries only failed rows and does not resend created or skipped rows', async () => {
savePostImportSession ({
source: 'https://example.com/post',
repairMode: 'failed',
rows: [
buildPostImportRow ({
sourceRow: 1,
importStatus: 'created',
createdPostId: 1 }),
buildPostImportRow ({
sourceRow: 2,
importStatus: 'skipped',
skipReason: 'existing',
existingPostId: 2 }),
buildPostImportRow ({
sourceRow: 3,
importStatus: 'failed',
recoverable: true,
importErrors: { base: ['failed'] } })] })
api.apiPost
.mockResolvedValueOnce ({
rows: [buildPostImportRow ({
sourceRow: 3,
importStatus: 'pending',
recoverable: true })] })
.mockResolvedValueOnce ({
created: 0,
skipped: 1,
failed: 0,
rows: [{
sourceRow: 3,
status: 'skipped',
existingPostId: 9 }] })
renderReviewPage ()
fireEvent.click (screen.getByRole ('button', { name: '再試行' }))
await waitFor (() => {
expect (api.apiPost.mock.calls[1]?.[1]?.rows).toEqual ([
expect.objectContaining ({ sourceRow: 3 })])
})
})
it ('navigates to /posts when the last failed row completes', async () => {
savePostImportSession ({
source: 'https://example.com/post',
repairMode: 'failed',
rows: [
buildPostImportRow ({
sourceRow: 1,
importStatus: 'created',
createdPostId: 1 }),
buildPostImportRow ({
sourceRow: 2,
importStatus: 'failed',
recoverable: true,
importErrors: { base: ['failed'] } })] })
api.apiPost
.mockResolvedValueOnce ({
rows: [buildPostImportRow ({
sourceRow: 2,
importStatus: 'pending',
recoverable: true })] })
.mockResolvedValueOnce ({
created: 1,
skipped: 0,
failed: 0,
rows: [{
sourceRow: 2,
status: 'created',
post: { id: 2 } }] })
renderReviewPage ()
fireEvent.click (screen.getByRole ('button', { name: '再試行' }))
await waitFor (() => {
expect (screen.getByText ('POSTS ROUTE')).toBeInTheDocument ()
}) })
}) })
it ('sorts recoverable pending validation rows to the top in repair mode', () => { it ('sorts recoverable pending validation rows to the top in repair mode', () => {
savePostImportSession ('review-repair-sort', { savePostImportSession ({
source: '', source: '',
repairMode: 'failed', repairMode: 'failed',
rows: [ rows: [
@@ -277,16 +360,7 @@ describe ('PostImportReviewPage', () => {
recoverable: true, recoverable: true,
validationErrors: { title: ['invalid'] } })] }) validationErrors: { title: ['invalid'] } })] })
const { container } = render ( const { container } = renderReviewPage ()
<HelmetProvider>
<MemoryRouter initialEntries={['/posts/import/review-repair-sort/review']}>
<Routes>
<Route
path="/posts/import/:sessionId/review"
element={<PostImportReviewPage user={buildUser ()}/>}/>
</Routes>
</MemoryRouter>
</HelmetProvider>)
const titles = Array.from (container.querySelectorAll ('.line-clamp-2')).map ( const titles = Array.from (container.querySelectorAll ('.line-clamp-2')).map (
node => node.textContent) node => node.textContent)
+251 -107
ファイルの表示
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { Helmet } from 'react-helmet-async' import { Helmet } from 'react-helmet-async'
import { useNavigate, useParams } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import PageTitle from '@/components/common/PageTitle' import PageTitle from '@/components/common/PageTitle'
import MainArea from '@/components/layout/MainArea' import MainArea from '@/components/layout/MainArea'
@@ -11,10 +11,12 @@ import { toast } from '@/components/ui/use-toast'
import { SITE_TITLE } from '@/config' import { SITE_TITLE } from '@/config'
import { apiPost } from '@/lib/api' import { apiPost } from '@/lib/api'
import useDialogue from '@/lib/dialogues/useDialogue' import useDialogue from '@/lib/dialogues/useDialogue'
import { canEditContent } from '@/lib/users' import { clearPostImportSession,
import { loadPostImportSession, clearPostImportSourceDraft,
loadPostImportSession,
buildNextEditedRow, buildNextEditedRow,
canEditReviewRow, canEditReviewRow,
canRetryResultRow,
creatableImportRows, creatableImportRows,
hasExactSourceRows, hasExactSourceRows,
initialisePreviewRows, initialisePreviewRows,
@@ -24,8 +26,11 @@ import { loadPostImportSession,
processableImportRows, processableImportRows,
replaceImportRow, replaceImportRow,
resultRepairMode, resultRepairMode,
resultRowMessages,
reviewSummaryCounts, reviewSummaryCounts,
retryImportRow,
savePostImportSession } from '@/lib/postImportSession' savePostImportSession } from '@/lib/postImportSession'
import { canEditContent } from '@/lib/users'
import Forbidden from '@/pages/Forbidden' import Forbidden from '@/pages/Forbidden'
import type { FC } from 'react' import type { FC } from 'react'
@@ -49,31 +54,23 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
const editable = canEditContent (user) const editable = canEditContent (user)
const dialogue = useDialogue () const dialogue = useDialogue ()
const navigate = useNavigate () const navigate = useNavigate ()
const { sessionId } = useParams ()
const [session, setSession] = useState<PostImportSession | null> (null) const [session, setSession] = useState<PostImportSession | null> (null)
const [loading, setLoading] = useState (false) const [loading, setLoading] = useState (false)
const [missing, setMissing] = useState (false) const [loadingRow, setLoadingRow] = useState<number | null> (null)
const [editingRow, setEditingRow] = useState<PostImportRow | null> (null) const [editingRow, setEditingRow] = useState<PostImportRow | null> (null)
const sessionRef = useRef<PostImportSession | null> (null) const sessionRef = useRef<PostImportSession | null> (null)
useEffect (() => { useEffect (() => {
if (sessionId == null) const loaded = loadPostImportSession (message =>
return
const loaded = loadPostImportSession (sessionId, message =>
toast ({ title: '取込状態を復元できませんでした', description: message })) toast ({ title: '取込状態を復元できませんでした', description: message }))
if (loaded == null)
{
navigate ('/posts/new', { replace: true })
return
}
setSession (loaded) setSession (loaded)
setMissing (loaded == null) }, [navigate])
}, [sessionId])
useEffect (() => {
if (sessionId == null || session == null)
return
savePostImportSession (sessionId, session, message =>
toast ({ title: '取込状態を保存できませんでした', description: message }))
}, [session, sessionId])
useEffect (() => { useEffect (() => {
sessionRef.current = session sessionRef.current = session
@@ -96,6 +93,22 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
return aRepair - bRepair || a.sourceRow - b.sourceRow return aRepair - bRepair || a.sourceRow - b.sourceRow
})) }))
: rows : rows
const busy = loading || loadingRow != null
const persistSession = (nextSession: PostImportSession) => {
sessionRef.current = nextSession
setSession (nextSession)
savePostImportSession (nextSession, message =>
toast ({ title: '取込状態を保存できませんでした', description: message }))
}
const finishImport = () => {
clearPostImportSession (message =>
toast ({ title: '取込状態を削除できませんでした', description: message }))
clearPostImportSourceDraft (message =>
toast ({ title: '入力内容を削除できませんでした', description: message }))
navigate ('/posts')
}
useEffect (() => { useEffect (() => {
if (editingRow == null || session?.repairMode !== 'failed') if (editingRow == null || session?.repairMode !== 'failed')
@@ -108,8 +121,8 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
const saveDraft = async ( const saveDraft = async (
row: PostImportRow, row: PostImportRow,
{ draft, resetRequested }: { { draft, resetRequested }: {
draft: PostImportRowDraft draft: PostImportRowDraft
resetRequested: boolean }, resetRequested: boolean },
): Promise<{ saved: boolean ): Promise<{ saved: boolean
row: PostImportRow | null }> => { row: PostImportRow | null }> => {
const currentSession = sessionRef.current const currentSession = sessionRef.current
@@ -133,55 +146,52 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
: row : row
const urlChanged = draft.url !== baseRow.url const urlChanged = draft.url !== baseRow.url
const nextRow = buildNextEditedRow (baseRow, draft, urlChanged) const nextRow = buildNextEditedRow (baseRow, draft, urlChanged)
const nextRows = currentSession.rows.map (currentRow =>
const nextRows = currentSession.rows.map (row => currentRow.sourceRow === baseRow.sourceRow
row.sourceRow === baseRow.sourceRow
? nextRow ? nextRow
: row) : currentRow)
try try
{ {
const validated = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/validate', { const validated = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/validate', {
rows: rows:
nextRows nextRows
.filter (row => row.importStatus !== 'created') .filter (currentRow => currentRow.importStatus !== 'created')
.map (row => ({ sourceRow: row.sourceRow, .map (currentRow => ({
url: row.url, sourceRow: currentRow.sourceRow,
attributes: row.attributes, url: currentRow.url,
provenance: row.provenance, attributes: currentRow.attributes,
tagSources: row.tagSources, provenance: currentRow.provenance,
metadataUrl: row.metadataUrl })), tagSources: currentRow.tagSources,
metadataUrl: currentRow.metadataUrl })),
changed_row: urlChanged ? baseRow.sourceRow : -1 }) changed_row: urlChanged ? baseRow.sourceRow : -1 })
const validatedRows = initialisePreviewRows (validated.rows) const validatedRows = initialisePreviewRows (validated.rows)
const target = validatedRows.find ( const target = validatedRows.find (
validatedRow => validatedRow.sourceRow === baseRow.sourceRow) validatedRow => validatedRow.sourceRow === baseRow.sourceRow)
const latestSession = sessionRef.current const latestSession = sessionRef.current
if (latestSession == null) if (latestSession == null)
return { saved: false, row: null } return { saved: false, row: null }
if (target == null) if (target == null)
{ {
const restoredRows = replaceImportRow (latestSession.rows, row) const restoredRows = replaceImportRow (latestSession.rows, row)
const restoredSession = { persistSession ({
...latestSession, ...latestSession,
rows: restoredRows, rows: restoredRows,
repairMode: resultRepairMode (restoredRows) } repairMode: resultRepairMode (restoredRows) })
sessionRef.current = restoredSession toast ({ title: '行の再検証結果が不完全でした' })
setSession (restoredSession) return { saved: false, row: null }
toast ({ title: '行の再検証結果が不完全でした' }) }
return { saved: false, row: null }
}
const editedRows = replaceImportRow (latestSession.rows, nextRow) const editedRows = replaceImportRow (latestSession.rows, nextRow)
const rows = mergeValidatedImportRow (editedRows, target) const mergedRows = mergeValidatedImportRow (editedRows, target)
const nextSession = { const nextSession = {
...latestSession, ...latestSession,
rows, rows: mergedRows,
repairMode: resultRepairMode (rows) } repairMode: resultRepairMode (mergedRows) }
sessionRef.current = nextSession persistSession (nextSession)
setSession (nextSession)
const mergedTarget = const mergedTarget =
rows.find (mergedRow => mergedRow.sourceRow === baseRow.sourceRow) mergedRows.find (mergedRow => mergedRow.sourceRow === baseRow.sourceRow)
?? target ?? target
if (Object.keys (mergedTarget.validationErrors).length > 0) if (Object.keys (mergedTarget.validationErrors).length > 0)
return { saved: false, row: mergedTarget } return { saved: false, row: mergedTarget }
return { saved: true, row: null } return { saved: true, row: null }
} }
catch catch
@@ -220,49 +230,197 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
finally finally
{ {
setEditingRow (current => setEditingRow (current =>
current?.sourceRow === row.sourceRow current?.sourceRow === row.sourceRow
? null ? null
: current) : current)
}
}
const retry = async (sourceRow: number) => {
if (busy)
return
const initialSession = sessionRef.current
if (initialSession == null)
return
setLoadingRow (sourceRow)
const originalRow = initialSession.rows.find (row => row.sourceRow === sourceRow)
if (originalRow == null)
{
setLoadingRow (null)
return
}
try
{
const pendingRows = retryImportRow (initialSession.rows, sourceRow)
const pendingSession = { ...initialSession, rows: pendingRows }
persistSession (pendingSession)
const requestedRows = pendingRows.filter (row => row.importStatus !== 'created')
const validated = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/validate', {
rows: requestedRows.map (row => ({
sourceRow: row.sourceRow,
url: row.url,
attributes: row.attributes,
provenance: row.provenance,
tagSources: row.tagSources,
metadataUrl: row.metadataUrl })),
changed_row: -1 })
const validatedRows = initialisePreviewRows (validated.rows)
if (!(hasExactSourceRows (requestedRows.map (row => row.sourceRow), validatedRows)))
{
const latest = sessionRef.current ?? pendingSession
const restoredRows = replaceImportRow (latest.rows, originalRow)
persistSession ({
...latest,
rows: restoredRows,
repairMode: resultRepairMode (restoredRows) })
toast ({ title: '再検証結果が不完全でした' })
return
}
const validatedTarget = validatedRows.find (row => row.sourceRow === sourceRow)
if (validatedTarget == null)
{
const latest = sessionRef.current ?? pendingSession
const restoredRows = replaceImportRow (latest.rows, originalRow)
persistSession ({
...latest,
rows: restoredRows,
repairMode: resultRepairMode (restoredRows) })
toast ({ title: '再検証結果が不完全でした' })
return
}
const latestAfterValidate = sessionRef.current ?? pendingSession
const mergedValidatedRows = mergeValidatedImportRow (
latestAfterValidate.rows,
validatedTarget)
const nextSession = {
...latestAfterValidate,
rows: mergedValidatedRows,
repairMode: resultRepairMode (mergedValidatedRows) }
persistSession (nextSession)
const target = mergedValidatedRows.find (row => row.sourceRow === sourceRow)
if (target == null)
{
const restoredRows = replaceImportRow (latestAfterValidate.rows, originalRow)
persistSession ({
...latestAfterValidate,
rows: restoredRows,
repairMode: resultRepairMode (restoredRows) })
toast ({ title: '再検証結果が不完全でした' })
return
}
if (Object.keys (target.validationErrors ?? { }).length > 0)
{
void editRow (target)
return
}
const result = await apiPost<{
created: number
skipped: number
failed: number
rows: PostImportResultRow[] }> ('/posts/import', {
rows: [{
sourceRow: target.sourceRow,
url: target.url,
attributes: target.attributes,
provenance: target.provenance,
tagSources: target.tagSources,
metadataUrl: target.metadataUrl }] })
if (!(hasExactSourceRows ([sourceRow], result.rows)))
{
const latest = sessionRef.current ?? nextSession
const restoredRows = replaceImportRow (latest.rows, originalRow)
persistSession ({
...latest,
rows: restoredRows,
repairMode: resultRepairMode (restoredRows) })
toast ({ title: '登録結果が不完全でした' })
return
}
const latestAfterImport = sessionRef.current ?? nextSession
const mergedRows = mergeImportResults (latestAfterImport.rows, result.rows)
const recoverableRows = result.rows.filter (row =>
row.status === 'failed'
&& row.recoverable
&& Object.keys (row.errors ?? { }).length > 0)
const nextRows = mergedRows.map ((row): PostImportRow => {
const recoverable = recoverableRows.find (
failedRow => failedRow.sourceRow === row.sourceRow)
if (recoverable == null)
return row
return {
...row,
importStatus: 'pending',
recoverable: true,
validationErrors: recoverable.errors ?? { },
importErrors: undefined }
})
const resultSession = {
...latestAfterImport,
rows: nextRows,
repairMode: resultRepairMode (nextRows) }
persistSession (resultSession)
if (nextRows.every (row =>
row.importStatus === 'created' || row.importStatus === 'skipped'))
{
finishImport ()
return
}
}
catch
{
const latest = sessionRef.current ?? initialSession
const restoredRows = replaceImportRow (latest.rows, originalRow)
persistSession ({
...latest,
rows: restoredRows,
repairMode: resultRepairMode (restoredRows) })
toast ({ title: '再試行に失敗しました' })
}
finally
{
setLoadingRow (null)
} }
} }
const submit = async () => { const submit = async () => {
const currentSession = sessionRef.current const currentSession = sessionRef.current
if (sessionId == null || currentSession == null || processable.length === 0) if (currentSession == null || processable.length === 0 || busy)
return return
setLoading (true) setLoading (true)
try try
{ {
const validatableRows = const validatableRows =
currentSession.rows.filter (row => row.importStatus !== 'created') currentSession.rows.filter (row => row.importStatus !== 'created')
const validated = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/validate', { const validated = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/validate', {
rows: rows:
validatableRows.map (row => ({ sourceRow: row.sourceRow, validatableRows.map (row => ({
url: row.url, sourceRow: row.sourceRow,
attributes: row.attributes, url: row.url,
provenance: row.provenance, attributes: row.attributes,
tagSources: row.tagSources, provenance: row.provenance,
metadataUrl: row.metadataUrl })), tagSources: row.tagSources,
metadataUrl: row.metadataUrl })),
changed_row: -1 }) changed_row: -1 })
const validatedRows = initialisePreviewRows (validated.rows) const validatedRows = initialisePreviewRows (validated.rows)
const expectedSourceRows = validatableRows.map (row => row.sourceRow) const expectedSourceRows = validatableRows.map (row => row.sourceRow)
if (!(hasExactSourceRows (expectedSourceRows, validatedRows))) if (!(hasExactSourceRows (expectedSourceRows, validatedRows)))
{ {
toast ({ title: '再検証結果が不完全でした' }) toast ({ title: '再検証結果が不完全でした' })
return return
} }
const latestAfterValidate = sessionRef.current ?? currentSession const latestAfterValidate = sessionRef.current ?? currentSession
const mergedRows = mergeValidatedImportRows (latestAfterValidate.rows, validatedRows) const mergedRows = mergeValidatedImportRows (latestAfterValidate.rows, validatedRows)
const firstInvalid = mergedRows.find (row => Object.keys (row.validationErrors).length > 0) const firstInvalid = mergedRows.find (row => Object.keys (row.validationErrors).length > 0)
if (firstInvalid != null) if (firstInvalid != null)
{ {
const nextSession = { persistSession ({
...latestAfterValidate, ...latestAfterValidate,
rows: mergedRows, rows: mergedRows,
repairMode: resultRepairMode (mergedRows) } repairMode: resultRepairMode (mergedRows) })
sessionRef.current = nextSession
setSession (nextSession)
void editRow (firstInvalid) void editRow (firstInvalid)
return return
} }
@@ -270,12 +428,7 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
...latestAfterValidate, ...latestAfterValidate,
rows: mergedRows, rows: mergedRows,
repairMode: resultRepairMode (mergedRows) } repairMode: resultRepairMode (mergedRows) }
sessionRef.current = validatedSession persistSession (validatedSession)
setSession (validatedSession)
const savedValidated = savePostImportSession (sessionId, validatedSession, message =>
toast ({ title: '取込状態を保存できませんでした', description: message }))
if (!(savedValidated))
return
const result = await apiPost<{ const result = await apiPost<{
created: number created: number
@@ -302,7 +455,7 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
&& row.recoverable && row.recoverable
&& Object.keys (row.errors ?? { }).length > 0) && Object.keys (row.errors ?? { }).length > 0)
const nextRows = mergedResults.map ((row): PostImportRow => { const nextRows = mergedResults.map ((row): PostImportRow => {
const recoverable = recoverableRows.find (rr => rr.sourceRow === row.sourceRow) const recoverable = recoverableRows.find (failedRow => failedRow.sourceRow === row.sourceRow)
if (recoverable == null) if (recoverable == null)
return row return row
return { return {
@@ -316,13 +469,12 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
...latestAfterImport, ...latestAfterImport,
rows: nextRows, rows: nextRows,
repairMode: resultRepairMode (nextRows) } repairMode: resultRepairMode (nextRows) }
sessionRef.current = nextSession persistSession (nextSession)
setSession (nextSession) if (nextRows.every (row =>
const saved = savePostImportSession (sessionId, nextSession, message => row.importStatus === 'created' || row.importStatus === 'skipped'))
toast ({ title: '取込状態を保存できませんでした', description: message })) {
if (!(saved)) finishImport ()
return }
navigate (`/posts/import/${ sessionId }/result`)
} }
catch catch
{ {
@@ -337,19 +489,8 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
if (!(editable)) if (!(editable))
return <Forbidden/> return <Forbidden/>
if (missing || sessionId == null || session == null) if (session == null)
{ return null
return (
<MainArea>
<div className="mx-auto max-w-4xl space-y-4 p-4">
<PageTitle>稿</PageTitle>
<div className="text-red-700 dark:text-red-300"></div>
<Button type="button" onClick={() => navigate ('/posts/import')}>
URL
</Button>
</div>
</MainArea>)
}
return ( return (
<> <>
@@ -366,19 +507,22 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
<div key={row.sourceRow} id={`post-import-row-${ row.sourceRow }`}> <div key={row.sourceRow} id={`post-import-row-${ row.sourceRow }`}>
<PostImportRowSummary <PostImportRowSummary
row={row} row={row}
editDisabled={loading} editDisabled={busy}
onEdit={() => void editRow (row)}/> retryDisabled={busy}
onEdit={() => void editRow (row)}
onRetry={canRetryResultRow (row) ? () => void retry (row.sourceRow) : undefined}
rowMessages={resultRowMessages (row)}/>
</div>))} </div>))}
</div> </div>
</div> </div>
</MainArea> </MainArea>
<PostImportFooter <PostImportFooter
loading={loading} loading={busy}
processableCount={processable.length} processableCount={processable.length}
creatableCount={creatable.length} creatableCount={creatable.length}
skipPlannedCount={counts.skipPlanned} skipPlannedCount={counts.skipPlanned}
onBack={() => navigate ('/posts/import')} onBack={() => navigate ('/posts/new')}
onSubmit={submit}/> onSubmit={submit}/>
</>) </>)
} }
@@ -389,12 +533,12 @@ const PostImportFooter = (
creatableCount, creatableCount,
skipPlannedCount, skipPlannedCount,
onBack, onBack,
onSubmit }: { loading: boolean onSubmit }: { loading: boolean
processableCount: number processableCount: number
creatableCount: number creatableCount: number
skipPlannedCount: number skipPlannedCount: number
onBack: () => void onBack: () => void
onSubmit: () => void }, onSubmit: () => void },
) => ( ) => (
<div <div
className="shrink-0 border-t bg-white/95 p-4 backdrop-blur className="shrink-0 border-t bg-white/95 p-4 backdrop-blur
+2 -3
ファイルの表示
@@ -82,12 +82,11 @@ describe ('PostImportSourcePage', () => {
fireEvent.click (screen.getByRole ('button', { name: '次へ' })) fireEvent.click (screen.getByRole ('button', { name: '次へ' }))
await waitFor (() => { await waitFor (() => {
expect (router.navigate).toHaveBeenCalledWith ( expect (router.navigate).toHaveBeenCalledWith ('/posts/new/review')
expect.stringMatching (/^\/posts\/import\/[^/]+\/review$/))
}) })
const sessionKeys = Array.from ({ length: sessionStorage.length }, (_, index) => const sessionKeys = Array.from ({ length: sessionStorage.length }, (_, index) =>
sessionStorage.key (index)).filter ( sessionStorage.key (index)).filter (
key => key?.startsWith ('post-import-session:')) key => key?.startsWith ('post-import-session:'))
expect (sessionKeys).toHaveLength (1) expect (sessionKeys).toEqual (['post-import-session:current'])
}) })
}) })
+13 -5
ファイルの表示
@@ -15,8 +15,9 @@ import { apiPost, isApiError } from '@/lib/api'
import { canEditContent } from '@/lib/users' import { canEditContent } from '@/lib/users'
import { countImportSourceLines, import { countImportSourceLines,
cleanupExpiredPostImportSessions, cleanupExpiredPostImportSessions,
createPostImportSessionId, clearPostImportSession,
initialisePreviewRows, initialisePreviewRows,
loadPostImportSession,
loadPostImportSourceDraft, loadPostImportSourceDraft,
savePostImportSession, savePostImportSession,
savePostImportSourceDraft, savePostImportSourceDraft,
@@ -67,11 +68,18 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
cleanupExpiredPostImportSessions (message => cleanupExpiredPostImportSessions (message =>
toast ({ title: '保存済みデータを整理できませんでした', description: message })) toast ({ title: '保存済みデータを整理できませんでした', description: message }))
const currentSession = loadPostImportSession (message =>
toast ({ title: '保存済み入力を復元できませんでした', description: message }))
const draft = loadPostImportSourceDraft (message => const draft = loadPostImportSourceDraft (message =>
toast ({ title: '保存済み入力を復元できませんでした', description: message })) toast ({ title: '保存済み入力を復元できませんでした', description: message }))
if (!(editedRef.current)) if (!(editedRef.current))
setSource (current => current === '' ? draft.source : current) {
setSource (current =>
current === ''
? (currentSession?.source || draft.source)
: current)
}
}, []) }, [])
useEffect (() => { useEffect (() => {
@@ -117,16 +125,16 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
setSourceIssues (urlIssues) setSourceIssues (urlIssues)
return return
} }
const sessionId = createPostImportSessionId () clearPostImportSession (message =>
toast ({ title: '取込状態を保存できませんでした', description: message }))
const saved = savePostImportSession ( const saved = savePostImportSession (
sessionId,
{ source, { source,
rows: initialisePreviewRows (data.rows), rows: initialisePreviewRows (data.rows),
repairMode: 'all' }, repairMode: 'all' },
message => toast ({ title: '取込状態を保存できませんでした', description: message })) message => toast ({ title: '取込状態を保存できませんでした', description: message }))
if (!(saved)) if (!(saved))
return return
navigate (`/posts/import/${ sessionId }/review`) navigate ('/posts/new/review')
} }
catch (requestError) catch (requestError)
{ {
+45 -35
ファイルの表示
@@ -37,11 +37,14 @@ describe ('PostNewPage', () => {
renderWithProviders (<PostNewPage user={buildUser ({ role: 'member' })}/>) renderWithProviders (<PostNewPage user={buildUser ({ role: 'member' })}/>)
const textboxes = screen.getAllByRole ('textbox') fireEvent.change (screen.getByLabelText ('URL'), {
fireEvent.change (textboxes[0], { target: { value: 'https://example.com/post' } }) target: { value: 'https://example.com/post' } })
fireEvent.change (textboxes[1], { target: { value: '投稿タイトル' } }) fireEvent.change (screen.getByLabelText ('タイトル'), {
fireEvent.change (textboxes[3], { target: { value: '1 2' } }) target: { value: '投稿タイトル' } })
fireEvent.change (textboxes[4], { target: { value: 'tag1 tag2' } }) fireEvent.change (screen.getByLabelText ('親投稿'), {
target: { value: '1 2' } })
fireEvent.change (screen.getByLabelText ('タグ'), {
target: { value: 'tag1 tag2' } })
fireEvent.click (screen.getByRole ('button', { name: '追加' })) fireEvent.click (screen.getByRole ('button', { name: '追加' }))
await waitFor (() => { await waitFor (() => {
@@ -59,24 +62,27 @@ describe ('PostNewPage', () => {
expect (toastApi.toast).toHaveBeenCalledWith ({ title: '投稿成功!' }) expect (toastApi.toast).toHaveBeenCalledWith ({ title: '投稿成功!' })
}) })
it ('preserves duration while the video tag is temporarily removed', () => { it ('shows the shared creation field order without duration or unrequested helper copy', () => {
api.apiGet.mockResolvedValue ([]) api.apiGet.mockResolvedValue ([])
renderWithProviders (<PostNewPage user={buildUser ({ role: 'member' })}/>) const { container } =
renderWithProviders (<PostNewPage user={buildUser ({ role: 'member' })}/>)
const tags = screen.getAllByRole ('textbox')[4] const labels = Array.from (container.querySelectorAll ('label'))
fireEvent.change (tags, { target: { value: '動画' } }) .map (node => node.textContent?.trim ())
fireEvent.change (
screen.getByPlaceholderText ('例: 2 / 2.5 / 1:23'),
{ target: { value: '180.5' } })
fireEvent.change (tags, { target: { value: 'general-tag' } }) expect (labels.slice (0, 6)).toEqual ([
'URL',
'サムネール',
'タイトル',
'オリジナルの作成日時',
'タグ',
'親投稿'])
expect (screen.queryByPlaceholderText ('例: 2 / 2.5 / 1:23')).not.toBeInTheDocument () expect (screen.queryByPlaceholderText ('例: 2 / 2.5 / 1:23')).not.toBeInTheDocument ()
expect (screen.queryByText ('必要なタイミングで URL から取得できます.'))
fireEvent.change (tags, { .not.toBeInTheDocument ()
target: { value: '動画 general-tag' }, expect (screen.getAllByRole ('button', { name: '取得' })).toHaveLength (2)
}) expect (screen.queryByRole ('button', { name: '変更をリセット' })).not.toBeInTheDocument ()
expect (screen.getByPlaceholderText ('例: 2 / 2.5 / 1:23')).toHaveValue ('180.5')
}) })
it ('shows 422 validation errors for post fields', async () => { it ('shows 422 validation errors for post fields', async () => {
@@ -96,30 +102,32 @@ describe ('PostNewPage', () => {
renderWithProviders (<PostNewPage user={buildUser ({ role: 'member' })}/>) renderWithProviders (<PostNewPage user={buildUser ({ role: 'member' })}/>)
const textboxes = screen.getAllByRole ('textbox') fireEvent.change (screen.getByLabelText ('URL'), {
fireEvent.change (textboxes[0], { target: { value: 'https://example.com/post' } }) target: { value: 'https://example.com/post' } })
fireEvent.change (textboxes[1], { target: { value: '投稿タイトル' } }) fireEvent.change (screen.getByLabelText ('タイトル'), {
fireEvent.change (textboxes[4], { target: { value: 'nico:nico_tag' } }) target: { value: '投稿タイトル' } })
fireEvent.change (screen.getByLabelText ('タグ'), {
target: { value: 'nico:nico_tag' } })
fireEvent.click (screen.getByRole ('button', { name: '追加' })) fireEvent.click (screen.getByRole ('button', { name: '追加' }))
expect (await screen.findByText ('投稿内容を確認してください.')).toBeInTheDocument () expect (await screen.findByText ('投稿内容を確認してください.')).toBeInTheDocument ()
expect (screen.getByText ('ニコニコ・タグは直接指定できません.')).toBeInTheDocument () expect (screen.getByText ('ニコニコ・タグは直接指定できません.')).toBeInTheDocument ()
expect (screen.getAllByRole ('textbox')[4]).toHaveAttribute ('aria-invalid', 'true') expect (screen.getByLabelText ('タグ')).toHaveAttribute ('aria-invalid', 'true')
}) })
it ('uses the shared duration, tags, and datetime value contract for post submission', async () => { it ('keeps shared tags and datetime submission values without showing duration', async () => {
api.apiPost.mockResolvedValueOnce ({}) api.apiPost.mockResolvedValueOnce ({})
api.apiGet.mockResolvedValue ([]) api.apiGet.mockResolvedValue ([])
const { container } = renderWithProviders (<PostNewPage user={buildUser ({ role: 'member' })}/>) const { container } = renderWithProviders (
<PostNewPage user={buildUser ({ role: 'member' })}/>)
const textboxes = screen.getAllByRole ('textbox') fireEvent.change (screen.getByLabelText ('URL'), {
fireEvent.change (textboxes[0], { target: { value: 'https://example.com/post' } }) target: { value: 'https://example.com/post' } })
fireEvent.change (textboxes[1], { target: { value: '投稿タイトル' } }) fireEvent.change (screen.getByLabelText ('タイトル'), {
fireEvent.change (textboxes[4], { target: { value: '動画 tag1 tag2' } }) target: { value: '投稿タイトル' } })
fireEvent.change ( fireEvent.change (screen.getByLabelText ('タグ'), {
screen.getByPlaceholderText ('例: 2 / 2.5 / 1:23'), target: { value: '動画 tag1 tag2' } })
{ target: { value: '2.5' } })
const datetimeInputs = container.querySelectorAll ('input[type="datetime-local"]') const datetimeInputs = container.querySelectorAll ('input[type="datetime-local"]')
fireEvent.change (datetimeInputs[0] as HTMLInputElement, { fireEvent.change (datetimeInputs[0] as HTMLInputElement, {
target: { value: '2024-01-01T12:34' } }) target: { value: '2024-01-01T12:34' } })
@@ -129,13 +137,15 @@ describe ('PostNewPage', () => {
const formData = api.apiPost.mock.calls[0]?.[1] as FormData const formData = api.apiPost.mock.calls[0]?.[1] as FormData
expect (container.querySelector ('input[type="file"]')).not.toBeNull () expect (container.querySelector ('input[type="file"]')).not.toBeNull ()
expect (formData.get ('duration')).toBe ('2.5') expect (formData.get ('duration')).toBeNull ()
expect (formData.get ('tags')).toBe ('動画 tag1 tag2') expect (formData.get ('tags')).toBe ('動画 tag1 tag2')
expect (formData.get ('original_created_from')).toBe ( expect (formData.get ('original_created_from')).toBe (
toMinutePrecisionIsoUtc ('2024-01-01T12:34')) toMinutePrecisionIsoUtc ('2024-01-01T12:34'))
}) })
it ('shows deduplicated original-created endpoint errors on the shared datetime field', async () => { it (
'shows deduplicated original-created endpoint errors on the shared datetime field',
async () => {
api.apiGet.mockResolvedValue ([]) api.apiGet.mockResolvedValue ([])
api.isApiError.mockReturnValue (true) api.isApiError.mockReturnValue (true)
api.apiPost.mockRejectedValueOnce ({ api.apiPost.mockRejectedValueOnce ({
@@ -158,5 +168,5 @@ describe ('PostNewPage', () => {
expect (await screen.findByText ('日時を確認してください.')).toBeInTheDocument () expect (await screen.findByText ('日時を確認してください.')).toBeInTheDocument ()
expect (screen.getByText ('終了を確認してください.')).toBeInTheDocument () expect (screen.getByText ('終了を確認してください.')).toBeInTheDocument ()
expect (screen.getAllByText ('日時を確認してください.')).toHaveLength (1) expect (screen.getAllByText ('日時を確認してください.')).toHaveLength (1)
}) })
}) })
+73 -101
ファイルの表示
@@ -1,14 +1,12 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { Helmet } from 'react-helmet-async' import { Helmet } from 'react-helmet-async'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
import FieldError from '@/components/common/FieldError' import FieldError from '@/components/common/FieldError'
import Form from '@/components/common/Form' import Form from '@/components/common/Form'
import PageTitle from '@/components/common/PageTitle' import PageTitle from '@/components/common/PageTitle'
import MainArea from '@/components/layout/MainArea' import MainArea from '@/components/layout/MainArea'
import PostDurationField from '@/components/posts/PostDurationField' import PostCreationDataFields from '@/components/posts/PostCreationDataFields'
import PostTagsField from '@/components/posts/PostTagsField'
import PostTextField from '@/components/posts/PostTextField' import PostTextField from '@/components/posts/PostTextField'
import PostThumbnailPreview from '@/components/posts/PostThumbnailPreview' import PostThumbnailPreview from '@/components/posts/PostThumbnailPreview'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
@@ -27,12 +25,9 @@ type Props = { user: User | null }
type PostFormField = type PostFormField =
'url' | 'title' | 'tags' | 'parentPostIds' 'url' | 'title' | 'tags' | 'parentPostIds'
| 'videoMs' | 'originalCreatedAt' | 'originalCreatedFrom' | 'originalCreatedAt' | 'originalCreatedFrom'
| 'originalCreatedBefore' | 'thumbnail' | 'originalCreatedBefore' | 'thumbnail'
const groupedMessages = (...values: (string[] | undefined)[]): string[] =>
[...new Set (values.flatMap (value => value ?? []))]
const PostNewPage: FC<Props> = ({ user }) => { const PostNewPage: FC<Props> = ({ user }) => {
const editable = canEditContent (user) const editable = canEditContent (user)
@@ -48,7 +43,6 @@ const PostNewPage: FC<Props> = ({ user }) => {
useState<string | null> (null) useState<string | null> (null)
const [parentPostIds, setParentPostIds] = useState ('') const [parentPostIds, setParentPostIds] = useState ('')
const [tags, setTags] = useState ('') const [tags, setTags] = useState ('')
const [duration, setDuration] = useState ('')
const [thumbnailFile, setThumbnailFile] = useState<File | null> (null) const [thumbnailFile, setThumbnailFile] = useState<File | null> (null)
const [thumbnailLoading, setThumbnailLoading] = useState (false) const [thumbnailLoading, setThumbnailLoading] = useState (false)
const [thumbnailPreview, setThumbnailPreview] = useState<string> ('') const [thumbnailPreview, setThumbnailPreview] = useState<string> ('')
@@ -57,9 +51,6 @@ const PostNewPage: FC<Props> = ({ user }) => {
const [url, setURL] = useState ('') const [url, setURL] = useState ('')
const thumbnailPreviewRef = useRef ('') const thumbnailPreviewRef = useRef ('')
const videoFlg =
useMemo (() => tags.split (/\s+/).some (tag => tag.replace (/\[.*\]$/, '') === '動画'),
[tags])
const handleSubmit = async () => { const handleSubmit = async () => {
clearValidationErrors () clearValidationErrors ()
@@ -69,8 +60,6 @@ const PostNewPage: FC<Props> = ({ user }) => {
formData.append ('url', url) formData.append ('url', url)
formData.append ('tags', tags) formData.append ('tags', tags)
formData.append ('parent_post_ids', parentPostIds) formData.append ('parent_post_ids', parentPostIds)
if (videoFlg && duration !== '')
formData.append ('duration', duration)
if (thumbnailFile) if (thumbnailFile)
formData.append ('thumbnail', thumbnailFile) formData.append ('thumbnail', thumbnailFile)
if (originalCreatedFrom) if (originalCreatedFrom)
@@ -143,93 +132,76 @@ const PostNewPage: FC<Props> = ({ user }) => {
<PageTitle>稿</PageTitle> <PageTitle>稿</PageTitle>
<FieldError messages={baseErrors}/> <FieldError messages={baseErrors}/>
<PostTextField <PostCreationDataFields
label="URL" url={{
type="url" value: url,
value={url} onChange: setURL,
errors={fieldErrors.url} errors: fieldErrors.url,
placeholder="例:https://www.nicovideo.jp/watch/..." type: 'url',
onChange={setURL}/> placeholder: '例:https://www.nicovideo.jp/watch/...' }}
thumbnailField={
<PostTextField <div className="space-y-2">
label="タイトル" <PostTextField
value={title} label="サムネール"
errors={fieldErrors.title} value={thumbnailFile?.name ?? ''}
disabled={titleLoading} errors={fieldErrors.thumbnail}
placeholder={titleLoading ? 'Loading...' : undefined} disabled
onChange={setTitle} onChange={() => {}}
after={ after={
<div className="flex flex-wrap items-center gap-2 text-sm"> <Button
<span> URL </span> type="button"
<Button variant="outline"
type="button" onClick={() => void fetchThumbnail ()}
variant="outline" disabled={!(url) || thumbnailLoading}>
onClick={() => void fetchTitle ()}
disabled={!(url) || titleLoading}> </Button>}/>
<input
</Button> type="file"
</div>}/> accept="image/*"
onChange={e => {
<div className="space-y-2"> const file = e.target.files?.[0]
<div className="flex flex-wrap items-center gap-2 text-sm"> if (file)
<span> URL </span> {
<Button setThumbnailFile (file)
type="button" setThumbnailPreview (URL.createObjectURL (file))
variant="outline" }
onClick={() => void fetchThumbnail ()} }}/>
disabled={!(url) || thumbnailLoading}> <PostThumbnailPreview
url={thumbnailPreview}
</Button> alt="preview"
</div> className="h-28 w-28"/>
{thumbnailLoading && ( </div>}
<p className="text-gray-500 text-sm">Loading...</p>)} core={{
<PostTextField title: {
label="サムネール" value: title,
value={thumbnailFile?.name ?? ''} onChange: setTitle,
errors={fieldErrors.thumbnail} errors: fieldErrors.title,
disabled disabled: titleLoading,
onChange={() => {}}/> after: (
<input <Button
type="file" type="button"
accept="image/*" variant="outline"
onChange={e => { onClick={() => void fetchTitle ()}
const file = e.target.files?.[0] disabled={!(url) || titleLoading}>
if (file)
{ </Button>) },
setThumbnailFile (file) originalCreated: {
setThumbnailPreview (URL.createObjectURL (file)) originalCreatedFrom,
} setOriginalCreatedFrom,
}}/> originalCreatedBefore,
<PostThumbnailPreview setOriginalCreatedBefore,
url={thumbnailPreview} errors: {
alt="preview" originalCreatedAt: fieldErrors.originalCreatedAt,
className="h-28 w-28"/> originalCreatedFrom: fieldErrors.originalCreatedFrom,
</div> originalCreatedBefore: fieldErrors.originalCreatedBefore } },
tags: {
<PostTextField value: tags,
label="親投稿" onChange: setTags,
value={parentPostIds} errors: fieldErrors.tags },
errors={fieldErrors.parentPostIds} parentPostIds: {
onChange={setParentPostIds}/> value: parentPostIds,
onChange: setParentPostIds,
<PostTagsField tags={tags} setTags={setTags} errors={fieldErrors.tags}/> errors: fieldErrors.parentPostIds } }}/>
{/* オリジナルの作成日時 */}
<PostOriginalCreatedTimeField
originalCreatedFrom={originalCreatedFrom}
setOriginalCreatedFrom={setOriginalCreatedFrom}
originalCreatedBefore={originalCreatedBefore}
setOriginalCreatedBefore={setOriginalCreatedBefore}
errors={groupedMessages (
fieldErrors.originalCreatedAt,
fieldErrors.originalCreatedFrom,
fieldErrors.originalCreatedBefore)}/>
{videoFlg && (
<PostDurationField
value={duration}
errors={fieldErrors.videoMs}
onChange={setDuration}/>)}
{/* 送信 */} {/* 送信 */}
<Button onClick={handleSubmit} <Button onClick={handleSubmit}