コミットを比較
7 コミット
cde0a2deae
...
dd2d199d04
| 作成者 | SHA1 | 日付 | |
|---|---|---|---|
| dd2d199d04 | |||
| 0a8ffc38b8 | |||
| 3f75994bd4 | |||
| f91b78bd47 | |||
| 7f8cce39bc | |||
| 5c5a9fa1b0 | |||
| 66c738cbff |
@@ -285,6 +285,13 @@ case 'no':
|
|||||||
Ruby-style numbered parameter names. Reserve numbered parameters for Ruby.
|
Ruby-style numbered parameter names. Reserve numbered parameters for Ruby.
|
||||||
Use a meaningful callback parameter name such as `row`, `item`, `value`,
|
Use a meaningful callback parameter name such as `row`, `item`, `value`,
|
||||||
`entry`, or `result`.
|
`entry`, or `result`.
|
||||||
|
- In JavaScript, JSX, TypeScript, and TSX, use `cn` from `@/lib/utils`
|
||||||
|
whenever `className` combines multiple values, conditional classes, or a
|
||||||
|
caller-provided `className` prop.
|
||||||
|
- Do not construct `className` with template literals, `${ ... }`, string
|
||||||
|
concatenation, arrays joined with spaces, or feature-local class-merging
|
||||||
|
helpers.
|
||||||
|
- A static `className="..."` containing only fixed classes does not need `cn`.
|
||||||
- If code appears to need a distinction between `null` and `undefined`, treat
|
- If code appears to need a distinction between `null` and `undefined`, treat
|
||||||
that as a design smell and revise the logic to avoid the distinction.
|
that as a design smell and revise the logic to avoid the distinction.
|
||||||
External library APIs that explicitly require distinguishing the two are the
|
External library APIs that explicitly require distinguishing the two are the
|
||||||
@@ -460,6 +467,12 @@ and layout reuse, follow `frontend/AGENTS.md`.
|
|||||||
structure, control flow, or variable mutability unless the requested style
|
structure, control flow, or variable mutability unless the requested style
|
||||||
explicitly requires it.
|
explicitly requires it.
|
||||||
- Do not add production dependencies without explicit approval.
|
- Do not add production dependencies without explicit approval.
|
||||||
|
- Do not add user-facing copy, helper text, descriptions, notes, tooltips,
|
||||||
|
placeholders, empty-state messages, loading messages, or explanatory text
|
||||||
|
unless the user explicitly specified the wording.
|
||||||
|
- When new user-facing wording appears necessary, ask the user for the exact
|
||||||
|
wording and placement before implementing it.
|
||||||
|
- Do not invent replacement copy when removing unrequested wording.
|
||||||
- Do not create, modify, or run tests unless the user explicitly asks for
|
- Do not create, modify, or run tests unless the user explicitly asks for
|
||||||
test work. When the user asks for tests, keep working and rerun them until
|
test work. When the user asks for tests, keep working and rerun them until
|
||||||
they pass or the remaining failure is clearly blocked.
|
they pass or the remaining failure is clearly blocked.
|
||||||
|
|||||||
@@ -11,7 +11,12 @@ class PostImportsController < ApplicationController
|
|||||||
|
|
||||||
def validate
|
def validate
|
||||||
rows = normalised_import_rows allow_warning_fields: true
|
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 =
|
result =
|
||||||
PostImportPreviewer.new.preview_rows(rows:,
|
PostImportPreviewer.new.preview_rows(rows:,
|
||||||
fetch_metadata: changed_row,
|
fetch_metadata: changed_row,
|
||||||
|
|||||||
@@ -38,6 +38,27 @@ class PreviewController < ApplicationController
|
|||||||
render_unprocessable_entity(e.message)
|
render_unprocessable_entity(e.message)
|
||||||
end
|
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
|
private
|
||||||
|
|
||||||
def require_member!
|
def require_member!
|
||||||
|
|||||||
@@ -238,7 +238,8 @@ class Post < ApplicationRecord
|
|||||||
value = raw_value.to_s.strip
|
value = raw_value.to_s.strip
|
||||||
return nil if value.blank?
|
return nil if value.blank?
|
||||||
|
|
||||||
if (match = value.match(/\A(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})\z/))
|
match = value.match(/\A(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})\z/)
|
||||||
|
if match
|
||||||
year = match[1].to_i
|
year = match[1].to_i
|
||||||
month = match[2].to_i
|
month = match[2].to_i
|
||||||
day = match[3].to_i
|
day = match[3].to_i
|
||||||
@@ -251,9 +252,7 @@ class Post < ApplicationRecord
|
|||||||
|
|
||||||
match =
|
match =
|
||||||
value.match(
|
value.match(
|
||||||
/\A(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/ \
|
/\A(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d+))?)?(Z|[+-]\d{2}:?\d{2})?\z/)
|
||||||
'(?::(\d{2})(?:\.(\d+))?)?' \
|
|
||||||
'(Z|[+-]\d{2}:?\d{2})?\z/')
|
|
||||||
return nil if match.nil?
|
return nil if match.nil?
|
||||||
|
|
||||||
year = match[1].to_i
|
year = match[1].to_i
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ class PostImportPreviewer
|
|||||||
FETCH_WARNING_FIELDS = ['url', 'title', 'thumbnail_base'].freeze
|
FETCH_WARNING_FIELDS = ['url', 'title', 'thumbnail_base'].freeze
|
||||||
TITLE_FETCH_WARNING = 'タイトルを取得できませんでした.'.freeze
|
TITLE_FETCH_WARNING = 'タイトルを取得できませんでした.'.freeze
|
||||||
THUMBNAIL_FETCH_WARNING = 'サムネールを取得できませんでした.'.freeze
|
THUMBNAIL_FETCH_WARNING = 'サムネールを取得できませんでした.'.freeze
|
||||||
METADATA_FETCH_WARNING = 'メタデータを取得できませんでした.'.freeze
|
METADATA_FETCH_WARNING = '自動取得に失敗しました.'.freeze
|
||||||
|
|
||||||
def preview_rows rows:, fetch_metadata: true, metadata_cache: { }
|
def preview_rows rows:, fetch_metadata: true, metadata_cache: { }
|
||||||
prepared_rows = rows.map { prepare_row(_1) }
|
prepared_rows = rows.map { prepare_row(_1) }
|
||||||
@@ -151,7 +151,7 @@ class PostImportPreviewer
|
|||||||
when true then true
|
when true then true
|
||||||
when Integer then fetch_metadata == source_row
|
when Integer then fetch_metadata == source_row
|
||||||
when false, nil then false
|
when false, nil then false
|
||||||
else raise ArgumentError, 'メタデータ取得対象が不正です.'
|
else raise ArgumentError, '取得対象が不正です.'
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ Rails.application.routes.draw do
|
|||||||
scope :preview, controller: :preview do
|
scope :preview, controller: :preview do
|
||||||
get :title
|
get :title
|
||||||
get :thumbnail
|
get :thumbnail
|
||||||
|
get :image
|
||||||
end
|
end
|
||||||
|
|
||||||
scope 'posts/import', controller: :post_imports do
|
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}`.
|
- Tailwind scans `src/**/*.{html,js,ts,jsx,tsx,mdx}`.
|
||||||
- Use `cn` from `src/lib/utils.ts` for conditional class names and class merging.
|
- Use `cn` from `src/lib/utils.ts` for conditional class names and class merging.
|
||||||
|
- In JavaScript, JSX, TypeScript, and TSX, use `cn` from `@/lib/utils`
|
||||||
|
whenever `className` combines multiple values, conditional classes, or a
|
||||||
|
caller-provided `className` prop.
|
||||||
|
- Do not construct `className` with template literals, `${ ... }`, string
|
||||||
|
concatenation, arrays joined with spaces, or feature-local class-merging
|
||||||
|
helpers.
|
||||||
|
- A static `className="..."` containing only fixed classes does not need `cn`.
|
||||||
- Reuse components from `src/components/common`, `src/components/layout`, and
|
- Reuse components from `src/components/common`, `src/components/layout`, and
|
||||||
`src/components/ui` before adding new primitives.
|
`src/components/ui` before adding new primitives.
|
||||||
- Keep Tailwind classes consistent with nearby components.
|
- Keep Tailwind classes consistent with nearby components.
|
||||||
@@ -140,6 +147,12 @@ pass or the remaining failure is clearly blocked.
|
|||||||
short Japanese labels that fit the control.
|
short Japanese labels that fit the control.
|
||||||
- Preserve existing Japanese tone and orthography in nearby UI text, including
|
- Preserve existing Japanese tone and orthography in nearby UI text, including
|
||||||
old-kana wording where the file already uses it.
|
old-kana wording where the file already uses it.
|
||||||
|
- Do not add user-facing copy, helper text, descriptions, notes, tooltips,
|
||||||
|
placeholders, empty-state messages, loading messages, or explanatory text
|
||||||
|
unless the user explicitly specified the wording.
|
||||||
|
- When new user-facing wording appears necessary, ask the user for the exact
|
||||||
|
wording and placement before implementing it.
|
||||||
|
- Do not invent replacement copy when removing unrequested wording.
|
||||||
- When adding dynamic tag colour classes, update `tailwind.config.js` safelist
|
- When adding dynamic tag colour classes, update `tailwind.config.js` safelist
|
||||||
if the class cannot be statically detected.
|
if the class cannot be statically detected.
|
||||||
- Do not introduce new UI libraries or production dependencies without approval.
|
- Do not introduce new UI libraries or production dependencies without approval.
|
||||||
|
|||||||
+1
-10
@@ -41,11 +41,8 @@ import NotFound from '@/pages/NotFound'
|
|||||||
import TOSPage from '@/pages/TOSPage.mdx'
|
import TOSPage from '@/pages/TOSPage.mdx'
|
||||||
import PostDetailPage from '@/pages/posts/PostDetailPage'
|
import PostDetailPage from '@/pages/posts/PostDetailPage'
|
||||||
import PostHistoryPage from '@/pages/posts/PostHistoryPage'
|
import PostHistoryPage from '@/pages/posts/PostHistoryPage'
|
||||||
import PostImportResultPage from '@/pages/posts/PostImportResultPage'
|
|
||||||
import PostImportReviewPage from '@/pages/posts/PostImportReviewPage'
|
|
||||||
import PostImportSourcePage from '@/pages/posts/PostImportSourcePage'
|
|
||||||
import PostListPage from '@/pages/posts/PostListPage'
|
|
||||||
import PostNewPage from '@/pages/posts/PostNewPage'
|
import PostNewPage from '@/pages/posts/PostNewPage'
|
||||||
|
import PostListPage from '@/pages/posts/PostListPage'
|
||||||
import PostSearchPage from '@/pages/posts/PostSearchPage'
|
import PostSearchPage from '@/pages/posts/PostSearchPage'
|
||||||
import ServiceUnavailable from '@/pages/ServiceUnavailable'
|
import ServiceUnavailable from '@/pages/ServiceUnavailable'
|
||||||
import SettingPage from '@/pages/users/SettingPage'
|
import SettingPage from '@/pages/users/SettingPage'
|
||||||
@@ -78,9 +75,6 @@ const RouteTransitionWrapper = ({ animationMode, user, setUser }: {
|
|||||||
<Route path="/" element={<Navigate to="/posts" replace/>}/>
|
<Route path="/" element={<Navigate to="/posts" replace/>}/>
|
||||||
<Route path="/posts" element={<PostListPage/>}/>
|
<Route path="/posts" element={<PostListPage/>}/>
|
||||||
<Route path="/posts/new" element={<PostNewPage user={user}/>}/>
|
<Route path="/posts/new" element={<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/search" element={<PostSearchPage/>}/>
|
||||||
<Route path="/posts/:id" element={<PostDetailRoute user={user}/>}/>
|
<Route path="/posts/:id" element={<PostDetailRoute user={user}/>}/>
|
||||||
<Route path="/posts/changes" element={<PostHistoryPage/>}/>
|
<Route path="/posts/changes" element={<PostHistoryPage/>}/>
|
||||||
@@ -119,9 +113,6 @@ const RouteTransitionWrapper = ({ animationMode, user, setUser }: {
|
|||||||
<Route path="/" element={<Navigate to="/posts" replace/>}/>
|
<Route path="/" element={<Navigate to="/posts" replace/>}/>
|
||||||
<Route path="/posts" element={<PostListPage/>}/>
|
<Route path="/posts" element={<PostListPage/>}/>
|
||||||
<Route path="/posts/new" element={<PostNewPage user={user}/>}/>
|
<Route path="/posts/new" element={<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/search" element={<PostSearchPage/>}/>
|
||||||
<Route path="/posts/:id" element={<PostDetailRoute user={user}/>}/>
|
<Route path="/posts/:id" element={<PostDetailRoute user={user}/>}/>
|
||||||
<Route path="/posts/changes" element={<PostHistoryPage/>}/>
|
<Route path="/posts/changes" element={<PostHistoryPage/>}/>
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ describe ('menuOutline', () => {
|
|||||||
expect (submenuItem ('guest', 'Wiki', '編輯')?.visible).toBe (false)
|
expect (submenuItem ('guest', 'Wiki', '編輯')?.visible).toBe (false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it ('uses /posts/new as the import entrypoint', () => {
|
||||||
|
expect (submenuItem ('member', '広場', '取込')?.to).toBe ('/posts/new')
|
||||||
|
})
|
||||||
|
|
||||||
it ('keeps material suppression admin-only', () => {
|
it ('keeps material suppression admin-only', () => {
|
||||||
expect (submenuItem ('member', '素材', '抑止')?.visible).toBe (false)
|
expect (submenuItem ('member', '素材', '抑止')?.visible).toBe (false)
|
||||||
expect (submenuItem ('admin', '素材', '抑止')?.visible).toBe (true)
|
expect (submenuItem ('admin', '素材', '抑止')?.visible).toBe (true)
|
||||||
|
|||||||
@@ -45,7 +45,6 @@ export const menuOutline = (
|
|||||||
{ name: '一覧', to: '/posts' },
|
{ name: '一覧', to: '/posts' },
|
||||||
{ name: '検索', to: '/posts/search' },
|
{ name: '検索', to: '/posts/search' },
|
||||||
{ name: '追加', to: '/posts/new', visible: editable },
|
{ name: '追加', to: '/posts/new', visible: editable },
|
||||||
{ name: '取込', to: '/posts/import', visible: editable },
|
|
||||||
{ name: '全体履歴', to: '/posts/changes' },
|
{ name: '全体履歴', to: '/posts/changes' },
|
||||||
{ name: 'ヘルプ', to: '/wiki/ヘルプ:広場' }] },
|
{ name: 'ヘルプ', to: '/wiki/ヘルプ:広場' }] },
|
||||||
{ name: 'タグ', to: '/tags', subMenu: [
|
{ name: 'タグ', to: '/tags', subMenu: [
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
|
||||||
|
import PostTagsField from '@/components/posts/PostTagsField'
|
||||||
|
import PostTextField from '@/components/posts/PostTextField'
|
||||||
|
|
||||||
|
import type { FC, ReactNode } from 'react'
|
||||||
|
|
||||||
|
type TextMessages = string[] | undefined
|
||||||
|
|
||||||
|
type CoreField = {
|
||||||
|
value: string
|
||||||
|
onChange: (value: string) => void
|
||||||
|
errors?: TextMessages
|
||||||
|
warnings?: TextMessages
|
||||||
|
disabled?: boolean }
|
||||||
|
|
||||||
|
type OriginalCreatedField = {
|
||||||
|
originalCreatedAt?: TextMessages
|
||||||
|
originalCreatedFrom?: TextMessages
|
||||||
|
originalCreatedBefore?: TextMessages }
|
||||||
|
|
||||||
|
type PostCoreDataFieldsProps = {
|
||||||
|
title: {
|
||||||
|
value: string
|
||||||
|
onChange: (value: string) => void
|
||||||
|
errors?: TextMessages
|
||||||
|
warnings?: TextMessages
|
||||||
|
disabled?: boolean
|
||||||
|
after?: ReactNode }
|
||||||
|
originalCreated: {
|
||||||
|
disabled?: boolean
|
||||||
|
originalCreatedFrom: string | null
|
||||||
|
setOriginalCreatedFrom: (value: string | null) => void
|
||||||
|
originalCreatedBefore: string | null
|
||||||
|
setOriginalCreatedBefore: (value: string | null) => void
|
||||||
|
errors?: OriginalCreatedField }
|
||||||
|
tags: {
|
||||||
|
value: string
|
||||||
|
onChange: (value: string) => void
|
||||||
|
errors?: TextMessages
|
||||||
|
warnings?: TextMessages
|
||||||
|
disabled?: boolean
|
||||||
|
rows?: number }
|
||||||
|
parentPostIds: CoreField }
|
||||||
|
|
||||||
|
const groupedMessages = (...values: (TextMessages | null | undefined)[]): string[] =>
|
||||||
|
[...new Set (values.flatMap (value => value ?? []))]
|
||||||
|
|
||||||
|
|
||||||
|
const PostCoreDataFields: FC<PostCoreDataFieldsProps> = (
|
||||||
|
{ title,
|
||||||
|
originalCreated,
|
||||||
|
tags,
|
||||||
|
parentPostIds },
|
||||||
|
) => (
|
||||||
|
<>
|
||||||
|
<PostTextField
|
||||||
|
label="タイトル"
|
||||||
|
value={title.value}
|
||||||
|
disabled={title.disabled}
|
||||||
|
warnings={title.warnings}
|
||||||
|
errors={title.errors}
|
||||||
|
after={title.after}
|
||||||
|
onChange={title.onChange}/>
|
||||||
|
|
||||||
|
<PostOriginalCreatedTimeField
|
||||||
|
disabled={originalCreated.disabled}
|
||||||
|
originalCreatedFrom={originalCreated.originalCreatedFrom}
|
||||||
|
setOriginalCreatedFrom={originalCreated.setOriginalCreatedFrom}
|
||||||
|
originalCreatedBefore={originalCreated.originalCreatedBefore}
|
||||||
|
setOriginalCreatedBefore={originalCreated.setOriginalCreatedBefore}
|
||||||
|
errors={groupedMessages (
|
||||||
|
originalCreated.errors?.originalCreatedAt,
|
||||||
|
originalCreated.errors?.originalCreatedFrom,
|
||||||
|
originalCreated.errors?.originalCreatedBefore)}/>
|
||||||
|
|
||||||
|
<PostTagsField
|
||||||
|
tags={tags.value}
|
||||||
|
disabled={tags.disabled}
|
||||||
|
setTags={tags.onChange}
|
||||||
|
warnings={tags.warnings}
|
||||||
|
errors={tags.errors}
|
||||||
|
rows={tags.rows}/>
|
||||||
|
|
||||||
|
<PostTextField
|
||||||
|
label="親投稿"
|
||||||
|
value={parentPostIds.value}
|
||||||
|
disabled={parentPostIds.disabled}
|
||||||
|
warnings={parentPostIds.warnings}
|
||||||
|
errors={parentPostIds.errors}
|
||||||
|
onChange={parentPostIds.onChange}/>
|
||||||
|
</>)
|
||||||
|
|
||||||
|
export default PostCoreDataFields
|
||||||
|
export type { PostCoreDataFieldsProps }
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import PostCoreDataFields from '@/components/posts/PostCoreDataFields'
|
||||||
|
import PostTextField from '@/components/posts/PostTextField'
|
||||||
|
|
||||||
|
import type { FC, ReactNode } from 'react'
|
||||||
|
|
||||||
|
import type { PostCoreDataFieldsProps } from '@/components/posts/PostCoreDataFields'
|
||||||
|
|
||||||
|
type TextMessages = string[] | undefined
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
url: {
|
||||||
|
value: string
|
||||||
|
onChange: (value: string) => void
|
||||||
|
errors?: TextMessages
|
||||||
|
warnings?: TextMessages
|
||||||
|
disabled?: boolean
|
||||||
|
type?: string
|
||||||
|
placeholder?: string }
|
||||||
|
thumbnailField: ReactNode
|
||||||
|
core: PostCoreDataFieldsProps }
|
||||||
|
|
||||||
|
|
||||||
|
const PostCreationDataFields: FC<Props> = (
|
||||||
|
{ url,
|
||||||
|
thumbnailField,
|
||||||
|
core },
|
||||||
|
) => (
|
||||||
|
<>
|
||||||
|
<PostTextField
|
||||||
|
label="URL"
|
||||||
|
type={url.type}
|
||||||
|
value={url.value}
|
||||||
|
disabled={url.disabled}
|
||||||
|
warnings={url.warnings}
|
||||||
|
errors={url.errors}
|
||||||
|
placeholder={url.placeholder}
|
||||||
|
onChange={url.onChange}/>
|
||||||
|
|
||||||
|
{thumbnailField}
|
||||||
|
|
||||||
|
<PostCoreDataFields {...core}/>
|
||||||
|
</>)
|
||||||
|
|
||||||
|
export default PostCreationDataFields
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import { buildPostImportRow } from '@/test/postImportFactories'
|
||||||
|
import { buildUser } from '@/test/factories'
|
||||||
|
import { renderWithProviders } from '@/test/render'
|
||||||
|
|
||||||
|
import type { DialogueFormControls } from '@/lib/dialogues/useDialogue'
|
||||||
|
|
||||||
|
const sharedFieldsSpy = vi.hoisted (() => vi.fn (() => <div data-testid="shared-fields"/>))
|
||||||
|
|
||||||
|
vi.mock ('@/components/posts/PostCreationDataFields', () => ({
|
||||||
|
default: sharedFieldsSpy,
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe ('PostCreationDataFields usage', () => {
|
||||||
|
it ('is used by PostNewPage', async () => {
|
||||||
|
const { default: PostNewPage } = await import ('@/pages/posts/PostNewPage')
|
||||||
|
|
||||||
|
renderWithProviders (<PostNewPage user={buildUser ({ role: 'member' })}/>)
|
||||||
|
|
||||||
|
expect (screen.getByTestId ('shared-fields')).toBeInTheDocument ()
|
||||||
|
})
|
||||||
|
|
||||||
|
it ('is used by PostImportRowForm', async () => {
|
||||||
|
const { default: PostImportRowForm } = await import (
|
||||||
|
'@/components/posts/import/PostImportRowForm')
|
||||||
|
|
||||||
|
render (
|
||||||
|
<PostImportRowForm
|
||||||
|
row={buildPostImportRow ()}
|
||||||
|
controls={{
|
||||||
|
close: vi.fn (),
|
||||||
|
confirm: vi.fn (),
|
||||||
|
setActions: vi.fn (),
|
||||||
|
} as DialogueFormControls}
|
||||||
|
onSave={vi.fn ()}/>)
|
||||||
|
|
||||||
|
expect (screen.getByTestId ('shared-fields')).toBeInTheDocument ()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -14,7 +14,8 @@ type Props = {
|
|||||||
type?: string
|
type?: string
|
||||||
placeholder?: string
|
placeholder?: string
|
||||||
className?: string
|
className?: string
|
||||||
after?: ReactNode }
|
after?: ReactNode
|
||||||
|
onBlur?: () => void }
|
||||||
|
|
||||||
|
|
||||||
const PostTextField: FC<Props> = (
|
const PostTextField: FC<Props> = (
|
||||||
@@ -27,7 +28,8 @@ const PostTextField: FC<Props> = (
|
|||||||
type = 'text',
|
type = 'text',
|
||||||
placeholder,
|
placeholder,
|
||||||
className,
|
className,
|
||||||
after },
|
after,
|
||||||
|
onBlur },
|
||||||
) => (
|
) => (
|
||||||
<FormField label={label} messages={errors}>
|
<FormField label={label} messages={errors}>
|
||||||
{({ describedBy, invalid }) => (
|
{({ describedBy, invalid }) => (
|
||||||
@@ -37,6 +39,7 @@ const PostTextField: FC<Props> = (
|
|||||||
value={value}
|
value={value}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
|
onBlur={onBlur}
|
||||||
onChange={ev => onChange (ev.target.value)}
|
onChange={ev => onChange (ev.target.value)}
|
||||||
aria-describedby={describedBy}
|
aria-describedby={describedBy}
|
||||||
aria-invalid={invalid}
|
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 { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
import PostThumbnailPreview from '@/components/posts/PostThumbnailPreview'
|
import PostThumbnailPreview from '@/components/posts/PostThumbnailPreview'
|
||||||
@@ -9,4 +9,28 @@ describe ('PostThumbnailPreview', () => {
|
|||||||
|
|
||||||
expect (screen.getByRole ('img')).toHaveAttribute ('src', 'blob:preview')
|
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 { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
import type { FC } from 'react'
|
import type { FC } from 'react'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -17,33 +19,20 @@ const PostThumbnailPreview: FC<Props> = (
|
|||||||
setFailed (false)
|
setFailed (false)
|
||||||
}, [url])
|
}, [url])
|
||||||
|
|
||||||
if (!(url))
|
if (!(url) || failed)
|
||||||
{
|
{
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`${ className } flex items-center justify-center rounded border
|
className={cn (
|
||||||
border-border bg-muted text-xs text-muted-foreground`}>
|
className,
|
||||||
なし
|
'rounded border border-border bg-muted')}/>)
|
||||||
</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>)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<img
|
<img
|
||||||
src={url}
|
src={url}
|
||||||
alt={alt}
|
alt={alt}
|
||||||
className={`${ className } rounded border border-border object-cover`}
|
className={cn (className, 'rounded border border-border object-cover')}
|
||||||
onError={() => setFailed (true)}/>)
|
onError={() => setFailed (true)}/>)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ describe ('PostImportRowForm', () => {
|
|||||||
it ('marks edited fields and areas invalid from field errors', () => {
|
it ('marks edited fields and areas invalid from field errors', () => {
|
||||||
const row = buildPostImportRow ({
|
const row = buildPostImportRow ({
|
||||||
validationErrors: { url: ['URL error'], tags: ['tag error'] },
|
validationErrors: { url: ['URL error'], tags: ['tag error'] },
|
||||||
importErrors: { duration: ['duration error'] },
|
importErrors: { title: ['title error'] },
|
||||||
fieldWarnings: { title: ['title warning'] } })
|
fieldWarnings: { title: ['title warning'] } })
|
||||||
|
|
||||||
render (
|
render (
|
||||||
@@ -96,7 +96,7 @@ describe ('PostImportRowForm', () => {
|
|||||||
|
|
||||||
expect (screen.getByText ('URL error')).toBeInTheDocument ()
|
expect (screen.getByText ('URL error')).toBeInTheDocument ()
|
||||||
expect (screen.getByText ('tag error')).toBeInTheDocument ()
|
expect (screen.getByText ('tag error')).toBeInTheDocument ()
|
||||||
expect (screen.getByText ('duration error')).toBeInTheDocument ()
|
expect (screen.getByText ('title error')).toBeInTheDocument ()
|
||||||
expect (screen.getByText ('title warning')).toBeInTheDocument ()
|
expect (screen.getByText ('title warning')).toBeInTheDocument ()
|
||||||
expect (screen.getAllByRole ('textbox').filter (
|
expect (screen.getAllByRole ('textbox').filter (
|
||||||
textbox => textbox.getAttribute ('aria-invalid') === 'true')).toHaveLength (3)
|
textbox => textbox.getAttribute ('aria-invalid') === 'true')).toHaveLength (3)
|
||||||
@@ -134,7 +134,9 @@ describe ('PostImportRowForm', () => {
|
|||||||
resetRequested: false })
|
resetRequested: false })
|
||||||
})
|
})
|
||||||
|
|
||||||
it ('keeps the shared duration and tags string contract without leaking file upload UI', async () => {
|
it (
|
||||||
|
'shows the shared creation field order without duration and without file upload UI',
|
||||||
|
async () => {
|
||||||
let actions: DialogueFormAction[] = []
|
let actions: DialogueFormAction[] = []
|
||||||
const controls: DialogueFormControls = {
|
const controls: DialogueFormControls = {
|
||||||
close: vi.fn (),
|
close: vi.fn (),
|
||||||
@@ -152,8 +154,18 @@ describe ('PostImportRowForm', () => {
|
|||||||
onSave={onSave}/>)
|
onSave={onSave}/>)
|
||||||
await waitFor (() => expect (actions.length).toBe (2))
|
await waitFor (() => expect (actions.length).toBe (2))
|
||||||
|
|
||||||
|
const labels = Array.from (container.querySelectorAll ('label'))
|
||||||
|
.map (node => node.textContent?.trim ())
|
||||||
|
|
||||||
|
expect (labels.slice (0, 6)).toEqual ([
|
||||||
|
'URL',
|
||||||
|
'サムネール',
|
||||||
|
'タイトル',
|
||||||
|
'オリジナルの作成日時',
|
||||||
|
'タグ',
|
||||||
|
'親投稿'])
|
||||||
expect (container.querySelector ('input[type="file"]')).toBeNull ()
|
expect (container.querySelector ('input[type="file"]')).toBeNull ()
|
||||||
expect (screen.getByPlaceholderText ('例: 2 / 2.5 / 1:23')).toHaveValue ('2')
|
expect (screen.queryByPlaceholderText ('例: 2 / 2.5 / 1:23')).not.toBeInTheDocument ()
|
||||||
expect (screen.getByDisplayValue ('tag1')).toBeInTheDocument ()
|
expect (screen.getByDisplayValue ('tag1')).toBeInTheDocument ()
|
||||||
|
|
||||||
await act (async () => {
|
await act (async () => {
|
||||||
@@ -162,10 +174,9 @@ describe ('PostImportRowForm', () => {
|
|||||||
|
|
||||||
expect (onSave).toHaveBeenCalledWith ({
|
expect (onSave).toHaveBeenCalledWith ({
|
||||||
draft: expect.objectContaining ({
|
draft: expect.objectContaining ({
|
||||||
duration: '2',
|
|
||||||
tags: 'tag1' }),
|
tags: 'tag1' }),
|
||||||
resetRequested: false })
|
resetRequested: false })
|
||||||
})
|
})
|
||||||
|
|
||||||
it ('keeps reset enabled when the value matches but provenance still differs', async () => {
|
it ('keeps reset enabled when the value matches but provenance still differs', async () => {
|
||||||
const row = buildPostImportRow ({
|
const row = buildPostImportRow ({
|
||||||
@@ -178,7 +189,6 @@ describe ('PostImportRowForm', () => {
|
|||||||
thumbnailBase: '',
|
thumbnailBase: '',
|
||||||
originalCreatedFrom: '',
|
originalCreatedFrom: '',
|
||||||
originalCreatedBefore: '',
|
originalCreatedBefore: '',
|
||||||
duration: '',
|
|
||||||
tags: '',
|
tags: '',
|
||||||
parentPostIds: '' },
|
parentPostIds: '' },
|
||||||
provenance: {
|
provenance: {
|
||||||
@@ -187,7 +197,6 @@ describe ('PostImportRowForm', () => {
|
|||||||
thumbnailBase: 'automatic',
|
thumbnailBase: 'automatic',
|
||||||
originalCreatedFrom: 'automatic',
|
originalCreatedFrom: 'automatic',
|
||||||
originalCreatedBefore: 'automatic',
|
originalCreatedBefore: 'automatic',
|
||||||
duration: 'automatic',
|
|
||||||
tags: 'automatic',
|
tags: 'automatic',
|
||||||
parentPostIds: 'automatic' },
|
parentPostIds: 'automatic' },
|
||||||
tagSources: { automatic: '', manual: '' },
|
tagSources: { automatic: '', manual: '' },
|
||||||
@@ -210,7 +219,9 @@ describe ('PostImportRowForm', () => {
|
|||||||
expect (actions.find (action => action.label === '変更をリセット')?.disabled).toBe (false)
|
expect (actions.find (action => action.label === '変更をリセット')?.disabled).toBe (false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it ('disables every field while save validation is pending and re-enables them afterwards', async () => {
|
it (
|
||||||
|
'disables every field while save validation is pending and re-enables them afterwards',
|
||||||
|
async () => {
|
||||||
let actions: DialogueFormAction[] = []
|
let actions: DialogueFormAction[] = []
|
||||||
let resolveSave:
|
let resolveSave:
|
||||||
((value: { saved: boolean
|
((value: { saved: boolean
|
||||||
@@ -234,8 +245,9 @@ describe ('PostImportRowForm', () => {
|
|||||||
onSave={onSave}/>)
|
onSave={onSave}/>)
|
||||||
await waitFor (() => expect (actions.length).toBe (2))
|
await waitFor (() => expect (actions.length).toBe (2))
|
||||||
|
|
||||||
|
let savePromise: Promise<boolean | void> | undefined
|
||||||
await act (async () => {
|
await act (async () => {
|
||||||
await actions.find (action => action.label === '編輯内容を保存')?.onSelect ()
|
savePromise = actions.find (action => action.label === '編輯内容を保存')?.onSelect ()
|
||||||
})
|
})
|
||||||
|
|
||||||
await waitFor (() => {
|
await waitFor (() => {
|
||||||
@@ -249,6 +261,9 @@ describe ('PostImportRowForm', () => {
|
|||||||
row: buildPostImportRow ({
|
row: buildPostImportRow ({
|
||||||
attributes: { title: 'draft title' },
|
attributes: { title: 'draft title' },
|
||||||
validationErrors: { title: ['タイトルを確認してください.'] } }) })
|
validationErrors: { title: ['タイトルを確認してください.'] } }) })
|
||||||
|
await act (async () => {
|
||||||
|
await savePromise
|
||||||
|
})
|
||||||
|
|
||||||
await waitFor (() => {
|
await waitFor (() => {
|
||||||
screen.getAllByRole ('textbox').forEach (textbox => {
|
screen.getAllByRole ('textbox').forEach (textbox => {
|
||||||
@@ -257,5 +272,5 @@ describe ('PostImportRowForm', () => {
|
|||||||
})
|
})
|
||||||
expect (screen.getByDisplayValue ('draft title')).toBeInTheDocument ()
|
expect (screen.getByDisplayValue ('draft title')).toBeInTheDocument ()
|
||||||
expect (screen.getByText ('タイトルを確認してください.')).toBeInTheDocument ()
|
expect (screen.getByText ('タイトルを確認してください.')).toBeInTheDocument ()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
|
|
||||||
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
|
|
||||||
import FieldError from '@/components/common/FieldError'
|
import FieldError from '@/components/common/FieldError'
|
||||||
import FieldWarning from '@/components/common/FieldWarning'
|
import FieldWarning from '@/components/common/FieldWarning'
|
||||||
import PostDurationField from '@/components/posts/PostDurationField'
|
import PostCreationDataFields from '@/components/posts/PostCreationDataFields'
|
||||||
import PostTagsField from '@/components/posts/PostTagsField'
|
|
||||||
import PostTextField from '@/components/posts/PostTextField'
|
import PostTextField from '@/components/posts/PostTextField'
|
||||||
import PostImportThumbnailPreview from '@/components/posts/import/PostImportThumbnailPreview'
|
import PostImportThumbnailPreview from '@/components/posts/import/PostImportThumbnailPreview'
|
||||||
|
|
||||||
@@ -29,7 +27,6 @@ const buildDraft = (row: PostImportRow): Draft => ({
|
|||||||
thumbnailBase: String (row.attributes.thumbnailBase ?? ''),
|
thumbnailBase: String (row.attributes.thumbnailBase ?? ''),
|
||||||
originalCreatedFrom: String (row.attributes.originalCreatedFrom ?? ''),
|
originalCreatedFrom: String (row.attributes.originalCreatedFrom ?? ''),
|
||||||
originalCreatedBefore: String (row.attributes.originalCreatedBefore ?? ''),
|
originalCreatedBefore: String (row.attributes.originalCreatedBefore ?? ''),
|
||||||
duration: String (row.attributes.duration ?? ''),
|
|
||||||
tags: String (row.attributes.tags ?? ''),
|
tags: String (row.attributes.tags ?? ''),
|
||||||
parentPostIds: String (row.attributes.parentPostIds ?? '') })
|
parentPostIds: String (row.attributes.parentPostIds ?? '') })
|
||||||
|
|
||||||
@@ -39,7 +36,6 @@ const buildResetDraft = (row: PostImportRow): Draft => ({
|
|||||||
thumbnailBase: String (row.resetSnapshot.attributes.thumbnailBase ?? ''),
|
thumbnailBase: String (row.resetSnapshot.attributes.thumbnailBase ?? ''),
|
||||||
originalCreatedFrom: String (row.resetSnapshot.attributes.originalCreatedFrom ?? ''),
|
originalCreatedFrom: String (row.resetSnapshot.attributes.originalCreatedFrom ?? ''),
|
||||||
originalCreatedBefore: String (row.resetSnapshot.attributes.originalCreatedBefore ?? ''),
|
originalCreatedBefore: String (row.resetSnapshot.attributes.originalCreatedBefore ?? ''),
|
||||||
duration: String (row.resetSnapshot.attributes.duration ?? ''),
|
|
||||||
tags: String (row.resetSnapshot.attributes.tags ?? ''),
|
tags: String (row.resetSnapshot.attributes.tags ?? ''),
|
||||||
parentPostIds: String (row.resetSnapshot.attributes.parentPostIds ?? '') })
|
parentPostIds: String (row.resetSnapshot.attributes.parentPostIds ?? '') })
|
||||||
|
|
||||||
@@ -52,7 +48,6 @@ const sameDraft = (left: Draft, right: Draft): boolean =>
|
|||||||
&& left.thumbnailBase === right.thumbnailBase
|
&& left.thumbnailBase === right.thumbnailBase
|
||||||
&& left.originalCreatedFrom === right.originalCreatedFrom
|
&& left.originalCreatedFrom === right.originalCreatedFrom
|
||||||
&& left.originalCreatedBefore === right.originalCreatedBefore
|
&& left.originalCreatedBefore === right.originalCreatedBefore
|
||||||
&& left.duration === right.duration
|
|
||||||
&& left.tags === right.tags
|
&& left.tags === right.tags
|
||||||
&& left.parentPostIds === right.parentPostIds
|
&& left.parentPostIds === right.parentPostIds
|
||||||
|
|
||||||
@@ -79,12 +74,15 @@ const PostImportRowForm: FC<Props> = (
|
|||||||
const [messageRow, setMessageRow] = useState<PostImportRow | null> (null)
|
const [messageRow, setMessageRow] = useState<PostImportRow | null> (null)
|
||||||
const [saving, setSaving] = useState (false)
|
const [saving, setSaving] = useState (false)
|
||||||
const [resetRequested, setResetRequested] = useState (false)
|
const [resetRequested, setResetRequested] = useState (false)
|
||||||
|
const [committedThumbnailBase, setCommittedThumbnailBase] = useState (
|
||||||
|
() => String (row.attributes.thumbnailBase ?? ''))
|
||||||
|
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
const nextDraft = buildDraft (row)
|
const nextDraft = buildDraft (row)
|
||||||
setDraft (nextDraft)
|
setDraft (nextDraft)
|
||||||
setMessageRow (null)
|
setMessageRow (null)
|
||||||
setResetRequested (false)
|
setResetRequested (false)
|
||||||
|
setCommittedThumbnailBase (String (row.attributes.thumbnailBase ?? ''))
|
||||||
}, [row])
|
}, [row])
|
||||||
|
|
||||||
const displayRow = messageRow ?? row
|
const displayRow = messageRow ?? row
|
||||||
@@ -123,6 +121,7 @@ const PostImportRowForm: FC<Props> = (
|
|||||||
setDraft (resetDraft)
|
setDraft (resetDraft)
|
||||||
setResetRequested (true)
|
setResetRequested (true)
|
||||||
setMessageRow (null)
|
setMessageRow (null)
|
||||||
|
setCommittedThumbnailBase (resetDraft.thumbnailBase)
|
||||||
return false
|
return false
|
||||||
}, [controls, resetDisabled, resetDraft])
|
}, [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="grid gap-6 md:grid-cols-[7rem_minmax(0,1fr)]">
|
||||||
<div className="space-y-3 md:sticky md:top-0 md:self-start">
|
<div className="space-y-3 md:sticky md:top-0 md:self-start">
|
||||||
<PostImportThumbnailPreview
|
<PostImportThumbnailPreview
|
||||||
url={draft.thumbnailBase}
|
url={committedThumbnailBase}
|
||||||
className="h-28 w-28"/>
|
className="h-28 w-28"/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<PostTextField
|
<PostCreationDataFields
|
||||||
label="URL"
|
url={{
|
||||||
value={draft.url}
|
value: draft.url,
|
||||||
disabled={saving}
|
onChange: value => update ('url', value),
|
||||||
warnings={displayRow.fieldWarnings.url}
|
disabled: saving,
|
||||||
errors={groupedMessages (
|
warnings: displayRow.fieldWarnings.url,
|
||||||
displayRow.validationErrors.url,
|
errors: groupedMessages (
|
||||||
displayRow.importErrors?.url)}
|
displayRow.validationErrors.url,
|
||||||
onChange={value => update ('url', value)}/>
|
displayRow.importErrors?.url) }}
|
||||||
<PostTextField
|
thumbnailField={
|
||||||
label="タイトル"
|
<>
|
||||||
value={draft.title}
|
<PostTextField
|
||||||
disabled={saving}
|
label="サムネール"
|
||||||
warnings={displayRow.fieldWarnings.title}
|
value={draft.thumbnailBase}
|
||||||
errors={groupedMessages (
|
disabled={saving}
|
||||||
displayRow.validationErrors.title,
|
warnings={displayRow.fieldWarnings.thumbnailBase}
|
||||||
displayRow.importErrors?.title)}
|
errors={groupedMessages (
|
||||||
onChange={value => update ('title', value)}/>
|
displayRow.validationErrors.thumbnailBase,
|
||||||
<PostTextField
|
displayRow.importErrors?.thumbnailBase)}
|
||||||
label="サムネール基底 URL"
|
onBlur={() => {
|
||||||
value={draft.thumbnailBase}
|
if (draft.thumbnailBase !== committedThumbnailBase)
|
||||||
disabled={saving}
|
setCommittedThumbnailBase (draft.thumbnailBase)
|
||||||
warnings={displayRow.fieldWarnings.thumbnailBase}
|
}}
|
||||||
errors={groupedMessages (
|
onChange={value => update ('thumbnailBase', value)}/>
|
||||||
displayRow.validationErrors.thumbnailBase,
|
</>}
|
||||||
displayRow.importErrors?.thumbnailBase)}
|
core={{
|
||||||
onChange={value => update ('thumbnailBase', value)}/>
|
title: {
|
||||||
<PostOriginalCreatedTimeField
|
value: draft.title,
|
||||||
disabled={saving}
|
onChange: value => update ('title', value),
|
||||||
originalCreatedFrom={draft.originalCreatedFrom || null}
|
disabled: saving,
|
||||||
setOriginalCreatedFrom={value => update ('originalCreatedFrom', value ?? '')}
|
warnings: displayRow.fieldWarnings.title,
|
||||||
originalCreatedBefore={draft.originalCreatedBefore || null}
|
errors: groupedMessages (
|
||||||
setOriginalCreatedBefore={value => update ('originalCreatedBefore', value ?? '')}
|
displayRow.validationErrors.title,
|
||||||
errors={groupedMessages (
|
displayRow.importErrors?.title) },
|
||||||
displayRow.validationErrors.originalCreatedAt,
|
originalCreated: {
|
||||||
displayRow.validationErrors.originalCreatedFrom,
|
disabled: saving,
|
||||||
displayRow.validationErrors.originalCreatedBefore,
|
originalCreatedFrom: draft.originalCreatedFrom || null,
|
||||||
displayRow.importErrors?.originalCreatedAt,
|
setOriginalCreatedFrom: value =>
|
||||||
displayRow.importErrors?.originalCreatedFrom,
|
update ('originalCreatedFrom', value ?? ''),
|
||||||
displayRow.importErrors?.originalCreatedBefore)}/>
|
originalCreatedBefore: draft.originalCreatedBefore || null,
|
||||||
<PostDurationField
|
setOriginalCreatedBefore: value =>
|
||||||
value={draft.duration}
|
update ('originalCreatedBefore', value ?? ''),
|
||||||
disabled={saving}
|
errors: {
|
||||||
errors={groupedMessages (
|
originalCreatedAt: groupedMessages (
|
||||||
displayRow.validationErrors.duration,
|
displayRow.validationErrors.originalCreatedAt,
|
||||||
displayRow.validationErrors.videoMs,
|
displayRow.importErrors?.originalCreatedAt),
|
||||||
displayRow.importErrors?.duration,
|
originalCreatedFrom: groupedMessages (
|
||||||
displayRow.importErrors?.videoMs)}
|
displayRow.validationErrors.originalCreatedFrom,
|
||||||
onChange={value => update ('duration', value)}/>
|
displayRow.importErrors?.originalCreatedFrom),
|
||||||
<PostTagsField
|
originalCreatedBefore: groupedMessages (
|
||||||
tags={draft.tags}
|
displayRow.validationErrors.originalCreatedBefore,
|
||||||
disabled={saving}
|
displayRow.importErrors?.originalCreatedBefore) } },
|
||||||
setTags={value => update ('tags', value)}
|
tags: {
|
||||||
warnings={displayRow.fieldWarnings.tags}
|
value: draft.tags,
|
||||||
errors={groupedMessages (
|
onChange: value => update ('tags', value),
|
||||||
displayRow.validationErrors.tags,
|
disabled: saving,
|
||||||
displayRow.importErrors?.tags)}
|
warnings: displayRow.fieldWarnings.tags,
|
||||||
rows={4}/>
|
errors: groupedMessages (
|
||||||
<PostTextField
|
displayRow.validationErrors.tags,
|
||||||
label="親投稿"
|
displayRow.importErrors?.tags),
|
||||||
value={draft.parentPostIds}
|
rows: 4 },
|
||||||
disabled={saving}
|
parentPostIds: {
|
||||||
errors={groupedMessages (
|
value: draft.parentPostIds,
|
||||||
displayRow.validationErrors.parentPostIds,
|
onChange: value => update ('parentPostIds', value),
|
||||||
displayRow.importErrors?.parentPostIds)}
|
disabled: saving,
|
||||||
onChange={value => update ('parentPostIds', value)}/>
|
errors: groupedMessages (
|
||||||
|
displayRow.validationErrors.parentPostIds,
|
||||||
|
displayRow.importErrors?.parentPostIds) } }}/>
|
||||||
<FieldWarning messages={displayRow.baseWarnings}/>
|
<FieldWarning messages={displayRow.baseWarnings}/>
|
||||||
<FieldError messages={displayRow.validationErrors.base}/>
|
<FieldError messages={displayRow.validationErrors.base}/>
|
||||||
<FieldError messages={displayRow.importErrors?.base}/>
|
<FieldError messages={displayRow.importErrors?.base}/>
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
|
import FieldError from '@/components/common/FieldError'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import PostImportThumbnailPreview from '@/components/posts/import/PostImportThumbnailPreview'
|
import PostImportThumbnailPreview from '@/components/posts/import/PostImportThumbnailPreview'
|
||||||
import PostImportStatusBadge from '@/components/posts/import/PostImportStatusBadge'
|
import PostImportStatusBadge from '@/components/posts/import/PostImportStatusBadge'
|
||||||
import { displayPostImportStatus } from '@/components/posts/import/postImportRowStatus'
|
import { displayPostImportStatus } from '@/components/posts/import/postImportRowStatus'
|
||||||
import { canEditReviewRow } from '@/lib/postImportSession'
|
import { canEditReviewRow, canRetryResultRow } from '@/lib/postImportSession'
|
||||||
import { cn, originalCreatedAtString } from '@/lib/utils'
|
import { cn, originalCreatedAtString } from '@/lib/utils'
|
||||||
|
|
||||||
import type { FC } from 'react'
|
import type { FC } from 'react'
|
||||||
@@ -10,9 +11,17 @@ import type { FC } from 'react'
|
|||||||
import type { PostImportRow } from '@/lib/postImportSession'
|
import type { PostImportRow } from '@/lib/postImportSession'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
row: PostImportRow
|
row: PostImportRow
|
||||||
onEdit: () => void
|
displayNumber?: number
|
||||||
editDisabled?: boolean }
|
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 =>
|
const summaryWarning = (row: PostImportRow): string | null =>
|
||||||
Object.values (row.fieldWarnings ?? { }).flat ()[0]
|
Object.values (row.fieldWarnings ?? { }).flat ()[0]
|
||||||
@@ -25,9 +34,37 @@ const summaryDate = (row: PostImportRow): string =>
|
|||||||
row.attributes.originalCreatedBefore?.toString () ?? null)
|
row.attributes.originalCreatedBefore?.toString () ?? null)
|
||||||
|
|
||||||
|
|
||||||
const PostImportRowSummary: FC<Props> = ({ row, onEdit, editDisabled }) => {
|
const PostImportRowSummary: FC<Props> = (
|
||||||
|
{ row,
|
||||||
|
displayNumber,
|
||||||
|
onEdit,
|
||||||
|
onRetry,
|
||||||
|
onToggleSkip,
|
||||||
|
rowMessages,
|
||||||
|
editDisabled,
|
||||||
|
retryDisabled,
|
||||||
|
skipDisabled,
|
||||||
|
showActions = true,
|
||||||
|
showSkipToggle = false },
|
||||||
|
) => {
|
||||||
const warning = summaryWarning (row)
|
const warning = summaryWarning (row)
|
||||||
const displayStatus = displayPostImportStatus (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 (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -37,7 +74,7 @@ const PostImportRowSummary: FC<Props> = ({ row, onEdit, editDisabled }) => {
|
|||||||
'md:grid-cols-[4rem_5rem_minmax(0,1fr)_auto_auto]',
|
'md:grid-cols-[4rem_5rem_minmax(0,1fr)_auto_auto]',
|
||||||
'transition-shadow hover:shadow-sm')}>
|
'transition-shadow hover:shadow-sm')}>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="text-sm font-medium">#{row.sourceRow}</div>
|
<div className="text-sm font-medium">#{rowNumber}</div>
|
||||||
</div>
|
</div>
|
||||||
<PostImportThumbnailPreview
|
<PostImportThumbnailPreview
|
||||||
url={String (row.attributes.thumbnailBase ?? '')}
|
url={String (row.attributes.thumbnailBase ?? '')}
|
||||||
@@ -54,24 +91,36 @@ const PostImportRowSummary: FC<Props> = ({ row, onEdit, editDisabled }) => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||||
{summaryDate (row)}
|
{summaryDate (row)}
|
||||||
{row.attributes.duration ? ` / ${ row.attributes.duration }` : ''}
|
|
||||||
</div>
|
</div>
|
||||||
{warning && (
|
{warning && (
|
||||||
<div className="text-xs text-amber-700 dark:text-amber-200">
|
<div className="text-xs text-amber-700 dark:text-amber-200">
|
||||||
{warning}
|
{warning}
|
||||||
</div>)}
|
</div>)}
|
||||||
|
<FieldError messages={rowMessages}/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{displayStatus != null && <PostImportStatusBadge value={displayStatus}/>}
|
{displayStatus != null && <PostImportStatusBadge value={displayStatus}/>}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button
|
<div className="flex items-center gap-2">
|
||||||
type="button"
|
{skipControl}
|
||||||
variant="outline"
|
{showActions && editVisible && (
|
||||||
onClick={onEdit}
|
<Button
|
||||||
disabled={!(canEditReviewRow (row)) || editDisabled === true}>
|
type="button"
|
||||||
編輯
|
variant="outline"
|
||||||
</Button>
|
onClick={onEdit}
|
||||||
|
disabled={editDisabled === true || !(editAllowed)}>
|
||||||
|
編輯
|
||||||
|
</Button>)}
|
||||||
|
{showActions && retryAllowed && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={onRetry}
|
||||||
|
disabled={retryDisabled === true}>
|
||||||
|
再試行
|
||||||
|
</Button>)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -79,6 +128,7 @@ const PostImportRowSummary: FC<Props> = ({ row, onEdit, editDisabled }) => {
|
|||||||
className={cn (
|
className={cn (
|
||||||
'space-y-3 rounded-lg border p-4 md:hidden',
|
'space-y-3 rounded-lg border p-4 md:hidden',
|
||||||
'transition-shadow hover:shadow-sm')}>
|
'transition-shadow hover:shadow-sm')}>
|
||||||
|
<div className="text-sm font-medium">#{rowNumber}</div>
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<PostImportThumbnailPreview
|
<PostImportThumbnailPreview
|
||||||
url={String (row.attributes.thumbnailBase ?? '')}
|
url={String (row.attributes.thumbnailBase ?? '')}
|
||||||
@@ -98,21 +148,34 @@ const PostImportRowSummary: FC<Props> = ({ row, onEdit, editDisabled }) => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||||
{summaryDate (row)}
|
{summaryDate (row)}
|
||||||
{row.attributes.duration ? ` / ${ row.attributes.duration }` : ''}
|
|
||||||
</div>
|
</div>
|
||||||
{warning && (
|
{warning && (
|
||||||
<div className="text-xs text-amber-700 dark:text-amber-200">
|
<div className="text-xs text-amber-700 dark:text-amber-200">
|
||||||
{warning}
|
{warning}
|
||||||
</div>)}
|
</div>)}
|
||||||
|
<FieldError messages={rowMessages}/>
|
||||||
|
{skipControl}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
{showActions && (editVisible || retryAllowed) && (
|
||||||
type="button"
|
<div className="flex flex-col gap-2 sm:flex-row">
|
||||||
variant="outline"
|
{editVisible && (
|
||||||
onClick={onEdit}
|
<Button
|
||||||
disabled={!(canEditReviewRow (row)) || editDisabled === true}>
|
type="button"
|
||||||
編輯
|
variant="outline"
|
||||||
</Button>
|
onClick={onEdit}
|
||||||
|
disabled={editDisabled === true || !(editAllowed)}>
|
||||||
|
編輯
|
||||||
|
</Button>)}
|
||||||
|
{retryAllowed && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={onRetry}
|
||||||
|
disabled={retryDisabled === true}>
|
||||||
|
再試行
|
||||||
|
</Button>)}
|
||||||
|
</div>)}
|
||||||
</div>
|
</div>
|
||||||
</>)
|
</>)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,12 +38,18 @@ describe ('PostImportThumbnailPreview', () => {
|
|||||||
it ('does not render the unsafe URL directly when preview fetching fails', async () => {
|
it ('does not render the unsafe URL directly when preview fetching fails', async () => {
|
||||||
api.apiGet.mockRejectedValueOnce (new Error ('unsafe'))
|
api.apiGet.mockRejectedValueOnce (new Error ('unsafe'))
|
||||||
|
|
||||||
render (
|
const { container } = render (
|
||||||
<PostImportThumbnailPreview
|
<PostImportThumbnailPreview
|
||||||
url="http://127.0.0.1/private.png"
|
url="http://127.0.0.1/private.png"
|
||||||
className="h-10 w-10"/>)
|
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 ()
|
expect (screen.queryByRole ('img')).toBeNull ()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { useEffect, useRef, useState } from 'react'
|
|||||||
|
|
||||||
import PostThumbnailPreview from '@/components/posts/PostThumbnailPreview'
|
import PostThumbnailPreview from '@/components/posts/PostThumbnailPreview'
|
||||||
import { apiGet } from '@/lib/api'
|
import { apiGet } from '@/lib/api'
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
|
|
||||||
import type { FC } from 'react'
|
import type { FC } from 'react'
|
||||||
|
|
||||||
@@ -18,7 +17,6 @@ const PostImportThumbnailPreview: FC<Props> = (
|
|||||||
className = 'h-16 w-16' },
|
className = 'h-16 w-16' },
|
||||||
) => {
|
) => {
|
||||||
const [previewUrl, setPreviewUrl] = useState ('')
|
const [previewUrl, setPreviewUrl] = useState ('')
|
||||||
const [unavailable, setUnavailable] = useState (false)
|
|
||||||
const previewUrlRef = useRef ('')
|
const previewUrlRef = useRef ('')
|
||||||
|
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
@@ -28,20 +26,20 @@ const PostImportThumbnailPreview: FC<Props> = (
|
|||||||
previewUrlRef.current = ''
|
previewUrlRef.current = ''
|
||||||
}
|
}
|
||||||
setPreviewUrl ('')
|
setPreviewUrl ('')
|
||||||
setUnavailable (false)
|
|
||||||
|
|
||||||
if (!(url))
|
if (!(url))
|
||||||
return
|
return
|
||||||
|
|
||||||
let active = true
|
const controller = new AbortController ()
|
||||||
|
|
||||||
const loadPreview = async () => {
|
const loadPreview = async () => {
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
const blob = await apiGet<Blob> ('/preview/thumbnail', {
|
const blob = await apiGet<Blob> ('/preview/image', {
|
||||||
params: { url },
|
params: { url },
|
||||||
|
signal: controller.signal,
|
||||||
responseType: 'blob' })
|
responseType: 'blob' })
|
||||||
if (!(active))
|
if (controller.signal.aborted)
|
||||||
return
|
return
|
||||||
|
|
||||||
const nextPreviewUrl = URL.createObjectURL (blob)
|
const nextPreviewUrl = URL.createObjectURL (blob)
|
||||||
@@ -50,15 +48,15 @@ const PostImportThumbnailPreview: FC<Props> = (
|
|||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
if (active)
|
if (!(controller.signal.aborted))
|
||||||
setUnavailable (true)
|
setPreviewUrl ('')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void loadPreview ()
|
void loadPreview ()
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
active = false
|
controller.abort ()
|
||||||
if (previewUrlRef.current)
|
if (previewUrlRef.current)
|
||||||
{
|
{
|
||||||
URL.revokeObjectURL (previewUrlRef.current)
|
URL.revokeObjectURL (previewUrlRef.current)
|
||||||
@@ -67,18 +65,6 @@ const PostImportThumbnailPreview: FC<Props> = (
|
|||||||
}
|
}
|
||||||
}, [url])
|
}, [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 (
|
return (
|
||||||
<PostThumbnailPreview
|
<PostThumbnailPreview
|
||||||
url={previewUrl}
|
url={previewUrl}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ describe ('displayPostImportStatus', () => {
|
|||||||
expect (displayPostImportStatus (buildPostImportRow ({
|
expect (displayPostImportStatus (buildPostImportRow ({
|
||||||
skipReason: 'existing',
|
skipReason: 'existing',
|
||||||
existingPostId: 2 }))).toBe ('skipped')
|
existingPostId: 2 }))).toBe ('skipped')
|
||||||
|
expect (displayPostImportStatus (buildPostImportRow ({
|
||||||
|
skipReason: 'manual' }))).toBe ('skipped')
|
||||||
})
|
})
|
||||||
|
|
||||||
it ('does not expose validation, failure, or created states as badges', () => {
|
it ('does not expose validation, failure, or created states as badges', () => {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ const hasWarnings = (row: PostImportRow): boolean =>
|
|||||||
export const displayPostImportStatus = (
|
export const displayPostImportStatus = (
|
||||||
row: PostImportRow,
|
row: PostImportRow,
|
||||||
): PostImportDisplayStatus | null =>
|
): PostImportDisplayStatus | null =>
|
||||||
(row.skipReason === 'existing' || row.importStatus === 'skipped')
|
(row.skipReason != null || row.importStatus === 'skipped')
|
||||||
? 'skipped'
|
? 'skipped'
|
||||||
: ((Object.keys (row.validationErrors ?? { }).length > 0
|
: ((Object.keys (row.validationErrors ?? { }).length > 0
|
||||||
|| row.importStatus === 'failed'
|
|| row.importStatus === 'failed'
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type { AxiosError, AxiosRequestConfig } from 'axios'
|
|||||||
type Opt = {
|
type Opt = {
|
||||||
params?: AxiosRequestConfig['params']
|
params?: AxiosRequestConfig['params']
|
||||||
headers?: Record<string, string>
|
headers?: Record<string, string>
|
||||||
|
signal?: AbortSignal
|
||||||
responseType?: 'blob' }
|
responseType?: 'blob' }
|
||||||
|
|
||||||
const client = axios.create ({ baseURL: API_BASE_URL })
|
const client = axios.create ({ baseURL: API_BASE_URL })
|
||||||
|
|||||||
@@ -26,22 +26,26 @@ describe ('post import row state', () => {
|
|||||||
sourceRow: 2,
|
sourceRow: 2,
|
||||||
skipReason: 'existing',
|
skipReason: 'existing',
|
||||||
existingPostId: 20 })
|
existingPostId: 20 })
|
||||||
const invalid = buildPostImportRow ({
|
const manual = buildPostImportRow ({
|
||||||
sourceRow: 3,
|
sourceRow: 3,
|
||||||
|
skipReason: 'manual' })
|
||||||
|
const invalid = buildPostImportRow ({
|
||||||
|
sourceRow: 4,
|
||||||
status: 'error',
|
status: 'error',
|
||||||
validationErrors: { url: ['invalid'] } })
|
validationErrors: { url: ['invalid'] } })
|
||||||
const created = buildPostImportRow ({
|
const created = buildPostImportRow ({
|
||||||
sourceRow: 4,
|
sourceRow: 5,
|
||||||
importStatus: 'created',
|
importStatus: 'created',
|
||||||
createdPostId: 40 })
|
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 (creatableImportRows (rows)).toEqual ([ready])
|
||||||
expect (reviewSummaryCounts (rows)).toEqual ({
|
expect (reviewSummaryCounts (rows)).toEqual ({
|
||||||
total: 4,
|
creatable: 1,
|
||||||
submittable: 1,
|
manualSkipped: 1,
|
||||||
skipPlanned: 1 })
|
existingSkipped: 1,
|
||||||
|
pendingOrError: 1 })
|
||||||
})
|
})
|
||||||
|
|
||||||
it ('preserves terminal rows while merging validation results', () => {
|
it ('preserves terminal rows while merging validation results', () => {
|
||||||
@@ -270,7 +274,6 @@ describe ('post import row state', () => {
|
|||||||
thumbnailBase: '',
|
thumbnailBase: '',
|
||||||
originalCreatedFrom: '',
|
originalCreatedFrom: '',
|
||||||
originalCreatedBefore: '',
|
originalCreatedBefore: '',
|
||||||
duration: '2.5',
|
|
||||||
tags: 'edited-tag',
|
tags: 'edited-tag',
|
||||||
parentPostIds: '' },
|
parentPostIds: '' },
|
||||||
true)
|
true)
|
||||||
@@ -279,7 +282,7 @@ describe ('post import row state', () => {
|
|||||||
expect (nextRow.importErrors).toBeUndefined ()
|
expect (nextRow.importErrors).toBeUndefined ()
|
||||||
expect (nextRow.url).toBe ('https://example.com/edited')
|
expect (nextRow.url).toBe ('https://example.com/edited')
|
||||||
expect (nextRow.attributes.title).toBe ('edited title')
|
expect (nextRow.attributes.title).toBe ('edited title')
|
||||||
expect (nextRow.attributes.duration).toBe ('2.5')
|
expect (nextRow.attributes.duration).toBe ('2')
|
||||||
expect (nextRow.attributes.tags).toBe ('edited-tag')
|
expect (nextRow.attributes.tags).toBe ('edited-tag')
|
||||||
expect (nextRow.provenance.title).toBe ('manual')
|
expect (nextRow.provenance.title).toBe ('manual')
|
||||||
expect (nextRow.provenance.url).toBe ('manual')
|
expect (nextRow.provenance.url).toBe ('manual')
|
||||||
|
|||||||
@@ -2,9 +2,17 @@ import type { PostImportEditableDraft,
|
|||||||
PostImportResultRow,
|
PostImportResultRow,
|
||||||
PostImportRow } from '@/lib/postImportTypes'
|
PostImportRow } from '@/lib/postImportTypes'
|
||||||
|
|
||||||
const hasSkipReason = (row: PostImportRow): boolean =>
|
export const isExistingSkipRow = (row: PostImportRow): boolean =>
|
||||||
row.skipReason === 'existing'
|
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 =>
|
const hasValidationErrors = (row: PostImportRow): boolean =>
|
||||||
Object.keys (row.validationErrors ?? { }).length > 0
|
Object.keys (row.validationErrors ?? { }).length > 0
|
||||||
|
|
||||||
@@ -15,6 +23,26 @@ const isRepairableImportStatus = (row: PostImportRow): boolean =>
|
|||||||
isRecoverableRow (row)
|
isRecoverableRow (row)
|
||||||
&& (row.importStatus === 'failed' || row.importStatus === 'pending')
|
&& (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) => ({
|
const buildResetSnapshot = (row: PostImportRow) => ({
|
||||||
url: row.url,
|
url: row.url,
|
||||||
attributes: { ...row.attributes },
|
attributes: { ...row.attributes },
|
||||||
@@ -29,7 +57,7 @@ const buildResetSnapshot = (row: PostImportRow) => ({
|
|||||||
|
|
||||||
|
|
||||||
export const processableImportRows = (rows: PostImportRow[]): PostImportRow[] =>
|
export const processableImportRows = (rows: PostImportRow[]): PostImportRow[] =>
|
||||||
rows.filter (row => {
|
validatableImportRows (rows).filter (row => {
|
||||||
if (row.importStatus === 'created')
|
if (row.importStatus === 'created')
|
||||||
return false
|
return false
|
||||||
if (row.importStatus === 'skipped')
|
if (row.importStatus === 'skipped')
|
||||||
@@ -46,13 +74,12 @@ export const creatableImportRows = (rows: PostImportRow[]): PostImportRow[] =>
|
|||||||
|
|
||||||
|
|
||||||
export const reviewSummaryCounts = (rows: PostImportRow[]) => ({
|
export const reviewSummaryCounts = (rows: PostImportRow[]) => ({
|
||||||
total: rows.length,
|
creatable: rows.filter (row => creatableImportRows ([row]).length > 0).length,
|
||||||
submittable: rows.filter (row =>
|
manualSkipped: rows.filter (row => isManualSkipRow (row)).length,
|
||||||
creatableImportRows ([row]).length > 0
|
existingSkipped: rows.filter (row => isExistingSkipRow (row)).length,
|
||||||
&& !(hasValidationErrors (row))).length,
|
pendingOrError: rows.filter (row =>
|
||||||
skipPlanned: rows.filter (row =>
|
!(isCompletedReviewRow (row))
|
||||||
processableImportRows ([row]).length > 0
|
&& creatableImportRows ([row]).length === 0).length })
|
||||||
&& hasSkipReason (row)).length })
|
|
||||||
|
|
||||||
|
|
||||||
export const resultSummaryCounts = (rows: PostImportRow[]) =>
|
export const resultSummaryCounts = (rows: PostImportRow[]) =>
|
||||||
@@ -85,19 +112,22 @@ export const resultRepairMode = (
|
|||||||
|
|
||||||
|
|
||||||
export const canEditReviewRow = (row: PostImportRow): boolean =>
|
export const canEditReviewRow = (row: PostImportRow): boolean =>
|
||||||
!(row.importStatus === 'created'
|
!(hasSkipReason (row)
|
||||||
|
|| row.importStatus === 'created'
|
||||||
|| row.importStatus === 'skipped'
|
|| row.importStatus === 'skipped'
|
||||||
|| (row.importStatus === 'failed' && row.recoverable !== true))
|
|| (row.importStatus === 'failed' && row.recoverable !== true))
|
||||||
|
|
||||||
|
|
||||||
export const canEditResultRow = (row: PostImportRow): boolean =>
|
export const canEditResultRow = (row: PostImportRow): boolean =>
|
||||||
row.recoverable === true
|
row.skipReason == null
|
||||||
|
&& row.recoverable === true
|
||||||
&& (row.importStatus === 'failed'
|
&& (row.importStatus === 'failed'
|
||||||
|| (row.importStatus === 'pending' && hasValidationErrors (row)))
|
|| (row.importStatus === 'pending' && hasValidationErrors (row)))
|
||||||
|
|
||||||
|
|
||||||
export const canRetryResultRow = (row: PostImportRow): boolean =>
|
export const canRetryResultRow = (row: PostImportRow): boolean =>
|
||||||
row.recoverable === true
|
row.skipReason == null
|
||||||
|
&& row.recoverable === true
|
||||||
&& (row.importStatus === 'failed'
|
&& (row.importStatus === 'failed'
|
||||||
|| (row.importStatus === 'pending' && !(hasValidationErrors (row))))
|
|| (row.importStatus === 'pending' && !(hasValidationErrors (row))))
|
||||||
|
|
||||||
@@ -128,7 +158,6 @@ export const buildNextEditedRow = (
|
|||||||
['thumbnailBase', draft.thumbnailBase],
|
['thumbnailBase', draft.thumbnailBase],
|
||||||
['originalCreatedFrom', draft.originalCreatedFrom],
|
['originalCreatedFrom', draft.originalCreatedFrom],
|
||||||
['originalCreatedBefore', draft.originalCreatedBefore],
|
['originalCreatedBefore', draft.originalCreatedBefore],
|
||||||
['duration', draft.duration],
|
|
||||||
['parentPostIds', draft.parentPostIds]] as const
|
['parentPostIds', draft.parentPostIds]] as const
|
||||||
draftFields.forEach (([field, value]) => {
|
draftFields.forEach (([field, value]) => {
|
||||||
nextAttributes[field] = value
|
nextAttributes[field] = value
|
||||||
|
|||||||
@@ -24,12 +24,15 @@ describe ('post import storage', () => {
|
|||||||
importStatus: 'skipped',
|
importStatus: 'skipped',
|
||||||
skipReason: 'existing',
|
skipReason: 'existing',
|
||||||
existingPostId: 10 })
|
existingPostId: 10 })
|
||||||
|
const manual = buildPostImportRow ({
|
||||||
|
sourceRow: 3,
|
||||||
|
skipReason: 'manual' })
|
||||||
|
|
||||||
expect (savePostImportSession ('session', {
|
expect (savePostImportSession ({
|
||||||
source: skipped.url,
|
source: skipped.url,
|
||||||
rows: [row, skipped],
|
rows: [row, skipped, manual],
|
||||||
repairMode: 'all' })).toBe (true)
|
repairMode: 'all' })).toBe (true)
|
||||||
expect (loadPostImportSession ('session')).toMatchObject ({
|
expect (loadPostImportSession ()).toMatchObject ({
|
||||||
version: 2,
|
version: 2,
|
||||||
source: skipped.url,
|
source: skipped.url,
|
||||||
rows: [{
|
rows: [{
|
||||||
@@ -39,7 +42,9 @@ describe ('post import storage', () => {
|
|||||||
{
|
{
|
||||||
importStatus: 'skipped',
|
importStatus: 'skipped',
|
||||||
skipReason: 'existing',
|
skipReason: 'existing',
|
||||||
existingPostId: 10 }] })
|
existingPostId: 10 },
|
||||||
|
{
|
||||||
|
skipReason: 'manual' }] })
|
||||||
|
|
||||||
expect (savePostImportSourceDraft ('https://example.com')).toBe (true)
|
expect (savePostImportSourceDraft ('https://example.com')).toBe (true)
|
||||||
expect (loadPostImportSourceDraft ()).toEqual ({ source: 'https://example.com' })
|
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], importStatus: 'skipped', recoverable: true },
|
||||||
{ ...session.rows[0], recoverable: true }]
|
{ ...session.rows[0], recoverable: true }]
|
||||||
|
|
||||||
for (const [index, row] of invalidRows.entries ())
|
for (const row of invalidRows)
|
||||||
{
|
{
|
||||||
sessionStorage.setItem (`post-import-session:invalid-${ index }`, JSON.stringify ({
|
sessionStorage.setItem ('post-import-session:current', JSON.stringify ({
|
||||||
...session,
|
...session,
|
||||||
rows: [row] }))
|
rows: [row] }))
|
||||||
expect (loadPostImportSession (`invalid-${ index }`)).toBeNull ()
|
expect (loadPostImportSession ()).toBeNull ()
|
||||||
|
sessionStorage.clear ()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -82,18 +88,19 @@ describe ('post import storage', () => {
|
|||||||
{ ...row, resetSnapshot: { ...row.resetSnapshot,
|
{ ...row, resetSnapshot: { ...row.resetSnapshot,
|
||||||
fieldWarnings: { title: 'warning' } } }]
|
fieldWarnings: { title: 'warning' } } }]
|
||||||
|
|
||||||
invalidRows.forEach ((invalidRow, index) => {
|
invalidRows.forEach (invalidRow => {
|
||||||
sessionStorage.setItem (`post-import-session:shape-${ index }`, JSON.stringify ({
|
sessionStorage.setItem ('post-import-session:current', JSON.stringify ({
|
||||||
version: 2,
|
version: 2,
|
||||||
savedAt: new Date ().toISOString (),
|
savedAt: new Date ().toISOString (),
|
||||||
source: '',
|
source: '',
|
||||||
rows: [invalidRow],
|
rows: [invalidRow],
|
||||||
repairMode: 'all' }))
|
repairMode: 'all' }))
|
||||||
expect (loadPostImportSession (`shape-${ index }`)).toBeNull ()
|
expect (loadPostImportSession ()).toBeNull ()
|
||||||
|
sessionStorage.clear ()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it ('removes expired and malformed sessions without touching current sessions', () => {
|
it ('keeps only the current fixed-key session and removes legacy prefixed entries', () => {
|
||||||
const current = {
|
const current = {
|
||||||
version: 2,
|
version: 2,
|
||||||
savedAt: new Date ().toISOString (),
|
savedAt: new Date ().toISOString (),
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type { PostImportOrigin,
|
|||||||
|
|
||||||
const SESSION_VERSION = 2
|
const SESSION_VERSION = 2
|
||||||
const SESSION_PREFIX = 'post-import-session:'
|
const SESSION_PREFIX = 'post-import-session:'
|
||||||
|
const CURRENT_SESSION_KEY = `${ SESSION_PREFIX }current`
|
||||||
const SOURCE_DRAFT_KEY = 'post-import-source-draft'
|
const SOURCE_DRAFT_KEY = 'post-import-source-draft'
|
||||||
const SESSION_MAX_AGE_MS = 24 * 60 * 60 * 1000
|
const SESSION_MAX_AGE_MS = 24 * 60 * 60 * 1000
|
||||||
const ATTRIBUTE_KEYS = [
|
const ATTRIBUTE_KEYS = [
|
||||||
@@ -28,7 +29,7 @@ const isPlainObject = (value: unknown): value is Record<string, unknown> =>
|
|||||||
typeof value === 'object' && value != null && !(Array.isArray (value))
|
typeof value === 'object' && value != null && !(Array.isArray (value))
|
||||||
|
|
||||||
|
|
||||||
const sessionKey = (sessionId: string): string => `${ SESSION_PREFIX }${ sessionId }`
|
const sessionKey = (): string => CURRENT_SESSION_KEY
|
||||||
|
|
||||||
|
|
||||||
const readStorage = (
|
const readStorage = (
|
||||||
@@ -128,7 +129,7 @@ const isValidOrigin = (
|
|||||||
const isValidSkipReason = (
|
const isValidSkipReason = (
|
||||||
value: unknown,
|
value: unknown,
|
||||||
): value is PostImportSkipReason =>
|
): value is PostImportSkipReason =>
|
||||||
value === 'existing'
|
value === 'existing' || value === 'manual'
|
||||||
|
|
||||||
const isPositiveInteger = (value: unknown): value is number =>
|
const isPositiveInteger = (value: unknown): value is number =>
|
||||||
Number.isInteger (value) && Number (value) > 0
|
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 = (
|
export const cleanupExpiredPostImportSessions = (
|
||||||
onError?: StorageErrorHandler,
|
onError?: StorageErrorHandler,
|
||||||
) => {
|
) => {
|
||||||
@@ -311,6 +306,13 @@ export const cleanupExpiredPostImportSessions = (
|
|||||||
const raw = sessionStorage.getItem (key)
|
const raw = sessionStorage.getItem (key)
|
||||||
if (raw == null)
|
if (raw == null)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
if (key !== CURRENT_SESSION_KEY)
|
||||||
|
{
|
||||||
|
sessionStorage.removeItem (key)
|
||||||
|
--i
|
||||||
|
continue
|
||||||
|
}
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
const value = JSON.parse (raw) as { savedAt?: string }
|
const value = JSON.parse (raw) as { savedAt?: string }
|
||||||
@@ -368,24 +370,38 @@ export const clearPostImportSourceDraft = (
|
|||||||
|
|
||||||
|
|
||||||
export const savePostImportSession = (
|
export const savePostImportSession = (
|
||||||
sessionId: string,
|
sessionOrLegacyId: string | Omit<PostImportSession, 'version' | 'savedAt'>,
|
||||||
session: Omit<PostImportSession, 'version' | 'savedAt'>,
|
sessionOrOnError?: Omit<PostImportSession, 'version' | 'savedAt'> | StorageErrorHandler,
|
||||||
onError?: StorageErrorHandler,
|
onError?: StorageErrorHandler,
|
||||||
): boolean =>
|
): boolean => {
|
||||||
writeStorage (
|
const session =
|
||||||
sessionKey (sessionId),
|
typeof sessionOrLegacyId === 'string'
|
||||||
|
? sessionOrOnError as Omit<PostImportSession, 'version' | 'savedAt'>
|
||||||
|
: sessionOrLegacyId
|
||||||
|
const errorHandler =
|
||||||
|
typeof sessionOrLegacyId === 'string'
|
||||||
|
? onError
|
||||||
|
: sessionOrOnError as StorageErrorHandler | undefined
|
||||||
|
|
||||||
|
return writeStorage (
|
||||||
|
sessionKey (),
|
||||||
JSON.stringify ({
|
JSON.stringify ({
|
||||||
...session,
|
...session,
|
||||||
version: SESSION_VERSION,
|
version: SESSION_VERSION,
|
||||||
savedAt: new Date ().toISOString () }),
|
savedAt: new Date ().toISOString () }),
|
||||||
onError)
|
errorHandler)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export const loadPostImportSession = (
|
export const loadPostImportSession = (
|
||||||
sessionId: string,
|
legacyIdOrOnError?: string | StorageErrorHandler,
|
||||||
onError?: StorageErrorHandler,
|
onError?: StorageErrorHandler,
|
||||||
): PostImportSession | null => {
|
): PostImportSession | null => {
|
||||||
const raw = readStorage (sessionKey (sessionId), onError)
|
const errorHandler =
|
||||||
|
typeof legacyIdOrOnError === 'string'
|
||||||
|
? onError
|
||||||
|
: legacyIdOrOnError
|
||||||
|
const raw = readStorage (sessionKey (), errorHandler)
|
||||||
if (raw == null)
|
if (raw == null)
|
||||||
return null
|
return null
|
||||||
|
|
||||||
@@ -396,7 +412,7 @@ export const loadPostImportSession = (
|
|||||||
return null
|
return null
|
||||||
if (typeof value.savedAt !== 'string' || isExpiredSession (value.savedAt))
|
if (typeof value.savedAt !== 'string' || isExpiredSession (value.savedAt))
|
||||||
{
|
{
|
||||||
removeStorage (sessionKey (sessionId), onError)
|
removeStorage (sessionKey (), errorHandler)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -416,3 +432,10 @@ export const loadPostImportSession = (
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const clearPostImportSession = (
|
||||||
|
onError?: StorageErrorHandler,
|
||||||
|
) => {
|
||||||
|
removeStorage (sessionKey (), onError)
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ export type PostImportStatus =
|
|||||||
| 'created'
|
| 'created'
|
||||||
| 'skipped'
|
| 'skipped'
|
||||||
| 'failed'
|
| 'failed'
|
||||||
export type PostImportSkipReason = 'existing'
|
export type PostImportSkipReason = 'existing' | 'manual'
|
||||||
export type PostImportResultStatus = 'created' | 'skipped' | 'failed'
|
export type PostImportResultStatus = 'created' | 'skipped' | 'failed'
|
||||||
export type PostImportAttributeValue = string | number
|
export type PostImportAttributeValue = string | number
|
||||||
|
|
||||||
@@ -78,7 +78,6 @@ export type PostImportEditableDraft = {
|
|||||||
thumbnailBase: string
|
thumbnailBase: string
|
||||||
originalCreatedFrom: string
|
originalCreatedFrom: string
|
||||||
originalCreatedBefore: string
|
originalCreatedBefore: string
|
||||||
duration: string
|
|
||||||
tags: string
|
tags: string
|
||||||
parentPostIds: string }
|
parentPostIds: string }
|
||||||
|
|
||||||
|
|||||||
@@ -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 { render, screen } from '@testing-library/react'
|
||||||
import { HelmetProvider } from 'react-helmet-async'
|
import { MemoryRouter, Navigate, Route, Routes } from 'react-router-dom'
|
||||||
import { MemoryRouter, Route, Routes } from 'react-router-dom'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
||||||
|
|
||||||
import {
|
describe ('legacy post import routes', () => {
|
||||||
loadPostImportSession,
|
it ('redirects /posts/import to /posts/new', () => {
|
||||||
savePostImportSession,
|
render (
|
||||||
} from '@/lib/postImportSession'
|
<MemoryRouter initialEntries={['/posts/import']}>
|
||||||
import PostImportResultPage from '@/pages/posts/PostImportResultPage'
|
<Routes>
|
||||||
import { buildUser } from '@/test/factories'
|
<Route path="/posts/import" element={<Navigate to="/posts/new" replace/>}/>
|
||||||
import { buildPostImportRow } from '@/test/postImportFactories'
|
<Route path="/posts/new" element={<div>SOURCE ROUTE</div>}/>
|
||||||
import { originalCreatedAtString } from '@/lib/utils'
|
</Routes>
|
||||||
|
</MemoryRouter>)
|
||||||
|
|
||||||
import type { PostImportRow } from '@/lib/postImportSession'
|
expect (screen.getByText ('SOURCE ROUTE')).toBeInTheDocument ()
|
||||||
import type { DialogueFormAction, DialogueFormControls } from '@/lib/dialogues/useDialogue'
|
|
||||||
|
|
||||||
const api = vi.hoisted (() => ({
|
|
||||||
apiGet: vi.fn (),
|
|
||||||
apiPost: vi.fn (),
|
|
||||||
}))
|
|
||||||
|
|
||||||
const toastApi = vi.hoisted (() => ({
|
|
||||||
toast: vi.fn (),
|
|
||||||
}))
|
|
||||||
|
|
||||||
const dialogue = vi.hoisted (() => ({
|
|
||||||
form: vi.fn (() => Promise.resolve ()),
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock ('@/components/ui/use-toast', () => toastApi)
|
|
||||||
vi.mock ('@/lib/api', () => api)
|
|
||||||
vi.mock ('@/lib/dialogues/useDialogue', () => ({
|
|
||||||
default: () => dialogue,
|
|
||||||
}))
|
|
||||||
|
|
||||||
describe ('PostImportResultPage', () => {
|
|
||||||
beforeEach (() => {
|
|
||||||
sessionStorage.clear ()
|
|
||||||
vi.clearAllMocks ()
|
|
||||||
globalThis.URL.createObjectURL = vi.fn (() => 'blob:preview')
|
|
||||||
globalThis.URL.revokeObjectURL = vi.fn ()
|
|
||||||
api.apiGet.mockResolvedValue (new Blob (['img'], { type: 'image/png' }))
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it ('shows edit and retry only for recoverable failures and shows warnings', () => {
|
it ('redirects legacy review routes to /posts/new', () => {
|
||||||
savePostImportSession ('result-session', {
|
|
||||||
source: '',
|
|
||||||
repairMode: 'failed',
|
|
||||||
rows: [
|
|
||||||
buildPostImportRow ({
|
|
||||||
sourceRow: 1,
|
|
||||||
attributes: { title: 'recoverable row' },
|
|
||||||
importStatus: 'failed',
|
|
||||||
recoverable: true,
|
|
||||||
importErrors: { base: ['recoverable failed'] } }),
|
|
||||||
buildPostImportRow ({
|
|
||||||
sourceRow: 2,
|
|
||||||
attributes: { title: 'hard failed row' },
|
|
||||||
importStatus: 'failed',
|
|
||||||
importErrors: { base: ['hard failed'] } }),
|
|
||||||
buildPostImportRow ({
|
|
||||||
sourceRow: 3,
|
|
||||||
attributes: {
|
|
||||||
title: 'created row',
|
|
||||||
originalCreatedFrom: '2024-01-01T00:00:00Z',
|
|
||||||
originalCreatedBefore: '2024-01-02T00:00:00Z' },
|
|
||||||
importStatus: 'created',
|
|
||||||
createdPostId: 3,
|
|
||||||
fieldWarnings: {
|
|
||||||
thumbnailBase: ['サムネール画像を取得できませんでした.'] } })] })
|
|
||||||
|
|
||||||
render (
|
render (
|
||||||
<HelmetProvider>
|
<MemoryRouter initialEntries={['/posts/import/legacy/review']}>
|
||||||
<MemoryRouter initialEntries={['/posts/import/result-session/result']}>
|
<Routes>
|
||||||
<Routes>
|
<Route
|
||||||
<Route
|
path="/posts/import/:sessionId/review"
|
||||||
path="/posts/import/:sessionId/result"
|
element={<Navigate to="/posts/new" replace/>}/>
|
||||||
element={<PostImportResultPage user={buildUser ()}/>}/>
|
<Route path="/posts/new" element={<div>SOURCE ROUTE</div>}/>
|
||||||
</Routes>
|
</Routes>
|
||||||
</MemoryRouter>
|
</MemoryRouter>)
|
||||||
</HelmetProvider>)
|
|
||||||
|
|
||||||
expect (screen.getAllByRole ('button', { name: '編輯' })).toHaveLength (1)
|
expect (screen.getByText ('SOURCE ROUTE')).toBeInTheDocument ()
|
||||||
expect (screen.getAllByRole ('button', { name: '再試行' })).toHaveLength (1)
|
|
||||||
expect (screen.getByText ('サムネール画像を取得できませんでした.'))
|
|
||||||
.toBeInTheDocument ()
|
|
||||||
expect (screen.getByText (
|
|
||||||
originalCreatedAtString ('2024-01-01T00:00:00Z', '2024-01-02T00:00:00Z'),
|
|
||||||
)).toBeInTheDocument ()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it ('shows hour-precision original-created ranges with the shared utility output', () => {
|
it ('redirects legacy result routes to /posts', () => {
|
||||||
const from = '2024-01-01T12:00:00Z'
|
|
||||||
const before = '2024-01-01T13:00:00Z'
|
|
||||||
savePostImportSession ('result-hour-range', {
|
|
||||||
source: '',
|
|
||||||
repairMode: 'all',
|
|
||||||
rows: [buildPostImportRow ({
|
|
||||||
sourceRow: 1,
|
|
||||||
importStatus: 'created',
|
|
||||||
createdPostId: 1,
|
|
||||||
attributes: {
|
|
||||||
title: 'created row',
|
|
||||||
originalCreatedFrom: from,
|
|
||||||
originalCreatedBefore: before } })] })
|
|
||||||
|
|
||||||
render (
|
render (
|
||||||
<HelmetProvider>
|
<MemoryRouter initialEntries={['/posts/import/legacy/result']}>
|
||||||
<MemoryRouter initialEntries={['/posts/import/result-hour-range/result']}>
|
<Routes>
|
||||||
<Routes>
|
<Route
|
||||||
<Route
|
path="/posts/import/:sessionId/result"
|
||||||
path="/posts/import/:sessionId/result"
|
element={<Navigate to="/posts" replace/>}/>
|
||||||
element={<PostImportResultPage user={buildUser ()}/>}/>
|
<Route
|
||||||
</Routes>
|
path="/posts/new/:sessionId/result"
|
||||||
</MemoryRouter>
|
element={<Navigate to="/posts" replace/>}/>
|
||||||
</HelmetProvider>)
|
<Route path="/posts" element={<div>POSTS ROUTE</div>}/>
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>)
|
||||||
|
|
||||||
expect (screen.getByText (originalCreatedAtString (from, before))).toBeInTheDocument ()
|
expect (screen.getByText ('POSTS ROUTE')).toBeInTheDocument ()
|
||||||
})
|
|
||||||
|
|
||||||
it ('keeps recoverable pending rows actionable and hides actions for hard failures', () => {
|
|
||||||
savePostImportSession ('result-pending', {
|
|
||||||
source: '',
|
|
||||||
repairMode: 'failed',
|
|
||||||
rows: [
|
|
||||||
buildPostImportRow ({
|
|
||||||
sourceRow: 1,
|
|
||||||
importStatus: 'pending',
|
|
||||||
recoverable: true,
|
|
||||||
validationErrors: { title: ['invalid'] } }),
|
|
||||||
buildPostImportRow ({
|
|
||||||
sourceRow: 2,
|
|
||||||
importStatus: 'pending',
|
|
||||||
recoverable: true }),
|
|
||||||
buildPostImportRow ({
|
|
||||||
sourceRow: 3,
|
|
||||||
importStatus: 'failed',
|
|
||||||
importErrors: { base: ['hard failed'] } })] })
|
|
||||||
|
|
||||||
render (
|
|
||||||
<HelmetProvider>
|
|
||||||
<MemoryRouter initialEntries={['/posts/import/result-pending/result']}>
|
|
||||||
<Routes>
|
|
||||||
<Route
|
|
||||||
path="/posts/import/:sessionId/result"
|
|
||||||
element={<PostImportResultPage user={buildUser ()}/>}/>
|
|
||||||
</Routes>
|
|
||||||
</MemoryRouter>
|
|
||||||
</HelmetProvider>)
|
|
||||||
|
|
||||||
expect (screen.getAllByRole ('button', { name: '編輯' })).toHaveLength (1)
|
|
||||||
expect (screen.getAllByRole ('button', { name: '再試行' })).toHaveLength (1)
|
|
||||||
expect (screen.getByText ('invalid')).toBeInTheDocument ()
|
|
||||||
expect (screen.getByText ('hard failed')).toBeInTheDocument ()
|
|
||||||
})
|
|
||||||
|
|
||||||
it ('disables editing, retry, and navigation while retry is running', async () => {
|
|
||||||
let resolveValidation: ((value: { rows: PostImportRow[] }) => void) | null = null
|
|
||||||
savePostImportSession ('result-retry-busy', {
|
|
||||||
source: '',
|
|
||||||
repairMode: 'failed',
|
|
||||||
rows: [
|
|
||||||
buildPostImportRow ({
|
|
||||||
sourceRow: 1,
|
|
||||||
importStatus: 'failed',
|
|
||||||
recoverable: true,
|
|
||||||
importErrors: { base: ['failed'] } }),
|
|
||||||
buildPostImportRow ({
|
|
||||||
sourceRow: 2,
|
|
||||||
importStatus: 'failed',
|
|
||||||
recoverable: true,
|
|
||||||
importErrors: { base: ['failed'] } })] })
|
|
||||||
api.apiPost.mockImplementationOnce (() =>
|
|
||||||
new Promise<{ rows: PostImportRow[] }> (resolve => {
|
|
||||||
resolveValidation = resolve
|
|
||||||
}))
|
|
||||||
api.apiPost.mockResolvedValueOnce ({
|
|
||||||
created: 0,
|
|
||||||
skipped: 0,
|
|
||||||
failed: 0,
|
|
||||||
rows: [] })
|
|
||||||
|
|
||||||
render (
|
|
||||||
<HelmetProvider>
|
|
||||||
<MemoryRouter initialEntries={['/posts/import/result-retry-busy/result']}>
|
|
||||||
<Routes>
|
|
||||||
<Route
|
|
||||||
path="/posts/import/:sessionId/result"
|
|
||||||
element={<PostImportResultPage user={buildUser ()}/>}/>
|
|
||||||
</Routes>
|
|
||||||
</MemoryRouter>
|
|
||||||
</HelmetProvider>)
|
|
||||||
|
|
||||||
fireEvent.click (screen.getAllByRole ('button', { name: '再試行' })[0])
|
|
||||||
|
|
||||||
await waitFor (() => {
|
|
||||||
screen.getAllByRole ('button', { name: '編輯' }).forEach (button => {
|
|
||||||
expect (button).toBeDisabled ()
|
|
||||||
})
|
|
||||||
screen.getAllByRole ('button', { name: '再試行' }).forEach (button => {
|
|
||||||
expect (button).toBeDisabled ()
|
|
||||||
})
|
|
||||||
expect (screen.getByRole ('button', { name: '確認画面へ戻る' })).toBeDisabled ()
|
|
||||||
expect (screen.getByRole ('button', { name: '新しい URL リストを入力' })).toBeDisabled ()
|
|
||||||
})
|
|
||||||
|
|
||||||
resolveValidation?.({ rows: [buildPostImportRow ({ sourceRow: 1 })] })
|
|
||||||
})
|
|
||||||
|
|
||||||
it ('persists retry success to sessionStorage', async () => {
|
|
||||||
savePostImportSession ('result-retry-success', {
|
|
||||||
source: '',
|
|
||||||
repairMode: 'failed',
|
|
||||||
rows: [buildPostImportRow ({
|
|
||||||
sourceRow: 1,
|
|
||||||
importStatus: 'failed',
|
|
||||||
recoverable: true,
|
|
||||||
importErrors: { base: ['failed'] } })] })
|
|
||||||
api.apiPost
|
|
||||||
.mockResolvedValueOnce ({
|
|
||||||
rows: [buildPostImportRow ({
|
|
||||||
sourceRow: 1,
|
|
||||||
importStatus: 'pending',
|
|
||||||
recoverable: true })] })
|
|
||||||
.mockResolvedValueOnce ({
|
|
||||||
created: 1,
|
|
||||||
skipped: 0,
|
|
||||||
failed: 0,
|
|
||||||
rows: [{
|
|
||||||
sourceRow: 1,
|
|
||||||
status: 'created',
|
|
||||||
post: { id: 10 } }] })
|
|
||||||
|
|
||||||
render (
|
|
||||||
<HelmetProvider>
|
|
||||||
<MemoryRouter initialEntries={['/posts/import/result-retry-success/result']}>
|
|
||||||
<Routes>
|
|
||||||
<Route
|
|
||||||
path="/posts/import/:sessionId/result"
|
|
||||||
element={<PostImportResultPage user={buildUser ()}/>}/>
|
|
||||||
</Routes>
|
|
||||||
</MemoryRouter>
|
|
||||||
</HelmetProvider>)
|
|
||||||
|
|
||||||
fireEvent.click (screen.getByRole ('button', { name: '再試行' }))
|
|
||||||
|
|
||||||
await waitFor (() => {
|
|
||||||
expect (loadPostImportSession ('result-retry-success')?.rows[0]?.importStatus)
|
|
||||||
.toBe ('created')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it ('keeps edited recoverable rows in session and retries with the edited values', async () => {
|
|
||||||
savePostImportSession ('result-edit-retry', {
|
|
||||||
source: '',
|
|
||||||
repairMode: 'failed',
|
|
||||||
rows: [buildPostImportRow ({
|
|
||||||
sourceRow: 1,
|
|
||||||
attributes: { title: 'old title' },
|
|
||||||
importStatus: 'failed',
|
|
||||||
recoverable: true,
|
|
||||||
importErrors: { base: ['failed'] } })] })
|
|
||||||
api.apiPost
|
|
||||||
.mockResolvedValueOnce ({
|
|
||||||
rows: [buildPostImportRow ({
|
|
||||||
sourceRow: 1,
|
|
||||||
attributes: { title: 'edited title' },
|
|
||||||
importStatus: 'pending',
|
|
||||||
recoverable: true })] })
|
|
||||||
.mockResolvedValueOnce ({
|
|
||||||
rows: [buildPostImportRow ({
|
|
||||||
sourceRow: 1,
|
|
||||||
attributes: { title: 'edited title' },
|
|
||||||
importStatus: 'pending',
|
|
||||||
recoverable: true })] })
|
|
||||||
.mockResolvedValueOnce ({
|
|
||||||
created: 1,
|
|
||||||
skipped: 0,
|
|
||||||
failed: 0,
|
|
||||||
rows: [{
|
|
||||||
sourceRow: 1,
|
|
||||||
status: 'created',
|
|
||||||
post: { id: 11 } }] })
|
|
||||||
dialogue.form.mockImplementationOnce (async options => {
|
|
||||||
let actions: DialogueFormAction[] = []
|
|
||||||
const controls: DialogueFormControls = {
|
|
||||||
close: vi.fn (),
|
|
||||||
confirm: vi.fn (),
|
|
||||||
setActions: next => {
|
|
||||||
actions = next
|
|
||||||
} }
|
|
||||||
|
|
||||||
render (options.body (controls))
|
|
||||||
await waitFor (() => expect (actions.length).toBe (2))
|
|
||||||
fireEvent.change (screen.getByDisplayValue ('old title'), {
|
|
||||||
target: { value: 'edited title' } })
|
|
||||||
await act (async () => {
|
|
||||||
await actions.find (action => action.label === '編輯内容を保存')?.onSelect ()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
render (
|
|
||||||
<HelmetProvider>
|
|
||||||
<MemoryRouter initialEntries={['/posts/import/result-edit-retry/result']}>
|
|
||||||
<Routes>
|
|
||||||
<Route
|
|
||||||
path="/posts/import/:sessionId/result"
|
|
||||||
element={<PostImportResultPage user={buildUser ()}/>}/>
|
|
||||||
</Routes>
|
|
||||||
</MemoryRouter>
|
|
||||||
</HelmetProvider>)
|
|
||||||
|
|
||||||
fireEvent.click (screen.getByRole ('button', { name: '編輯' }))
|
|
||||||
|
|
||||||
await waitFor (() => {
|
|
||||||
const saved = loadPostImportSession ('result-edit-retry')
|
|
||||||
expect (saved?.rows[0]?.attributes.title).toBe ('edited title')
|
|
||||||
expect (saved?.rows[0]?.importStatus).toBe ('pending')
|
|
||||||
})
|
|
||||||
|
|
||||||
fireEvent.click (screen.getByRole ('button', { name: '再試行' }))
|
|
||||||
|
|
||||||
await waitFor (() => {
|
|
||||||
expect (api.apiPost.mock.calls[2]?.[1]?.rows?.[0]?.attributes?.title)
|
|
||||||
.toBe ('edited title')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it ('persists restored failed state when retry fails', async () => {
|
|
||||||
savePostImportSession ('result-retry-failure', {
|
|
||||||
source: '',
|
|
||||||
repairMode: 'failed',
|
|
||||||
rows: [buildPostImportRow ({
|
|
||||||
sourceRow: 1,
|
|
||||||
importStatus: 'failed',
|
|
||||||
recoverable: true,
|
|
||||||
importErrors: { base: ['failed'] } })] })
|
|
||||||
api.apiPost.mockRejectedValueOnce (new Error ('network error'))
|
|
||||||
|
|
||||||
render (
|
|
||||||
<HelmetProvider>
|
|
||||||
<MemoryRouter initialEntries={['/posts/import/result-retry-failure/result']}>
|
|
||||||
<Routes>
|
|
||||||
<Route
|
|
||||||
path="/posts/import/:sessionId/result"
|
|
||||||
element={<PostImportResultPage user={buildUser ()}/>}/>
|
|
||||||
</Routes>
|
|
||||||
</MemoryRouter>
|
|
||||||
</HelmetProvider>)
|
|
||||||
|
|
||||||
fireEvent.click (screen.getByRole ('button', { name: '再試行' }))
|
|
||||||
|
|
||||||
await waitFor (() => {
|
|
||||||
const saved = loadPostImportSession ('result-retry-failure')
|
|
||||||
expect (saved?.rows[0]?.importStatus).toBe ('failed')
|
|
||||||
expect (saved?.rows[0]?.recoverable).toBe (true)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it ('restores the failed row when the retry import response is incomplete', async () => {
|
|
||||||
savePostImportSession ('result-retry-missing-row', {
|
|
||||||
source: '',
|
|
||||||
repairMode: 'failed',
|
|
||||||
rows: [buildPostImportRow ({
|
|
||||||
sourceRow: 1,
|
|
||||||
importStatus: 'failed',
|
|
||||||
recoverable: true,
|
|
||||||
importErrors: { base: ['failed'] } })] })
|
|
||||||
api.apiPost
|
|
||||||
.mockResolvedValueOnce ({
|
|
||||||
rows: [buildPostImportRow ({
|
|
||||||
sourceRow: 1,
|
|
||||||
importStatus: 'pending',
|
|
||||||
recoverable: true })] })
|
|
||||||
.mockResolvedValueOnce ({
|
|
||||||
created: 1,
|
|
||||||
skipped: 0,
|
|
||||||
failed: 0,
|
|
||||||
rows: [] })
|
|
||||||
|
|
||||||
render (
|
|
||||||
<HelmetProvider>
|
|
||||||
<MemoryRouter initialEntries={['/posts/import/result-retry-missing-row/result']}>
|
|
||||||
<Routes>
|
|
||||||
<Route
|
|
||||||
path="/posts/import/:sessionId/result"
|
|
||||||
element={<PostImportResultPage user={buildUser ()}/>}/>
|
|
||||||
</Routes>
|
|
||||||
</MemoryRouter>
|
|
||||||
</HelmetProvider>)
|
|
||||||
|
|
||||||
fireEvent.click (screen.getByRole ('button', { name: '再試行' }))
|
|
||||||
|
|
||||||
await waitFor (() => {
|
|
||||||
const saved = loadPostImportSession ('result-retry-missing-row')
|
|
||||||
expect (saved?.rows[0]?.importStatus).toBe ('failed')
|
|
||||||
expect (toastApi.toast).toHaveBeenCalledWith (
|
|
||||||
expect.objectContaining ({ title: '登録結果が不完全でした' }))
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -30,6 +30,20 @@ vi.mock ('@/lib/dialogues/useDialogue', () => ({
|
|||||||
default: () => dialogue,
|
default: () => dialogue,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
const renderReviewPage = (route = '/posts/new/review') =>
|
||||||
|
render (
|
||||||
|
<HelmetProvider>
|
||||||
|
<MemoryRouter initialEntries={[route]}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/posts/new" element={<div>SOURCE ROUTE</div>}/>
|
||||||
|
<Route
|
||||||
|
path="/posts/new/review"
|
||||||
|
element={<PostImportReviewPage user={buildUser ()}/>}/>
|
||||||
|
<Route path="/posts" element={<div>POSTS ROUTE</div>}/>
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>
|
||||||
|
</HelmetProvider>)
|
||||||
|
|
||||||
describe ('PostImportReviewPage', () => {
|
describe ('PostImportReviewPage', () => {
|
||||||
beforeEach (() => {
|
beforeEach (() => {
|
||||||
sessionStorage.clear ()
|
sessionStorage.clear ()
|
||||||
@@ -39,8 +53,140 @@ describe ('PostImportReviewPage', () => {
|
|||||||
api.apiGet.mockResolvedValue (new Blob (['img'], { type: 'image/png' }))
|
api.apiGet.mockResolvedValue (new Blob (['img'], { type: 'image/png' }))
|
||||||
})
|
})
|
||||||
|
|
||||||
it ('navigates to the result route after an initial recoverable failure', async () => {
|
it ('redirects to /posts/new when no current work exists', async () => {
|
||||||
savePostImportSession ('review-session', {
|
renderReviewPage ()
|
||||||
|
|
||||||
|
await waitFor (() => {
|
||||||
|
expect (screen.getByText ('SOURCE ROUTE')).toBeInTheDocument ()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it ('restores the current work on review reload', async () => {
|
||||||
|
savePostImportSession ({
|
||||||
|
source: 'https://example.com/post',
|
||||||
|
repairMode: 'all',
|
||||||
|
rows: [buildPostImportRow ({
|
||||||
|
sourceRow: 1,
|
||||||
|
attributes: { title: 'restored row' } })] })
|
||||||
|
|
||||||
|
renderReviewPage ()
|
||||||
|
|
||||||
|
expect (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',
|
source: 'https://example.com/post',
|
||||||
repairMode: 'all',
|
repairMode: 'all',
|
||||||
rows: [buildPostImportRow ({ sourceRow: 1 })] })
|
rows: [buildPostImportRow ({ sourceRow: 1 })] })
|
||||||
@@ -57,140 +203,21 @@ describe ('PostImportReviewPage', () => {
|
|||||||
recoverable: true,
|
recoverable: true,
|
||||||
errors: { title: ['invalid'] } }] })
|
errors: { title: ['invalid'] } }] })
|
||||||
|
|
||||||
render (
|
renderReviewPage ()
|
||||||
<HelmetProvider>
|
|
||||||
<MemoryRouter initialEntries={['/posts/import/review-session/review']}>
|
|
||||||
<Routes>
|
|
||||||
<Route
|
|
||||||
path="/posts/import/:sessionId/review"
|
|
||||||
element={<PostImportReviewPage user={buildUser ()}/>}/>
|
|
||||||
<Route
|
|
||||||
path="/posts/import/:sessionId/result"
|
|
||||||
element={<div>RESULT ROUTE</div>}/>
|
|
||||||
</Routes>
|
|
||||||
</MemoryRouter>
|
|
||||||
</HelmetProvider>)
|
|
||||||
|
|
||||||
fireEvent.click (screen.getByRole ('button', { name: '取込実行' }))
|
fireEvent.click (screen.getByRole ('button', { name: '取込実行' }))
|
||||||
|
|
||||||
await waitFor (() => {
|
expect (await screen.findByText ('invalid')).toBeInTheDocument ()
|
||||||
expect (screen.getByText ('RESULT ROUTE')).toBeInTheDocument ()
|
expect (screen.queryByText ('POSTS ROUTE')).not.toBeInTheDocument ()
|
||||||
})
|
|
||||||
expect (dialogue.form).not.toHaveBeenCalled ()
|
|
||||||
})
|
|
||||||
|
|
||||||
it ('disables row editing and navigation while batch import is running', async () => {
|
|
||||||
let resolveValidation: ((value: { rows: PostImportRow[] }) => void) | null = null
|
|
||||||
savePostImportSession ('review-loading', {
|
|
||||||
source: 'https://example.com/post',
|
|
||||||
repairMode: 'all',
|
|
||||||
rows: [buildPostImportRow ({ sourceRow: 1 })] })
|
|
||||||
api.apiPost.mockImplementationOnce (() =>
|
|
||||||
new Promise<{ rows: PostImportRow[] }> (resolve => {
|
|
||||||
resolveValidation = resolve
|
|
||||||
}))
|
|
||||||
api.apiPost.mockResolvedValueOnce ({
|
|
||||||
created: 0,
|
|
||||||
skipped: 0,
|
|
||||||
failed: 0,
|
|
||||||
rows: [] })
|
|
||||||
|
|
||||||
render (
|
|
||||||
<HelmetProvider>
|
|
||||||
<MemoryRouter initialEntries={['/posts/import/review-loading/review']}>
|
|
||||||
<Routes>
|
|
||||||
<Route
|
|
||||||
path="/posts/import/:sessionId/review"
|
|
||||||
element={<PostImportReviewPage user={buildUser ()}/>}/>
|
|
||||||
</Routes>
|
|
||||||
</MemoryRouter>
|
|
||||||
</HelmetProvider>)
|
|
||||||
|
|
||||||
fireEvent.click (screen.getByRole ('button', { name: '取込実行' }))
|
|
||||||
|
|
||||||
await waitFor (() => {
|
|
||||||
expect (screen.getByRole ('button', { name: '編輯' })).toBeDisabled ()
|
|
||||||
expect (screen.getByRole ('button', { name: 'URL リスト入力へ戻る' })).toBeDisabled ()
|
|
||||||
expect (screen.getByRole ('button', { name: '取込実行' })).toBeDisabled ()
|
|
||||||
})
|
|
||||||
|
|
||||||
resolveValidation?.({ rows: [buildPostImportRow ({ sourceRow: 1 })] })
|
|
||||||
})
|
|
||||||
|
|
||||||
it ('does not call import when validation omits a requested source row', async () => {
|
|
||||||
savePostImportSession ('review-missing-row', {
|
|
||||||
source: 'https://example.com/post-1\nhttps://example.com/post-2',
|
|
||||||
repairMode: 'all',
|
|
||||||
rows: [
|
|
||||||
buildPostImportRow ({ sourceRow: 1 }),
|
|
||||||
buildPostImportRow ({ sourceRow: 2 })] })
|
|
||||||
api.apiPost.mockResolvedValueOnce ({
|
|
||||||
rows: [buildPostImportRow ({ sourceRow: 1 })] })
|
|
||||||
|
|
||||||
render (
|
|
||||||
<HelmetProvider>
|
|
||||||
<MemoryRouter initialEntries={['/posts/import/review-missing-row/review']}>
|
|
||||||
<Routes>
|
|
||||||
<Route
|
|
||||||
path="/posts/import/:sessionId/review"
|
|
||||||
element={<PostImportReviewPage user={buildUser ()}/>}/>
|
|
||||||
</Routes>
|
|
||||||
</MemoryRouter>
|
|
||||||
</HelmetProvider>)
|
|
||||||
|
|
||||||
fireEvent.click (screen.getByRole ('button', { name: '取込実行' }))
|
|
||||||
|
|
||||||
await waitFor (() => {
|
|
||||||
expect (toastApi.toast).toHaveBeenCalledWith (
|
|
||||||
expect.objectContaining ({ title: '再検証結果が不完全でした' }))
|
|
||||||
})
|
|
||||||
expect (api.apiPost).toHaveBeenCalledTimes (1)
|
|
||||||
})
|
|
||||||
|
|
||||||
it ('does not navigate to result when import omits a requested source row', async () => {
|
|
||||||
savePostImportSession ('review-missing-import-row', {
|
|
||||||
source: 'https://example.com/post',
|
|
||||||
repairMode: 'all',
|
|
||||||
rows: [buildPostImportRow ({ sourceRow: 1 })] })
|
|
||||||
api.apiPost
|
|
||||||
.mockResolvedValueOnce ({
|
|
||||||
rows: [buildPostImportRow ({ sourceRow: 1 })] })
|
|
||||||
.mockResolvedValueOnce ({
|
|
||||||
created: 1,
|
|
||||||
skipped: 0,
|
|
||||||
failed: 0,
|
|
||||||
rows: [] })
|
|
||||||
|
|
||||||
render (
|
|
||||||
<HelmetProvider>
|
|
||||||
<MemoryRouter initialEntries={['/posts/import/review-missing-import-row/review']}>
|
|
||||||
<Routes>
|
|
||||||
<Route
|
|
||||||
path="/posts/import/:sessionId/review"
|
|
||||||
element={<PostImportReviewPage user={buildUser ()}/>}/>
|
|
||||||
<Route
|
|
||||||
path="/posts/import/:sessionId/result"
|
|
||||||
element={<div>RESULT ROUTE</div>}/>
|
|
||||||
</Routes>
|
|
||||||
</MemoryRouter>
|
|
||||||
</HelmetProvider>)
|
|
||||||
|
|
||||||
fireEvent.click (screen.getByRole ('button', { name: '取込実行' }))
|
|
||||||
|
|
||||||
await waitFor (() => {
|
|
||||||
expect (toastApi.toast).toHaveBeenCalledWith (
|
|
||||||
expect.objectContaining ({ title: '登録結果が不完全でした' }))
|
|
||||||
})
|
|
||||||
expect (screen.queryByText ('RESULT ROUTE')).not.toBeInTheDocument ()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it ('keeps edited recoverable rows in session before the next batch submit', async () => {
|
it ('keeps edited recoverable rows in session before the next batch submit', async () => {
|
||||||
savePostImportSession ('review-edit-retry', {
|
savePostImportSession ({
|
||||||
source: 'https://example.com/post',
|
source: 'https://example.com/post',
|
||||||
repairMode: 'failed',
|
repairMode: 'failed',
|
||||||
rows: [buildPostImportRow ({
|
rows: [buildPostImportRow ({
|
||||||
sourceRow: 1,
|
sourceRow: 1,
|
||||||
attributes: { title: 'old title' },
|
attributes: { title: 'old title', duration: '2' },
|
||||||
importStatus: 'failed',
|
importStatus: 'failed',
|
||||||
recoverable: true,
|
recoverable: true,
|
||||||
importErrors: { base: ['failed'] } })] })
|
importErrors: { base: ['failed'] } })] })
|
||||||
@@ -198,13 +225,13 @@ describe ('PostImportReviewPage', () => {
|
|||||||
.mockResolvedValueOnce ({
|
.mockResolvedValueOnce ({
|
||||||
rows: [buildPostImportRow ({
|
rows: [buildPostImportRow ({
|
||||||
sourceRow: 1,
|
sourceRow: 1,
|
||||||
attributes: { title: 'edited title' },
|
attributes: { title: 'edited title', duration: '2' },
|
||||||
importStatus: 'pending',
|
importStatus: 'pending',
|
||||||
recoverable: true })] })
|
recoverable: true })] })
|
||||||
.mockResolvedValueOnce ({
|
.mockResolvedValueOnce ({
|
||||||
rows: [buildPostImportRow ({
|
rows: [buildPostImportRow ({
|
||||||
sourceRow: 1,
|
sourceRow: 1,
|
||||||
attributes: { title: 'edited title' },
|
attributes: { title: 'edited title', duration: '2' },
|
||||||
importStatus: 'pending',
|
importStatus: 'pending',
|
||||||
recoverable: true })] })
|
recoverable: true })] })
|
||||||
.mockResolvedValueOnce ({
|
.mockResolvedValueOnce ({
|
||||||
@@ -232,24 +259,12 @@ describe ('PostImportReviewPage', () => {
|
|||||||
await actions.find (action => action.label === '編輯内容を保存')?.onSelect ()
|
await actions.find (action => action.label === '編輯内容を保存')?.onSelect ()
|
||||||
})
|
})
|
||||||
|
|
||||||
render (
|
renderReviewPage ()
|
||||||
<HelmetProvider>
|
|
||||||
<MemoryRouter initialEntries={['/posts/import/review-edit-retry/review']}>
|
|
||||||
<Routes>
|
|
||||||
<Route
|
|
||||||
path="/posts/import/:sessionId/review"
|
|
||||||
element={<PostImportReviewPage user={buildUser ()}/>}/>
|
|
||||||
<Route
|
|
||||||
path="/posts/import/:sessionId/result"
|
|
||||||
element={<div>RESULT ROUTE</div>}/>
|
|
||||||
</Routes>
|
|
||||||
</MemoryRouter>
|
|
||||||
</HelmetProvider>)
|
|
||||||
|
|
||||||
fireEvent.click (screen.getByRole ('button', { name: '編輯' }))
|
fireEvent.click (screen.getByRole ('button', { name: '編輯' }))
|
||||||
|
|
||||||
await waitFor (() => {
|
await waitFor (() => {
|
||||||
const saved = loadPostImportSession ('review-edit-retry')
|
const saved = loadPostImportSession ()
|
||||||
expect (saved?.rows[0]?.attributes.title).toBe ('edited title')
|
expect (saved?.rows[0]?.attributes.title).toBe ('edited title')
|
||||||
expect (saved?.rows[0]?.importStatus).toBe ('pending')
|
expect (saved?.rows[0]?.importStatus).toBe ('pending')
|
||||||
})
|
})
|
||||||
@@ -259,11 +274,158 @@ describe ('PostImportReviewPage', () => {
|
|||||||
await waitFor (() => {
|
await waitFor (() => {
|
||||||
expect (api.apiPost.mock.calls[1]?.[1]?.rows?.[0]?.attributes?.title)
|
expect (api.apiPost.mock.calls[1]?.[1]?.rows?.[0]?.attributes?.title)
|
||||||
.toBe ('edited title')
|
.toBe ('edited title')
|
||||||
|
expect (api.apiPost.mock.calls[1]?.[1]?.rows?.[0]?.attributes?.duration)
|
||||||
|
.toBe ('2')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it ('retries only failed rows and does not resend created or skipped rows', async () => {
|
||||||
|
savePostImportSession ({
|
||||||
|
source: 'https://example.com/post',
|
||||||
|
repairMode: 'failed',
|
||||||
|
rows: [
|
||||||
|
buildPostImportRow ({
|
||||||
|
sourceRow: 1,
|
||||||
|
importStatus: 'created',
|
||||||
|
createdPostId: 1 }),
|
||||||
|
buildPostImportRow ({
|
||||||
|
sourceRow: 2,
|
||||||
|
importStatus: 'skipped',
|
||||||
|
skipReason: 'existing',
|
||||||
|
existingPostId: 2 }),
|
||||||
|
buildPostImportRow ({
|
||||||
|
sourceRow: 3,
|
||||||
|
importStatus: 'failed',
|
||||||
|
recoverable: true,
|
||||||
|
importErrors: { base: ['failed'] } })] })
|
||||||
|
api.apiPost
|
||||||
|
.mockResolvedValueOnce ({
|
||||||
|
rows: [buildPostImportRow ({
|
||||||
|
sourceRow: 3,
|
||||||
|
importStatus: 'pending',
|
||||||
|
recoverable: true })] })
|
||||||
|
.mockResolvedValueOnce ({
|
||||||
|
created: 0,
|
||||||
|
skipped: 1,
|
||||||
|
failed: 0,
|
||||||
|
rows: [{
|
||||||
|
sourceRow: 3,
|
||||||
|
status: 'skipped',
|
||||||
|
existingPostId: 9 }] })
|
||||||
|
|
||||||
|
renderReviewPage ()
|
||||||
|
|
||||||
|
fireEvent.click (screen.getByRole ('button', { name: '再試行' }))
|
||||||
|
|
||||||
|
await waitFor (() => {
|
||||||
|
expect (api.apiPost.mock.calls[1]?.[1]?.rows).toEqual ([
|
||||||
|
expect.objectContaining ({ sourceRow: 3 })])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it ('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', () => {
|
it ('sorts recoverable pending validation rows to the top in repair mode', () => {
|
||||||
savePostImportSession ('review-repair-sort', {
|
savePostImportSession ({
|
||||||
source: '',
|
source: '',
|
||||||
repairMode: 'failed',
|
repairMode: 'failed',
|
||||||
rows: [
|
rows: [
|
||||||
@@ -277,16 +439,7 @@ describe ('PostImportReviewPage', () => {
|
|||||||
recoverable: true,
|
recoverable: true,
|
||||||
validationErrors: { title: ['invalid'] } })] })
|
validationErrors: { title: ['invalid'] } })] })
|
||||||
|
|
||||||
const { container } = render (
|
const { container } = renderReviewPage ()
|
||||||
<HelmetProvider>
|
|
||||||
<MemoryRouter initialEntries={['/posts/import/review-repair-sort/review']}>
|
|
||||||
<Routes>
|
|
||||||
<Route
|
|
||||||
path="/posts/import/:sessionId/review"
|
|
||||||
element={<PostImportReviewPage user={buildUser ()}/>}/>
|
|
||||||
</Routes>
|
|
||||||
</MemoryRouter>
|
|
||||||
</HelmetProvider>)
|
|
||||||
|
|
||||||
const titles = Array.from (container.querySelectorAll ('.line-clamp-2')).map (
|
const titles = Array.from (container.querySelectorAll ('.line-clamp-2')).map (
|
||||||
node => node.textContent)
|
node => node.textContent)
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
|
import { useQueries } from '@tanstack/react-query'
|
||||||
|
import { AnimatePresence, motion } from 'framer-motion'
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { Helmet } from 'react-helmet-async'
|
import { Helmet } from 'react-helmet-async'
|
||||||
import { useNavigate, useParams } from 'react-router-dom'
|
import { useLocation, useNavigate } from 'react-router-dom'
|
||||||
|
|
||||||
import PageTitle from '@/components/common/PageTitle'
|
import PageTitle from '@/components/common/PageTitle'
|
||||||
import MainArea from '@/components/layout/MainArea'
|
import MainArea from '@/components/layout/MainArea'
|
||||||
|
import PrefetchLink from '@/components/PrefetchLink'
|
||||||
|
import PostThumbnailPreview from '@/components/posts/PostThumbnailPreview'
|
||||||
import PostImportRowForm from '@/components/posts/import/PostImportRowForm'
|
import PostImportRowForm from '@/components/posts/import/PostImportRowForm'
|
||||||
import PostImportRowSummary from '@/components/posts/import/PostImportRowSummary'
|
import PostImportRowSummary from '@/components/posts/import/PostImportRowSummary'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
@@ -11,33 +15,53 @@ import { toast } from '@/components/ui/use-toast'
|
|||||||
import { SITE_TITLE } from '@/config'
|
import { SITE_TITLE } from '@/config'
|
||||||
import { apiPost } from '@/lib/api'
|
import { apiPost } from '@/lib/api'
|
||||||
import useDialogue from '@/lib/dialogues/useDialogue'
|
import useDialogue from '@/lib/dialogues/useDialogue'
|
||||||
|
import { 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 { 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 Forbidden from '@/pages/Forbidden'
|
||||||
|
|
||||||
import type { FC } from 'react'
|
import type { FC } from 'react'
|
||||||
|
|
||||||
import type { PostImportRowDraft } from '@/components/posts/import/PostImportRowForm'
|
import type { PostImportRowDraft } from '@/components/posts/import/PostImportRowForm'
|
||||||
import type { PostImportResultRow,
|
import type {
|
||||||
PostImportRow,
|
PostImportResultRow,
|
||||||
PostImportSession } from '@/lib/postImportSession'
|
PostImportRow,
|
||||||
import type { User } from '@/types'
|
PostImportSession,
|
||||||
|
} from '@/lib/postImportSession'
|
||||||
|
import type { Post, User } from '@/types'
|
||||||
|
|
||||||
type Props = { user: User | null }
|
type Props = { user: User | null }
|
||||||
|
|
||||||
|
type ExistingSkippedRowsProps = {
|
||||||
|
open: boolean
|
||||||
|
rows: PostImportRow[] }
|
||||||
|
|
||||||
const isRepairRow = (row: PostImportRow): boolean =>
|
const isRepairRow = (row: PostImportRow): boolean =>
|
||||||
row.recoverable === true
|
row.recoverable === true
|
||||||
&& (row.importStatus === 'failed'
|
&& (row.importStatus === 'failed'
|
||||||
@@ -45,57 +69,216 @@ const isRepairRow = (row: PostImportRow): boolean =>
|
|||||||
&& Object.keys (row.validationErrors).length > 0))
|
&& 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 PostImportReviewPage: FC<Props> = ({ user }) => {
|
||||||
const editable = canEditContent (user)
|
const editable = canEditContent (user)
|
||||||
const dialogue = useDialogue ()
|
const dialogue = useDialogue ()
|
||||||
|
const location = useLocation ()
|
||||||
const navigate = useNavigate ()
|
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 [session, setSession] = useState<PostImportSession | null> (null)
|
||||||
const [loading, setLoading] = useState (false)
|
const [loading, setLoading] = useState (false)
|
||||||
const [missing, setMissing] = useState (false)
|
const [loadingRow, setLoadingRow] = useState<number | null> (null)
|
||||||
const [editingRow, setEditingRow] = useState<PostImportRow | null> (null)
|
const [editingRow, setEditingRow] = useState<PostImportRow | null> (null)
|
||||||
|
const [showExistingRows, setShowExistingRows] = useState (false)
|
||||||
const sessionRef = useRef<PostImportSession | null> (null)
|
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 (() => {
|
useEffect (() => {
|
||||||
if (sessionId == null)
|
let active = true
|
||||||
return
|
|
||||||
|
|
||||||
const loaded = loadPostImportSession (sessionId, message =>
|
if (sessionRef.current != null && persistedSearchRef.current === location.search)
|
||||||
toast ({ title: '取込状態を復元できませんでした', description: message }))
|
return () => {
|
||||||
setSession (loaded)
|
}
|
||||||
setMissing (loaded == null)
|
|
||||||
}, [sessionId])
|
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 (() => {
|
useEffect (() => {
|
||||||
if (sessionId == null || session == null)
|
if (session != null)
|
||||||
return
|
sessionRef.current = session
|
||||||
|
|
||||||
savePostImportSession (sessionId, session, message =>
|
|
||||||
toast ({ title: '取込状態を保存できませんでした', description: message }))
|
|
||||||
}, [session, sessionId])
|
|
||||||
|
|
||||||
useEffect (() => {
|
|
||||||
sessionRef.current = session
|
|
||||||
}, [session])
|
}, [session])
|
||||||
|
|
||||||
const rows = session?.rows ?? []
|
useEffect (() => {
|
||||||
const counts = useMemo (() => reviewSummaryCounts (rows), [rows])
|
if (session == null || session.rows.length === 0)
|
||||||
const processable = useMemo (
|
return
|
||||||
() => processableImportRows (rows),
|
if (session.rows.every (row => isCompletedReviewRow (row)))
|
||||||
[rows])
|
finishImport ()
|
||||||
const creatable = useMemo (
|
}, [session])
|
||||||
() => 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 (() => {
|
useEffect (() => {
|
||||||
if (editingRow == null || session?.repairMode !== 'failed')
|
if (editingRow == null || session?.repairMode !== 'failed')
|
||||||
@@ -105,11 +288,49 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
|||||||
element?.scrollIntoView ({ block: 'center', behavior: 'smooth' })
|
element?.scrollIntoView ({ block: 'center', behavior: 'smooth' })
|
||||||
}, [editingRow, session?.repairMode])
|
}, [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 (
|
const saveDraft = async (
|
||||||
row: PostImportRow,
|
row: PostImportRow,
|
||||||
{ draft, resetRequested }: {
|
{ draft, resetRequested }: {
|
||||||
draft: PostImportRowDraft
|
draft: PostImportRowDraft
|
||||||
resetRequested: boolean },
|
resetRequested: boolean },
|
||||||
): Promise<{ saved: boolean
|
): Promise<{ saved: boolean
|
||||||
row: PostImportRow | null }> => {
|
row: PostImportRow | null }> => {
|
||||||
const currentSession = sessionRef.current
|
const currentSession = sessionRef.current
|
||||||
@@ -133,55 +354,47 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
|||||||
: row
|
: row
|
||||||
const urlChanged = draft.url !== baseRow.url
|
const urlChanged = draft.url !== baseRow.url
|
||||||
const nextRow = buildNextEditedRow (baseRow, draft, urlChanged)
|
const nextRow = buildNextEditedRow (baseRow, draft, urlChanged)
|
||||||
|
const nextRows = currentSession.rows.map (currentRow =>
|
||||||
const nextRows = currentSession.rows.map (row =>
|
currentRow.sourceRow === baseRow.sourceRow
|
||||||
row.sourceRow === baseRow.sourceRow
|
|
||||||
? nextRow
|
? nextRow
|
||||||
: row)
|
: currentRow)
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
const validated = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/validate', {
|
const validated = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/validate', {
|
||||||
rows:
|
rows:
|
||||||
nextRows
|
validatableImportRows (nextRows)
|
||||||
.filter (row => row.importStatus !== 'created')
|
.map (currentRow => ({
|
||||||
.map (row => ({ sourceRow: row.sourceRow,
|
sourceRow: currentRow.sourceRow,
|
||||||
url: row.url,
|
url: currentRow.url,
|
||||||
attributes: row.attributes,
|
attributes: currentRow.attributes,
|
||||||
provenance: row.provenance,
|
provenance: currentRow.provenance,
|
||||||
tagSources: row.tagSources,
|
tagSources: currentRow.tagSources,
|
||||||
metadataUrl: row.metadataUrl })),
|
metadataUrl: currentRow.metadataUrl })),
|
||||||
changed_row: urlChanged ? baseRow.sourceRow : -1 })
|
changed_row: urlChanged ? baseRow.sourceRow : -1 })
|
||||||
const validatedRows = initialisePreviewRows (validated.rows)
|
const validatedRows = initialisePreviewRows (validated.rows)
|
||||||
const target = validatedRows.find (
|
const target = validatedRows.find (
|
||||||
validatedRow => validatedRow.sourceRow === baseRow.sourceRow)
|
validatedRow => validatedRow.sourceRow === baseRow.sourceRow)
|
||||||
const latestSession = sessionRef.current
|
const latestSession = sessionRef.current
|
||||||
if (latestSession == null)
|
if (latestSession == null)
|
||||||
return { saved: false, row: null }
|
return { saved: false, row: null }
|
||||||
if (target == null)
|
if (target == null)
|
||||||
{
|
{
|
||||||
const restoredRows = replaceImportRow (latestSession.rows, row)
|
const restoredRows = replaceImportRow (latestSession.rows, row)
|
||||||
const restoredSession = {
|
await persistSession (buildSession (restoredRows))
|
||||||
...latestSession,
|
toast ({ title: '行の再検証結果が不完全でした' })
|
||||||
rows: restoredRows,
|
return { saved: false, row: null }
|
||||||
repairMode: resultRepairMode (restoredRows) }
|
}
|
||||||
sessionRef.current = restoredSession
|
|
||||||
setSession (restoredSession)
|
|
||||||
toast ({ title: '行の再検証結果が不完全でした' })
|
|
||||||
return { saved: false, row: null }
|
|
||||||
}
|
|
||||||
const editedRows = replaceImportRow (latestSession.rows, nextRow)
|
const editedRows = replaceImportRow (latestSession.rows, nextRow)
|
||||||
const rows = mergeValidatedImportRow (editedRows, target)
|
const mergedRows = mergeValidatedImportRow (editedRows, target)
|
||||||
const nextSession = {
|
const nextSession = await persistSession (buildSession (mergedRows))
|
||||||
...latestSession,
|
if (nextSession == null)
|
||||||
rows,
|
return { saved: false, row: null }
|
||||||
repairMode: resultRepairMode (rows) }
|
|
||||||
sessionRef.current = nextSession
|
|
||||||
setSession (nextSession)
|
|
||||||
const mergedTarget =
|
const mergedTarget =
|
||||||
rows.find (mergedRow => mergedRow.sourceRow === baseRow.sourceRow)
|
nextSession.rows.find (mergedRow => mergedRow.sourceRow === baseRow.sourceRow)
|
||||||
?? target
|
?? target
|
||||||
if (Object.keys (mergedTarget.validationErrors).length > 0)
|
if (Object.keys (mergedTarget.validationErrors).length > 0)
|
||||||
return { saved: false, row: mergedTarget }
|
return { saved: false, row: mergedTarget }
|
||||||
return { saved: true, row: null }
|
return { saved: true, row: null }
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
@@ -220,89 +433,115 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
|||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
setEditingRow (current =>
|
setEditingRow (current =>
|
||||||
current?.sourceRow === row.sourceRow
|
current?.sourceRow === row.sourceRow
|
||||||
? null
|
? null
|
||||||
: current)
|
: current)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const submit = async () => {
|
const retry = async (sourceRow: number) => {
|
||||||
const currentSession = sessionRef.current
|
if (busy)
|
||||||
if (sessionId == null || currentSession == null || processable.length === 0)
|
|
||||||
return
|
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
|
try
|
||||||
{
|
{
|
||||||
const validatableRows =
|
const pendingRows = retryImportRow (initialSession.rows, sourceRow)
|
||||||
currentSession.rows.filter (row => row.importStatus !== 'created')
|
const pendingSession = await persistSession (buildSession (pendingRows))
|
||||||
|
if (pendingSession == null)
|
||||||
|
return
|
||||||
|
|
||||||
|
const requestedRows = validatableImportRows (pendingSession.rows)
|
||||||
const validated = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/validate', {
|
const validated = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/validate', {
|
||||||
rows:
|
rows: requestedRows.map (row => ({
|
||||||
validatableRows.map (row => ({ sourceRow: row.sourceRow,
|
sourceRow: row.sourceRow,
|
||||||
url: row.url,
|
url: row.url,
|
||||||
attributes: row.attributes,
|
attributes: row.attributes,
|
||||||
provenance: row.provenance,
|
provenance: row.provenance,
|
||||||
tagSources: row.tagSources,
|
tagSources: row.tagSources,
|
||||||
metadataUrl: row.metadataUrl })),
|
metadataUrl: row.metadataUrl })),
|
||||||
changed_row: -1 })
|
changed_row: -1 })
|
||||||
const validatedRows = initialisePreviewRows (validated.rows)
|
const validatedRows = initialisePreviewRows (validated.rows)
|
||||||
const expectedSourceRows = validatableRows.map (row => row.sourceRow)
|
if (!(hasExactSourceRows (requestedRows.map (row => row.sourceRow), validatedRows)))
|
||||||
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)
|
|
||||||
{
|
{
|
||||||
const nextSession = {
|
const latest = sessionRef.current ?? pendingSession
|
||||||
...latestAfterValidate,
|
const restoredRows = replaceImportRow (latest.rows, originalRow)
|
||||||
rows: mergedRows,
|
await persistSession (buildSession (restoredRows))
|
||||||
repairMode: resultRepairMode (mergedRows) }
|
toast ({ title: '再検証結果が不完全でした' })
|
||||||
sessionRef.current = nextSession
|
|
||||||
setSession (nextSession)
|
|
||||||
void editRow (firstInvalid)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const validatedSession = {
|
|
||||||
...latestAfterValidate,
|
const validatedTarget = validatedRows.find (row => row.sourceRow === sourceRow)
|
||||||
rows: mergedRows,
|
if (validatedTarget == null)
|
||||||
repairMode: resultRepairMode (mergedRows) }
|
{
|
||||||
sessionRef.current = validatedSession
|
const latest = sessionRef.current ?? pendingSession
|
||||||
setSession (validatedSession)
|
const restoredRows = replaceImportRow (latest.rows, originalRow)
|
||||||
const savedValidated = savePostImportSession (sessionId, validatedSession, message =>
|
await persistSession (buildSession (restoredRows))
|
||||||
toast ({ title: '取込状態を保存できませんでした', description: message }))
|
toast ({ title: '再検証結果が不完全でした' })
|
||||||
if (!(savedValidated))
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const latestAfterValidate = sessionRef.current ?? pendingSession
|
||||||
|
const mergedValidatedRows = mergeValidatedImportRow (
|
||||||
|
latestAfterValidate.rows,
|
||||||
|
validatedTarget)
|
||||||
|
const nextSession = await persistSession (buildSession (mergedValidatedRows))
|
||||||
|
if (nextSession == null)
|
||||||
return
|
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<{
|
const result = await apiPost<{
|
||||||
created: number
|
created: number
|
||||||
skipped: number
|
skipped: number
|
||||||
failed: number
|
failed: number
|
||||||
rows: PostImportResultRow[] }> ('/posts/import', {
|
rows: PostImportResultRow[] }> ('/posts/import', {
|
||||||
rows: processableImportRows (mergedRows).map (row => ({
|
rows: [{
|
||||||
sourceRow: row.sourceRow,
|
sourceRow: target.sourceRow,
|
||||||
url: row.url,
|
url: target.url,
|
||||||
attributes: row.attributes,
|
attributes: target.attributes,
|
||||||
provenance: row.provenance,
|
provenance: target.provenance,
|
||||||
tagSources: row.tagSources,
|
tagSources: target.tagSources,
|
||||||
metadataUrl: row.metadataUrl })) })
|
metadataUrl: target.metadataUrl }] })
|
||||||
const expectedImportRows = processableImportRows (mergedRows).map (row => row.sourceRow)
|
if (!(hasExactSourceRows ([sourceRow], result.rows)))
|
||||||
if (!(hasExactSourceRows (expectedImportRows, result.rows)))
|
|
||||||
{
|
{
|
||||||
|
const latest = sessionRef.current ?? nextSession
|
||||||
|
const restoredRows = replaceImportRow (latest.rows, originalRow)
|
||||||
|
await persistSession (buildSession (restoredRows))
|
||||||
toast ({ title: '登録結果が不完全でした' })
|
toast ({ title: '登録結果が不完全でした' })
|
||||||
return
|
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 =>
|
const recoverableRows = result.rows.filter (row =>
|
||||||
row.status === 'failed'
|
row.status === 'failed'
|
||||||
&& row.recoverable
|
&& row.recoverable
|
||||||
&& Object.keys (row.errors ?? { }).length > 0)
|
&& Object.keys (row.errors ?? { }).length > 0)
|
||||||
const nextRows = mergedResults.map ((row): PostImportRow => {
|
const nextRows = mergedRows.map ((row): PostImportRow => {
|
||||||
const recoverable = recoverableRows.find (rr => rr.sourceRow === row.sourceRow)
|
const recoverable = recoverableRows.find (
|
||||||
|
failedRow => failedRow.sourceRow === row.sourceRow)
|
||||||
if (recoverable == null)
|
if (recoverable == null)
|
||||||
return row
|
return row
|
||||||
return {
|
return {
|
||||||
@@ -312,17 +551,110 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
|||||||
validationErrors: recoverable.errors ?? { },
|
validationErrors: recoverable.errors ?? { },
|
||||||
importErrors: undefined }
|
importErrors: undefined }
|
||||||
})
|
})
|
||||||
const nextSession = {
|
await persistSession (buildSession (nextRows))
|
||||||
...latestAfterImport,
|
}
|
||||||
rows: nextRows,
|
catch
|
||||||
repairMode: resultRepairMode (nextRows) }
|
{
|
||||||
sessionRef.current = nextSession
|
const latest = sessionRef.current ?? initialSession
|
||||||
setSession (nextSession)
|
const restoredRows = replaceImportRow (latest.rows, originalRow)
|
||||||
const saved = savePostImportSession (sessionId, nextSession, message =>
|
await persistSession (buildSession (restoredRows))
|
||||||
toast ({ title: '取込状態を保存できませんでした', description: message }))
|
toast ({ title: '再試行に失敗しました' })
|
||||||
if (!(saved))
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
setLoadingRow (null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
const currentSession = sessionRef.current
|
||||||
|
if (currentSession == null || busy)
|
||||||
|
return
|
||||||
|
if (currentSession.rows.every (row => isCompletedReviewRow (row)))
|
||||||
|
{
|
||||||
|
finishImport ()
|
||||||
return
|
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
|
catch
|
||||||
{
|
{
|
||||||
@@ -337,64 +669,92 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
|||||||
if (!(editable))
|
if (!(editable))
|
||||||
return <Forbidden/>
|
return <Forbidden/>
|
||||||
|
|
||||||
if (missing || sessionId == null || session == null)
|
if (session == null)
|
||||||
{
|
return null
|
||||||
return (
|
|
||||||
<MainArea>
|
|
||||||
<div className="mx-auto max-w-4xl space-y-4 p-4">
|
|
||||||
<PageTitle>投稿インポート</PageTitle>
|
|
||||||
<div className="text-red-700 dark:text-red-300">取込状態が見つかりません.</div>
|
|
||||||
<Button type="button" onClick={() => navigate ('/posts/import')}>
|
|
||||||
URL リスト入力へ戻る
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</MainArea>)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Helmet>
|
<Helmet>
|
||||||
<title>{`投稿インポート確認 | ${ SITE_TITLE }`}</title>
|
<title>{`追加内容確認 | ${ SITE_TITLE }`}</title>
|
||||||
</Helmet>
|
</Helmet>
|
||||||
|
|
||||||
<MainArea className="min-h-0">
|
<MainArea className="min-h-0">
|
||||||
<div className="mx-auto max-w-6xl space-y-4 p-4">
|
<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">
|
<div className="space-y-3">
|
||||||
{reviewRows.map (row => (
|
{reviewRows.map ((row, index) => (
|
||||||
<div key={row.sourceRow} id={`post-import-row-${ row.sourceRow }`}>
|
<div key={row.sourceRow} id={`post-import-row-${ row.sourceRow }`}>
|
||||||
<PostImportRowSummary
|
<PostImportRowSummary
|
||||||
row={row}
|
row={row}
|
||||||
editDisabled={loading}
|
displayNumber={index + 1}
|
||||||
onEdit={() => void editRow (row)}/>
|
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</MainArea>
|
</MainArea>
|
||||||
|
|
||||||
<PostImportFooter
|
<PostImportFooter
|
||||||
loading={loading}
|
loading={busy}
|
||||||
processableCount={processable.length}
|
creatableCount={counts.creatable}
|
||||||
creatableCount={creatable.length}
|
manualSkippedCount={counts.manualSkipped}
|
||||||
skipPlannedCount={counts.skipPlanned}
|
existingSkippedCount={counts.existingSkipped}
|
||||||
onBack={() => navigate ('/posts/import')}
|
pendingOrErrorCount={counts.pendingOrError}
|
||||||
onSubmit={submit}/>
|
onBack={() => navigate (-1)}
|
||||||
|
onSubmit={() => submit ()}/>
|
||||||
</>)
|
</>)
|
||||||
}
|
}
|
||||||
|
|
||||||
const PostImportFooter = (
|
const PostImportFooter = (
|
||||||
{ loading,
|
{ loading,
|
||||||
processableCount,
|
|
||||||
creatableCount,
|
creatableCount,
|
||||||
skipPlannedCount,
|
manualSkippedCount,
|
||||||
|
existingSkippedCount,
|
||||||
|
pendingOrErrorCount,
|
||||||
onBack,
|
onBack,
|
||||||
onSubmit }: { loading: boolean
|
onSubmit }: { loading: boolean
|
||||||
processableCount: number
|
creatableCount: number
|
||||||
creatableCount: number
|
manualSkippedCount: number
|
||||||
skipPlannedCount: number
|
existingSkippedCount: number
|
||||||
onBack: () => void
|
pendingOrErrorCount: number
|
||||||
onSubmit: () => void },
|
onBack: () => void
|
||||||
|
onSubmit: () => void },
|
||||||
) => (
|
) => (
|
||||||
<div
|
<div
|
||||||
className="shrink-0 border-t bg-white/95 p-4 backdrop-blur
|
className="shrink-0 border-t bg-white/95 p-4 backdrop-blur
|
||||||
@@ -402,9 +762,10 @@ const PostImportFooter = (
|
|||||||
<div className="mx-auto flex max-w-6xl flex-col gap-3 md:flex-row
|
<div className="mx-auto flex max-w-6xl flex-col gap-3 md:flex-row
|
||||||
md:items-center md:justify-between">
|
md:items-center md:justify-between">
|
||||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||||
<span>処理対象 {processableCount}件</span>
|
<span>作成対象 {creatableCount}件</span>
|
||||||
<span>登録対象 {creatableCount}件</span>
|
<span>手動スキップ {manualSkippedCount}件</span>
|
||||||
<span>スキップ予定 {skipPlannedCount}件</span>
|
<span>既存投稿による自動スキップ {existingSkippedCount}件</span>
|
||||||
|
<span>error/未処理 {pendingOrErrorCount}件</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2 sm:flex-row">
|
<div className="flex flex-col gap-2 sm:flex-row">
|
||||||
<Button type="button" variant="outline" onClick={onBack} disabled={loading}>
|
<Button type="button" variant="outline" onClick={onBack} disabled={loading}>
|
||||||
@@ -413,8 +774,8 @@ const PostImportFooter = (
|
|||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onSubmit}
|
onClick={onSubmit}
|
||||||
disabled={loading || processableCount === 0}>
|
disabled={loading || creatableCount === 0}>
|
||||||
取込実行
|
{creatableCount > 1 ? '一括追加' : '追加'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ describe ('PostImportSourcePage', () => {
|
|||||||
it ('shows no empty error initially and validates only after Next is pressed', () => {
|
it ('shows no empty error initially and validates only after Next is pressed', () => {
|
||||||
renderWithProviders (<PostImportSourcePage user={buildUser ()}/>)
|
renderWithProviders (<PostImportSourcePage user={buildUser ()}/>)
|
||||||
|
|
||||||
|
expect (screen.getByRole ('heading', { name: '広場に投稿を追加' })).toBeInTheDocument ()
|
||||||
|
expect (screen.queryByText ('投稿インポート')).not.toBeInTheDocument ()
|
||||||
expect (screen.queryByText ('URL を入力してください.')).not.toBeInTheDocument ()
|
expect (screen.queryByText ('URL を入力してください.')).not.toBeInTheDocument ()
|
||||||
fireEvent.click (screen.getByRole ('button', { name: '次へ' }))
|
fireEvent.click (screen.getByRole ('button', { name: '次へ' }))
|
||||||
expect (screen.getByText ('URL を入力してください.')).toBeInTheDocument ()
|
expect (screen.getByText ('URL を入力してください.')).toBeInTheDocument ()
|
||||||
@@ -73,7 +75,7 @@ describe ('PostImportSourcePage', () => {
|
|||||||
expect.stringMatching (/^post-import-session:/))
|
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 ()] })
|
api.apiPost.mockResolvedValue ({ rows: [buildPostImportRow ()] })
|
||||||
renderWithProviders (<PostImportSourcePage user={buildUser ()}/>)
|
renderWithProviders (<PostImportSourcePage user={buildUser ()}/>)
|
||||||
|
|
||||||
@@ -83,11 +85,10 @@ describe ('PostImportSourcePage', () => {
|
|||||||
|
|
||||||
await waitFor (() => {
|
await waitFor (() => {
|
||||||
expect (router.navigate).toHaveBeenCalledWith (
|
expect (router.navigate).toHaveBeenCalledWith (
|
||||||
expect.stringMatching (/^\/posts\/import\/[^/]+\/review$/))
|
expect.stringMatching (/^\/posts\/new\?/))
|
||||||
})
|
})
|
||||||
const sessionKeys = Array.from ({ length: sessionStorage.length }, (_, index) =>
|
expect (Array.from ({ length: sessionStorage.length }, (_, index) =>
|
||||||
sessionStorage.key (index)).filter (
|
sessionStorage.key (index))).not.toContainEqual(
|
||||||
key => key?.startsWith ('post-import-session:'))
|
expect.stringMatching (/^post-import-session:/))
|
||||||
expect (sessionKeys).toHaveLength (1)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -12,13 +12,12 @@ import { Button } from '@/components/ui/button'
|
|||||||
import { toast } from '@/components/ui/use-toast'
|
import { toast } from '@/components/ui/use-toast'
|
||||||
import { SITE_TITLE } from '@/config'
|
import { SITE_TITLE } from '@/config'
|
||||||
import { apiPost, isApiError } from '@/lib/api'
|
import { apiPost, isApiError } from '@/lib/api'
|
||||||
|
import { serialisePostNewState } from '@/lib/postNewQueryState'
|
||||||
import { canEditContent } from '@/lib/users'
|
import { canEditContent } from '@/lib/users'
|
||||||
import { countImportSourceLines,
|
import { countImportSourceLines,
|
||||||
cleanupExpiredPostImportSessions,
|
cleanupExpiredPostImportSessions,
|
||||||
createPostImportSessionId,
|
|
||||||
initialisePreviewRows,
|
initialisePreviewRows,
|
||||||
loadPostImportSourceDraft,
|
loadPostImportSourceDraft,
|
||||||
savePostImportSession,
|
|
||||||
savePostImportSourceDraft,
|
savePostImportSourceDraft,
|
||||||
validateImportSource } from '@/lib/postImportSession'
|
validateImportSource } from '@/lib/postImportSession'
|
||||||
import Forbidden from '@/pages/Forbidden'
|
import Forbidden from '@/pages/Forbidden'
|
||||||
@@ -71,7 +70,12 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
|
|||||||
toast ({ title: '保存済み入力を復元できませんでした', description: message }))
|
toast ({ title: '保存済み入力を復元できませんでした', description: message }))
|
||||||
|
|
||||||
if (!(editedRef.current))
|
if (!(editedRef.current))
|
||||||
setSource (current => current === '' ? draft.source : current)
|
{
|
||||||
|
setSource (current =>
|
||||||
|
current === ''
|
||||||
|
? draft.source
|
||||||
|
: current)
|
||||||
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
@@ -117,16 +121,10 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
|
|||||||
setSourceIssues (urlIssues)
|
setSourceIssues (urlIssues)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const sessionId = createPostImportSessionId ()
|
const nextRows = initialisePreviewRows (data.rows)
|
||||||
const saved = savePostImportSession (
|
const serialised = await serialisePostNewState (nextRows)
|
||||||
sessionId,
|
if (serialised != null)
|
||||||
{ source,
|
navigate (serialised.path)
|
||||||
rows: initialisePreviewRows (data.rows),
|
|
||||||
repairMode: 'all' },
|
|
||||||
message => toast ({ title: '取込状態を保存できませんでした', description: message }))
|
|
||||||
if (!(saved))
|
|
||||||
return
|
|
||||||
navigate (`/posts/import/${ sessionId }/review`)
|
|
||||||
}
|
}
|
||||||
catch (requestError)
|
catch (requestError)
|
||||||
{
|
{
|
||||||
@@ -146,16 +144,16 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!(editable))
|
if (!(editable))
|
||||||
return <Forbidden/>
|
return <Forbidden/>
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MainArea>
|
<MainArea>
|
||||||
<Helmet>
|
<Helmet>
|
||||||
<title>{`投稿インポート | ${ SITE_TITLE }`}</title>
|
<title>{`広場に投稿を追加 | ${ SITE_TITLE }`}</title>
|
||||||
</Helmet>
|
</Helmet>
|
||||||
|
|
||||||
<Form className="max-w-4xl">
|
<Form className="max-w-4xl">
|
||||||
<PageTitle>投稿インポート</PageTitle>
|
<PageTitle>広場に投稿を追加</PageTitle>
|
||||||
<FormField label="URL リスト">
|
<FormField label="URL リスト">
|
||||||
{() => (
|
{() => (
|
||||||
<TextArea
|
<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 { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
import { toMinutePrecisionIsoUtc } from '@/components/common/DateTimeField'
|
|
||||||
import PostNewPage from '@/pages/posts/PostNewPage'
|
import PostNewPage from '@/pages/posts/PostNewPage'
|
||||||
import { buildUser } from '@/test/factories'
|
import { buildUser } from '@/test/factories'
|
||||||
import { renderWithProviders } from '@/test/render'
|
import { renderWithProviders } from '@/test/render'
|
||||||
|
|
||||||
const api = vi.hoisted (() => ({
|
const api = vi.hoisted (() => ({
|
||||||
apiGet: vi.fn (),
|
apiPost: vi.fn (),
|
||||||
apiPost: vi.fn (),
|
isApiError: vi.fn () }))
|
||||||
isApiError: vi.fn (),
|
|
||||||
}))
|
|
||||||
|
|
||||||
const toastApi = vi.hoisted (() => ({
|
|
||||||
toast: vi.fn (),
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock ('@/lib/api', () => api)
|
vi.mock ('@/lib/api', () => api)
|
||||||
vi.mock ('@/components/ui/use-toast', () => toastApi)
|
|
||||||
|
|
||||||
describe ('PostNewPage', () => {
|
describe ('PostNewPage', () => {
|
||||||
beforeEach (() => {
|
beforeEach (() => {
|
||||||
vi.clearAllMocks ()
|
vi.clearAllMocks ()
|
||||||
api.isApiError.mockReturnValue (false)
|
api.isApiError.mockReturnValue (false)
|
||||||
|
sessionStorage.clear ()
|
||||||
})
|
})
|
||||||
|
|
||||||
it ('blocks guests', () => {
|
it ('shows the source page on /posts/new', () => {
|
||||||
renderWithProviders (<PostNewPage user={buildUser ({ role: 'guest' })}/>)
|
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 () => {
|
it ('shows the review page when query state is present', () => {
|
||||||
api.apiPost.mockResolvedValueOnce ({})
|
renderWithProviders (
|
||||||
api.apiGet.mockResolvedValue ([])
|
<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' })}/>)
|
expect (screen.getByRole ('heading', { name: '追加内容確認' })).toBeInTheDocument ()
|
||||||
|
expect (screen.queryByText ('広場に投稿を追加')).not.toBeInTheDocument ()
|
||||||
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)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,23 +1,9 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { Helmet } from 'react-helmet-async'
|
import { useLocation } from 'react-router-dom'
|
||||||
import { useNavigate } from 'react-router-dom'
|
|
||||||
|
|
||||||
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
|
import PostImportReviewPage from '@/pages/posts/PostImportReviewPage'
|
||||||
import FieldError from '@/components/common/FieldError'
|
import PostImportSourcePage from '@/pages/posts/PostImportSourcePage'
|
||||||
import Form from '@/components/common/Form'
|
import { hasPostNewReviewState } from '@/lib/postNewQueryState'
|
||||||
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 type { FC } from 'react'
|
import type { FC } from 'react'
|
||||||
|
|
||||||
@@ -25,220 +11,16 @@ import type { User } from '@/types'
|
|||||||
|
|
||||||
type Props = { user: User | null }
|
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 PostNewPage: FC<Props> = ({ user }) => {
|
||||||
const editable = canEditContent (user)
|
const location = useLocation ()
|
||||||
|
const reviewMode = useMemo (
|
||||||
|
() => hasPostNewReviewState (location.search),
|
||||||
|
[location.search])
|
||||||
|
|
||||||
const navigate = useNavigate ()
|
return reviewMode
|
||||||
|
? <PostImportReviewPage user={user}/>
|
||||||
const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } =
|
: <PostImportSourcePage user={user}/>
|
||||||
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>)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default PostNewPage
|
export default PostNewPage
|
||||||
|
|||||||
新しい課題から参照
ユーザをブロックする