コミットを比較

...

7 コミット

作成者 SHA1 メッセージ 日付
みてるぞ dd2d199d04 #399 2026-07-16 23:39:13 +09:00
みてるぞ 0a8ffc38b8 #399 2026-07-16 23:23:32 +09:00
みてるぞ 3f75994bd4 #399 2026-07-16 23:12:14 +09:00
みてるぞ f91b78bd47 #399 2026-07-16 22:31:32 +09:00
みてるぞ 7f8cce39bc #399 2026-07-16 22:05:40 +09:00
みてるぞ 5c5a9fa1b0 #399 2026-07-16 22:00:16 +09:00
みてるぞ 66c738cbff #399 2026-07-16 21:45:02 +09:00
37個のファイルの変更2234行の追加1349行の削除
+13
ファイルの表示
@@ -285,6 +285,13 @@ case 'no':
Ruby-style numbered parameter names. Reserve numbered parameters for Ruby. Ruby-style numbered parameter names. Reserve numbered parameters for Ruby.
Use a meaningful callback parameter name such as `row`, `item`, `value`, Use a meaningful callback parameter name such as `row`, `item`, `value`,
`entry`, or `result`. `entry`, or `result`.
- In JavaScript, JSX, TypeScript, and TSX, use `cn` from `@/lib/utils`
whenever `className` combines multiple values, conditional classes, or a
caller-provided `className` prop.
- Do not construct `className` with template literals, `${ ... }`, string
concatenation, arrays joined with spaces, or feature-local class-merging
helpers.
- A static `className="..."` containing only fixed classes does not need `cn`.
- If code appears to need a distinction between `null` and `undefined`, treat - If code appears to need a distinction between `null` and `undefined`, treat
that as a design smell and revise the logic to avoid the distinction. that as a design smell and revise the logic to avoid the distinction.
External library APIs that explicitly require distinguishing the two are the External library APIs that explicitly require distinguishing the two are the
@@ -460,6 +467,12 @@ and layout reuse, follow `frontend/AGENTS.md`.
structure, control flow, or variable mutability unless the requested style structure, control flow, or variable mutability unless the requested style
explicitly requires it. explicitly requires it.
- Do not add production dependencies without explicit approval. - Do not add production dependencies without explicit approval.
- Do not add user-facing copy, helper text, descriptions, notes, tooltips,
placeholders, empty-state messages, loading messages, or explanatory text
unless the user explicitly specified the wording.
- When new user-facing wording appears necessary, ask the user for the exact
wording and placement before implementing it.
- Do not invent replacement copy when removing unrequested wording.
- Do not create, modify, or run tests unless the user explicitly asks for - Do not create, modify, or run tests unless the user explicitly asks for
test work. When the user asks for tests, keep working and rerun them until test work. When the user asks for tests, keep working and rerun them until
they pass or the remaining failure is clearly blocked. they pass or the remaining failure is clearly blocked.
+6 -1
ファイルの表示
@@ -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,
+21
ファイルの表示
@@ -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!
+3 -4
ファイルの表示
@@ -238,7 +238,8 @@ class Post < ApplicationRecord
value = raw_value.to_s.strip value = raw_value.to_s.strip
return nil if value.blank? return nil if value.blank?
if (match = value.match(/\A(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})\z/)) match = value.match(/\A(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})\z/)
if match
year = match[1].to_i year = match[1].to_i
month = match[2].to_i month = match[2].to_i
day = match[3].to_i day = match[3].to_i
@@ -251,9 +252,7 @@ class Post < ApplicationRecord
match = match =
value.match( value.match(
/\A(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/ \ /\A(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d+))?)?(Z|[+-]\d{2}:?\d{2})?\z/)
'(?::(\d{2})(?:\.(\d+))?)?' \
'(Z|[+-]\d{2}:?\d{2})?\z/')
return nil if match.nil? return nil if match.nil?
year = match[1].to_i year = match[1].to_i
+2 -2
ファイルの表示
@@ -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
+1
ファイルの表示
@@ -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
+13
ファイルの表示
@@ -130,6 +130,13 @@ pass or the remaining failure is clearly blocked.
- Tailwind scans `src/**/*.{html,js,ts,jsx,tsx,mdx}`. - Tailwind scans `src/**/*.{html,js,ts,jsx,tsx,mdx}`.
- Use `cn` from `src/lib/utils.ts` for conditional class names and class merging. - Use `cn` from `src/lib/utils.ts` for conditional class names and class merging.
- In JavaScript, JSX, TypeScript, and TSX, use `cn` from `@/lib/utils`
whenever `className` combines multiple values, conditional classes, or a
caller-provided `className` prop.
- Do not construct `className` with template literals, `${ ... }`, string
concatenation, arrays joined with spaces, or feature-local class-merging
helpers.
- A static `className="..."` containing only fixed classes does not need `cn`.
- Reuse components from `src/components/common`, `src/components/layout`, and - Reuse components from `src/components/common`, `src/components/layout`, and
`src/components/ui` before adding new primitives. `src/components/ui` before adding new primitives.
- Keep Tailwind classes consistent with nearby components. - Keep Tailwind classes consistent with nearby components.
@@ -140,6 +147,12 @@ pass or the remaining failure is clearly blocked.
short Japanese labels that fit the control. short Japanese labels that fit the control.
- Preserve existing Japanese tone and orthography in nearby UI text, including - Preserve existing Japanese tone and orthography in nearby UI text, including
old-kana wording where the file already uses it. old-kana wording where the file already uses it.
- Do not add user-facing copy, helper text, descriptions, notes, tooltips,
placeholders, empty-state messages, loading messages, or explanatory text
unless the user explicitly specified the wording.
- When new user-facing wording appears necessary, ask the user for the exact
wording and placement before implementing it.
- Do not invent replacement copy when removing unrequested wording.
- When adding dynamic tag colour classes, update `tailwind.config.js` safelist - When adding dynamic tag colour classes, update `tailwind.config.js` safelist
if the class cannot be statically detected. if the class cannot be statically detected.
- Do not introduce new UI libraries or production dependencies without approval. - Do not introduce new UI libraries or production dependencies without approval.
+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/>}/>
+4
ファイルの表示
@@ -30,6 +30,10 @@ describe ('menuOutline', () => {
expect (submenuItem ('guest', 'Wiki', '編輯')?.visible).toBe (false) expect (submenuItem ('guest', 'Wiki', '編輯')?.visible).toBe (false)
}) })
it ('uses /posts/new as the import entrypoint', () => {
expect (submenuItem ('member', '広場', '取込')?.to).toBe ('/posts/new')
})
it ('keeps material suppression admin-only', () => { it ('keeps material suppression admin-only', () => {
expect (submenuItem ('member', '素材', '抑止')?.visible).toBe (false) expect (submenuItem ('member', '素材', '抑止')?.visible).toBe (false)
expect (submenuItem ('admin', '素材', '抑止')?.visible).toBe (true) expect (submenuItem ('admin', '素材', '抑止')?.visible).toBe (true)
-1
ファイルの表示
@@ -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: [
+94
ファイルの表示
@@ -0,0 +1,94 @@
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
import PostTagsField from '@/components/posts/PostTagsField'
import PostTextField from '@/components/posts/PostTextField'
import type { FC, ReactNode } from 'react'
type TextMessages = string[] | undefined
type CoreField = {
value: string
onChange: (value: string) => void
errors?: TextMessages
warnings?: TextMessages
disabled?: boolean }
type OriginalCreatedField = {
originalCreatedAt?: TextMessages
originalCreatedFrom?: TextMessages
originalCreatedBefore?: TextMessages }
type PostCoreDataFieldsProps = {
title: {
value: string
onChange: (value: string) => void
errors?: TextMessages
warnings?: TextMessages
disabled?: boolean
after?: ReactNode }
originalCreated: {
disabled?: boolean
originalCreatedFrom: string | null
setOriginalCreatedFrom: (value: string | null) => void
originalCreatedBefore: string | null
setOriginalCreatedBefore: (value: string | null) => void
errors?: OriginalCreatedField }
tags: {
value: string
onChange: (value: string) => void
errors?: TextMessages
warnings?: TextMessages
disabled?: boolean
rows?: number }
parentPostIds: CoreField }
const groupedMessages = (...values: (TextMessages | null | undefined)[]): string[] =>
[...new Set (values.flatMap (value => value ?? []))]
const PostCoreDataFields: FC<PostCoreDataFieldsProps> = (
{ title,
originalCreated,
tags,
parentPostIds },
) => (
<>
<PostTextField
label="タイトル"
value={title.value}
disabled={title.disabled}
warnings={title.warnings}
errors={title.errors}
after={title.after}
onChange={title.onChange}/>
<PostOriginalCreatedTimeField
disabled={originalCreated.disabled}
originalCreatedFrom={originalCreated.originalCreatedFrom}
setOriginalCreatedFrom={originalCreated.setOriginalCreatedFrom}
originalCreatedBefore={originalCreated.originalCreatedBefore}
setOriginalCreatedBefore={originalCreated.setOriginalCreatedBefore}
errors={groupedMessages (
originalCreated.errors?.originalCreatedAt,
originalCreated.errors?.originalCreatedFrom,
originalCreated.errors?.originalCreatedBefore)}/>
<PostTagsField
tags={tags.value}
disabled={tags.disabled}
setTags={tags.onChange}
warnings={tags.warnings}
errors={tags.errors}
rows={tags.rows}/>
<PostTextField
label="親投稿"
value={parentPostIds.value}
disabled={parentPostIds.disabled}
warnings={parentPostIds.warnings}
errors={parentPostIds.errors}
onChange={parentPostIds.onChange}/>
</>)
export default PostCoreDataFields
export type { PostCoreDataFieldsProps }
+44
ファイルの表示
@@ -0,0 +1,44 @@
import PostCoreDataFields from '@/components/posts/PostCoreDataFields'
import PostTextField from '@/components/posts/PostTextField'
import type { FC, ReactNode } from 'react'
import type { PostCoreDataFieldsProps } from '@/components/posts/PostCoreDataFields'
type TextMessages = string[] | undefined
type Props = {
url: {
value: string
onChange: (value: string) => void
errors?: TextMessages
warnings?: TextMessages
disabled?: boolean
type?: string
placeholder?: string }
thumbnailField: ReactNode
core: PostCoreDataFieldsProps }
const PostCreationDataFields: FC<Props> = (
{ url,
thumbnailField,
core },
) => (
<>
<PostTextField
label="URL"
type={url.type}
value={url.value}
disabled={url.disabled}
warnings={url.warnings}
errors={url.errors}
placeholder={url.placeholder}
onChange={url.onChange}/>
{thumbnailField}
<PostCoreDataFields {...core}/>
</>)
export default PostCreationDataFields
+41
ファイルの表示
@@ -0,0 +1,41 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { buildPostImportRow } from '@/test/postImportFactories'
import { buildUser } from '@/test/factories'
import { renderWithProviders } from '@/test/render'
import type { DialogueFormControls } from '@/lib/dialogues/useDialogue'
const sharedFieldsSpy = vi.hoisted (() => vi.fn (() => <div data-testid="shared-fields"/>))
vi.mock ('@/components/posts/PostCreationDataFields', () => ({
default: sharedFieldsSpy,
}))
describe ('PostCreationDataFields usage', () => {
it ('is used by PostNewPage', async () => {
const { default: PostNewPage } = await import ('@/pages/posts/PostNewPage')
renderWithProviders (<PostNewPage user={buildUser ({ role: 'member' })}/>)
expect (screen.getByTestId ('shared-fields')).toBeInTheDocument ()
})
it ('is used by PostImportRowForm', async () => {
const { default: PostImportRowForm } = await import (
'@/components/posts/import/PostImportRowForm')
render (
<PostImportRowForm
row={buildPostImportRow ()}
controls={{
close: vi.fn (),
confirm: vi.fn (),
setActions: vi.fn (),
} as DialogueFormControls}
onSave={vi.fn ()}/>)
expect (screen.getByTestId ('shared-fields')).toBeInTheDocument ()
})
})
+5 -2
ファイルの表示
@@ -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}
+25 -1
ファイルの表示
@@ -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 ('')
})
}) })
+7 -18
ファイルの表示
@@ -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)}/>)
} }
+26 -11
ファイルの表示
@@ -85,7 +85,7 @@ describe ('PostImportRowForm', () => {
it ('marks edited fields and areas invalid from field errors', () => { it ('marks edited fields and areas invalid from field errors', () => {
const row = buildPostImportRow ({ const row = buildPostImportRow ({
validationErrors: { url: ['URL error'], tags: ['tag error'] }, validationErrors: { url: ['URL error'], tags: ['tag error'] },
importErrors: { duration: ['duration error'] }, importErrors: { title: ['title error'] },
fieldWarnings: { title: ['title warning'] } }) fieldWarnings: { title: ['title warning'] } })
render ( render (
@@ -96,7 +96,7 @@ describe ('PostImportRowForm', () => {
expect (screen.getByText ('URL error')).toBeInTheDocument () expect (screen.getByText ('URL error')).toBeInTheDocument ()
expect (screen.getByText ('tag error')).toBeInTheDocument () expect (screen.getByText ('tag error')).toBeInTheDocument ()
expect (screen.getByText ('duration error')).toBeInTheDocument () expect (screen.getByText ('title error')).toBeInTheDocument ()
expect (screen.getByText ('title warning')).toBeInTheDocument () expect (screen.getByText ('title warning')).toBeInTheDocument ()
expect (screen.getAllByRole ('textbox').filter ( expect (screen.getAllByRole ('textbox').filter (
textbox => textbox.getAttribute ('aria-invalid') === 'true')).toHaveLength (3) textbox => textbox.getAttribute ('aria-invalid') === 'true')).toHaveLength (3)
@@ -134,7 +134,9 @@ describe ('PostImportRowForm', () => {
resetRequested: false }) resetRequested: false })
}) })
it ('keeps the shared duration and tags string contract without leaking file upload UI', async () => { it (
'shows the shared creation field order without duration and without file upload UI',
async () => {
let actions: DialogueFormAction[] = [] let actions: DialogueFormAction[] = []
const controls: DialogueFormControls = { const controls: DialogueFormControls = {
close: vi.fn (), close: vi.fn (),
@@ -152,8 +154,18 @@ describe ('PostImportRowForm', () => {
onSave={onSave}/>) onSave={onSave}/>)
await waitFor (() => expect (actions.length).toBe (2)) await waitFor (() => expect (actions.length).toBe (2))
const labels = Array.from (container.querySelectorAll ('label'))
.map (node => node.textContent?.trim ())
expect (labels.slice (0, 6)).toEqual ([
'URL',
'サムネール',
'タイトル',
'オリジナルの作成日時',
'タグ',
'親投稿'])
expect (container.querySelector ('input[type="file"]')).toBeNull () expect (container.querySelector ('input[type="file"]')).toBeNull ()
expect (screen.getByPlaceholderText ('例: 2 / 2.5 / 1:23')).toHaveValue ('2') expect (screen.queryByPlaceholderText ('例: 2 / 2.5 / 1:23')).not.toBeInTheDocument ()
expect (screen.getByDisplayValue ('tag1')).toBeInTheDocument () expect (screen.getByDisplayValue ('tag1')).toBeInTheDocument ()
await act (async () => { await act (async () => {
@@ -162,10 +174,9 @@ describe ('PostImportRowForm', () => {
expect (onSave).toHaveBeenCalledWith ({ expect (onSave).toHaveBeenCalledWith ({
draft: expect.objectContaining ({ draft: expect.objectContaining ({
duration: '2',
tags: 'tag1' }), tags: 'tag1' }),
resetRequested: false }) resetRequested: false })
}) })
it ('keeps reset enabled when the value matches but provenance still differs', async () => { it ('keeps reset enabled when the value matches but provenance still differs', async () => {
const row = buildPostImportRow ({ const row = buildPostImportRow ({
@@ -178,7 +189,6 @@ describe ('PostImportRowForm', () => {
thumbnailBase: '', thumbnailBase: '',
originalCreatedFrom: '', originalCreatedFrom: '',
originalCreatedBefore: '', originalCreatedBefore: '',
duration: '',
tags: '', tags: '',
parentPostIds: '' }, parentPostIds: '' },
provenance: { provenance: {
@@ -187,7 +197,6 @@ describe ('PostImportRowForm', () => {
thumbnailBase: 'automatic', thumbnailBase: 'automatic',
originalCreatedFrom: 'automatic', originalCreatedFrom: 'automatic',
originalCreatedBefore: 'automatic', originalCreatedBefore: 'automatic',
duration: 'automatic',
tags: 'automatic', tags: 'automatic',
parentPostIds: 'automatic' }, parentPostIds: 'automatic' },
tagSources: { automatic: '', manual: '' }, tagSources: { automatic: '', manual: '' },
@@ -210,7 +219,9 @@ describe ('PostImportRowForm', () => {
expect (actions.find (action => action.label === '変更をリセット')?.disabled).toBe (false) expect (actions.find (action => action.label === '変更をリセット')?.disabled).toBe (false)
}) })
it ('disables every field while save validation is pending and re-enables them afterwards', async () => { it (
'disables every field while save validation is pending and re-enables them afterwards',
async () => {
let actions: DialogueFormAction[] = [] let actions: DialogueFormAction[] = []
let resolveSave: let resolveSave:
((value: { saved: boolean ((value: { saved: boolean
@@ -234,8 +245,9 @@ describe ('PostImportRowForm', () => {
onSave={onSave}/>) onSave={onSave}/>)
await waitFor (() => expect (actions.length).toBe (2)) await waitFor (() => expect (actions.length).toBe (2))
let savePromise: Promise<boolean | void> | undefined
await act (async () => { await act (async () => {
await actions.find (action => action.label === '編輯内容を保存')?.onSelect () savePromise = actions.find (action => action.label === '編輯内容を保存')?.onSelect ()
}) })
await waitFor (() => { await waitFor (() => {
@@ -249,6 +261,9 @@ describe ('PostImportRowForm', () => {
row: buildPostImportRow ({ row: buildPostImportRow ({
attributes: { title: 'draft title' }, attributes: { title: 'draft title' },
validationErrors: { title: ['タイトルを確認してください.'] } }) }) validationErrors: { title: ['タイトルを確認してください.'] } }) })
await act (async () => {
await savePromise
})
await waitFor (() => { await waitFor (() => {
screen.getAllByRole ('textbox').forEach (textbox => { screen.getAllByRole ('textbox').forEach (textbox => {
@@ -257,5 +272,5 @@ describe ('PostImportRowForm', () => {
}) })
expect (screen.getByDisplayValue ('draft title')).toBeInTheDocument () expect (screen.getByDisplayValue ('draft title')).toBeInTheDocument ()
expect (screen.getByText ('タイトルを確認してください.')).toBeInTheDocument () expect (screen.getByText ('タイトルを確認してください.')).toBeInTheDocument ()
}) })
}) })
+74 -73
ファイルの表示
@@ -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}/>
+85 -22
ファイルの表示
@@ -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>
</>) </>)
} }
+8 -2
ファイルの表示
@@ -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 ()
}) })
+7 -21
ファイルの表示
@@ -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}
+2
ファイルの表示
@@ -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', () => {
+1 -1
ファイルの表示
@@ -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'
+1
ファイルの表示
@@ -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 })
+12 -9
ファイルの表示
@@ -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')
+42 -13
ファイルの表示
@@ -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
+18 -11
ファイルの表示
@@ -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 (),
+40 -17
ファイルの表示
@@ -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)
}
+1 -2
ファイルの表示
@@ -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 }
+686
ファイルの表示
@@ -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)
}
+36 -390
ファイルの表示
@@ -1,402 +1,48 @@
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import { render, screen } from '@testing-library/react'
import { HelmetProvider } from 'react-helmet-async' import { MemoryRouter, Navigate, Route, Routes } from 'react-router-dom'
import { MemoryRouter, Route, Routes } from 'react-router-dom' import { describe, expect, it } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { describe ('legacy post import routes', () => {
loadPostImportSession, it ('redirects /posts/import to /posts/new', () => {
savePostImportSession, render (
} from '@/lib/postImportSession' <MemoryRouter initialEntries={['/posts/import']}>
import PostImportResultPage from '@/pages/posts/PostImportResultPage' <Routes>
import { buildUser } from '@/test/factories' <Route path="/posts/import" element={<Navigate to="/posts/new" replace/>}/>
import { buildPostImportRow } from '@/test/postImportFactories' <Route path="/posts/new" element={<div>SOURCE ROUTE</div>}/>
import { originalCreatedAtString } from '@/lib/utils' </Routes>
</MemoryRouter>)
import type { PostImportRow } from '@/lib/postImportSession' expect (screen.getByText ('SOURCE ROUTE')).toBeInTheDocument ()
import type { DialogueFormAction, DialogueFormControls } from '@/lib/dialogues/useDialogue'
const api = vi.hoisted (() => ({
apiGet: vi.fn (),
apiPost: vi.fn (),
}))
const toastApi = vi.hoisted (() => ({
toast: vi.fn (),
}))
const dialogue = vi.hoisted (() => ({
form: vi.fn (() => Promise.resolve ()),
}))
vi.mock ('@/components/ui/use-toast', () => toastApi)
vi.mock ('@/lib/api', () => api)
vi.mock ('@/lib/dialogues/useDialogue', () => ({
default: () => dialogue,
}))
describe ('PostImportResultPage', () => {
beforeEach (() => {
sessionStorage.clear ()
vi.clearAllMocks ()
globalThis.URL.createObjectURL = vi.fn (() => 'blob:preview')
globalThis.URL.revokeObjectURL = vi.fn ()
api.apiGet.mockResolvedValue (new Blob (['img'], { type: 'image/png' }))
}) })
it ('shows edit and retry only for recoverable failures and shows warnings', () => { it ('redirects legacy review routes to /posts/new', () => {
savePostImportSession ('result-session', {
source: '',
repairMode: 'failed',
rows: [
buildPostImportRow ({
sourceRow: 1,
attributes: { title: 'recoverable row' },
importStatus: 'failed',
recoverable: true,
importErrors: { base: ['recoverable failed'] } }),
buildPostImportRow ({
sourceRow: 2,
attributes: { title: 'hard failed row' },
importStatus: 'failed',
importErrors: { base: ['hard failed'] } }),
buildPostImportRow ({
sourceRow: 3,
attributes: {
title: 'created row',
originalCreatedFrom: '2024-01-01T00:00:00Z',
originalCreatedBefore: '2024-01-02T00:00:00Z' },
importStatus: 'created',
createdPostId: 3,
fieldWarnings: {
thumbnailBase: ['サムネール画像を取得できませんでした.'] } })] })
render ( render (
<HelmetProvider> <MemoryRouter initialEntries={['/posts/import/legacy/review']}>
<MemoryRouter initialEntries={['/posts/import/result-session/result']}> <Routes>
<Routes> <Route
<Route path="/posts/import/:sessionId/review"
path="/posts/import/:sessionId/result" element={<Navigate to="/posts/new" replace/>}/>
element={<PostImportResultPage user={buildUser ()}/>}/> <Route path="/posts/new" element={<div>SOURCE ROUTE</div>}/>
</Routes> </Routes>
</MemoryRouter> </MemoryRouter>)
</HelmetProvider>)
expect (screen.getAllByRole ('button', { name: '編輯' })).toHaveLength (1) expect (screen.getByText ('SOURCE ROUTE')).toBeInTheDocument ()
expect (screen.getAllByRole ('button', { name: '再試行' })).toHaveLength (1)
expect (screen.getByText ('サムネール画像を取得できませんでした.'))
.toBeInTheDocument ()
expect (screen.getByText (
originalCreatedAtString ('2024-01-01T00:00:00Z', '2024-01-02T00:00:00Z'),
)).toBeInTheDocument ()
}) })
it ('shows hour-precision original-created ranges with the shared utility output', () => { it ('redirects legacy result routes to /posts', () => {
const from = '2024-01-01T12:00:00Z'
const before = '2024-01-01T13:00:00Z'
savePostImportSession ('result-hour-range', {
source: '',
repairMode: 'all',
rows: [buildPostImportRow ({
sourceRow: 1,
importStatus: 'created',
createdPostId: 1,
attributes: {
title: 'created row',
originalCreatedFrom: from,
originalCreatedBefore: before } })] })
render ( render (
<HelmetProvider> <MemoryRouter initialEntries={['/posts/import/legacy/result']}>
<MemoryRouter initialEntries={['/posts/import/result-hour-range/result']}> <Routes>
<Routes> <Route
<Route path="/posts/import/:sessionId/result"
path="/posts/import/:sessionId/result" element={<Navigate to="/posts" replace/>}/>
element={<PostImportResultPage user={buildUser ()}/>}/> <Route
</Routes> path="/posts/new/:sessionId/result"
</MemoryRouter> element={<Navigate to="/posts" replace/>}/>
</HelmetProvider>) <Route path="/posts" element={<div>POSTS ROUTE</div>}/>
</Routes>
</MemoryRouter>)
expect (screen.getByText (originalCreatedAtString (from, before))).toBeInTheDocument () expect (screen.getByText ('POSTS ROUTE')).toBeInTheDocument ()
})
it ('keeps recoverable pending rows actionable and hides actions for hard failures', () => {
savePostImportSession ('result-pending', {
source: '',
repairMode: 'failed',
rows: [
buildPostImportRow ({
sourceRow: 1,
importStatus: 'pending',
recoverable: true,
validationErrors: { title: ['invalid'] } }),
buildPostImportRow ({
sourceRow: 2,
importStatus: 'pending',
recoverable: true }),
buildPostImportRow ({
sourceRow: 3,
importStatus: 'failed',
importErrors: { base: ['hard failed'] } })] })
render (
<HelmetProvider>
<MemoryRouter initialEntries={['/posts/import/result-pending/result']}>
<Routes>
<Route
path="/posts/import/:sessionId/result"
element={<PostImportResultPage user={buildUser ()}/>}/>
</Routes>
</MemoryRouter>
</HelmetProvider>)
expect (screen.getAllByRole ('button', { name: '編輯' })).toHaveLength (1)
expect (screen.getAllByRole ('button', { name: '再試行' })).toHaveLength (1)
expect (screen.getByText ('invalid')).toBeInTheDocument ()
expect (screen.getByText ('hard failed')).toBeInTheDocument ()
})
it ('disables editing, retry, and navigation while retry is running', async () => {
let resolveValidation: ((value: { rows: PostImportRow[] }) => void) | null = null
savePostImportSession ('result-retry-busy', {
source: '',
repairMode: 'failed',
rows: [
buildPostImportRow ({
sourceRow: 1,
importStatus: 'failed',
recoverable: true,
importErrors: { base: ['failed'] } }),
buildPostImportRow ({
sourceRow: 2,
importStatus: 'failed',
recoverable: true,
importErrors: { base: ['failed'] } })] })
api.apiPost.mockImplementationOnce (() =>
new Promise<{ rows: PostImportRow[] }> (resolve => {
resolveValidation = resolve
}))
api.apiPost.mockResolvedValueOnce ({
created: 0,
skipped: 0,
failed: 0,
rows: [] })
render (
<HelmetProvider>
<MemoryRouter initialEntries={['/posts/import/result-retry-busy/result']}>
<Routes>
<Route
path="/posts/import/:sessionId/result"
element={<PostImportResultPage user={buildUser ()}/>}/>
</Routes>
</MemoryRouter>
</HelmetProvider>)
fireEvent.click (screen.getAllByRole ('button', { name: '再試行' })[0])
await waitFor (() => {
screen.getAllByRole ('button', { name: '編輯' }).forEach (button => {
expect (button).toBeDisabled ()
})
screen.getAllByRole ('button', { name: '再試行' }).forEach (button => {
expect (button).toBeDisabled ()
})
expect (screen.getByRole ('button', { name: '確認画面へ戻る' })).toBeDisabled ()
expect (screen.getByRole ('button', { name: '新しい URL リストを入力' })).toBeDisabled ()
})
resolveValidation?.({ rows: [buildPostImportRow ({ sourceRow: 1 })] })
})
it ('persists retry success to sessionStorage', async () => {
savePostImportSession ('result-retry-success', {
source: '',
repairMode: 'failed',
rows: [buildPostImportRow ({
sourceRow: 1,
importStatus: 'failed',
recoverable: true,
importErrors: { base: ['failed'] } })] })
api.apiPost
.mockResolvedValueOnce ({
rows: [buildPostImportRow ({
sourceRow: 1,
importStatus: 'pending',
recoverable: true })] })
.mockResolvedValueOnce ({
created: 1,
skipped: 0,
failed: 0,
rows: [{
sourceRow: 1,
status: 'created',
post: { id: 10 } }] })
render (
<HelmetProvider>
<MemoryRouter initialEntries={['/posts/import/result-retry-success/result']}>
<Routes>
<Route
path="/posts/import/:sessionId/result"
element={<PostImportResultPage user={buildUser ()}/>}/>
</Routes>
</MemoryRouter>
</HelmetProvider>)
fireEvent.click (screen.getByRole ('button', { name: '再試行' }))
await waitFor (() => {
expect (loadPostImportSession ('result-retry-success')?.rows[0]?.importStatus)
.toBe ('created')
})
})
it ('keeps edited recoverable rows in session and retries with the edited values', async () => {
savePostImportSession ('result-edit-retry', {
source: '',
repairMode: 'failed',
rows: [buildPostImportRow ({
sourceRow: 1,
attributes: { title: 'old title' },
importStatus: 'failed',
recoverable: true,
importErrors: { base: ['failed'] } })] })
api.apiPost
.mockResolvedValueOnce ({
rows: [buildPostImportRow ({
sourceRow: 1,
attributes: { title: 'edited title' },
importStatus: 'pending',
recoverable: true })] })
.mockResolvedValueOnce ({
rows: [buildPostImportRow ({
sourceRow: 1,
attributes: { title: 'edited title' },
importStatus: 'pending',
recoverable: true })] })
.mockResolvedValueOnce ({
created: 1,
skipped: 0,
failed: 0,
rows: [{
sourceRow: 1,
status: 'created',
post: { id: 11 } }] })
dialogue.form.mockImplementationOnce (async options => {
let actions: DialogueFormAction[] = []
const controls: DialogueFormControls = {
close: vi.fn (),
confirm: vi.fn (),
setActions: next => {
actions = next
} }
render (options.body (controls))
await waitFor (() => expect (actions.length).toBe (2))
fireEvent.change (screen.getByDisplayValue ('old title'), {
target: { value: 'edited title' } })
await act (async () => {
await actions.find (action => action.label === '編輯内容を保存')?.onSelect ()
})
})
render (
<HelmetProvider>
<MemoryRouter initialEntries={['/posts/import/result-edit-retry/result']}>
<Routes>
<Route
path="/posts/import/:sessionId/result"
element={<PostImportResultPage user={buildUser ()}/>}/>
</Routes>
</MemoryRouter>
</HelmetProvider>)
fireEvent.click (screen.getByRole ('button', { name: '編輯' }))
await waitFor (() => {
const saved = loadPostImportSession ('result-edit-retry')
expect (saved?.rows[0]?.attributes.title).toBe ('edited title')
expect (saved?.rows[0]?.importStatus).toBe ('pending')
})
fireEvent.click (screen.getByRole ('button', { name: '再試行' }))
await waitFor (() => {
expect (api.apiPost.mock.calls[2]?.[1]?.rows?.[0]?.attributes?.title)
.toBe ('edited title')
})
})
it ('persists restored failed state when retry fails', async () => {
savePostImportSession ('result-retry-failure', {
source: '',
repairMode: 'failed',
rows: [buildPostImportRow ({
sourceRow: 1,
importStatus: 'failed',
recoverable: true,
importErrors: { base: ['failed'] } })] })
api.apiPost.mockRejectedValueOnce (new Error ('network error'))
render (
<HelmetProvider>
<MemoryRouter initialEntries={['/posts/import/result-retry-failure/result']}>
<Routes>
<Route
path="/posts/import/:sessionId/result"
element={<PostImportResultPage user={buildUser ()}/>}/>
</Routes>
</MemoryRouter>
</HelmetProvider>)
fireEvent.click (screen.getByRole ('button', { name: '再試行' }))
await waitFor (() => {
const saved = loadPostImportSession ('result-retry-failure')
expect (saved?.rows[0]?.importStatus).toBe ('failed')
expect (saved?.rows[0]?.recoverable).toBe (true)
})
})
it ('restores the failed row when the retry import response is incomplete', async () => {
savePostImportSession ('result-retry-missing-row', {
source: '',
repairMode: 'failed',
rows: [buildPostImportRow ({
sourceRow: 1,
importStatus: 'failed',
recoverable: true,
importErrors: { base: ['failed'] } })] })
api.apiPost
.mockResolvedValueOnce ({
rows: [buildPostImportRow ({
sourceRow: 1,
importStatus: 'pending',
recoverable: true })] })
.mockResolvedValueOnce ({
created: 1,
skipped: 0,
failed: 0,
rows: [] })
render (
<HelmetProvider>
<MemoryRouter initialEntries={['/posts/import/result-retry-missing-row/result']}>
<Routes>
<Route
path="/posts/import/:sessionId/result"
element={<PostImportResultPage user={buildUser ()}/>}/>
</Routes>
</MemoryRouter>
</HelmetProvider>)
fireEvent.click (screen.getByRole ('button', { name: '再試行' }))
await waitFor (() => {
const saved = loadPostImportSession ('result-retry-missing-row')
expect (saved?.rows[0]?.importStatus).toBe ('failed')
expect (toastApi.toast).toHaveBeenCalledWith (
expect.objectContaining ({ title: '登録結果が不完全でした' }))
})
}) })
}) })
+306 -153
ファイルの表示
@@ -30,6 +30,20 @@ vi.mock ('@/lib/dialogues/useDialogue', () => ({
default: () => dialogue, default: () => dialogue,
})) }))
const renderReviewPage = (route = '/posts/new/review') =>
render (
<HelmetProvider>
<MemoryRouter initialEntries={[route]}>
<Routes>
<Route path="/posts/new" element={<div>SOURCE ROUTE</div>}/>
<Route
path="/posts/new/review"
element={<PostImportReviewPage user={buildUser ()}/>}/>
<Route path="/posts" element={<div>POSTS ROUTE</div>}/>
</Routes>
</MemoryRouter>
</HelmetProvider>)
describe ('PostImportReviewPage', () => { describe ('PostImportReviewPage', () => {
beforeEach (() => { beforeEach (() => {
sessionStorage.clear () sessionStorage.clear ()
@@ -39,8 +53,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)
+554 -193
ファイルの表示
@@ -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>
+7 -6
ファイルの表示
@@ -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)
}) })
}) })
+14 -16
ファイルの表示
@@ -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
+22 -140
ファイルの表示
@@ -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)
}) })
}) })
+12 -230
ファイルの表示
@@ -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