このコミットが含まれているのは:
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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/>}/>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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: [
|
||||||
|
|||||||
@@ -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 }
|
||||||
@@ -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
|
||||||
@@ -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 ()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)}/>)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 ()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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}/>
|
||||||
|
|||||||
@@ -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>
|
||||||
</>)
|
</>)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 (),
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|
||||||
|
|||||||
@@ -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: '登録結果が不完全でした' }))
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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'])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
新しい課題から参照
ユーザをブロックする