コミットを比較
7 コミット
| 作成者 | SHA1 | 日付 | |
|---|---|---|---|
| dd2d199d04 | |||
| 0a8ffc38b8 | |||
| 3f75994bd4 | |||
| f91b78bd47 | |||
| 7f8cce39bc | |||
| 5c5a9fa1b0 | |||
| 66c738cbff |
@@ -285,6 +285,13 @@ case 'no':
|
||||
Ruby-style numbered parameter names. Reserve numbered parameters for Ruby.
|
||||
Use a meaningful callback parameter name such as `row`, `item`, `value`,
|
||||
`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
|
||||
that as a design smell and revise the logic to avoid the distinction.
|
||||
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
|
||||
explicitly requires it.
|
||||
- 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
|
||||
test work. When the user asks for tests, keep working and rerun them until
|
||||
they pass or the remaining failure is clearly blocked.
|
||||
|
||||
@@ -11,7 +11,12 @@ class PostImportsController < ApplicationController
|
||||
|
||||
def validate
|
||||
rows = normalised_import_rows allow_warning_fields: true
|
||||
changed_row = Integer(params[:changed_row], exception: false)
|
||||
changed_row =
|
||||
if params[:changed_row].to_s == 'all'
|
||||
true
|
||||
else
|
||||
Integer(params[:changed_row], exception: false)
|
||||
end
|
||||
result =
|
||||
PostImportPreviewer.new.preview_rows(rows:,
|
||||
fetch_metadata: changed_row,
|
||||
|
||||
@@ -38,6 +38,27 @@ class PreviewController < ApplicationController
|
||||
render_unprocessable_entity(e.message)
|
||||
end
|
||||
|
||||
def image
|
||||
return render_bad_request('URL は必須です.') if params[:url].blank?
|
||||
|
||||
attachment = Post.resized_thumbnail_attachment(
|
||||
StringIO.new(Preview::ThumbnailFetcher.fetch_image_response(params[:url]).body))
|
||||
attachment[:io].rewind
|
||||
send_data attachment[:io].read,
|
||||
type: attachment[:content_type],
|
||||
disposition: 'inline'
|
||||
rescue Preview::UrlSafety::UnsafeUrl => e
|
||||
render_bad_request(e.message)
|
||||
rescue Preview::HttpFetcher::FetchTimeout => e
|
||||
render_preview_error(e.message, :gateway_timeout)
|
||||
rescue Preview::HttpFetcher::ResponseTooLarge => e
|
||||
render_preview_error(e.message, :payload_too_large)
|
||||
rescue Preview::HttpFetcher::FetchFailed => e
|
||||
render_preview_error(e.message, :bad_gateway)
|
||||
rescue Preview::ThumbnailFetcher::GenerationFailed, MiniMagick::Error => e
|
||||
render_unprocessable_entity(e.message)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def require_member!
|
||||
|
||||
@@ -238,7 +238,8 @@ class Post < ApplicationRecord
|
||||
value = raw_value.to_s.strip
|
||||
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
|
||||
month = match[2].to_i
|
||||
day = match[3].to_i
|
||||
@@ -251,9 +252,7 @@ class Post < ApplicationRecord
|
||||
|
||||
match =
|
||||
value.match(
|
||||
/\A(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/ \
|
||||
'(?::(\d{2})(?:\.(\d+))?)?' \
|
||||
'(Z|[+-]\d{2}:?\d{2})?\z/')
|
||||
/\A(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d+))?)?(Z|[+-]\d{2}:?\d{2})?\z/)
|
||||
return nil if match.nil?
|
||||
|
||||
year = match[1].to_i
|
||||
|
||||
@@ -13,7 +13,7 @@ class PostImportPreviewer
|
||||
FETCH_WARNING_FIELDS = ['url', 'title', 'thumbnail_base'].freeze
|
||||
TITLE_FETCH_WARNING = 'タイトルを取得できませんでした.'.freeze
|
||||
THUMBNAIL_FETCH_WARNING = 'サムネールを取得できませんでした.'.freeze
|
||||
METADATA_FETCH_WARNING = 'メタデータを取得できませんでした.'.freeze
|
||||
METADATA_FETCH_WARNING = '自動取得に失敗しました.'.freeze
|
||||
|
||||
def preview_rows rows:, fetch_metadata: true, metadata_cache: { }
|
||||
prepared_rows = rows.map { prepare_row(_1) }
|
||||
@@ -151,7 +151,7 @@ class PostImportPreviewer
|
||||
when true then true
|
||||
when Integer then fetch_metadata == source_row
|
||||
when false, nil then false
|
||||
else raise ArgumentError, 'メタデータ取得対象が不正です.'
|
||||
else raise ArgumentError, '取得対象が不正です.'
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ Rails.application.routes.draw do
|
||||
scope :preview, controller: :preview do
|
||||
get :title
|
||||
get :thumbnail
|
||||
get :image
|
||||
end
|
||||
|
||||
scope 'posts/import', controller: :post_imports do
|
||||
|
||||
@@ -130,6 +130,13 @@ pass or the remaining failure is clearly blocked.
|
||||
|
||||
- Tailwind scans `src/**/*.{html,js,ts,jsx,tsx,mdx}`.
|
||||
- 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
|
||||
`src/components/ui` before adding new primitives.
|
||||
- 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.
|
||||
- Preserve existing Japanese tone and orthography in nearby UI text, including
|
||||
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
|
||||
if the class cannot be statically detected.
|
||||
- Do not introduce new UI libraries or production dependencies without approval.
|
||||
|
||||
+1
-10
@@ -41,11 +41,8 @@ import NotFound from '@/pages/NotFound'
|
||||
import TOSPage from '@/pages/TOSPage.mdx'
|
||||
import PostDetailPage from '@/pages/posts/PostDetailPage'
|
||||
import PostHistoryPage from '@/pages/posts/PostHistoryPage'
|
||||
import PostImportResultPage from '@/pages/posts/PostImportResultPage'
|
||||
import PostImportReviewPage from '@/pages/posts/PostImportReviewPage'
|
||||
import PostImportSourcePage from '@/pages/posts/PostImportSourcePage'
|
||||
import PostListPage from '@/pages/posts/PostListPage'
|
||||
import PostNewPage from '@/pages/posts/PostNewPage'
|
||||
import PostListPage from '@/pages/posts/PostListPage'
|
||||
import PostSearchPage from '@/pages/posts/PostSearchPage'
|
||||
import ServiceUnavailable from '@/pages/ServiceUnavailable'
|
||||
import SettingPage from '@/pages/users/SettingPage'
|
||||
@@ -78,9 +75,6 @@ const RouteTransitionWrapper = ({ animationMode, user, setUser }: {
|
||||
<Route path="/" element={<Navigate to="/posts" replace/>}/>
|
||||
<Route path="/posts" element={<PostListPage/>}/>
|
||||
<Route path="/posts/new" element={<PostNewPage user={user}/>}/>
|
||||
<Route path="/posts/import" element={<PostImportSourcePage user={user}/>}/>
|
||||
<Route path="/posts/import/:sessionId/review" element={<PostImportReviewPage user={user}/>}/>
|
||||
<Route path="/posts/import/:sessionId/result" element={<PostImportResultPage user={user}/>}/>
|
||||
<Route path="/posts/search" element={<PostSearchPage/>}/>
|
||||
<Route path="/posts/:id" element={<PostDetailRoute user={user}/>}/>
|
||||
<Route path="/posts/changes" element={<PostHistoryPage/>}/>
|
||||
@@ -119,9 +113,6 @@ const RouteTransitionWrapper = ({ animationMode, user, setUser }: {
|
||||
<Route path="/" element={<Navigate to="/posts" replace/>}/>
|
||||
<Route path="/posts" element={<PostListPage/>}/>
|
||||
<Route path="/posts/new" element={<PostNewPage user={user}/>}/>
|
||||
<Route path="/posts/import" element={<PostImportSourcePage user={user}/>}/>
|
||||
<Route path="/posts/import/:sessionId/review" element={<PostImportReviewPage user={user}/>}/>
|
||||
<Route path="/posts/import/:sessionId/result" element={<PostImportResultPage user={user}/>}/>
|
||||
<Route path="/posts/search" element={<PostSearchPage/>}/>
|
||||
<Route path="/posts/:id" element={<PostDetailRoute user={user}/>}/>
|
||||
<Route path="/posts/changes" element={<PostHistoryPage/>}/>
|
||||
|
||||
@@ -30,6 +30,10 @@ describe ('menuOutline', () => {
|
||||
expect (submenuItem ('guest', 'Wiki', '編輯')?.visible).toBe (false)
|
||||
})
|
||||
|
||||
it ('uses /posts/new as the import entrypoint', () => {
|
||||
expect (submenuItem ('member', '広場', '取込')?.to).toBe ('/posts/new')
|
||||
})
|
||||
|
||||
it ('keeps material suppression admin-only', () => {
|
||||
expect (submenuItem ('member', '素材', '抑止')?.visible).toBe (false)
|
||||
expect (submenuItem ('admin', '素材', '抑止')?.visible).toBe (true)
|
||||
|
||||
@@ -45,7 +45,6 @@ export const menuOutline = (
|
||||
{ name: '一覧', to: '/posts' },
|
||||
{ name: '検索', to: '/posts/search' },
|
||||
{ name: '追加', to: '/posts/new', visible: editable },
|
||||
{ name: '取込', to: '/posts/import', visible: editable },
|
||||
{ name: '全体履歴', to: '/posts/changes' },
|
||||
{ name: 'ヘルプ', to: '/wiki/ヘルプ:広場' }] },
|
||||
{ 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 ()
|
||||
})
|
||||
})
|
||||
@@ -14,7 +14,8 @@ type Props = {
|
||||
type?: string
|
||||
placeholder?: string
|
||||
className?: string
|
||||
after?: ReactNode }
|
||||
after?: ReactNode
|
||||
onBlur?: () => void }
|
||||
|
||||
|
||||
const PostTextField: FC<Props> = (
|
||||
@@ -27,7 +28,8 @@ const PostTextField: FC<Props> = (
|
||||
type = 'text',
|
||||
placeholder,
|
||||
className,
|
||||
after },
|
||||
after,
|
||||
onBlur },
|
||||
) => (
|
||||
<FormField label={label} messages={errors}>
|
||||
{({ describedBy, invalid }) => (
|
||||
@@ -37,6 +39,7 @@ const PostTextField: FC<Props> = (
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
onBlur={onBlur}
|
||||
onChange={ev => onChange (ev.target.value)}
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={invalid}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import PostThumbnailPreview from '@/components/posts/PostThumbnailPreview'
|
||||
@@ -9,4 +9,28 @@ describe ('PostThumbnailPreview', () => {
|
||||
|
||||
expect (screen.getByRole ('img')).toHaveAttribute ('src', 'blob:preview')
|
||||
})
|
||||
|
||||
it ('renders an empty thumbnail frame without text when the URL is empty', () => {
|
||||
const { container } = render (
|
||||
<PostThumbnailPreview url="" className="h-10 w-10"/>)
|
||||
|
||||
expect (screen.queryByRole ('img')).toBeNull ()
|
||||
expect (screen.queryByText ('サムネールを表示できません')).toBeNull ()
|
||||
expect (screen.queryByText ('なし')).toBeNull ()
|
||||
expect (container.querySelector ('div.rounded.border.bg-muted')).not.toBeNull ()
|
||||
expect (container.textContent).toBe ('')
|
||||
})
|
||||
|
||||
it ('renders an empty thumbnail frame without text when image loading fails', () => {
|
||||
const { container } = render (
|
||||
<PostThumbnailPreview url="blob:preview" className="h-10 w-10"/>)
|
||||
|
||||
fireEvent.error (screen.getByRole ('img'))
|
||||
|
||||
expect (screen.queryByRole ('img')).toBeNull ()
|
||||
expect (screen.queryByText ('サムネールを表示できません')).toBeNull ()
|
||||
expect (screen.queryByText ('なし')).toBeNull ()
|
||||
expect (container.querySelector ('div.rounded.border.bg-muted')).not.toBeNull ()
|
||||
expect (container.textContent).toBe ('')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import type { FC } from 'react'
|
||||
|
||||
type Props = {
|
||||
@@ -17,33 +19,20 @@ const PostThumbnailPreview: FC<Props> = (
|
||||
setFailed (false)
|
||||
}, [url])
|
||||
|
||||
if (!(url))
|
||||
if (!(url) || failed)
|
||||
{
|
||||
return (
|
||||
<div
|
||||
className={`${ className } flex items-center justify-center rounded border
|
||||
border-border bg-muted text-xs text-muted-foreground`}>
|
||||
なし
|
||||
</div>)
|
||||
}
|
||||
|
||||
if (failed)
|
||||
{
|
||||
return (
|
||||
<div
|
||||
className={`${ className } flex items-center justify-center rounded border
|
||||
border-amber-300 bg-amber-50 p-2 text-center text-xs
|
||||
text-amber-700 dark:border-amber-900 dark:bg-amber-950
|
||||
dark:text-amber-200`}>
|
||||
サムネールを表示できません
|
||||
</div>)
|
||||
className={cn (
|
||||
className,
|
||||
'rounded border border-border bg-muted')}/>)
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
src={url}
|
||||
alt={alt}
|
||||
className={`${ className } rounded border border-border object-cover`}
|
||||
className={cn (className, 'rounded border border-border object-cover')}
|
||||
onError={() => setFailed (true)}/>)
|
||||
}
|
||||
|
||||
|
||||
@@ -85,7 +85,7 @@ describe ('PostImportRowForm', () => {
|
||||
it ('marks edited fields and areas invalid from field errors', () => {
|
||||
const row = buildPostImportRow ({
|
||||
validationErrors: { url: ['URL error'], tags: ['tag error'] },
|
||||
importErrors: { duration: ['duration error'] },
|
||||
importErrors: { title: ['title error'] },
|
||||
fieldWarnings: { title: ['title warning'] } })
|
||||
|
||||
render (
|
||||
@@ -96,7 +96,7 @@ describe ('PostImportRowForm', () => {
|
||||
|
||||
expect (screen.getByText ('URL error')).toBeInTheDocument ()
|
||||
expect (screen.getByText ('tag error')).toBeInTheDocument ()
|
||||
expect (screen.getByText ('duration error')).toBeInTheDocument ()
|
||||
expect (screen.getByText ('title error')).toBeInTheDocument ()
|
||||
expect (screen.getByText ('title warning')).toBeInTheDocument ()
|
||||
expect (screen.getAllByRole ('textbox').filter (
|
||||
textbox => textbox.getAttribute ('aria-invalid') === 'true')).toHaveLength (3)
|
||||
@@ -134,7 +134,9 @@ describe ('PostImportRowForm', () => {
|
||||
resetRequested: false })
|
||||
})
|
||||
|
||||
it ('keeps the shared duration and tags string contract without leaking file upload UI', async () => {
|
||||
it (
|
||||
'shows the shared creation field order without duration and without file upload UI',
|
||||
async () => {
|
||||
let actions: DialogueFormAction[] = []
|
||||
const controls: DialogueFormControls = {
|
||||
close: vi.fn (),
|
||||
@@ -152,8 +154,18 @@ describe ('PostImportRowForm', () => {
|
||||
onSave={onSave}/>)
|
||||
await waitFor (() => expect (actions.length).toBe (2))
|
||||
|
||||
const labels = Array.from (container.querySelectorAll ('label'))
|
||||
.map (node => node.textContent?.trim ())
|
||||
|
||||
expect (labels.slice (0, 6)).toEqual ([
|
||||
'URL',
|
||||
'サムネール',
|
||||
'タイトル',
|
||||
'オリジナルの作成日時',
|
||||
'タグ',
|
||||
'親投稿'])
|
||||
expect (container.querySelector ('input[type="file"]')).toBeNull ()
|
||||
expect (screen.getByPlaceholderText ('例: 2 / 2.5 / 1:23')).toHaveValue ('2')
|
||||
expect (screen.queryByPlaceholderText ('例: 2 / 2.5 / 1:23')).not.toBeInTheDocument ()
|
||||
expect (screen.getByDisplayValue ('tag1')).toBeInTheDocument ()
|
||||
|
||||
await act (async () => {
|
||||
@@ -162,10 +174,9 @@ describe ('PostImportRowForm', () => {
|
||||
|
||||
expect (onSave).toHaveBeenCalledWith ({
|
||||
draft: expect.objectContaining ({
|
||||
duration: '2',
|
||||
tags: 'tag1' }),
|
||||
resetRequested: false })
|
||||
})
|
||||
})
|
||||
|
||||
it ('keeps reset enabled when the value matches but provenance still differs', async () => {
|
||||
const row = buildPostImportRow ({
|
||||
@@ -178,7 +189,6 @@ describe ('PostImportRowForm', () => {
|
||||
thumbnailBase: '',
|
||||
originalCreatedFrom: '',
|
||||
originalCreatedBefore: '',
|
||||
duration: '',
|
||||
tags: '',
|
||||
parentPostIds: '' },
|
||||
provenance: {
|
||||
@@ -187,7 +197,6 @@ describe ('PostImportRowForm', () => {
|
||||
thumbnailBase: 'automatic',
|
||||
originalCreatedFrom: 'automatic',
|
||||
originalCreatedBefore: 'automatic',
|
||||
duration: 'automatic',
|
||||
tags: 'automatic',
|
||||
parentPostIds: 'automatic' },
|
||||
tagSources: { automatic: '', manual: '' },
|
||||
@@ -210,7 +219,9 @@ describe ('PostImportRowForm', () => {
|
||||
expect (actions.find (action => action.label === '変更をリセット')?.disabled).toBe (false)
|
||||
})
|
||||
|
||||
it ('disables every field while save validation is pending and re-enables them afterwards', async () => {
|
||||
it (
|
||||
'disables every field while save validation is pending and re-enables them afterwards',
|
||||
async () => {
|
||||
let actions: DialogueFormAction[] = []
|
||||
let resolveSave:
|
||||
((value: { saved: boolean
|
||||
@@ -234,8 +245,9 @@ describe ('PostImportRowForm', () => {
|
||||
onSave={onSave}/>)
|
||||
await waitFor (() => expect (actions.length).toBe (2))
|
||||
|
||||
let savePromise: Promise<boolean | void> | undefined
|
||||
await act (async () => {
|
||||
await actions.find (action => action.label === '編輯内容を保存')?.onSelect ()
|
||||
savePromise = actions.find (action => action.label === '編輯内容を保存')?.onSelect ()
|
||||
})
|
||||
|
||||
await waitFor (() => {
|
||||
@@ -249,6 +261,9 @@ describe ('PostImportRowForm', () => {
|
||||
row: buildPostImportRow ({
|
||||
attributes: { title: 'draft title' },
|
||||
validationErrors: { title: ['タイトルを確認してください.'] } }) })
|
||||
await act (async () => {
|
||||
await savePromise
|
||||
})
|
||||
|
||||
await waitFor (() => {
|
||||
screen.getAllByRole ('textbox').forEach (textbox => {
|
||||
@@ -257,5 +272,5 @@ describe ('PostImportRowForm', () => {
|
||||
})
|
||||
expect (screen.getByDisplayValue ('draft title')).toBeInTheDocument ()
|
||||
expect (screen.getByText ('タイトルを確認してください.')).toBeInTheDocument ()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
|
||||
import FieldError from '@/components/common/FieldError'
|
||||
import FieldWarning from '@/components/common/FieldWarning'
|
||||
import PostDurationField from '@/components/posts/PostDurationField'
|
||||
import PostTagsField from '@/components/posts/PostTagsField'
|
||||
import PostCreationDataFields from '@/components/posts/PostCreationDataFields'
|
||||
import PostTextField from '@/components/posts/PostTextField'
|
||||
import PostImportThumbnailPreview from '@/components/posts/import/PostImportThumbnailPreview'
|
||||
|
||||
@@ -29,7 +27,6 @@ const buildDraft = (row: PostImportRow): Draft => ({
|
||||
thumbnailBase: String (row.attributes.thumbnailBase ?? ''),
|
||||
originalCreatedFrom: String (row.attributes.originalCreatedFrom ?? ''),
|
||||
originalCreatedBefore: String (row.attributes.originalCreatedBefore ?? ''),
|
||||
duration: String (row.attributes.duration ?? ''),
|
||||
tags: String (row.attributes.tags ?? ''),
|
||||
parentPostIds: String (row.attributes.parentPostIds ?? '') })
|
||||
|
||||
@@ -39,7 +36,6 @@ const buildResetDraft = (row: PostImportRow): Draft => ({
|
||||
thumbnailBase: String (row.resetSnapshot.attributes.thumbnailBase ?? ''),
|
||||
originalCreatedFrom: String (row.resetSnapshot.attributes.originalCreatedFrom ?? ''),
|
||||
originalCreatedBefore: String (row.resetSnapshot.attributes.originalCreatedBefore ?? ''),
|
||||
duration: String (row.resetSnapshot.attributes.duration ?? ''),
|
||||
tags: String (row.resetSnapshot.attributes.tags ?? ''),
|
||||
parentPostIds: String (row.resetSnapshot.attributes.parentPostIds ?? '') })
|
||||
|
||||
@@ -52,7 +48,6 @@ const sameDraft = (left: Draft, right: Draft): boolean =>
|
||||
&& left.thumbnailBase === right.thumbnailBase
|
||||
&& left.originalCreatedFrom === right.originalCreatedFrom
|
||||
&& left.originalCreatedBefore === right.originalCreatedBefore
|
||||
&& left.duration === right.duration
|
||||
&& left.tags === right.tags
|
||||
&& left.parentPostIds === right.parentPostIds
|
||||
|
||||
@@ -79,12 +74,15 @@ const PostImportRowForm: FC<Props> = (
|
||||
const [messageRow, setMessageRow] = useState<PostImportRow | null> (null)
|
||||
const [saving, setSaving] = useState (false)
|
||||
const [resetRequested, setResetRequested] = useState (false)
|
||||
const [committedThumbnailBase, setCommittedThumbnailBase] = useState (
|
||||
() => String (row.attributes.thumbnailBase ?? ''))
|
||||
|
||||
useEffect (() => {
|
||||
const nextDraft = buildDraft (row)
|
||||
setDraft (nextDraft)
|
||||
setMessageRow (null)
|
||||
setResetRequested (false)
|
||||
setCommittedThumbnailBase (String (row.attributes.thumbnailBase ?? ''))
|
||||
}, [row])
|
||||
|
||||
const displayRow = messageRow ?? row
|
||||
@@ -123,6 +121,7 @@ const PostImportRowForm: FC<Props> = (
|
||||
setDraft (resetDraft)
|
||||
setResetRequested (true)
|
||||
setMessageRow (null)
|
||||
setCommittedThumbnailBase (resetDraft.thumbnailBase)
|
||||
return false
|
||||
}, [controls, resetDisabled, resetDraft])
|
||||
|
||||
@@ -164,77 +163,79 @@ const PostImportRowForm: FC<Props> = (
|
||||
<div className="grid gap-6 md:grid-cols-[7rem_minmax(0,1fr)]">
|
||||
<div className="space-y-3 md:sticky md:top-0 md:self-start">
|
||||
<PostImportThumbnailPreview
|
||||
url={draft.thumbnailBase}
|
||||
url={committedThumbnailBase}
|
||||
className="h-28 w-28"/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<PostTextField
|
||||
label="URL"
|
||||
value={draft.url}
|
||||
disabled={saving}
|
||||
warnings={displayRow.fieldWarnings.url}
|
||||
errors={groupedMessages (
|
||||
displayRow.validationErrors.url,
|
||||
displayRow.importErrors?.url)}
|
||||
onChange={value => update ('url', value)}/>
|
||||
<PostTextField
|
||||
label="タイトル"
|
||||
value={draft.title}
|
||||
disabled={saving}
|
||||
warnings={displayRow.fieldWarnings.title}
|
||||
errors={groupedMessages (
|
||||
displayRow.validationErrors.title,
|
||||
displayRow.importErrors?.title)}
|
||||
onChange={value => update ('title', value)}/>
|
||||
<PostTextField
|
||||
label="サムネール基底 URL"
|
||||
value={draft.thumbnailBase}
|
||||
disabled={saving}
|
||||
warnings={displayRow.fieldWarnings.thumbnailBase}
|
||||
errors={groupedMessages (
|
||||
displayRow.validationErrors.thumbnailBase,
|
||||
displayRow.importErrors?.thumbnailBase)}
|
||||
onChange={value => update ('thumbnailBase', value)}/>
|
||||
<PostOriginalCreatedTimeField
|
||||
disabled={saving}
|
||||
originalCreatedFrom={draft.originalCreatedFrom || null}
|
||||
setOriginalCreatedFrom={value => update ('originalCreatedFrom', value ?? '')}
|
||||
originalCreatedBefore={draft.originalCreatedBefore || null}
|
||||
setOriginalCreatedBefore={value => update ('originalCreatedBefore', value ?? '')}
|
||||
errors={groupedMessages (
|
||||
displayRow.validationErrors.originalCreatedAt,
|
||||
displayRow.validationErrors.originalCreatedFrom,
|
||||
displayRow.validationErrors.originalCreatedBefore,
|
||||
displayRow.importErrors?.originalCreatedAt,
|
||||
displayRow.importErrors?.originalCreatedFrom,
|
||||
displayRow.importErrors?.originalCreatedBefore)}/>
|
||||
<PostDurationField
|
||||
value={draft.duration}
|
||||
disabled={saving}
|
||||
errors={groupedMessages (
|
||||
displayRow.validationErrors.duration,
|
||||
displayRow.validationErrors.videoMs,
|
||||
displayRow.importErrors?.duration,
|
||||
displayRow.importErrors?.videoMs)}
|
||||
onChange={value => update ('duration', value)}/>
|
||||
<PostTagsField
|
||||
tags={draft.tags}
|
||||
disabled={saving}
|
||||
setTags={value => update ('tags', value)}
|
||||
warnings={displayRow.fieldWarnings.tags}
|
||||
errors={groupedMessages (
|
||||
displayRow.validationErrors.tags,
|
||||
displayRow.importErrors?.tags)}
|
||||
rows={4}/>
|
||||
<PostTextField
|
||||
label="親投稿"
|
||||
value={draft.parentPostIds}
|
||||
disabled={saving}
|
||||
errors={groupedMessages (
|
||||
displayRow.validationErrors.parentPostIds,
|
||||
displayRow.importErrors?.parentPostIds)}
|
||||
onChange={value => update ('parentPostIds', value)}/>
|
||||
<PostCreationDataFields
|
||||
url={{
|
||||
value: draft.url,
|
||||
onChange: value => update ('url', value),
|
||||
disabled: saving,
|
||||
warnings: displayRow.fieldWarnings.url,
|
||||
errors: groupedMessages (
|
||||
displayRow.validationErrors.url,
|
||||
displayRow.importErrors?.url) }}
|
||||
thumbnailField={
|
||||
<>
|
||||
<PostTextField
|
||||
label="サムネール"
|
||||
value={draft.thumbnailBase}
|
||||
disabled={saving}
|
||||
warnings={displayRow.fieldWarnings.thumbnailBase}
|
||||
errors={groupedMessages (
|
||||
displayRow.validationErrors.thumbnailBase,
|
||||
displayRow.importErrors?.thumbnailBase)}
|
||||
onBlur={() => {
|
||||
if (draft.thumbnailBase !== committedThumbnailBase)
|
||||
setCommittedThumbnailBase (draft.thumbnailBase)
|
||||
}}
|
||||
onChange={value => update ('thumbnailBase', value)}/>
|
||||
</>}
|
||||
core={{
|
||||
title: {
|
||||
value: draft.title,
|
||||
onChange: value => update ('title', value),
|
||||
disabled: saving,
|
||||
warnings: displayRow.fieldWarnings.title,
|
||||
errors: groupedMessages (
|
||||
displayRow.validationErrors.title,
|
||||
displayRow.importErrors?.title) },
|
||||
originalCreated: {
|
||||
disabled: saving,
|
||||
originalCreatedFrom: draft.originalCreatedFrom || null,
|
||||
setOriginalCreatedFrom: value =>
|
||||
update ('originalCreatedFrom', value ?? ''),
|
||||
originalCreatedBefore: draft.originalCreatedBefore || null,
|
||||
setOriginalCreatedBefore: value =>
|
||||
update ('originalCreatedBefore', value ?? ''),
|
||||
errors: {
|
||||
originalCreatedAt: groupedMessages (
|
||||
displayRow.validationErrors.originalCreatedAt,
|
||||
displayRow.importErrors?.originalCreatedAt),
|
||||
originalCreatedFrom: groupedMessages (
|
||||
displayRow.validationErrors.originalCreatedFrom,
|
||||
displayRow.importErrors?.originalCreatedFrom),
|
||||
originalCreatedBefore: groupedMessages (
|
||||
displayRow.validationErrors.originalCreatedBefore,
|
||||
displayRow.importErrors?.originalCreatedBefore) } },
|
||||
tags: {
|
||||
value: draft.tags,
|
||||
onChange: value => update ('tags', value),
|
||||
disabled: saving,
|
||||
warnings: displayRow.fieldWarnings.tags,
|
||||
errors: groupedMessages (
|
||||
displayRow.validationErrors.tags,
|
||||
displayRow.importErrors?.tags),
|
||||
rows: 4 },
|
||||
parentPostIds: {
|
||||
value: draft.parentPostIds,
|
||||
onChange: value => update ('parentPostIds', value),
|
||||
disabled: saving,
|
||||
errors: groupedMessages (
|
||||
displayRow.validationErrors.parentPostIds,
|
||||
displayRow.importErrors?.parentPostIds) } }}/>
|
||||
<FieldWarning messages={displayRow.baseWarnings}/>
|
||||
<FieldError messages={displayRow.validationErrors.base}/>
|
||||
<FieldError messages={displayRow.importErrors?.base}/>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import FieldError from '@/components/common/FieldError'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import PostImportThumbnailPreview from '@/components/posts/import/PostImportThumbnailPreview'
|
||||
import PostImportStatusBadge from '@/components/posts/import/PostImportStatusBadge'
|
||||
import { displayPostImportStatus } from '@/components/posts/import/postImportRowStatus'
|
||||
import { canEditReviewRow } from '@/lib/postImportSession'
|
||||
import { canEditReviewRow, canRetryResultRow } from '@/lib/postImportSession'
|
||||
import { cn, originalCreatedAtString } from '@/lib/utils'
|
||||
|
||||
import type { FC } from 'react'
|
||||
@@ -10,9 +11,17 @@ import type { FC } from 'react'
|
||||
import type { PostImportRow } from '@/lib/postImportSession'
|
||||
|
||||
type Props = {
|
||||
row: PostImportRow
|
||||
onEdit: () => void
|
||||
editDisabled?: boolean }
|
||||
row: PostImportRow
|
||||
displayNumber?: number
|
||||
onEdit?: () => void
|
||||
onRetry?: () => void
|
||||
onToggleSkip?: (checked: boolean) => void
|
||||
rowMessages?: string[]
|
||||
editDisabled?: boolean
|
||||
retryDisabled?: boolean
|
||||
skipDisabled?: boolean
|
||||
showActions?: boolean
|
||||
showSkipToggle?: boolean }
|
||||
|
||||
const summaryWarning = (row: PostImportRow): string | null =>
|
||||
Object.values (row.fieldWarnings ?? { }).flat ()[0]
|
||||
@@ -25,9 +34,37 @@ const summaryDate = (row: PostImportRow): string =>
|
||||
row.attributes.originalCreatedBefore?.toString () ?? null)
|
||||
|
||||
|
||||
const PostImportRowSummary: FC<Props> = ({ row, onEdit, editDisabled }) => {
|
||||
const PostImportRowSummary: FC<Props> = (
|
||||
{ row,
|
||||
displayNumber,
|
||||
onEdit,
|
||||
onRetry,
|
||||
onToggleSkip,
|
||||
rowMessages,
|
||||
editDisabled,
|
||||
retryDisabled,
|
||||
skipDisabled,
|
||||
showActions = true,
|
||||
showSkipToggle = false },
|
||||
) => {
|
||||
const warning = summaryWarning (row)
|
||||
const displayStatus = displayPostImportStatus (row)
|
||||
const editVisible = onEdit != null
|
||||
const editAllowed = editVisible && canEditReviewRow (row)
|
||||
const retryAllowed = onRetry != null && canRetryResultRow (row)
|
||||
const skipChecked = row.skipReason === 'manual'
|
||||
const rowNumber = displayNumber ?? row.sourceRow
|
||||
const skipControl = showSkipToggle
|
||||
? (
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={skipChecked}
|
||||
onChange={event => onToggleSkip?.(event.target.checked)}
|
||||
disabled={skipDisabled === true}/>
|
||||
<span>スキップ</span>
|
||||
</label>)
|
||||
: null
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -37,7 +74,7 @@ const PostImportRowSummary: FC<Props> = ({ row, onEdit, editDisabled }) => {
|
||||
'md:grid-cols-[4rem_5rem_minmax(0,1fr)_auto_auto]',
|
||||
'transition-shadow hover:shadow-sm')}>
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-medium">#{row.sourceRow}</div>
|
||||
<div className="text-sm font-medium">#{rowNumber}</div>
|
||||
</div>
|
||||
<PostImportThumbnailPreview
|
||||
url={String (row.attributes.thumbnailBase ?? '')}
|
||||
@@ -54,24 +91,36 @@ const PostImportRowSummary: FC<Props> = ({ row, onEdit, editDisabled }) => {
|
||||
</div>
|
||||
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{summaryDate (row)}
|
||||
{row.attributes.duration ? ` / ${ row.attributes.duration }` : ''}
|
||||
</div>
|
||||
{warning && (
|
||||
<div className="text-xs text-amber-700 dark:text-amber-200">
|
||||
{warning}
|
||||
</div>)}
|
||||
<FieldError messages={rowMessages}/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{displayStatus != null && <PostImportStatusBadge value={displayStatus}/>}
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onEdit}
|
||||
disabled={!(canEditReviewRow (row)) || editDisabled === true}>
|
||||
編輯
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
{skipControl}
|
||||
{showActions && editVisible && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onEdit}
|
||||
disabled={editDisabled === true || !(editAllowed)}>
|
||||
編輯
|
||||
</Button>)}
|
||||
{showActions && retryAllowed && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onRetry}
|
||||
disabled={retryDisabled === true}>
|
||||
再試行
|
||||
</Button>)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -79,6 +128,7 @@ const PostImportRowSummary: FC<Props> = ({ row, onEdit, editDisabled }) => {
|
||||
className={cn (
|
||||
'space-y-3 rounded-lg border p-4 md:hidden',
|
||||
'transition-shadow hover:shadow-sm')}>
|
||||
<div className="text-sm font-medium">#{rowNumber}</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<PostImportThumbnailPreview
|
||||
url={String (row.attributes.thumbnailBase ?? '')}
|
||||
@@ -98,21 +148,34 @@ const PostImportRowSummary: FC<Props> = ({ row, onEdit, editDisabled }) => {
|
||||
</div>
|
||||
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{summaryDate (row)}
|
||||
{row.attributes.duration ? ` / ${ row.attributes.duration }` : ''}
|
||||
</div>
|
||||
{warning && (
|
||||
<div className="text-xs text-amber-700 dark:text-amber-200">
|
||||
{warning}
|
||||
</div>)}
|
||||
<FieldError messages={rowMessages}/>
|
||||
{skipControl}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onEdit}
|
||||
disabled={!(canEditReviewRow (row)) || editDisabled === true}>
|
||||
編輯
|
||||
</Button>
|
||||
{showActions && (editVisible || retryAllowed) && (
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
{editVisible && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onEdit}
|
||||
disabled={editDisabled === true || !(editAllowed)}>
|
||||
編輯
|
||||
</Button>)}
|
||||
{retryAllowed && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onRetry}
|
||||
disabled={retryDisabled === true}>
|
||||
再試行
|
||||
</Button>)}
|
||||
</div>)}
|
||||
</div>
|
||||
</>)
|
||||
}
|
||||
|
||||
@@ -38,12 +38,18 @@ describe ('PostImportThumbnailPreview', () => {
|
||||
it ('does not render the unsafe URL directly when preview fetching fails', async () => {
|
||||
api.apiGet.mockRejectedValueOnce (new Error ('unsafe'))
|
||||
|
||||
render (
|
||||
const { container } = render (
|
||||
<PostImportThumbnailPreview
|
||||
url="http://127.0.0.1/private.png"
|
||||
className="h-10 w-10"/>)
|
||||
|
||||
expect (await screen.findByText ('サムネールを表示できません')).toBeInTheDocument ()
|
||||
await waitFor (() => {
|
||||
expect (screen.queryByRole ('img')).toBeNull ()
|
||||
})
|
||||
expect (screen.queryByText ('サムネールを表示できません')).toBeNull ()
|
||||
expect (screen.queryByText ('なし')).toBeNull ()
|
||||
expect (container.querySelector ('div.rounded.border.bg-muted')).not.toBeNull ()
|
||||
expect (container.textContent).toBe ('')
|
||||
expect (screen.queryByRole ('img')).toBeNull ()
|
||||
})
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import PostThumbnailPreview from '@/components/posts/PostThumbnailPreview'
|
||||
import { apiGet } from '@/lib/api'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import type { FC } from 'react'
|
||||
|
||||
@@ -18,7 +17,6 @@ const PostImportThumbnailPreview: FC<Props> = (
|
||||
className = 'h-16 w-16' },
|
||||
) => {
|
||||
const [previewUrl, setPreviewUrl] = useState ('')
|
||||
const [unavailable, setUnavailable] = useState (false)
|
||||
const previewUrlRef = useRef ('')
|
||||
|
||||
useEffect (() => {
|
||||
@@ -28,20 +26,20 @@ const PostImportThumbnailPreview: FC<Props> = (
|
||||
previewUrlRef.current = ''
|
||||
}
|
||||
setPreviewUrl ('')
|
||||
setUnavailable (false)
|
||||
|
||||
if (!(url))
|
||||
return
|
||||
|
||||
let active = true
|
||||
const controller = new AbortController ()
|
||||
|
||||
const loadPreview = async () => {
|
||||
try
|
||||
{
|
||||
const blob = await apiGet<Blob> ('/preview/thumbnail', {
|
||||
const blob = await apiGet<Blob> ('/preview/image', {
|
||||
params: { url },
|
||||
signal: controller.signal,
|
||||
responseType: 'blob' })
|
||||
if (!(active))
|
||||
if (controller.signal.aborted)
|
||||
return
|
||||
|
||||
const nextPreviewUrl = URL.createObjectURL (blob)
|
||||
@@ -50,15 +48,15 @@ const PostImportThumbnailPreview: FC<Props> = (
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (active)
|
||||
setUnavailable (true)
|
||||
if (!(controller.signal.aborted))
|
||||
setPreviewUrl ('')
|
||||
}
|
||||
}
|
||||
|
||||
void loadPreview ()
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
controller.abort ()
|
||||
if (previewUrlRef.current)
|
||||
{
|
||||
URL.revokeObjectURL (previewUrlRef.current)
|
||||
@@ -67,18 +65,6 @@ const PostImportThumbnailPreview: FC<Props> = (
|
||||
}
|
||||
}, [url])
|
||||
|
||||
if (unavailable)
|
||||
{
|
||||
return (
|
||||
<div
|
||||
className={`${ className } flex items-center justify-center rounded border
|
||||
border-amber-300 bg-amber-50 p-2 text-center text-xs
|
||||
text-amber-700 dark:border-amber-900 dark:bg-amber-950
|
||||
dark:text-amber-200`}>
|
||||
サムネールを表示できません
|
||||
</div>)
|
||||
}
|
||||
|
||||
return (
|
||||
<PostThumbnailPreview
|
||||
url={previewUrl}
|
||||
|
||||
@@ -12,6 +12,8 @@ describe ('displayPostImportStatus', () => {
|
||||
expect (displayPostImportStatus (buildPostImportRow ({
|
||||
skipReason: 'existing',
|
||||
existingPostId: 2 }))).toBe ('skipped')
|
||||
expect (displayPostImportStatus (buildPostImportRow ({
|
||||
skipReason: 'manual' }))).toBe ('skipped')
|
||||
})
|
||||
|
||||
it ('does not expose validation, failure, or created states as badges', () => {
|
||||
|
||||
@@ -11,7 +11,7 @@ const hasWarnings = (row: PostImportRow): boolean =>
|
||||
export const displayPostImportStatus = (
|
||||
row: PostImportRow,
|
||||
): PostImportDisplayStatus | null =>
|
||||
(row.skipReason === 'existing' || row.importStatus === 'skipped')
|
||||
(row.skipReason != null || row.importStatus === 'skipped')
|
||||
? 'skipped'
|
||||
: ((Object.keys (row.validationErrors ?? { }).length > 0
|
||||
|| row.importStatus === 'failed'
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { AxiosError, AxiosRequestConfig } from 'axios'
|
||||
type Opt = {
|
||||
params?: AxiosRequestConfig['params']
|
||||
headers?: Record<string, string>
|
||||
signal?: AbortSignal
|
||||
responseType?: 'blob' }
|
||||
|
||||
const client = axios.create ({ baseURL: API_BASE_URL })
|
||||
|
||||
@@ -26,22 +26,26 @@ describe ('post import row state', () => {
|
||||
sourceRow: 2,
|
||||
skipReason: 'existing',
|
||||
existingPostId: 20 })
|
||||
const invalid = buildPostImportRow ({
|
||||
const manual = buildPostImportRow ({
|
||||
sourceRow: 3,
|
||||
skipReason: 'manual' })
|
||||
const invalid = buildPostImportRow ({
|
||||
sourceRow: 4,
|
||||
status: 'error',
|
||||
validationErrors: { url: ['invalid'] } })
|
||||
const created = buildPostImportRow ({
|
||||
sourceRow: 4,
|
||||
sourceRow: 5,
|
||||
importStatus: 'created',
|
||||
createdPostId: 40 })
|
||||
const rows = [ready, existing, invalid, created]
|
||||
const rows = [ready, existing, manual, invalid, created]
|
||||
|
||||
expect (processableImportRows (rows)).toEqual ([ready, existing])
|
||||
expect (processableImportRows (rows)).toEqual ([ready])
|
||||
expect (creatableImportRows (rows)).toEqual ([ready])
|
||||
expect (reviewSummaryCounts (rows)).toEqual ({
|
||||
total: 4,
|
||||
submittable: 1,
|
||||
skipPlanned: 1 })
|
||||
creatable: 1,
|
||||
manualSkipped: 1,
|
||||
existingSkipped: 1,
|
||||
pendingOrError: 1 })
|
||||
})
|
||||
|
||||
it ('preserves terminal rows while merging validation results', () => {
|
||||
@@ -270,7 +274,6 @@ describe ('post import row state', () => {
|
||||
thumbnailBase: '',
|
||||
originalCreatedFrom: '',
|
||||
originalCreatedBefore: '',
|
||||
duration: '2.5',
|
||||
tags: 'edited-tag',
|
||||
parentPostIds: '' },
|
||||
true)
|
||||
@@ -279,7 +282,7 @@ describe ('post import row state', () => {
|
||||
expect (nextRow.importErrors).toBeUndefined ()
|
||||
expect (nextRow.url).toBe ('https://example.com/edited')
|
||||
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.provenance.title).toBe ('manual')
|
||||
expect (nextRow.provenance.url).toBe ('manual')
|
||||
|
||||
@@ -2,9 +2,17 @@ import type { PostImportEditableDraft,
|
||||
PostImportResultRow,
|
||||
PostImportRow } from '@/lib/postImportTypes'
|
||||
|
||||
const hasSkipReason = (row: PostImportRow): boolean =>
|
||||
export const isExistingSkipRow = (row: PostImportRow): boolean =>
|
||||
row.skipReason === 'existing'
|
||||
|
||||
|
||||
export const isManualSkipRow = (row: PostImportRow): boolean =>
|
||||
row.skipReason === 'manual'
|
||||
|
||||
|
||||
const hasSkipReason = (row: PostImportRow): boolean =>
|
||||
row.skipReason != null
|
||||
|
||||
const hasValidationErrors = (row: PostImportRow): boolean =>
|
||||
Object.keys (row.validationErrors ?? { }).length > 0
|
||||
|
||||
@@ -15,6 +23,26 @@ const isRepairableImportStatus = (row: PostImportRow): boolean =>
|
||||
isRecoverableRow (row)
|
||||
&& (row.importStatus === 'failed' || row.importStatus === 'pending')
|
||||
|
||||
|
||||
export const isNonRecoverableFailedRow = (row: PostImportRow): boolean =>
|
||||
row.importStatus === 'failed' && row.recoverable !== true
|
||||
|
||||
|
||||
export const isTerminalRow = (row: PostImportRow): boolean =>
|
||||
row.importStatus === 'created'
|
||||
|| row.importStatus === 'skipped'
|
||||
|| isNonRecoverableFailedRow (row)
|
||||
|
||||
|
||||
export const isCompletedReviewRow = (row: PostImportRow): boolean =>
|
||||
row.importStatus === 'created'
|
||||
|| row.importStatus === 'skipped'
|
||||
|| hasSkipReason (row)
|
||||
|
||||
|
||||
export const validatableImportRows = (rows: PostImportRow[]): PostImportRow[] =>
|
||||
rows.filter (row => !(hasSkipReason (row)) && !(isTerminalRow (row)))
|
||||
|
||||
const buildResetSnapshot = (row: PostImportRow) => ({
|
||||
url: row.url,
|
||||
attributes: { ...row.attributes },
|
||||
@@ -29,7 +57,7 @@ const buildResetSnapshot = (row: PostImportRow) => ({
|
||||
|
||||
|
||||
export const processableImportRows = (rows: PostImportRow[]): PostImportRow[] =>
|
||||
rows.filter (row => {
|
||||
validatableImportRows (rows).filter (row => {
|
||||
if (row.importStatus === 'created')
|
||||
return false
|
||||
if (row.importStatus === 'skipped')
|
||||
@@ -46,13 +74,12 @@ export const creatableImportRows = (rows: PostImportRow[]): PostImportRow[] =>
|
||||
|
||||
|
||||
export const reviewSummaryCounts = (rows: PostImportRow[]) => ({
|
||||
total: rows.length,
|
||||
submittable: rows.filter (row =>
|
||||
creatableImportRows ([row]).length > 0
|
||||
&& !(hasValidationErrors (row))).length,
|
||||
skipPlanned: rows.filter (row =>
|
||||
processableImportRows ([row]).length > 0
|
||||
&& hasSkipReason (row)).length })
|
||||
creatable: rows.filter (row => creatableImportRows ([row]).length > 0).length,
|
||||
manualSkipped: rows.filter (row => isManualSkipRow (row)).length,
|
||||
existingSkipped: rows.filter (row => isExistingSkipRow (row)).length,
|
||||
pendingOrError: rows.filter (row =>
|
||||
!(isCompletedReviewRow (row))
|
||||
&& creatableImportRows ([row]).length === 0).length })
|
||||
|
||||
|
||||
export const resultSummaryCounts = (rows: PostImportRow[]) =>
|
||||
@@ -85,19 +112,22 @@ export const resultRepairMode = (
|
||||
|
||||
|
||||
export const canEditReviewRow = (row: PostImportRow): boolean =>
|
||||
!(row.importStatus === 'created'
|
||||
!(hasSkipReason (row)
|
||||
|| row.importStatus === 'created'
|
||||
|| row.importStatus === 'skipped'
|
||||
|| (row.importStatus === 'failed' && row.recoverable !== true))
|
||||
|
||||
|
||||
export const canEditResultRow = (row: PostImportRow): boolean =>
|
||||
row.recoverable === true
|
||||
row.skipReason == null
|
||||
&& row.recoverable === true
|
||||
&& (row.importStatus === 'failed'
|
||||
|| (row.importStatus === 'pending' && hasValidationErrors (row)))
|
||||
|
||||
|
||||
export const canRetryResultRow = (row: PostImportRow): boolean =>
|
||||
row.recoverable === true
|
||||
row.skipReason == null
|
||||
&& row.recoverable === true
|
||||
&& (row.importStatus === 'failed'
|
||||
|| (row.importStatus === 'pending' && !(hasValidationErrors (row))))
|
||||
|
||||
@@ -128,7 +158,6 @@ export const buildNextEditedRow = (
|
||||
['thumbnailBase', draft.thumbnailBase],
|
||||
['originalCreatedFrom', draft.originalCreatedFrom],
|
||||
['originalCreatedBefore', draft.originalCreatedBefore],
|
||||
['duration', draft.duration],
|
||||
['parentPostIds', draft.parentPostIds]] as const
|
||||
draftFields.forEach (([field, value]) => {
|
||||
nextAttributes[field] = value
|
||||
|
||||
@@ -24,12 +24,15 @@ describe ('post import storage', () => {
|
||||
importStatus: 'skipped',
|
||||
skipReason: 'existing',
|
||||
existingPostId: 10 })
|
||||
const manual = buildPostImportRow ({
|
||||
sourceRow: 3,
|
||||
skipReason: 'manual' })
|
||||
|
||||
expect (savePostImportSession ('session', {
|
||||
expect (savePostImportSession ({
|
||||
source: skipped.url,
|
||||
rows: [row, skipped],
|
||||
rows: [row, skipped, manual],
|
||||
repairMode: 'all' })).toBe (true)
|
||||
expect (loadPostImportSession ('session')).toMatchObject ({
|
||||
expect (loadPostImportSession ()).toMatchObject ({
|
||||
version: 2,
|
||||
source: skipped.url,
|
||||
rows: [{
|
||||
@@ -39,7 +42,9 @@ describe ('post import storage', () => {
|
||||
{
|
||||
importStatus: 'skipped',
|
||||
skipReason: 'existing',
|
||||
existingPostId: 10 }] })
|
||||
existingPostId: 10 },
|
||||
{
|
||||
skipReason: 'manual' }] })
|
||||
|
||||
expect (savePostImportSourceDraft ('https://example.com')).toBe (true)
|
||||
expect (loadPostImportSourceDraft ()).toEqual ({ source: 'https://example.com' })
|
||||
@@ -63,12 +68,13 @@ describe ('post import storage', () => {
|
||||
{ ...session.rows[0], importStatus: 'skipped', 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,
|
||||
rows: [row] }))
|
||||
expect (loadPostImportSession (`invalid-${ index }`)).toBeNull ()
|
||||
expect (loadPostImportSession ()).toBeNull ()
|
||||
sessionStorage.clear ()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -82,18 +88,19 @@ describe ('post import storage', () => {
|
||||
{ ...row, resetSnapshot: { ...row.resetSnapshot,
|
||||
fieldWarnings: { title: 'warning' } } }]
|
||||
|
||||
invalidRows.forEach ((invalidRow, index) => {
|
||||
sessionStorage.setItem (`post-import-session:shape-${ index }`, JSON.stringify ({
|
||||
invalidRows.forEach (invalidRow => {
|
||||
sessionStorage.setItem ('post-import-session:current', JSON.stringify ({
|
||||
version: 2,
|
||||
savedAt: new Date ().toISOString (),
|
||||
source: '',
|
||||
rows: [invalidRow],
|
||||
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 = {
|
||||
version: 2,
|
||||
savedAt: new Date ().toISOString (),
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { PostImportOrigin,
|
||||
|
||||
const SESSION_VERSION = 2
|
||||
const SESSION_PREFIX = 'post-import-session:'
|
||||
const CURRENT_SESSION_KEY = `${ SESSION_PREFIX }current`
|
||||
const SOURCE_DRAFT_KEY = 'post-import-source-draft'
|
||||
const SESSION_MAX_AGE_MS = 24 * 60 * 60 * 1000
|
||||
const ATTRIBUTE_KEYS = [
|
||||
@@ -28,7 +29,7 @@ const isPlainObject = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === 'object' && value != null && !(Array.isArray (value))
|
||||
|
||||
|
||||
const sessionKey = (sessionId: string): string => `${ SESSION_PREFIX }${ sessionId }`
|
||||
const sessionKey = (): string => CURRENT_SESSION_KEY
|
||||
|
||||
|
||||
const readStorage = (
|
||||
@@ -128,7 +129,7 @@ const isValidOrigin = (
|
||||
const isValidSkipReason = (
|
||||
value: unknown,
|
||||
): value is PostImportSkipReason =>
|
||||
value === 'existing'
|
||||
value === 'existing' || value === 'manual'
|
||||
|
||||
const isPositiveInteger = (value: unknown): value is number =>
|
||||
Number.isInteger (value) && Number (value) > 0
|
||||
@@ -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 = (
|
||||
onError?: StorageErrorHandler,
|
||||
) => {
|
||||
@@ -311,6 +306,13 @@ export const cleanupExpiredPostImportSessions = (
|
||||
const raw = sessionStorage.getItem (key)
|
||||
if (raw == null)
|
||||
continue
|
||||
|
||||
if (key !== CURRENT_SESSION_KEY)
|
||||
{
|
||||
sessionStorage.removeItem (key)
|
||||
--i
|
||||
continue
|
||||
}
|
||||
try
|
||||
{
|
||||
const value = JSON.parse (raw) as { savedAt?: string }
|
||||
@@ -368,24 +370,38 @@ export const clearPostImportSourceDraft = (
|
||||
|
||||
|
||||
export const savePostImportSession = (
|
||||
sessionId: string,
|
||||
session: Omit<PostImportSession, 'version' | 'savedAt'>,
|
||||
sessionOrLegacyId: string | Omit<PostImportSession, 'version' | 'savedAt'>,
|
||||
sessionOrOnError?: Omit<PostImportSession, 'version' | 'savedAt'> | StorageErrorHandler,
|
||||
onError?: StorageErrorHandler,
|
||||
): boolean =>
|
||||
writeStorage (
|
||||
sessionKey (sessionId),
|
||||
): boolean => {
|
||||
const session =
|
||||
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 ({
|
||||
...session,
|
||||
version: SESSION_VERSION,
|
||||
savedAt: new Date ().toISOString () }),
|
||||
onError)
|
||||
errorHandler)
|
||||
}
|
||||
|
||||
|
||||
export const loadPostImportSession = (
|
||||
sessionId: string,
|
||||
legacyIdOrOnError?: string | StorageErrorHandler,
|
||||
onError?: StorageErrorHandler,
|
||||
): PostImportSession | null => {
|
||||
const raw = readStorage (sessionKey (sessionId), onError)
|
||||
const errorHandler =
|
||||
typeof legacyIdOrOnError === 'string'
|
||||
? onError
|
||||
: legacyIdOrOnError
|
||||
const raw = readStorage (sessionKey (), errorHandler)
|
||||
if (raw == null)
|
||||
return null
|
||||
|
||||
@@ -396,7 +412,7 @@ export const loadPostImportSession = (
|
||||
return null
|
||||
if (typeof value.savedAt !== 'string' || isExpiredSession (value.savedAt))
|
||||
{
|
||||
removeStorage (sessionKey (sessionId), onError)
|
||||
removeStorage (sessionKey (), errorHandler)
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -416,3 +432,10 @@ export const loadPostImportSession = (
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export const clearPostImportSession = (
|
||||
onError?: StorageErrorHandler,
|
||||
) => {
|
||||
removeStorage (sessionKey (), onError)
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ export type PostImportStatus =
|
||||
| 'created'
|
||||
| 'skipped'
|
||||
| 'failed'
|
||||
export type PostImportSkipReason = 'existing'
|
||||
export type PostImportSkipReason = 'existing' | 'manual'
|
||||
export type PostImportResultStatus = 'created' | 'skipped' | 'failed'
|
||||
export type PostImportAttributeValue = string | number
|
||||
|
||||
@@ -78,7 +78,6 @@ export type PostImportEditableDraft = {
|
||||
thumbnailBase: string
|
||||
originalCreatedFrom: string
|
||||
originalCreatedBefore: string
|
||||
duration: string
|
||||
tags: string
|
||||
parentPostIds: string }
|
||||
|
||||
|
||||
@@ -0,0 +1,686 @@
|
||||
import { resultRepairMode } from '@/lib/postImportRows'
|
||||
|
||||
import type {
|
||||
PostImportOrigin,
|
||||
PostImportRepairMode,
|
||||
PostImportRow,
|
||||
PostImportSkipReason,
|
||||
} from '@/lib/postImportTypes'
|
||||
|
||||
type QueryRowState = {
|
||||
title: string
|
||||
thumbnail_base: string
|
||||
original_created_from: string
|
||||
original_created_before: string
|
||||
duration: string
|
||||
tags: string
|
||||
parent_post_ids: string
|
||||
manual: string[]
|
||||
skip: PostImportSkipReason | null
|
||||
existing_post_id: number | null
|
||||
import_status: PostImportRow['importStatus'] | null
|
||||
created_post_id: number | null
|
||||
recoverable: boolean | null }
|
||||
|
||||
type FullRowState = QueryRowState & { url: string }
|
||||
|
||||
type MultiRowMetaState = {
|
||||
v: 1
|
||||
rows: EncodedRowState[] }
|
||||
|
||||
type EncodedSkip = 'e' | 'm'
|
||||
type EncodedImportStatus = 'p' | 'c' | 's' | 'f'
|
||||
|
||||
type EncodedRowState = {
|
||||
u?: string
|
||||
t?: string
|
||||
h?: string
|
||||
f?: string
|
||||
b?: string
|
||||
d?: string
|
||||
g?: string
|
||||
p?: string
|
||||
m?: string[]
|
||||
s?: EncodedSkip
|
||||
e?: number
|
||||
i?: EncodedImportStatus
|
||||
c?: number
|
||||
r?: boolean }
|
||||
|
||||
type EncodedState = {
|
||||
v: 1
|
||||
rows: EncodedRowState[] }
|
||||
|
||||
export type ParsedPostNewState = {
|
||||
rows: PostImportRow[]
|
||||
source: string
|
||||
repairMode: PostImportRepairMode
|
||||
shortcut: boolean }
|
||||
|
||||
export type SerialisedPostNewState = {
|
||||
path: string
|
||||
rows: PostImportRow[] }
|
||||
|
||||
const BASIC_KEYS = [
|
||||
'url',
|
||||
'title',
|
||||
'thumbnail_base',
|
||||
'original_created_from',
|
||||
'original_created_before',
|
||||
'duration',
|
||||
'tags',
|
||||
'parent_post_ids'] as const
|
||||
const STATE_KEYS = [
|
||||
'manual',
|
||||
'skip',
|
||||
'existing_post_id',
|
||||
'import_status',
|
||||
'created_post_id',
|
||||
'recoverable'] as const
|
||||
const SINGLE_ROW_KEYS = [...BASIC_KEYS, ...STATE_KEYS] as const
|
||||
const MAX_REGULAR_URL_LENGTH = 768
|
||||
const MAX_COMPRESSED_STATE_LENGTH = 767
|
||||
const COMPRESSED_STATE_PREFIX = 'z1.'
|
||||
|
||||
const textEncoder = new TextEncoder ()
|
||||
const textDecoder = new TextDecoder ()
|
||||
|
||||
const isValidSkipReason = (value: unknown): value is PostImportSkipReason =>
|
||||
value === 'existing' || value === 'manual'
|
||||
|
||||
|
||||
const isValidImportStatus = (
|
||||
value: unknown,
|
||||
): value is PostImportRow['importStatus'] =>
|
||||
value === 'pending'
|
||||
|| value === 'created'
|
||||
|| value === 'skipped'
|
||||
|| value === 'failed'
|
||||
|
||||
|
||||
const encodeSkip = (
|
||||
value: PostImportSkipReason | null,
|
||||
): EncodedSkip | undefined =>
|
||||
value === 'existing'
|
||||
? 'e'
|
||||
: value === 'manual'
|
||||
? 'm'
|
||||
: undefined
|
||||
|
||||
|
||||
const decodeSkip = (
|
||||
value: unknown,
|
||||
): PostImportSkipReason | null => {
|
||||
if (value == null)
|
||||
return null
|
||||
if (value === 'e')
|
||||
return 'existing'
|
||||
if (value === 'm')
|
||||
return 'manual'
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
const encodeImportStatus = (
|
||||
value: PostImportRow['importStatus'] | null,
|
||||
): EncodedImportStatus | undefined =>
|
||||
value === 'pending'
|
||||
? 'p'
|
||||
: value === 'created'
|
||||
? 'c'
|
||||
: value === 'skipped'
|
||||
? 's'
|
||||
: value === 'failed'
|
||||
? 'f'
|
||||
: undefined
|
||||
|
||||
|
||||
const decodeImportStatus = (
|
||||
value: unknown,
|
||||
): PostImportRow['importStatus'] | null => {
|
||||
if (value == null)
|
||||
return null
|
||||
if (value === 'p')
|
||||
return 'pending'
|
||||
if (value === 'c')
|
||||
return 'created'
|
||||
if (value === 's')
|
||||
return 'skipped'
|
||||
if (value === 'f')
|
||||
return 'failed'
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
const parsePositiveInt = (value: string | null): number | null => {
|
||||
if (value == null || value === '')
|
||||
return null
|
||||
|
||||
const parsed = Number.parseInt (value, 10)
|
||||
return Number.isInteger (parsed) && parsed > 0 ? parsed : null
|
||||
}
|
||||
|
||||
|
||||
const decodeBase64UrlBytes = (value: string): Uint8Array | null => {
|
||||
try
|
||||
{
|
||||
const padded = value.replaceAll ('-', '+').replaceAll ('_', '/')
|
||||
const remainder = padded.length % 4
|
||||
const base64 =
|
||||
remainder === 0
|
||||
? padded
|
||||
: `${ padded }${ '='.repeat (4 - remainder) }`
|
||||
const binary = window.atob (base64)
|
||||
return Uint8Array.from (binary, char => char.charCodeAt (0))
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const encodeBase64UrlBytes = (value: Uint8Array): string => {
|
||||
const binary = Array.from (value, byte => String.fromCharCode (byte)).join ('')
|
||||
return window.btoa (binary).replaceAll ('+', '-').replaceAll ('/', '_').replaceAll ('=', '')
|
||||
}
|
||||
|
||||
|
||||
const compressBytes = async (value: Uint8Array): Promise<Uint8Array> => {
|
||||
const stream =
|
||||
new Blob ([value]).stream ().pipeThrough (new CompressionStream ('gzip'))
|
||||
return new Uint8Array (await new Response (stream).arrayBuffer ())
|
||||
}
|
||||
|
||||
|
||||
const decompressBytes = async (value: Uint8Array): Promise<Uint8Array> => {
|
||||
const stream =
|
||||
new Blob ([value]).stream ().pipeThrough (new DecompressionStream ('gzip'))
|
||||
return new Uint8Array (await new Response (stream).arrayBuffer ())
|
||||
}
|
||||
|
||||
|
||||
const decodeCompressedState = async (value: string): Promise<string | null> => {
|
||||
if (!(value.startsWith (COMPRESSED_STATE_PREFIX)))
|
||||
return null
|
||||
|
||||
const encoded = value.slice (COMPRESSED_STATE_PREFIX.length)
|
||||
const compressed = decodeBase64UrlBytes (encoded)
|
||||
if (compressed == null)
|
||||
return null
|
||||
|
||||
try
|
||||
{
|
||||
return textDecoder.decode (await decompressBytes (compressed))
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const encodeCompressedState = async (value: string): Promise<string> => {
|
||||
const compressed = await compressBytes (textEncoder.encode (value))
|
||||
return `${ COMPRESSED_STATE_PREFIX }${ encodeBase64UrlBytes (compressed) }`
|
||||
}
|
||||
|
||||
|
||||
const manualFields = (row: PostImportRow): string[] =>
|
||||
['title',
|
||||
'thumbnailBase',
|
||||
'originalCreatedFrom',
|
||||
'originalCreatedBefore',
|
||||
'tags',
|
||||
'parentPostIds']
|
||||
.filter (field => row.provenance[field] === 'manual')
|
||||
|
||||
|
||||
const baseRowState = (row: PostImportRow): QueryRowState => ({
|
||||
title: String (row.attributes.title ?? ''),
|
||||
thumbnail_base: String (row.attributes.thumbnailBase ?? ''),
|
||||
original_created_from: String (row.attributes.originalCreatedFrom ?? ''),
|
||||
original_created_before: String (row.attributes.originalCreatedBefore ?? ''),
|
||||
duration: String (row.attributes.duration ?? ''),
|
||||
tags: String (row.attributes.tags ?? ''),
|
||||
parent_post_ids: String (row.attributes.parentPostIds ?? ''),
|
||||
manual: manualFields (row),
|
||||
skip: row.skipReason ?? null,
|
||||
existing_post_id: row.existingPostId ?? null,
|
||||
import_status: row.importStatus ?? null,
|
||||
created_post_id: row.createdPostId ?? null,
|
||||
recoverable: row.recoverable ?? null })
|
||||
|
||||
|
||||
const buildRow = (
|
||||
sourceRow: number,
|
||||
state: FullRowState,
|
||||
): PostImportRow => {
|
||||
const manual = new Set (state.manual)
|
||||
const provenance: Record<string, PostImportOrigin> = {
|
||||
url: 'manual',
|
||||
title: manual.has ('title') ? 'manual' : 'automatic',
|
||||
thumbnailBase: manual.has ('thumbnailBase') ? 'manual' : 'automatic',
|
||||
originalCreatedFrom: manual.has ('originalCreatedFrom') ? 'manual' : 'automatic',
|
||||
originalCreatedBefore: manual.has ('originalCreatedBefore') ? 'manual' : 'automatic',
|
||||
duration: 'automatic',
|
||||
tags: manual.has ('tags') ? 'manual' : 'automatic',
|
||||
parentPostIds: manual.has ('parentPostIds') ? 'manual' : 'automatic' }
|
||||
const tagSources = provenance.tags === 'manual'
|
||||
? { automatic: '', manual: state.tags }
|
||||
: { automatic: state.tags, manual: '' }
|
||||
const attributes = {
|
||||
title: state.title,
|
||||
thumbnailBase: state.thumbnail_base,
|
||||
originalCreatedFrom: state.original_created_from,
|
||||
originalCreatedBefore: state.original_created_before,
|
||||
duration: state.duration,
|
||||
tags: state.tags,
|
||||
parentPostIds: state.parent_post_ids }
|
||||
|
||||
return {
|
||||
sourceRow,
|
||||
url: state.url,
|
||||
attributes,
|
||||
fieldWarnings: { },
|
||||
baseWarnings: [],
|
||||
validationErrors: { },
|
||||
provenance,
|
||||
tagSources,
|
||||
status: 'ready',
|
||||
skipReason: state.skip ?? undefined,
|
||||
existingPostId: state.existing_post_id ?? undefined,
|
||||
resetSnapshot: {
|
||||
url: state.url,
|
||||
attributes: { ...attributes },
|
||||
provenance: { ...provenance },
|
||||
tagSources: { ...tagSources },
|
||||
fieldWarnings: { },
|
||||
baseWarnings: [] },
|
||||
createdPostId: state.created_post_id ?? undefined,
|
||||
importStatus: state.import_status ?? undefined,
|
||||
recoverable: state.recoverable ?? undefined }
|
||||
}
|
||||
|
||||
|
||||
const parseManual = (value: string | null): string[] =>
|
||||
(value ?? '')
|
||||
.split (',')
|
||||
.map (entry => entry.trim ())
|
||||
.filter (entry => entry !== '')
|
||||
|
||||
|
||||
const isStringArray = (value: unknown): value is string[] =>
|
||||
Array.isArray (value) && value.every (entry => typeof entry === 'string')
|
||||
|
||||
|
||||
const parseEncodedRow = (
|
||||
value: unknown,
|
||||
requireUrl: boolean,
|
||||
): FullRowState | null => {
|
||||
if (typeof value !== 'object' || value == null || Array.isArray (value))
|
||||
return null
|
||||
|
||||
const row = value as Record<string, unknown>
|
||||
const url = row['u']
|
||||
const manual = row['m']
|
||||
const skip = decodeSkip (row['s'])
|
||||
const importStatus = decodeImportStatus (row['i'])
|
||||
const existingPostId = row['e']
|
||||
const createdPostId = row['c']
|
||||
const recoverable = row['r']
|
||||
|
||||
if (requireUrl && typeof url !== 'string')
|
||||
return null
|
||||
if (manual != null && !(isStringArray (manual)))
|
||||
return null
|
||||
if (row['s'] != null && skip == null)
|
||||
return null
|
||||
if (row['i'] != null && importStatus == null)
|
||||
return null
|
||||
if (existingPostId != null
|
||||
&& !(typeof existingPostId === 'number'
|
||||
&& Number.isInteger (existingPostId)
|
||||
&& existingPostId > 0))
|
||||
return null
|
||||
if (createdPostId != null
|
||||
&& !(typeof createdPostId === 'number'
|
||||
&& Number.isInteger (createdPostId)
|
||||
&& createdPostId > 0))
|
||||
return null
|
||||
if (recoverable != null && typeof recoverable !== 'boolean')
|
||||
return null
|
||||
|
||||
const stringFields = [
|
||||
['t', 'title'],
|
||||
['h', 'thumbnail_base'],
|
||||
['f', 'original_created_from'],
|
||||
['b', 'original_created_before'],
|
||||
['d', 'duration'],
|
||||
['g', 'tags'],
|
||||
['p', 'parent_post_ids']] as const
|
||||
let parsedStrings: Record<(typeof stringFields)[number][1], string>
|
||||
try
|
||||
{
|
||||
parsedStrings = Object.fromEntries (
|
||||
stringFields.map (([key, target]) => {
|
||||
const fieldValue = row[key]
|
||||
if (fieldValue != null && typeof fieldValue !== 'string')
|
||||
throw new Error ('invalid row')
|
||||
return [target, fieldValue ?? '']
|
||||
}),
|
||||
) as Record<(typeof stringFields)[number][1], string>
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
url: typeof url === 'string' ? url : '',
|
||||
title: parsedStrings.title,
|
||||
thumbnail_base: parsedStrings.thumbnail_base,
|
||||
original_created_from: parsedStrings.original_created_from,
|
||||
original_created_before: parsedStrings.original_created_before,
|
||||
duration: parsedStrings.duration,
|
||||
tags: parsedStrings.tags,
|
||||
parent_post_ids: parsedStrings.parent_post_ids,
|
||||
manual: manual ?? [],
|
||||
skip,
|
||||
existing_post_id: existingPostId ?? null,
|
||||
import_status: importStatus,
|
||||
created_post_id: createdPostId ?? null,
|
||||
recoverable: recoverable ?? null }
|
||||
}
|
||||
|
||||
|
||||
const parseSingleRow = (params: URLSearchParams): ParsedPostNewState | null => {
|
||||
const url = params.get ('url')
|
||||
if (url == null || url === '')
|
||||
return null
|
||||
|
||||
const hasOnlyUrl = Array.from (params.keys ()).every (key => key === 'url')
|
||||
if (hasOnlyUrl)
|
||||
{
|
||||
const row = buildRow (1, {
|
||||
url,
|
||||
title: '',
|
||||
thumbnail_base: '',
|
||||
original_created_from: '',
|
||||
original_created_before: '',
|
||||
duration: '',
|
||||
tags: '',
|
||||
parent_post_ids: '',
|
||||
manual: [],
|
||||
skip: null,
|
||||
existing_post_id: null,
|
||||
import_status: null,
|
||||
created_post_id: null,
|
||||
recoverable: null })
|
||||
return {
|
||||
rows: [row],
|
||||
source: url,
|
||||
repairMode: 'all',
|
||||
shortcut: true }
|
||||
}
|
||||
|
||||
if (!(Array.from (params.keys ()).every (key => SINGLE_ROW_KEYS.includes (key as never))))
|
||||
return null
|
||||
|
||||
const skipValue = params.get ('skip')
|
||||
const importStatus = params.get ('import_status')
|
||||
const recoverable =
|
||||
params.get ('recoverable') == null
|
||||
? null
|
||||
: params.get ('recoverable') === 'true'
|
||||
if (skipValue != null && !(isValidSkipReason (skipValue)))
|
||||
return null
|
||||
if (importStatus != null && !(isValidImportStatus (importStatus)))
|
||||
return null
|
||||
|
||||
const row = buildRow (1, {
|
||||
url,
|
||||
title: params.get ('title') ?? '',
|
||||
thumbnail_base: params.get ('thumbnail_base') ?? '',
|
||||
original_created_from: params.get ('original_created_from') ?? '',
|
||||
original_created_before: params.get ('original_created_before') ?? '',
|
||||
duration: params.get ('duration') ?? '',
|
||||
tags: params.get ('tags') ?? '',
|
||||
parent_post_ids: params.get ('parent_post_ids') ?? '',
|
||||
manual: parseManual (params.get ('manual')),
|
||||
skip: skipValue,
|
||||
existing_post_id: parsePositiveInt (params.get ('existing_post_id')),
|
||||
import_status: importStatus,
|
||||
created_post_id: parsePositiveInt (params.get ('created_post_id')),
|
||||
recoverable })
|
||||
|
||||
return {
|
||||
rows: [row],
|
||||
source: url,
|
||||
repairMode: resultRepairMode ([row]),
|
||||
shortcut: false }
|
||||
}
|
||||
|
||||
|
||||
const parseMetaRows = (
|
||||
urlsValue: string,
|
||||
metaValue: string,
|
||||
): ParsedPostNewState | null => {
|
||||
const decodedBytes = decodeBase64UrlBytes (metaValue)
|
||||
if (decodedBytes == null)
|
||||
return null
|
||||
|
||||
let parsed: MultiRowMetaState
|
||||
try
|
||||
{
|
||||
parsed = JSON.parse (textDecoder.decode (decodedBytes)) as MultiRowMetaState
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null
|
||||
}
|
||||
|
||||
if (parsed.v !== 1 || !(Array.isArray (parsed.rows)))
|
||||
return null
|
||||
|
||||
const urls = urlsValue.split (' ').filter (url => url !== '')
|
||||
if (urls.length < 2 || urls.length !== parsed.rows.length)
|
||||
return null
|
||||
|
||||
const fullRows = parsed.rows.map ((row, index) => {
|
||||
const parsedRow = parseEncodedRow (row, false)
|
||||
return parsedRow == null
|
||||
? null
|
||||
: { ...parsedRow, url: urls[index] ?? '' }
|
||||
})
|
||||
if (fullRows.some (row => row == null))
|
||||
return null
|
||||
|
||||
const rows = fullRows.map ((row, index) => buildRow (index + 1, row as FullRowState))
|
||||
return {
|
||||
rows,
|
||||
source: urls.join ('\n'),
|
||||
repairMode: resultRepairMode (rows),
|
||||
shortcut: false }
|
||||
}
|
||||
|
||||
|
||||
const parseFullState = async (stateValue: string): Promise<ParsedPostNewState | null> => {
|
||||
const decoded = await decodeCompressedState (stateValue)
|
||||
if (decoded == null)
|
||||
return null
|
||||
|
||||
let parsed: EncodedState
|
||||
try
|
||||
{
|
||||
parsed = JSON.parse (decoded) as EncodedState
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null
|
||||
}
|
||||
|
||||
if (parsed.v !== 1 || !(Array.isArray (parsed.rows)) || parsed.rows.length === 0)
|
||||
return null
|
||||
|
||||
const fullRows = parsed.rows.map (row => parseEncodedRow (row, true))
|
||||
if (fullRows.some (row => row == null))
|
||||
return null
|
||||
|
||||
const rows = fullRows.map ((row, index) => buildRow (index + 1, row as FullRowState))
|
||||
return {
|
||||
rows,
|
||||
source: rows.map (row => row.url).join ('\n'),
|
||||
repairMode: resultRepairMode (rows),
|
||||
shortcut: false }
|
||||
}
|
||||
|
||||
|
||||
const regularQuery = (rows: PostImportRow[]): URLSearchParams => {
|
||||
if (rows.length === 1)
|
||||
{
|
||||
const row = rows[0]
|
||||
const params = new URLSearchParams ()
|
||||
params.set ('url', row?.url ?? '')
|
||||
params.set ('title', String (row?.attributes.title ?? ''))
|
||||
params.set ('thumbnail_base', String (row?.attributes.thumbnailBase ?? ''))
|
||||
params.set ('original_created_from', String (row?.attributes.originalCreatedFrom ?? ''))
|
||||
params.set ('original_created_before', String (row?.attributes.originalCreatedBefore ?? ''))
|
||||
params.set ('duration', String (row?.attributes.duration ?? ''))
|
||||
params.set ('tags', String (row?.attributes.tags ?? ''))
|
||||
params.set ('parent_post_ids', String (row?.attributes.parentPostIds ?? ''))
|
||||
const manual = manualFields (row).join (',')
|
||||
if (manual !== '')
|
||||
params.set ('manual', manual)
|
||||
if (row.skipReason != null)
|
||||
params.set ('skip', row.skipReason)
|
||||
if (row.existingPostId != null)
|
||||
params.set ('existing_post_id', String (row.existingPostId))
|
||||
if (row.importStatus != null)
|
||||
params.set ('import_status', row.importStatus)
|
||||
if (row.createdPostId != null)
|
||||
params.set ('created_post_id', String (row.createdPostId))
|
||||
if (row.recoverable != null)
|
||||
params.set ('recoverable', row.recoverable ? 'true' : 'false')
|
||||
return params
|
||||
}
|
||||
|
||||
const params = new URLSearchParams ()
|
||||
params.set ('urls', rows.map (row => row.url).join (' '))
|
||||
params.set ('meta', encodeBase64UrlBytes (textEncoder.encode (JSON.stringify ({
|
||||
v: 1,
|
||||
rows: rows.map (row => encodeRowState (row, false)) }))))
|
||||
return params
|
||||
}
|
||||
|
||||
|
||||
const encodeRowState = (
|
||||
row: PostImportRow,
|
||||
includeUrl: boolean,
|
||||
): EncodedRowState => {
|
||||
const base = baseRowState (row)
|
||||
const encoded: EncodedRowState = includeUrl ? { u: row.url } : { }
|
||||
|
||||
if (base.title !== '')
|
||||
encoded.t = base.title
|
||||
if (base.thumbnail_base !== '')
|
||||
encoded.h = base.thumbnail_base
|
||||
if (base.original_created_from !== '')
|
||||
encoded.f = base.original_created_from
|
||||
if (base.original_created_before !== '')
|
||||
encoded.b = base.original_created_before
|
||||
if (base.duration !== '')
|
||||
encoded.d = base.duration
|
||||
if (base.tags !== '')
|
||||
encoded.g = base.tags
|
||||
if (base.parent_post_ids !== '')
|
||||
encoded.p = base.parent_post_ids
|
||||
if (base.manual.length > 0)
|
||||
encoded.m = [...base.manual]
|
||||
if (base.skip != null)
|
||||
encoded.s = encodeSkip (base.skip)
|
||||
if (base.existing_post_id != null)
|
||||
encoded.e = base.existing_post_id
|
||||
if (base.import_status != null)
|
||||
encoded.i = encodeImportStatus (base.import_status)
|
||||
if (base.created_post_id != null)
|
||||
encoded.c = base.created_post_id
|
||||
if (base.recoverable != null)
|
||||
encoded.r = base.recoverable
|
||||
|
||||
return encoded
|
||||
}
|
||||
|
||||
|
||||
const encodeStateRows = (rows: PostImportRow[]): EncodedState => ({
|
||||
v: 1,
|
||||
rows: rows.map (row => encodeRowState (row, true)) })
|
||||
|
||||
|
||||
export const hasPostNewReviewState = (search: string): boolean => {
|
||||
const params = new URLSearchParams (search)
|
||||
return params.has ('state')
|
||||
|| params.has ('urls')
|
||||
|| params.has ('meta')
|
||||
|| params.has ('url')
|
||||
}
|
||||
|
||||
|
||||
export const parsePostNewState = async (
|
||||
search: string,
|
||||
): Promise<ParsedPostNewState | null> => {
|
||||
const params = new URLSearchParams (search)
|
||||
const stateValue = params.get ('state')
|
||||
if (stateValue != null)
|
||||
return await parseFullState (stateValue)
|
||||
|
||||
const urlsValue = params.get ('urls')
|
||||
const metaValue = params.get ('meta')
|
||||
if (urlsValue != null || metaValue != null)
|
||||
{
|
||||
if (urlsValue == null || metaValue == null)
|
||||
return null
|
||||
return parseMetaRows (urlsValue, metaValue)
|
||||
}
|
||||
|
||||
return parseSingleRow (params)
|
||||
}
|
||||
|
||||
|
||||
const serialiseCompressedRows = async (
|
||||
rows: PostImportRow[],
|
||||
): Promise<SerialisedPostNewState | null> => {
|
||||
try
|
||||
{
|
||||
for (let count = rows.length; count > 0; --count)
|
||||
{
|
||||
const nextRows = rows.slice (0, count)
|
||||
const encoded = encodeStateRows (nextRows)
|
||||
const state = await encodeCompressedState (JSON.stringify (encoded))
|
||||
const payload = state.slice (COMPRESSED_STATE_PREFIX.length)
|
||||
if (payload.length <= MAX_COMPRESSED_STATE_LENGTH)
|
||||
return {
|
||||
path: `/posts/new?state=${ state }`,
|
||||
rows: nextRows }
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
export const serialisePostNewState = async (
|
||||
rows: PostImportRow[],
|
||||
): Promise<SerialisedPostNewState | null> => {
|
||||
const regular = regularQuery (rows).toString ()
|
||||
const regularPath = `/posts/new?${ regular }`
|
||||
if (regularPath.length < MAX_REGULAR_URL_LENGTH)
|
||||
return {
|
||||
path: regularPath,
|
||||
rows }
|
||||
|
||||
return await serialiseCompressedRows (rows)
|
||||
}
|
||||
@@ -1,402 +1,48 @@
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { HelmetProvider } from 'react-helmet-async'
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { MemoryRouter, Navigate, Route, Routes } from 'react-router-dom'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
loadPostImportSession,
|
||||
savePostImportSession,
|
||||
} from '@/lib/postImportSession'
|
||||
import PostImportResultPage from '@/pages/posts/PostImportResultPage'
|
||||
import { buildUser } from '@/test/factories'
|
||||
import { buildPostImportRow } from '@/test/postImportFactories'
|
||||
import { originalCreatedAtString } from '@/lib/utils'
|
||||
describe ('legacy post import routes', () => {
|
||||
it ('redirects /posts/import to /posts/new', () => {
|
||||
render (
|
||||
<MemoryRouter initialEntries={['/posts/import']}>
|
||||
<Routes>
|
||||
<Route path="/posts/import" element={<Navigate to="/posts/new" replace/>}/>
|
||||
<Route path="/posts/new" element={<div>SOURCE ROUTE</div>}/>
|
||||
</Routes>
|
||||
</MemoryRouter>)
|
||||
|
||||
import type { PostImportRow } from '@/lib/postImportSession'
|
||||
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' }))
|
||||
expect (screen.getByText ('SOURCE ROUTE')).toBeInTheDocument ()
|
||||
})
|
||||
|
||||
it ('shows edit and retry only for recoverable failures and shows warnings', () => {
|
||||
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: ['サムネール画像を取得できませんでした.'] } })] })
|
||||
|
||||
it ('redirects legacy review routes to /posts/new', () => {
|
||||
render (
|
||||
<HelmetProvider>
|
||||
<MemoryRouter initialEntries={['/posts/import/result-session/result']}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/posts/import/:sessionId/result"
|
||||
element={<PostImportResultPage user={buildUser ()}/>}/>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</HelmetProvider>)
|
||||
<MemoryRouter initialEntries={['/posts/import/legacy/review']}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/posts/import/:sessionId/review"
|
||||
element={<Navigate to="/posts/new" replace/>}/>
|
||||
<Route path="/posts/new" element={<div>SOURCE ROUTE</div>}/>
|
||||
</Routes>
|
||||
</MemoryRouter>)
|
||||
|
||||
expect (screen.getAllByRole ('button', { name: '編輯' })).toHaveLength (1)
|
||||
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 ()
|
||||
expect (screen.getByText ('SOURCE ROUTE')).toBeInTheDocument ()
|
||||
})
|
||||
|
||||
it ('shows hour-precision original-created ranges with the shared utility output', () => {
|
||||
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 } })] })
|
||||
|
||||
it ('redirects legacy result routes to /posts', () => {
|
||||
render (
|
||||
<HelmetProvider>
|
||||
<MemoryRouter initialEntries={['/posts/import/result-hour-range/result']}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/posts/import/:sessionId/result"
|
||||
element={<PostImportResultPage user={buildUser ()}/>}/>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</HelmetProvider>)
|
||||
<MemoryRouter initialEntries={['/posts/import/legacy/result']}>
|
||||
<Routes>
|
||||
<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" element={<div>POSTS ROUTE</div>}/>
|
||||
</Routes>
|
||||
</MemoryRouter>)
|
||||
|
||||
expect (screen.getByText (originalCreatedAtString (from, before))).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: '登録結果が不完全でした' }))
|
||||
})
|
||||
expect (screen.getByText ('POSTS ROUTE')).toBeInTheDocument ()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -30,6 +30,20 @@ vi.mock ('@/lib/dialogues/useDialogue', () => ({
|
||||
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', () => {
|
||||
beforeEach (() => {
|
||||
sessionStorage.clear ()
|
||||
@@ -39,8 +53,140 @@ describe ('PostImportReviewPage', () => {
|
||||
api.apiGet.mockResolvedValue (new Blob (['img'], { type: 'image/png' }))
|
||||
})
|
||||
|
||||
it ('navigates to the result route after an initial recoverable failure', async () => {
|
||||
savePostImportSession ('review-session', {
|
||||
it ('redirects to /posts/new when no current work exists', async () => {
|
||||
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 (screen.getByRole ('heading', { name: '追加内容確認' })).toBeInTheDocument ()
|
||||
expect (screen.queryByText ('広場に投稿を追加')).not.toBeInTheDocument ()
|
||||
expect (screen.queryByText ('投稿インポート')).not.toBeInTheDocument ()
|
||||
expect (await screen.findByText ('restored row')).toBeInTheDocument ()
|
||||
})
|
||||
|
||||
it ('does not expose メタデータ to the user', async () => {
|
||||
savePostImportSession ({
|
||||
source: 'https://example.com/post',
|
||||
repairMode: 'all',
|
||||
rows: [buildPostImportRow ({
|
||||
sourceRow: 1,
|
||||
fieldWarnings: { title: ['自動取得に失敗しました.'] } })] })
|
||||
|
||||
renderReviewPage ()
|
||||
|
||||
expect (screen.queryByText (/メタデータ/)).not.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',
|
||||
repairMode: 'all',
|
||||
rows: [buildPostImportRow ({ sourceRow: 1 })] })
|
||||
@@ -57,140 +203,21 @@ describe ('PostImportReviewPage', () => {
|
||||
recoverable: true,
|
||||
errors: { title: ['invalid'] } }] })
|
||||
|
||||
render (
|
||||
<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>)
|
||||
renderReviewPage ()
|
||||
|
||||
fireEvent.click (screen.getByRole ('button', { name: '取込実行' }))
|
||||
|
||||
await waitFor (() => {
|
||||
expect (screen.getByText ('RESULT ROUTE')).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 ()
|
||||
expect (await screen.findByText ('invalid')).toBeInTheDocument ()
|
||||
expect (screen.queryByText ('POSTS ROUTE')).not.toBeInTheDocument ()
|
||||
})
|
||||
|
||||
it ('keeps edited recoverable rows in session before the next batch submit', async () => {
|
||||
savePostImportSession ('review-edit-retry', {
|
||||
savePostImportSession ({
|
||||
source: 'https://example.com/post',
|
||||
repairMode: 'failed',
|
||||
rows: [buildPostImportRow ({
|
||||
sourceRow: 1,
|
||||
attributes: { title: 'old title' },
|
||||
attributes: { title: 'old title', duration: '2' },
|
||||
importStatus: 'failed',
|
||||
recoverable: true,
|
||||
importErrors: { base: ['failed'] } })] })
|
||||
@@ -198,13 +225,13 @@ describe ('PostImportReviewPage', () => {
|
||||
.mockResolvedValueOnce ({
|
||||
rows: [buildPostImportRow ({
|
||||
sourceRow: 1,
|
||||
attributes: { title: 'edited title' },
|
||||
attributes: { title: 'edited title', duration: '2' },
|
||||
importStatus: 'pending',
|
||||
recoverable: true })] })
|
||||
.mockResolvedValueOnce ({
|
||||
rows: [buildPostImportRow ({
|
||||
sourceRow: 1,
|
||||
attributes: { title: 'edited title' },
|
||||
attributes: { title: 'edited title', duration: '2' },
|
||||
importStatus: 'pending',
|
||||
recoverable: true })] })
|
||||
.mockResolvedValueOnce ({
|
||||
@@ -232,24 +259,12 @@ describe ('PostImportReviewPage', () => {
|
||||
await actions.find (action => action.label === '編輯内容を保存')?.onSelect ()
|
||||
})
|
||||
|
||||
render (
|
||||
<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>)
|
||||
renderReviewPage ()
|
||||
|
||||
fireEvent.click (screen.getByRole ('button', { name: '編輯' }))
|
||||
|
||||
await waitFor (() => {
|
||||
const saved = loadPostImportSession ('review-edit-retry')
|
||||
const saved = loadPostImportSession ()
|
||||
expect (saved?.rows[0]?.attributes.title).toBe ('edited title')
|
||||
expect (saved?.rows[0]?.importStatus).toBe ('pending')
|
||||
})
|
||||
@@ -259,11 +274,158 @@ describe ('PostImportReviewPage', () => {
|
||||
await waitFor (() => {
|
||||
expect (api.apiPost.mock.calls[1]?.[1]?.rows?.[0]?.attributes?.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 ('allows ready rows to be manually skipped and excludes them from API payloads', async () => {
|
||||
savePostImportSession ({
|
||||
source: 'https://example.com/post',
|
||||
repairMode: 'all',
|
||||
rows: [buildPostImportRow ({ sourceRow: 1 })] })
|
||||
|
||||
renderReviewPage ()
|
||||
|
||||
fireEvent.click (screen.getByRole ('checkbox', { name: 'スキップ' }))
|
||||
|
||||
await waitFor (() => {
|
||||
expect (screen.getByText ('POSTS ROUTE')).toBeInTheDocument ()
|
||||
})
|
||||
expect (api.apiPost).not.toHaveBeenCalled ()
|
||||
})
|
||||
|
||||
it ('restores values and errors when manual skip is cleared', async () => {
|
||||
savePostImportSession ({
|
||||
source: 'https://example.com/post',
|
||||
repairMode: 'all',
|
||||
rows: [buildPostImportRow ({
|
||||
sourceRow: 1,
|
||||
status: 'error',
|
||||
attributes: { title: 'kept title' },
|
||||
validationErrors: { title: ['invalid'] } })] })
|
||||
|
||||
renderReviewPage ()
|
||||
|
||||
const toggle = await screen.findByRole ('checkbox', { name: 'スキップ' })
|
||||
fireEvent.click (toggle)
|
||||
fireEvent.click (screen.getByRole ('checkbox', { name: 'スキップ' }))
|
||||
|
||||
expect (screen.getByText ('kept title')).toBeInTheDocument ()
|
||||
expect (screen.getByText ('invalid')).toBeInTheDocument ()
|
||||
})
|
||||
|
||||
it ('moves existing skipped rows into the collapsed section', async () => {
|
||||
savePostImportSession ({
|
||||
source: 'https://example.com/post',
|
||||
repairMode: 'all',
|
||||
rows: [
|
||||
buildPostImportRow ({
|
||||
sourceRow: 1,
|
||||
attributes: { title: 'existing row' },
|
||||
skipReason: 'existing',
|
||||
existingPostId: 2 }),
|
||||
buildPostImportRow ({
|
||||
sourceRow: 2,
|
||||
attributes: { title: 'normal row' } })] })
|
||||
|
||||
renderReviewPage ()
|
||||
|
||||
expect (screen.getByText ('既存投稿による自動スキップ 1件')).toBeInTheDocument ()
|
||||
expect (screen.queryByText ('existing row')).not.toBeInTheDocument ()
|
||||
expect (screen.getByText ('normal row')).toBeInTheDocument ()
|
||||
|
||||
fireEvent.click (screen.getByRole ('button', { name: '既存投稿による自動スキップ 1件' }))
|
||||
|
||||
expect (screen.getByText ('existing row')).toBeInTheDocument ()
|
||||
expect (screen.queryAllByRole ('button', { name: '編輯' })).toHaveLength (1)
|
||||
expect (screen.queryAllByRole ('checkbox', { name: 'スキップ' })).toHaveLength (1)
|
||||
})
|
||||
|
||||
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', () => {
|
||||
savePostImportSession ('review-repair-sort', {
|
||||
savePostImportSession ({
|
||||
source: '',
|
||||
repairMode: 'failed',
|
||||
rows: [
|
||||
@@ -277,16 +439,7 @@ describe ('PostImportReviewPage', () => {
|
||||
recoverable: true,
|
||||
validationErrors: { title: ['invalid'] } })] })
|
||||
|
||||
const { container } = render (
|
||||
<HelmetProvider>
|
||||
<MemoryRouter initialEntries={['/posts/import/review-repair-sort/review']}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/posts/import/:sessionId/review"
|
||||
element={<PostImportReviewPage user={buildUser ()}/>}/>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</HelmetProvider>)
|
||||
const { container } = renderReviewPage ()
|
||||
|
||||
const titles = Array.from (container.querySelectorAll ('.line-clamp-2')).map (
|
||||
node => node.textContent)
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { useQueries } from '@tanstack/react-query'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
|
||||
import PageTitle from '@/components/common/PageTitle'
|
||||
import MainArea from '@/components/layout/MainArea'
|
||||
import PrefetchLink from '@/components/PrefetchLink'
|
||||
import PostThumbnailPreview from '@/components/posts/PostThumbnailPreview'
|
||||
import PostImportRowForm from '@/components/posts/import/PostImportRowForm'
|
||||
import PostImportRowSummary from '@/components/posts/import/PostImportRowSummary'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -11,33 +15,53 @@ import { toast } from '@/components/ui/use-toast'
|
||||
import { SITE_TITLE } from '@/config'
|
||||
import { apiPost } from '@/lib/api'
|
||||
import useDialogue from '@/lib/dialogues/useDialogue'
|
||||
import { fetchPost } from '@/lib/posts'
|
||||
import {
|
||||
parsePostNewState,
|
||||
serialisePostNewState,
|
||||
} from '@/lib/postNewQueryState'
|
||||
import {
|
||||
buildNextEditedRow,
|
||||
canEditReviewRow,
|
||||
canRetryResultRow,
|
||||
clearPostImportSourceDraft,
|
||||
hasExactSourceRows,
|
||||
initialisePreviewRows,
|
||||
isCompletedReviewRow,
|
||||
isExistingSkipRow,
|
||||
isNonRecoverableFailedRow,
|
||||
mergeImportResults,
|
||||
mergeValidatedImportRow,
|
||||
mergeValidatedImportRows,
|
||||
processableImportRows,
|
||||
replaceImportRow,
|
||||
resultRepairMode,
|
||||
resultRowMessages,
|
||||
reviewSummaryCounts,
|
||||
retryImportRow,
|
||||
validatableImportRows,
|
||||
} from '@/lib/postImportSession'
|
||||
import { postsKeys } from '@/lib/queryKeys'
|
||||
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
|
||||
import { canEditContent } from '@/lib/users'
|
||||
import { loadPostImportSession,
|
||||
buildNextEditedRow,
|
||||
canEditReviewRow,
|
||||
creatableImportRows,
|
||||
hasExactSourceRows,
|
||||
initialisePreviewRows,
|
||||
mergeImportResults,
|
||||
mergeValidatedImportRow,
|
||||
mergeValidatedImportRows,
|
||||
processableImportRows,
|
||||
replaceImportRow,
|
||||
resultRepairMode,
|
||||
reviewSummaryCounts,
|
||||
savePostImportSession } from '@/lib/postImportSession'
|
||||
import Forbidden from '@/pages/Forbidden'
|
||||
|
||||
import type { FC } from 'react'
|
||||
|
||||
import type { PostImportRowDraft } from '@/components/posts/import/PostImportRowForm'
|
||||
import type { PostImportResultRow,
|
||||
PostImportRow,
|
||||
PostImportSession } from '@/lib/postImportSession'
|
||||
import type { User } from '@/types'
|
||||
import type {
|
||||
PostImportResultRow,
|
||||
PostImportRow,
|
||||
PostImportSession,
|
||||
} from '@/lib/postImportSession'
|
||||
import type { Post, User } from '@/types'
|
||||
|
||||
type Props = { user: User | null }
|
||||
|
||||
type ExistingSkippedRowsProps = {
|
||||
open: boolean
|
||||
rows: PostImportRow[] }
|
||||
|
||||
const isRepairRow = (row: PostImportRow): boolean =>
|
||||
row.recoverable === true
|
||||
&& (row.importStatus === 'failed'
|
||||
@@ -45,57 +69,216 @@ const isRepairRow = (row: PostImportRow): boolean =>
|
||||
&& Object.keys (row.validationErrors).length > 0))
|
||||
|
||||
|
||||
const buildSession = (rows: PostImportRow[]): PostImportSession => ({
|
||||
version: 2,
|
||||
savedAt: new Date ().toISOString (),
|
||||
source: rows.map (row => row.url).join ('\n'),
|
||||
rows,
|
||||
repairMode: resultRepairMode (rows) })
|
||||
|
||||
|
||||
const ExistingSkippedRows: FC<ExistingSkippedRowsProps> = ({ open, rows }) => {
|
||||
const existingPostIds = useMemo (
|
||||
() =>
|
||||
open
|
||||
? Array.from (
|
||||
new Set (
|
||||
rows
|
||||
.map (row => row.existingPostId)
|
||||
.filter ((postId): postId is number => postId != null),
|
||||
),
|
||||
)
|
||||
: [],
|
||||
[open, rows],
|
||||
)
|
||||
const posts = useQueries ({
|
||||
queries: existingPostIds.map (postId => ({
|
||||
enabled: open,
|
||||
queryKey: postsKeys.show (String (postId)),
|
||||
queryFn: () => fetchPost (String (postId)) })),
|
||||
})
|
||||
const postsById = new Map<number, Post> (
|
||||
posts
|
||||
.map (query => query.data)
|
||||
.filter ((post): post is Post => post != null)
|
||||
.map (post => [post.id, post]),
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="mt-3 max-h-72 overflow-y-auto rounded border">
|
||||
<div className="divide-y">
|
||||
{existingPostIds.map (postId => {
|
||||
const post = postsById.get (postId)
|
||||
if (post == null)
|
||||
return <div key={postId} className="h-14"/>
|
||||
|
||||
return (
|
||||
<PrefetchLink
|
||||
key={postId}
|
||||
to={`/posts/${ post.id }`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-3 p-3">
|
||||
<PostThumbnailPreview
|
||||
url={post.thumbnail ?? ''}
|
||||
className="h-10 w-10 shrink-0"/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium">
|
||||
{post.title ?? ''}
|
||||
</div>
|
||||
<div className="truncate text-xs text-neutral-600 dark:text-neutral-300">
|
||||
{post.url}
|
||||
</div>
|
||||
</div>
|
||||
</PrefetchLink>)
|
||||
})}
|
||||
</div>
|
||||
</div>)
|
||||
}
|
||||
|
||||
|
||||
const PostImportReviewPage: FC<Props> = ({ user }) => {
|
||||
const editable = canEditContent (user)
|
||||
const dialogue = useDialogue ()
|
||||
const location = useLocation ()
|
||||
const navigate = useNavigate ()
|
||||
const { sessionId } = useParams ()
|
||||
const behaviourSettings = useClientBehaviourSettings ()
|
||||
const animationMode = behaviourSettings.animation ?? 'normal'
|
||||
const existingRowsTransition =
|
||||
animationMode === 'off'
|
||||
? { duration: 0 }
|
||||
: animationMode === 'reduced'
|
||||
? { duration: .08, ease: 'linear' as const }
|
||||
: { duration: .2, ease: 'easeOut' as const }
|
||||
|
||||
const [session, setSession] = useState<PostImportSession | null> (null)
|
||||
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 [showExistingRows, setShowExistingRows] = useState (false)
|
||||
const sessionRef = useRef<PostImportSession | null> (null)
|
||||
const persistedSearchRef = useRef<string | null> (null)
|
||||
const persistSequenceRef = useRef (0)
|
||||
|
||||
const persistSession = async (
|
||||
nextSession: PostImportSession,
|
||||
): Promise<PostImportSession | null> => {
|
||||
const sequence = ++persistSequenceRef.current
|
||||
const serialised = await serialisePostNewState (nextSession.rows)
|
||||
if (serialised == null)
|
||||
return null
|
||||
if (persistSequenceRef.current !== sequence)
|
||||
return sessionRef.current
|
||||
|
||||
const persistedSession = buildSession (serialised.rows)
|
||||
persistedSearchRef.current =
|
||||
(new URL (serialised.path, window.location.origin)).search
|
||||
sessionRef.current = persistedSession
|
||||
setSession (persistedSession)
|
||||
navigate (serialised.path, { replace: true })
|
||||
return persistedSession
|
||||
}
|
||||
|
||||
const finishImport = () => {
|
||||
clearPostImportSourceDraft (message =>
|
||||
toast ({ title: '入力内容を削除できませんでした', description: message }))
|
||||
navigate ('/posts')
|
||||
}
|
||||
|
||||
useEffect (() => {
|
||||
if (sessionId == null)
|
||||
return
|
||||
let active = true
|
||||
|
||||
const loaded = loadPostImportSession (sessionId, message =>
|
||||
toast ({ title: '取込状態を復元できませんでした', description: message }))
|
||||
setSession (loaded)
|
||||
setMissing (loaded == null)
|
||||
}, [sessionId])
|
||||
if (sessionRef.current != null && persistedSearchRef.current === location.search)
|
||||
return () => {
|
||||
}
|
||||
|
||||
const hydrate = async () => {
|
||||
const parsed = await parsePostNewState (location.search)
|
||||
if (!(active))
|
||||
return
|
||||
if (parsed == null)
|
||||
{
|
||||
navigate ('/posts/new', { replace: true })
|
||||
return
|
||||
}
|
||||
|
||||
const loaded = buildSession (parsed.rows)
|
||||
persistedSearchRef.current = location.search
|
||||
sessionRef.current = loaded
|
||||
setSession (loaded)
|
||||
|
||||
if (parsed.shortcut)
|
||||
{
|
||||
try
|
||||
{
|
||||
const preview = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/preview', {
|
||||
source: parsed.source })
|
||||
if (!(active))
|
||||
return
|
||||
const rows = initialisePreviewRows (preview.rows)
|
||||
const persisted = await persistSession (buildSession (rows))
|
||||
if (persisted == null)
|
||||
return
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (active)
|
||||
navigate ('/posts/new', { replace: true })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const requestedRows = validatableImportRows (parsed.rows)
|
||||
if (requestedRows.length === 0)
|
||||
return
|
||||
|
||||
try
|
||||
{
|
||||
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: 'all' })
|
||||
if (!(active))
|
||||
return
|
||||
|
||||
const validatedRows = initialisePreviewRows (validated.rows)
|
||||
if (!(hasExactSourceRows (requestedRows.map (row => row.sourceRow), validatedRows)))
|
||||
return
|
||||
|
||||
const latest = sessionRef.current ?? loaded
|
||||
const rows = mergeValidatedImportRows (latest.rows, validatedRows)
|
||||
const persisted = await persistSession (buildSession (rows))
|
||||
if (persisted == null)
|
||||
return
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
void hydrate ()
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [location.search, navigate])
|
||||
|
||||
useEffect (() => {
|
||||
if (sessionId == null || session == null)
|
||||
return
|
||||
|
||||
savePostImportSession (sessionId, session, message =>
|
||||
toast ({ title: '取込状態を保存できませんでした', description: message }))
|
||||
}, [session, sessionId])
|
||||
|
||||
useEffect (() => {
|
||||
sessionRef.current = session
|
||||
if (session != null)
|
||||
sessionRef.current = session
|
||||
}, [session])
|
||||
|
||||
const rows = session?.rows ?? []
|
||||
const counts = useMemo (() => reviewSummaryCounts (rows), [rows])
|
||||
const processable = useMemo (
|
||||
() => processableImportRows (rows),
|
||||
[rows])
|
||||
const creatable = useMemo (
|
||||
() => creatableImportRows (rows),
|
||||
[rows])
|
||||
const reviewRows =
|
||||
session?.repairMode === 'failed'
|
||||
? (
|
||||
[...rows].sort ((a, b) => {
|
||||
const aRepair = isRepairRow (a) ? 0 : 1
|
||||
const bRepair = isRepairRow (b) ? 0 : 1
|
||||
return aRepair - bRepair || a.sourceRow - b.sourceRow
|
||||
}))
|
||||
: rows
|
||||
useEffect (() => {
|
||||
if (session == null || session.rows.length === 0)
|
||||
return
|
||||
if (session.rows.every (row => isCompletedReviewRow (row)))
|
||||
finishImport ()
|
||||
}, [session])
|
||||
|
||||
useEffect (() => {
|
||||
if (editingRow == null || session?.repairMode !== 'failed')
|
||||
@@ -105,11 +288,49 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
||||
element?.scrollIntoView ({ block: 'center', behavior: 'smooth' })
|
||||
}, [editingRow, session?.repairMode])
|
||||
|
||||
const rows = session?.rows ?? []
|
||||
const counts = useMemo (() => reviewSummaryCounts (rows), [rows])
|
||||
const sortedRows =
|
||||
session?.repairMode === 'failed'
|
||||
? (
|
||||
[...rows].sort ((a, b) => {
|
||||
const aRepair = isRepairRow (a) ? 0 : 1
|
||||
const bRepair = isRepairRow (b) ? 0 : 1
|
||||
return aRepair - bRepair || a.sourceRow - b.sourceRow
|
||||
}))
|
||||
: rows
|
||||
const existingRows = sortedRows.filter (row => isExistingSkipRow (row))
|
||||
const reviewRows = sortedRows.filter (row => !(isExistingSkipRow (row)))
|
||||
const busy = loading || loadingRow != null
|
||||
|
||||
const toggleManualSkip = async (sourceRow: number, checked: boolean) => {
|
||||
if (busy)
|
||||
return
|
||||
|
||||
const currentSession = sessionRef.current
|
||||
if (currentSession == null)
|
||||
return
|
||||
|
||||
const nextRows = currentSession.rows.map (row => {
|
||||
if (row.sourceRow !== sourceRow)
|
||||
return row
|
||||
if (isExistingSkipRow (row)
|
||||
|| row.importStatus === 'created'
|
||||
|| isNonRecoverableFailedRow (row))
|
||||
return row
|
||||
return {
|
||||
...row,
|
||||
skipReason: checked ? 'manual' : undefined,
|
||||
existingPostId: checked ? undefined : row.existingPostId }
|
||||
})
|
||||
await persistSession (buildSession (nextRows))
|
||||
}
|
||||
|
||||
const saveDraft = async (
|
||||
row: PostImportRow,
|
||||
{ draft, resetRequested }: {
|
||||
draft: PostImportRowDraft
|
||||
resetRequested: boolean },
|
||||
draft: PostImportRowDraft
|
||||
resetRequested: boolean },
|
||||
): Promise<{ saved: boolean
|
||||
row: PostImportRow | null }> => {
|
||||
const currentSession = sessionRef.current
|
||||
@@ -133,55 +354,47 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
||||
: row
|
||||
const urlChanged = draft.url !== baseRow.url
|
||||
const nextRow = buildNextEditedRow (baseRow, draft, urlChanged)
|
||||
|
||||
const nextRows = currentSession.rows.map (row =>
|
||||
row.sourceRow === baseRow.sourceRow
|
||||
const nextRows = currentSession.rows.map (currentRow =>
|
||||
currentRow.sourceRow === baseRow.sourceRow
|
||||
? nextRow
|
||||
: row)
|
||||
: currentRow)
|
||||
try
|
||||
{
|
||||
const validated = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/validate', {
|
||||
rows:
|
||||
nextRows
|
||||
.filter (row => row.importStatus !== 'created')
|
||||
.map (row => ({ sourceRow: row.sourceRow,
|
||||
url: row.url,
|
||||
attributes: row.attributes,
|
||||
provenance: row.provenance,
|
||||
tagSources: row.tagSources,
|
||||
metadataUrl: row.metadataUrl })),
|
||||
validatableImportRows (nextRows)
|
||||
.map (currentRow => ({
|
||||
sourceRow: currentRow.sourceRow,
|
||||
url: currentRow.url,
|
||||
attributes: currentRow.attributes,
|
||||
provenance: currentRow.provenance,
|
||||
tagSources: currentRow.tagSources,
|
||||
metadataUrl: currentRow.metadataUrl })),
|
||||
changed_row: urlChanged ? baseRow.sourceRow : -1 })
|
||||
const validatedRows = initialisePreviewRows (validated.rows)
|
||||
const target = validatedRows.find (
|
||||
validatedRow => validatedRow.sourceRow === baseRow.sourceRow)
|
||||
validatedRow => validatedRow.sourceRow === baseRow.sourceRow)
|
||||
const latestSession = sessionRef.current
|
||||
if (latestSession == null)
|
||||
return { saved: false, row: null }
|
||||
return { saved: false, row: null }
|
||||
if (target == null)
|
||||
{
|
||||
const restoredRows = replaceImportRow (latestSession.rows, row)
|
||||
const restoredSession = {
|
||||
...latestSession,
|
||||
rows: restoredRows,
|
||||
repairMode: resultRepairMode (restoredRows) }
|
||||
sessionRef.current = restoredSession
|
||||
setSession (restoredSession)
|
||||
toast ({ title: '行の再検証結果が不完全でした' })
|
||||
return { saved: false, row: null }
|
||||
}
|
||||
{
|
||||
const restoredRows = replaceImportRow (latestSession.rows, row)
|
||||
await persistSession (buildSession (restoredRows))
|
||||
toast ({ title: '行の再検証結果が不完全でした' })
|
||||
return { saved: false, row: null }
|
||||
}
|
||||
|
||||
const editedRows = replaceImportRow (latestSession.rows, nextRow)
|
||||
const rows = mergeValidatedImportRow (editedRows, target)
|
||||
const nextSession = {
|
||||
...latestSession,
|
||||
rows,
|
||||
repairMode: resultRepairMode (rows) }
|
||||
sessionRef.current = nextSession
|
||||
setSession (nextSession)
|
||||
const mergedRows = mergeValidatedImportRow (editedRows, target)
|
||||
const nextSession = await persistSession (buildSession (mergedRows))
|
||||
if (nextSession == null)
|
||||
return { saved: false, row: null }
|
||||
const mergedTarget =
|
||||
rows.find (mergedRow => mergedRow.sourceRow === baseRow.sourceRow)
|
||||
?? target
|
||||
nextSession.rows.find (mergedRow => mergedRow.sourceRow === baseRow.sourceRow)
|
||||
?? target
|
||||
if (Object.keys (mergedTarget.validationErrors).length > 0)
|
||||
return { saved: false, row: mergedTarget }
|
||||
return { saved: false, row: mergedTarget }
|
||||
return { saved: true, row: null }
|
||||
}
|
||||
catch
|
||||
@@ -220,89 +433,115 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
||||
finally
|
||||
{
|
||||
setEditingRow (current =>
|
||||
current?.sourceRow === row.sourceRow
|
||||
? null
|
||||
: current)
|
||||
current?.sourceRow === row.sourceRow
|
||||
? null
|
||||
: current)
|
||||
}
|
||||
}
|
||||
|
||||
const submit = async () => {
|
||||
const currentSession = sessionRef.current
|
||||
if (sessionId == null || currentSession == null || processable.length === 0)
|
||||
const retry = async (sourceRow: number) => {
|
||||
if (busy)
|
||||
return
|
||||
|
||||
setLoading (true)
|
||||
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 validatableRows =
|
||||
currentSession.rows.filter (row => row.importStatus !== 'created')
|
||||
const pendingRows = retryImportRow (initialSession.rows, sourceRow)
|
||||
const pendingSession = await persistSession (buildSession (pendingRows))
|
||||
if (pendingSession == null)
|
||||
return
|
||||
|
||||
const requestedRows = validatableImportRows (pendingSession.rows)
|
||||
const validated = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/validate', {
|
||||
rows:
|
||||
validatableRows.map (row => ({ sourceRow: row.sourceRow,
|
||||
url: row.url,
|
||||
attributes: row.attributes,
|
||||
provenance: row.provenance,
|
||||
tagSources: row.tagSources,
|
||||
metadataUrl: row.metadataUrl })),
|
||||
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)
|
||||
const expectedSourceRows = validatableRows.map (row => row.sourceRow)
|
||||
if (!(hasExactSourceRows (expectedSourceRows, validatedRows)))
|
||||
{
|
||||
toast ({ title: '再検証結果が不完全でした' })
|
||||
return
|
||||
}
|
||||
const latestAfterValidate = sessionRef.current ?? currentSession
|
||||
const mergedRows = mergeValidatedImportRows (latestAfterValidate.rows, validatedRows)
|
||||
const firstInvalid = mergedRows.find (row => Object.keys (row.validationErrors).length > 0)
|
||||
if (firstInvalid != null)
|
||||
if (!(hasExactSourceRows (requestedRows.map (row => row.sourceRow), validatedRows)))
|
||||
{
|
||||
const nextSession = {
|
||||
...latestAfterValidate,
|
||||
rows: mergedRows,
|
||||
repairMode: resultRepairMode (mergedRows) }
|
||||
sessionRef.current = nextSession
|
||||
setSession (nextSession)
|
||||
void editRow (firstInvalid)
|
||||
const latest = sessionRef.current ?? pendingSession
|
||||
const restoredRows = replaceImportRow (latest.rows, originalRow)
|
||||
await persistSession (buildSession (restoredRows))
|
||||
toast ({ title: '再検証結果が不完全でした' })
|
||||
return
|
||||
}
|
||||
const validatedSession = {
|
||||
...latestAfterValidate,
|
||||
rows: mergedRows,
|
||||
repairMode: resultRepairMode (mergedRows) }
|
||||
sessionRef.current = validatedSession
|
||||
setSession (validatedSession)
|
||||
const savedValidated = savePostImportSession (sessionId, validatedSession, message =>
|
||||
toast ({ title: '取込状態を保存できませんでした', description: message }))
|
||||
if (!(savedValidated))
|
||||
|
||||
const validatedTarget = validatedRows.find (row => row.sourceRow === sourceRow)
|
||||
if (validatedTarget == null)
|
||||
{
|
||||
const latest = sessionRef.current ?? pendingSession
|
||||
const restoredRows = replaceImportRow (latest.rows, originalRow)
|
||||
await persistSession (buildSession (restoredRows))
|
||||
toast ({ title: '再検証結果が不完全でした' })
|
||||
return
|
||||
}
|
||||
|
||||
const latestAfterValidate = sessionRef.current ?? pendingSession
|
||||
const mergedValidatedRows = mergeValidatedImportRow (
|
||||
latestAfterValidate.rows,
|
||||
validatedTarget)
|
||||
const nextSession = await persistSession (buildSession (mergedValidatedRows))
|
||||
if (nextSession == null)
|
||||
return
|
||||
const target = nextSession.rows.find (row => row.sourceRow === sourceRow)
|
||||
if (target == null)
|
||||
{
|
||||
const restoredRows = replaceImportRow (latestAfterValidate.rows, originalRow)
|
||||
await persistSession (buildSession (restoredRows))
|
||||
toast ({ title: '再検証結果が不完全でした' })
|
||||
return
|
||||
}
|
||||
if (Object.keys (target.validationErrors ?? { }).length > 0)
|
||||
{
|
||||
editRow (target)
|
||||
return
|
||||
}
|
||||
|
||||
const result = await apiPost<{
|
||||
created: number
|
||||
skipped: number
|
||||
failed: number
|
||||
rows: PostImportResultRow[] }> ('/posts/import', {
|
||||
rows: processableImportRows (mergedRows).map (row => ({
|
||||
sourceRow: row.sourceRow,
|
||||
url: row.url,
|
||||
attributes: row.attributes,
|
||||
provenance: row.provenance,
|
||||
tagSources: row.tagSources,
|
||||
metadataUrl: row.metadataUrl })) })
|
||||
const expectedImportRows = processableImportRows (mergedRows).map (row => row.sourceRow)
|
||||
if (!(hasExactSourceRows (expectedImportRows, result.rows)))
|
||||
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)
|
||||
await persistSession (buildSession (restoredRows))
|
||||
toast ({ title: '登録結果が不完全でした' })
|
||||
return
|
||||
}
|
||||
const latestAfterImport = sessionRef.current ?? validatedSession
|
||||
const mergedResults = mergeImportResults (latestAfterImport.rows, result.rows)
|
||||
|
||||
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 = mergedResults.map ((row): PostImportRow => {
|
||||
const recoverable = recoverableRows.find (rr => rr.sourceRow === row.sourceRow)
|
||||
const nextRows = mergedRows.map ((row): PostImportRow => {
|
||||
const recoverable = recoverableRows.find (
|
||||
failedRow => failedRow.sourceRow === row.sourceRow)
|
||||
if (recoverable == null)
|
||||
return row
|
||||
return {
|
||||
@@ -312,17 +551,110 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
||||
validationErrors: recoverable.errors ?? { },
|
||||
importErrors: undefined }
|
||||
})
|
||||
const nextSession = {
|
||||
...latestAfterImport,
|
||||
rows: nextRows,
|
||||
repairMode: resultRepairMode (nextRows) }
|
||||
sessionRef.current = nextSession
|
||||
setSession (nextSession)
|
||||
const saved = savePostImportSession (sessionId, nextSession, message =>
|
||||
toast ({ title: '取込状態を保存できませんでした', description: message }))
|
||||
if (!(saved))
|
||||
await persistSession (buildSession (nextRows))
|
||||
}
|
||||
catch
|
||||
{
|
||||
const latest = sessionRef.current ?? initialSession
|
||||
const restoredRows = replaceImportRow (latest.rows, originalRow)
|
||||
await persistSession (buildSession (restoredRows))
|
||||
toast ({ title: '再試行に失敗しました' })
|
||||
}
|
||||
finally
|
||||
{
|
||||
setLoadingRow (null)
|
||||
}
|
||||
}
|
||||
|
||||
const submit = async () => {
|
||||
const currentSession = sessionRef.current
|
||||
if (currentSession == null || busy)
|
||||
return
|
||||
if (currentSession.rows.every (row => isCompletedReviewRow (row)))
|
||||
{
|
||||
finishImport ()
|
||||
return
|
||||
navigate (`/posts/import/${ sessionId }/result`)
|
||||
}
|
||||
|
||||
setLoading (true)
|
||||
try
|
||||
{
|
||||
const validatableRows = validatableImportRows (currentSession.rows)
|
||||
if (validatableRows.length === 0)
|
||||
{
|
||||
finishImport ()
|
||||
return
|
||||
}
|
||||
const validated = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/validate', {
|
||||
rows:
|
||||
validatableRows.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)
|
||||
const expectedSourceRows = validatableRows.map (row => row.sourceRow)
|
||||
if (!(hasExactSourceRows (expectedSourceRows, validatedRows)))
|
||||
{
|
||||
toast ({ title: '再検証結果が不完全でした' })
|
||||
return
|
||||
}
|
||||
|
||||
const latestAfterValidate = sessionRef.current ?? currentSession
|
||||
const mergedRows = mergeValidatedImportRows (latestAfterValidate.rows, validatedRows)
|
||||
const persistedValidated = await persistSession (buildSession (mergedRows))
|
||||
if (persistedValidated == null)
|
||||
return
|
||||
const currentRows = persistedValidated.rows
|
||||
const firstInvalid = currentRows.find (
|
||||
row => Object.keys (row.validationErrors).length > 0)
|
||||
if (firstInvalid != null)
|
||||
{
|
||||
editRow (firstInvalid)
|
||||
return
|
||||
}
|
||||
|
||||
const result = await apiPost<{
|
||||
created: number
|
||||
skipped: number
|
||||
failed: number
|
||||
rows: PostImportResultRow[] }> ('/posts/import', {
|
||||
rows: processableImportRows (currentRows).map (row => ({
|
||||
sourceRow: row.sourceRow,
|
||||
url: row.url,
|
||||
attributes: row.attributes,
|
||||
provenance: row.provenance,
|
||||
tagSources: row.tagSources,
|
||||
metadataUrl: row.metadataUrl })) })
|
||||
const expectedImportRows = processableImportRows (currentRows).map (row => row.sourceRow)
|
||||
if (!(hasExactSourceRows (expectedImportRows, result.rows)))
|
||||
{
|
||||
toast ({ title: '登録結果が不完全でした' })
|
||||
return
|
||||
}
|
||||
|
||||
const latestAfterImport = sessionRef.current ?? buildSession (currentRows)
|
||||
const mergedResults = mergeImportResults (latestAfterImport.rows, result.rows)
|
||||
const recoverableRows = result.rows.filter (row =>
|
||||
row.status === 'failed'
|
||||
&& row.recoverable
|
||||
&& Object.keys (row.errors ?? { }).length > 0)
|
||||
const nextRows = mergedResults.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 }
|
||||
})
|
||||
await persistSession (buildSession (nextRows))
|
||||
}
|
||||
catch
|
||||
{
|
||||
@@ -337,64 +669,92 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
||||
if (!(editable))
|
||||
return <Forbidden/>
|
||||
|
||||
if (missing || sessionId == null || session == 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>)
|
||||
}
|
||||
if (session == null)
|
||||
return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{`投稿インポート確認 | ${ SITE_TITLE }`}</title>
|
||||
<title>{`追加内容確認 | ${ SITE_TITLE }`}</title>
|
||||
</Helmet>
|
||||
|
||||
<MainArea className="min-h-0">
|
||||
<div className="mx-auto max-w-6xl space-y-4 p-4">
|
||||
<PageTitle>投稿情報の確認</PageTitle>
|
||||
<PageTitle>追加内容確認</PageTitle>
|
||||
|
||||
{counts.existingSkipped > 0 && (
|
||||
<div className="rounded-lg border p-4">
|
||||
<button
|
||||
type="button"
|
||||
className="text-left text-sm font-medium"
|
||||
onClick={() => setShowExistingRows (current => !(current))}>
|
||||
既存投稿による自動スキップ {counts.existingSkipped}件
|
||||
</button>
|
||||
<AnimatePresence initial={false}>
|
||||
{showExistingRows && (
|
||||
<motion.div
|
||||
initial={
|
||||
animationMode === 'off'
|
||||
? false
|
||||
: { height: 0, opacity: 0 }
|
||||
}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={existingRowsTransition}
|
||||
className="overflow-hidden">
|
||||
<ExistingSkippedRows open={showExistingRows} rows={existingRows}/>
|
||||
</motion.div>)}
|
||||
</AnimatePresence>
|
||||
</div>)}
|
||||
|
||||
<div className="space-y-3">
|
||||
{reviewRows.map (row => (
|
||||
{reviewRows.map ((row, index) => (
|
||||
<div key={row.sourceRow} id={`post-import-row-${ row.sourceRow }`}>
|
||||
<PostImportRowSummary
|
||||
row={row}
|
||||
editDisabled={loading}
|
||||
onEdit={() => void editRow (row)}/>
|
||||
displayNumber={index + 1}
|
||||
showSkipToggle={true}
|
||||
editDisabled={busy}
|
||||
retryDisabled={busy}
|
||||
skipDisabled={
|
||||
busy
|
||||
|| isExistingSkipRow (row)
|
||||
|| row.importStatus === 'created'
|
||||
|| isNonRecoverableFailedRow (row)}
|
||||
onEdit={() => editRow (row)}
|
||||
onToggleSkip={checked => toggleManualSkip (row.sourceRow, checked)}
|
||||
onRetry={canRetryResultRow (row) ? () => retry (row.sourceRow) : undefined}
|
||||
rowMessages={resultRowMessages (row)}/>
|
||||
</div>))}
|
||||
</div>
|
||||
</div>
|
||||
</MainArea>
|
||||
|
||||
<PostImportFooter
|
||||
loading={loading}
|
||||
processableCount={processable.length}
|
||||
creatableCount={creatable.length}
|
||||
skipPlannedCount={counts.skipPlanned}
|
||||
onBack={() => navigate ('/posts/import')}
|
||||
onSubmit={submit}/>
|
||||
loading={busy}
|
||||
creatableCount={counts.creatable}
|
||||
manualSkippedCount={counts.manualSkipped}
|
||||
existingSkippedCount={counts.existingSkipped}
|
||||
pendingOrErrorCount={counts.pendingOrError}
|
||||
onBack={() => navigate (-1)}
|
||||
onSubmit={() => submit ()}/>
|
||||
</>)
|
||||
}
|
||||
|
||||
const PostImportFooter = (
|
||||
{ loading,
|
||||
processableCount,
|
||||
creatableCount,
|
||||
skipPlannedCount,
|
||||
manualSkippedCount,
|
||||
existingSkippedCount,
|
||||
pendingOrErrorCount,
|
||||
onBack,
|
||||
onSubmit }: { loading: boolean
|
||||
processableCount: number
|
||||
creatableCount: number
|
||||
skipPlannedCount: number
|
||||
onBack: () => void
|
||||
onSubmit: () => void },
|
||||
onSubmit }: { loading: boolean
|
||||
creatableCount: number
|
||||
manualSkippedCount: number
|
||||
existingSkippedCount: number
|
||||
pendingOrErrorCount: number
|
||||
onBack: () => void
|
||||
onSubmit: () => void },
|
||||
) => (
|
||||
<div
|
||||
className="shrink-0 border-t bg-white/95 p-4 backdrop-blur
|
||||
@@ -402,9 +762,10 @@ const PostImportFooter = (
|
||||
<div className="mx-auto flex max-w-6xl flex-col gap-3 md:flex-row
|
||||
md:items-center md:justify-between">
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span>処理対象 {processableCount}件</span>
|
||||
<span>登録対象 {creatableCount}件</span>
|
||||
<span>スキップ予定 {skipPlannedCount}件</span>
|
||||
<span>作成対象 {creatableCount}件</span>
|
||||
<span>手動スキップ {manualSkippedCount}件</span>
|
||||
<span>既存投稿による自動スキップ {existingSkippedCount}件</span>
|
||||
<span>error/未処理 {pendingOrErrorCount}件</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<Button type="button" variant="outline" onClick={onBack} disabled={loading}>
|
||||
@@ -413,8 +774,8 @@ const PostImportFooter = (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onSubmit}
|
||||
disabled={loading || processableCount === 0}>
|
||||
取込実行
|
||||
disabled={loading || creatableCount === 0}>
|
||||
{creatableCount > 1 ? '一括追加' : '追加'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -30,6 +30,8 @@ describe ('PostImportSourcePage', () => {
|
||||
it ('shows no empty error initially and validates only after Next is pressed', () => {
|
||||
renderWithProviders (<PostImportSourcePage user={buildUser ()}/>)
|
||||
|
||||
expect (screen.getByRole ('heading', { name: '広場に投稿を追加' })).toBeInTheDocument ()
|
||||
expect (screen.queryByText ('投稿インポート')).not.toBeInTheDocument ()
|
||||
expect (screen.queryByText ('URL を入力してください.')).not.toBeInTheDocument ()
|
||||
fireEvent.click (screen.getByRole ('button', { name: '次へ' }))
|
||||
expect (screen.getByText ('URL を入力してください.')).toBeInTheDocument ()
|
||||
@@ -73,7 +75,7 @@ describe ('PostImportSourcePage', () => {
|
||||
expect.stringMatching (/^post-import-session:/))
|
||||
})
|
||||
|
||||
it ('stores a successful preview before navigating to the review route', async () => {
|
||||
it ('navigates to /posts/new with query state after a successful preview', async () => {
|
||||
api.apiPost.mockResolvedValue ({ rows: [buildPostImportRow ()] })
|
||||
renderWithProviders (<PostImportSourcePage user={buildUser ()}/>)
|
||||
|
||||
@@ -83,11 +85,10 @@ describe ('PostImportSourcePage', () => {
|
||||
|
||||
await waitFor (() => {
|
||||
expect (router.navigate).toHaveBeenCalledWith (
|
||||
expect.stringMatching (/^\/posts\/import\/[^/]+\/review$/))
|
||||
expect.stringMatching (/^\/posts\/new\?/))
|
||||
})
|
||||
const sessionKeys = Array.from ({ length: sessionStorage.length }, (_, index) =>
|
||||
sessionStorage.key (index)).filter (
|
||||
key => key?.startsWith ('post-import-session:'))
|
||||
expect (sessionKeys).toHaveLength (1)
|
||||
expect (Array.from ({ length: sessionStorage.length }, (_, index) =>
|
||||
sessionStorage.key (index))).not.toContainEqual(
|
||||
expect.stringMatching (/^post-import-session:/))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,13 +12,12 @@ import { Button } from '@/components/ui/button'
|
||||
import { toast } from '@/components/ui/use-toast'
|
||||
import { SITE_TITLE } from '@/config'
|
||||
import { apiPost, isApiError } from '@/lib/api'
|
||||
import { serialisePostNewState } from '@/lib/postNewQueryState'
|
||||
import { canEditContent } from '@/lib/users'
|
||||
import { countImportSourceLines,
|
||||
cleanupExpiredPostImportSessions,
|
||||
createPostImportSessionId,
|
||||
initialisePreviewRows,
|
||||
loadPostImportSourceDraft,
|
||||
savePostImportSession,
|
||||
savePostImportSourceDraft,
|
||||
validateImportSource } from '@/lib/postImportSession'
|
||||
import Forbidden from '@/pages/Forbidden'
|
||||
@@ -71,7 +70,12 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
|
||||
toast ({ title: '保存済み入力を復元できませんでした', description: message }))
|
||||
|
||||
if (!(editedRef.current))
|
||||
setSource (current => current === '' ? draft.source : current)
|
||||
{
|
||||
setSource (current =>
|
||||
current === ''
|
||||
? draft.source
|
||||
: current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect (() => {
|
||||
@@ -117,16 +121,10 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
|
||||
setSourceIssues (urlIssues)
|
||||
return
|
||||
}
|
||||
const sessionId = createPostImportSessionId ()
|
||||
const saved = savePostImportSession (
|
||||
sessionId,
|
||||
{ source,
|
||||
rows: initialisePreviewRows (data.rows),
|
||||
repairMode: 'all' },
|
||||
message => toast ({ title: '取込状態を保存できませんでした', description: message }))
|
||||
if (!(saved))
|
||||
return
|
||||
navigate (`/posts/import/${ sessionId }/review`)
|
||||
const nextRows = initialisePreviewRows (data.rows)
|
||||
const serialised = await serialisePostNewState (nextRows)
|
||||
if (serialised != null)
|
||||
navigate (serialised.path)
|
||||
}
|
||||
catch (requestError)
|
||||
{
|
||||
@@ -146,16 +144,16 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
|
||||
}
|
||||
|
||||
if (!(editable))
|
||||
return <Forbidden/>
|
||||
return <Forbidden/>
|
||||
|
||||
return (
|
||||
<MainArea>
|
||||
<Helmet>
|
||||
<title>{`投稿インポート | ${ SITE_TITLE }`}</title>
|
||||
<title>{`広場に投稿を追加 | ${ SITE_TITLE }`}</title>
|
||||
</Helmet>
|
||||
|
||||
<Form className="max-w-4xl">
|
||||
<PageTitle>投稿インポート</PageTitle>
|
||||
<PageTitle>広場に投稿を追加</PageTitle>
|
||||
<FormField label="URL リスト">
|
||||
{() => (
|
||||
<TextArea
|
||||
|
||||
@@ -1,162 +1,44 @@
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { screen } from '@testing-library/react'
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { toMinutePrecisionIsoUtc } from '@/components/common/DateTimeField'
|
||||
import PostNewPage from '@/pages/posts/PostNewPage'
|
||||
import { buildUser } from '@/test/factories'
|
||||
import { renderWithProviders } from '@/test/render'
|
||||
|
||||
const api = vi.hoisted (() => ({
|
||||
apiGet: vi.fn (),
|
||||
apiPost: vi.fn (),
|
||||
isApiError: vi.fn (),
|
||||
}))
|
||||
|
||||
const toastApi = vi.hoisted (() => ({
|
||||
toast: vi.fn (),
|
||||
}))
|
||||
apiPost: vi.fn (),
|
||||
isApiError: vi.fn () }))
|
||||
|
||||
vi.mock ('@/lib/api', () => api)
|
||||
vi.mock ('@/components/ui/use-toast', () => toastApi)
|
||||
|
||||
describe ('PostNewPage', () => {
|
||||
beforeEach (() => {
|
||||
vi.clearAllMocks ()
|
||||
api.isApiError.mockReturnValue (false)
|
||||
sessionStorage.clear ()
|
||||
})
|
||||
|
||||
it ('blocks guests', () => {
|
||||
renderWithProviders (<PostNewPage user={buildUser ({ role: 'guest' })}/>)
|
||||
it ('shows the source page on /posts/new', () => {
|
||||
renderWithProviders (
|
||||
<MemoryRouter initialEntries={['/posts/new']}>
|
||||
<Routes>
|
||||
<Route path="/posts/new" element={<PostNewPage user={buildUser ()}/>}/>
|
||||
</Routes>
|
||||
</MemoryRouter>)
|
||||
|
||||
expect (screen.getByText ('403')).toBeInTheDocument ()
|
||||
expect (screen.getByRole ('heading', { name: '広場に投稿を追加' })).toBeInTheDocument ()
|
||||
})
|
||||
|
||||
it ('submits a new post with manual title and thumbnail fetch UI', async () => {
|
||||
api.apiPost.mockResolvedValueOnce ({})
|
||||
api.apiGet.mockResolvedValue ([])
|
||||
it ('shows the review page when query state is present', () => {
|
||||
renderWithProviders (
|
||||
<MemoryRouter initialEntries={['/posts/new?url=https%3A%2F%2Fexample.com%2Fpost']}>
|
||||
<Routes>
|
||||
<Route path="/posts/new" element={<PostNewPage user={buildUser ()}/>}/>
|
||||
</Routes>
|
||||
</MemoryRouter>)
|
||||
|
||||
renderWithProviders (<PostNewPage user={buildUser ({ role: 'member' })}/>)
|
||||
|
||||
const textboxes = screen.getAllByRole ('textbox')
|
||||
fireEvent.change (textboxes[0], { target: { value: 'https://example.com/post' } })
|
||||
fireEvent.change (textboxes[1], { target: { value: '投稿タイトル' } })
|
||||
fireEvent.change (textboxes[3], { target: { value: '1 2' } })
|
||||
fireEvent.change (textboxes[4], { target: { value: 'tag1 tag2' } })
|
||||
fireEvent.click (screen.getByRole ('button', { name: '追加' }))
|
||||
|
||||
await waitFor (() => {
|
||||
expect (api.apiPost).toHaveBeenCalledWith (
|
||||
'/posts',
|
||||
expect.any (FormData),
|
||||
{ headers: { 'Content-Type': 'multipart/form-data' } },
|
||||
)
|
||||
})
|
||||
const formData = api.apiPost.mock.calls[0]?.[1] as FormData
|
||||
expect (formData.get ('url')).toBe ('https://example.com/post')
|
||||
expect (formData.get ('title')).toBe ('投稿タイトル')
|
||||
expect (formData.get ('parent_post_ids')).toBe ('1 2')
|
||||
expect (formData.get ('tags')).toBe ('tag1 tag2')
|
||||
expect (toastApi.toast).toHaveBeenCalledWith ({ title: '投稿成功!' })
|
||||
})
|
||||
|
||||
it ('preserves duration while the video tag is temporarily removed', () => {
|
||||
api.apiGet.mockResolvedValue ([])
|
||||
|
||||
renderWithProviders (<PostNewPage user={buildUser ({ role: 'member' })}/>)
|
||||
|
||||
const tags = screen.getAllByRole ('textbox')[4]
|
||||
fireEvent.change (tags, { target: { value: '動画' } })
|
||||
fireEvent.change (
|
||||
screen.getByPlaceholderText ('例: 2 / 2.5 / 1:23'),
|
||||
{ target: { value: '180.5' } })
|
||||
|
||||
fireEvent.change (tags, { target: { value: 'general-tag' } })
|
||||
expect (screen.queryByPlaceholderText ('例: 2 / 2.5 / 1:23')).not.toBeInTheDocument ()
|
||||
|
||||
fireEvent.change (tags, {
|
||||
target: { value: '動画 general-tag' },
|
||||
})
|
||||
expect (screen.getByPlaceholderText ('例: 2 / 2.5 / 1:23')).toHaveValue ('180.5')
|
||||
})
|
||||
|
||||
it ('shows 422 validation errors for post fields', async () => {
|
||||
api.apiGet.mockResolvedValue ([])
|
||||
api.isApiError.mockReturnValue (true)
|
||||
api.apiPost.mockRejectedValueOnce ({
|
||||
response: {
|
||||
status: 422,
|
||||
data: {
|
||||
type: 'validation_error',
|
||||
message: '入力内容を確認してください.',
|
||||
errors: { tags: ['ニコニコ・タグは直接指定できません.'] },
|
||||
base_errors: ['投稿内容を確認してください.'],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
renderWithProviders (<PostNewPage user={buildUser ({ role: 'member' })}/>)
|
||||
|
||||
const textboxes = screen.getAllByRole ('textbox')
|
||||
fireEvent.change (textboxes[0], { target: { value: 'https://example.com/post' } })
|
||||
fireEvent.change (textboxes[1], { target: { value: '投稿タイトル' } })
|
||||
fireEvent.change (textboxes[4], { target: { value: 'nico:nico_tag' } })
|
||||
fireEvent.click (screen.getByRole ('button', { name: '追加' }))
|
||||
|
||||
expect (await screen.findByText ('投稿内容を確認してください.')).toBeInTheDocument ()
|
||||
expect (screen.getByText ('ニコニコ・タグは直接指定できません.')).toBeInTheDocument ()
|
||||
expect (screen.getAllByRole ('textbox')[4]).toHaveAttribute ('aria-invalid', 'true')
|
||||
})
|
||||
|
||||
it ('uses the shared duration, tags, and datetime value contract for post submission', async () => {
|
||||
api.apiPost.mockResolvedValueOnce ({})
|
||||
api.apiGet.mockResolvedValue ([])
|
||||
|
||||
const { container } = renderWithProviders (<PostNewPage user={buildUser ({ role: 'member' })}/>)
|
||||
|
||||
const textboxes = screen.getAllByRole ('textbox')
|
||||
fireEvent.change (textboxes[0], { target: { value: 'https://example.com/post' } })
|
||||
fireEvent.change (textboxes[1], { target: { value: '投稿タイトル' } })
|
||||
fireEvent.change (textboxes[4], { target: { value: '動画 tag1 tag2' } })
|
||||
fireEvent.change (
|
||||
screen.getByPlaceholderText ('例: 2 / 2.5 / 1:23'),
|
||||
{ target: { value: '2.5' } })
|
||||
const datetimeInputs = container.querySelectorAll ('input[type="datetime-local"]')
|
||||
fireEvent.change (datetimeInputs[0] as HTMLInputElement, {
|
||||
target: { value: '2024-01-01T12:34' } })
|
||||
fireEvent.click (screen.getByRole ('button', { name: '追加' }))
|
||||
|
||||
await waitFor (() => expect (api.apiPost).toHaveBeenCalled ())
|
||||
|
||||
const formData = api.apiPost.mock.calls[0]?.[1] as FormData
|
||||
expect (container.querySelector ('input[type="file"]')).not.toBeNull ()
|
||||
expect (formData.get ('duration')).toBe ('2.5')
|
||||
expect (formData.get ('tags')).toBe ('動画 tag1 tag2')
|
||||
expect (formData.get ('original_created_from')).toBe (
|
||||
toMinutePrecisionIsoUtc ('2024-01-01T12:34'))
|
||||
})
|
||||
|
||||
it ('shows deduplicated original-created endpoint errors on the shared datetime field', async () => {
|
||||
api.apiGet.mockResolvedValue ([])
|
||||
api.isApiError.mockReturnValue (true)
|
||||
api.apiPost.mockRejectedValueOnce ({
|
||||
response: {
|
||||
status: 422,
|
||||
data: {
|
||||
type: 'validation_error',
|
||||
errors: {
|
||||
original_created_at: ['日時を確認してください.'],
|
||||
original_created_from: ['日時を確認してください.'],
|
||||
original_created_before: ['終了を確認してください.'] },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
renderWithProviders (<PostNewPage user={buildUser ({ role: 'member' })}/>)
|
||||
|
||||
fireEvent.click (screen.getByRole ('button', { name: '追加' }))
|
||||
|
||||
expect (await screen.findByText ('日時を確認してください.')).toBeInTheDocument ()
|
||||
expect (screen.getByText ('終了を確認してください.')).toBeInTheDocument ()
|
||||
expect (screen.getAllByText ('日時を確認してください.')).toHaveLength (1)
|
||||
expect (screen.getByRole ('heading', { name: '追加内容確認' })).toBeInTheDocument ()
|
||||
expect (screen.queryByText ('広場に投稿を追加')).not.toBeInTheDocument ()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,23 +1,9 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useMemo } from 'react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
|
||||
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
|
||||
import FieldError from '@/components/common/FieldError'
|
||||
import Form from '@/components/common/Form'
|
||||
import PageTitle from '@/components/common/PageTitle'
|
||||
import MainArea from '@/components/layout/MainArea'
|
||||
import PostDurationField from '@/components/posts/PostDurationField'
|
||||
import PostTagsField from '@/components/posts/PostTagsField'
|
||||
import PostTextField from '@/components/posts/PostTextField'
|
||||
import PostThumbnailPreview from '@/components/posts/PostThumbnailPreview'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { toast } from '@/components/ui/use-toast'
|
||||
import { SITE_TITLE } from '@/config'
|
||||
import { apiGet, apiPost } from '@/lib/api'
|
||||
import { canEditContent } from '@/lib/users'
|
||||
import { useValidationErrors } from '@/lib/useValidationErrors'
|
||||
import Forbidden from '@/pages/Forbidden'
|
||||
import PostImportReviewPage from '@/pages/posts/PostImportReviewPage'
|
||||
import PostImportSourcePage from '@/pages/posts/PostImportSourcePage'
|
||||
import { hasPostNewReviewState } from '@/lib/postNewQueryState'
|
||||
|
||||
import type { FC } from 'react'
|
||||
|
||||
@@ -25,220 +11,16 @@ import type { User } from '@/types'
|
||||
|
||||
type Props = { user: User | null }
|
||||
|
||||
type PostFormField =
|
||||
'url' | 'title' | 'tags' | 'parentPostIds'
|
||||
| 'videoMs' | 'originalCreatedAt' | 'originalCreatedFrom'
|
||||
| 'originalCreatedBefore' | 'thumbnail'
|
||||
|
||||
const groupedMessages = (...values: (string[] | undefined)[]): string[] =>
|
||||
[...new Set (values.flatMap (value => value ?? []))]
|
||||
|
||||
|
||||
const PostNewPage: FC<Props> = ({ user }) => {
|
||||
const editable = canEditContent (user)
|
||||
const location = useLocation ()
|
||||
const reviewMode = useMemo (
|
||||
() => hasPostNewReviewState (location.search),
|
||||
[location.search])
|
||||
|
||||
const navigate = useNavigate ()
|
||||
|
||||
const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } =
|
||||
useValidationErrors<PostFormField> ()
|
||||
|
||||
const [originalCreatedBefore, setOriginalCreatedBefore] =
|
||||
useState<string | null> (null)
|
||||
const [originalCreatedFrom, setOriginalCreatedFrom] =
|
||||
useState<string | null> (null)
|
||||
const [parentPostIds, setParentPostIds] = useState ('')
|
||||
const [tags, setTags] = useState ('')
|
||||
const [duration, setDuration] = useState ('')
|
||||
const [thumbnailFile, setThumbnailFile] = useState<File | null> (null)
|
||||
const [thumbnailLoading, setThumbnailLoading] = useState (false)
|
||||
const [thumbnailPreview, setThumbnailPreview] = useState<string> ('')
|
||||
const [title, setTitle] = useState ('')
|
||||
const [titleLoading, setTitleLoading] = useState (false)
|
||||
const [url, setURL] = useState ('')
|
||||
|
||||
const thumbnailPreviewRef = useRef ('')
|
||||
const videoFlg =
|
||||
useMemo (() => tags.split (/\s+/).some (tag => tag.replace (/\[.*\]$/, '') === '動画'),
|
||||
[tags])
|
||||
|
||||
const handleSubmit = async () => {
|
||||
clearValidationErrors ()
|
||||
|
||||
const formData = new FormData
|
||||
formData.append ('title', title)
|
||||
formData.append ('url', url)
|
||||
formData.append ('tags', tags)
|
||||
formData.append ('parent_post_ids', parentPostIds)
|
||||
if (videoFlg && duration !== '')
|
||||
formData.append ('duration', duration)
|
||||
if (thumbnailFile)
|
||||
formData.append ('thumbnail', thumbnailFile)
|
||||
if (originalCreatedFrom)
|
||||
formData.append ('original_created_from', originalCreatedFrom)
|
||||
if (originalCreatedBefore)
|
||||
formData.append ('original_created_before', originalCreatedBefore)
|
||||
|
||||
try
|
||||
{
|
||||
await apiPost ('/posts', formData, { headers: { 'Content-Type': 'multipart/form-data' } })
|
||||
toast ({ title: '投稿成功!' })
|
||||
navigate ('/posts')
|
||||
}
|
||||
catch (e)
|
||||
{
|
||||
applyValidationError (e)
|
||||
toast ({ title: '投稿失敗', description: '入力を確認してください.' })
|
||||
}
|
||||
}
|
||||
|
||||
const fetchTitle = useCallback (async () => {
|
||||
setTitleLoading (true)
|
||||
try
|
||||
{
|
||||
const data = await apiGet<{ title: string }> ('/preview/title', { params: { url } })
|
||||
setTitle (data.title || '')
|
||||
}
|
||||
finally
|
||||
{
|
||||
setTitleLoading (false)
|
||||
}
|
||||
}, [url])
|
||||
|
||||
const fetchThumbnail = useCallback (async () => {
|
||||
setThumbnailPreview ('')
|
||||
setThumbnailFile (null)
|
||||
setThumbnailLoading (true)
|
||||
if (thumbnailPreviewRef.current)
|
||||
URL.revokeObjectURL (thumbnailPreviewRef.current)
|
||||
try
|
||||
{
|
||||
const data = await apiGet<Blob> ('/preview/thumbnail',
|
||||
{ params: { url },
|
||||
responseType: 'blob' })
|
||||
const imageURL = URL.createObjectURL (data)
|
||||
setThumbnailPreview (imageURL)
|
||||
setThumbnailFile (new File ([data],
|
||||
'thumbnail.png',
|
||||
{ type: data.type || 'image/png' }))
|
||||
}
|
||||
finally
|
||||
{
|
||||
setThumbnailLoading (false)
|
||||
}
|
||||
}, [url])
|
||||
|
||||
useEffect (() => {
|
||||
thumbnailPreviewRef.current = thumbnailPreview
|
||||
}, [thumbnailPreview])
|
||||
|
||||
if (!(editable))
|
||||
return <Forbidden/>
|
||||
|
||||
return (
|
||||
<MainArea>
|
||||
<Helmet>
|
||||
<title>{`広場に投稿を追加 | ${ SITE_TITLE }`}</title>
|
||||
</Helmet>
|
||||
<Form>
|
||||
<PageTitle>広場に投稿を追加する</PageTitle>
|
||||
<FieldError messages={baseErrors}/>
|
||||
|
||||
<PostTextField
|
||||
label="URL"
|
||||
type="url"
|
||||
value={url}
|
||||
errors={fieldErrors.url}
|
||||
placeholder="例:https://www.nicovideo.jp/watch/..."
|
||||
onChange={setURL}/>
|
||||
|
||||
<PostTextField
|
||||
label="タイトル"
|
||||
value={title}
|
||||
errors={fieldErrors.title}
|
||||
disabled={titleLoading}
|
||||
placeholder={titleLoading ? 'Loading...' : undefined}
|
||||
onChange={setTitle}
|
||||
after={
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span>必要なタイミングで URL から取得できます.</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => void fetchTitle ()}
|
||||
disabled={!(url) || titleLoading}>
|
||||
取得
|
||||
</Button>
|
||||
</div>}/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span>必要なタイミングで URL から取得できます.</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => void fetchThumbnail ()}
|
||||
disabled={!(url) || thumbnailLoading}>
|
||||
取得
|
||||
</Button>
|
||||
</div>
|
||||
{thumbnailLoading && (
|
||||
<p className="text-gray-500 text-sm">Loading...</p>)}
|
||||
<PostTextField
|
||||
label="サムネール"
|
||||
value={thumbnailFile?.name ?? ''}
|
||||
errors={fieldErrors.thumbnail}
|
||||
disabled
|
||||
onChange={() => {}}/>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={e => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file)
|
||||
{
|
||||
setThumbnailFile (file)
|
||||
setThumbnailPreview (URL.createObjectURL (file))
|
||||
}
|
||||
}}/>
|
||||
<PostThumbnailPreview
|
||||
url={thumbnailPreview}
|
||||
alt="preview"
|
||||
className="h-28 w-28"/>
|
||||
</div>
|
||||
|
||||
<PostTextField
|
||||
label="親投稿"
|
||||
value={parentPostIds}
|
||||
errors={fieldErrors.parentPostIds}
|
||||
onChange={setParentPostIds}/>
|
||||
|
||||
<PostTagsField tags={tags} setTags={setTags} errors={fieldErrors.tags}/>
|
||||
|
||||
{/* オリジナルの作成日時 */}
|
||||
<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}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400"
|
||||
disabled={titleLoading || thumbnailLoading}>
|
||||
追加
|
||||
</Button>
|
||||
</Form>
|
||||
</MainArea>)
|
||||
return reviewMode
|
||||
? <PostImportReviewPage user={user}/>
|
||||
: <PostImportSourcePage user={user}/>
|
||||
}
|
||||
|
||||
export default PostNewPage
|
||||
|
||||
新しい課題から参照
ユーザをブロックする