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

Loading...

)} - {}}/> - { - const file = e.target.files?.[0] - if (file) - { - setThumbnailFile (file) - setThumbnailPreview (URL.createObjectURL (file)) - } - }}/> - -
- - - - - - {/* オリジナルの作成日時 */} - - - {videoFlg && ( - )} + + {}} + after={ + }/> + { + const file = e.target.files?.[0] + if (file) + { + setThumbnailFile (file) + setThumbnailPreview (URL.createObjectURL (file)) + } + }}/> + + } + core={{ + title: { + value: title, + onChange: setTitle, + errors: fieldErrors.title, + disabled: titleLoading, + after: ( + ) }, + originalCreated: { + originalCreatedFrom, + setOriginalCreatedFrom, + originalCreatedBefore, + setOriginalCreatedBefore, + errors: { + originalCreatedAt: fieldErrors.originalCreatedAt, + originalCreatedFrom: fieldErrors.originalCreatedFrom, + originalCreatedBefore: fieldErrors.originalCreatedBefore } }, + tags: { + value: tags, + onChange: setTags, + errors: fieldErrors.tags }, + parentPostIds: { + value: parentPostIds, + onChange: setParentPostIds, + errors: fieldErrors.parentPostIds } }}/> {/* 送信 */}