diff --git a/AGENTS.md b/AGENTS.md index 371090a..aa4f8de 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -281,6 +281,10 @@ case 'no': - In TypeScript and TSX, use `value == null` and `value != null` as the default nullish checks. Do not use `=== null`, `=== undefined`, `!== null`, or `!== undefined`. +- In JavaScript, JSX, TypeScript, and TSX, never use `_1`, `_2`, or similar + Ruby-style numbered parameter names. Reserve numbered parameters for Ruby. + Use a meaningful callback parameter name such as `row`, `item`, `value`, + `entry`, or `result`. - 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 diff --git a/backend/app/models/post.rb b/backend/app/models/post.rb index 001fb6f..d9d4416 100644 --- a/backend/app/models/post.rb +++ b/backend/app/models/post.rb @@ -253,7 +253,7 @@ class Post < ApplicationRecord value.match( /\A(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/ \ '(?::(\d{2})(?:\.(\d+))?)?' \ - '(Z|[+-]\d{2}:?\d{2})\z/') + '(Z|[+-]\d{2}:?\d{2})?\z/') return nil if match.nil? year = match[1].to_i @@ -265,16 +265,26 @@ class Post < ApplicationRecord fraction = match[7] offset = match[8] return nil unless valid_original_created_components?(year, month, day, hour, minute, second) - return nil unless valid_original_created_offset?(offset) + return nil if offset.present? && !(valid_original_created_offset?(offset)) - Time.new( + if offset.present? + return Time.new( + year, + month, + day, + hour, + minute, + second + Rational(parse_original_created_nanoseconds(fraction), 1_000_000_000), + normalise_original_created_offset(offset)).in_time_zone + end + + Time.zone.local( year, month, day, hour, minute, - second + Rational(parse_original_created_nanoseconds(fraction), 1_000_000_000), - normalise_original_created_offset(offset)).in_time_zone + second).change(nsec: parse_original_created_nanoseconds(fraction)) rescue ArgumentError, TypeError nil end diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index ba29a77..5c64a93 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -173,6 +173,10 @@ pass or the remaining failure is clearly blocked. `?` / `:` pairing. - Keep short inline props types local when they remain readable and within the line limit; do not mechanically extract a named type with no reuse benefit. +- In JavaScript, JSX, TypeScript, and TSX, never use `_1`, `_2`, or similar + 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 multi-line object literals, keep the opening `{` with the first pair when the line length allows it; do not mechanically explode short objects into Prettier-style vertical blocks. @@ -258,7 +262,7 @@ finally ```ts const editingRow = Number.isFinite (editingSourceRow) - ? rows.find (_1 => _1.sourceRow === editingSourceRow) ?? null + ? rows.find (row => row.sourceRow === editingSourceRow) ?? null : null ``` diff --git a/frontend/src/components/TopNav.test.tsx b/frontend/src/components/TopNav.test.tsx index 4d41dcd..d3714d3 100644 --- a/frontend/src/components/TopNav.test.tsx +++ b/frontend/src/components/TopNav.test.tsx @@ -8,7 +8,8 @@ const submenuItem = (role: 'guest' | 'member' | 'admin', section: string, item: user: buildUser ({ role }), wikiId: section === 'Wiki' ? 10 : null, pathName: section === 'Wiki' ? '/wiki/page' : '/posts' }) - return menu.find (_1 => _1.name === section)?.subMenu.find (_1 => _1.name === item) + return menu.find (entry => entry.name === section)?.subMenu.find ( + subMenuItem => subMenuItem.name === item) } describe ('menuOutline', () => { diff --git a/frontend/src/components/dialogues/DialogueProvider.tsx b/frontend/src/components/dialogues/DialogueProvider.tsx index 41c887f..3ab04f3 100644 --- a/frontend/src/components/dialogues/DialogueProvider.tsx +++ b/frontend/src/components/dialogues/DialogueProvider.tsx @@ -86,7 +86,7 @@ const DialogueProvider: FC = ({ children }) => { return current.filter (request => request.id !== id) }) - setPendingIds (current => current.filter (_1 => _1 !== id)) + setPendingIds (current => current.filter (pendingId => pendingId !== id)) setFormActions (current => { const { [id]: _, ...rest } = current return rest @@ -147,7 +147,7 @@ const DialogueProvider: FC = ({ children }) => { } finally { - setPendingIds (current => current.filter (_1 => _1 !== id)) + setPendingIds (current => current.filter (pendingId => pendingId !== id)) } }, [closeRequest, pendingIds]) diff --git a/frontend/src/components/posts/import/PostImportRowForm.test.tsx b/frontend/src/components/posts/import/PostImportRowForm.test.tsx index a80864c..e4f3d08 100644 --- a/frontend/src/components/posts/import/PostImportRowForm.test.tsx +++ b/frontend/src/components/posts/import/PostImportRowForm.test.tsx @@ -24,7 +24,7 @@ describe ('PostImportRowForm', () => { const titleInput = screen.getByDisplayValue ('manual title') await waitFor (() => expect (actions.at (-1)?.length).toBe (2)) - const reset = actions.at (-1)?.find (_1 => _1.label === '変更をリセット') + const reset = actions.at (-1)?.find (action => action.label === '変更をリセット') expect (reset).toMatchObject ({ placement: 'start', variant: 'danger', disabled: false }) await act (async () => { await reset?.onSelect () @@ -34,7 +34,7 @@ describe ('PostImportRowForm', () => { expect (titleInput).toHaveValue ('') expect (onSave).not.toHaveBeenCalled () - const save = actions.at (-1)?.find (_1 => _1.label === '編輯内容を保存') + const save = actions.at (-1)?.find (action => action.label === '編輯内容を保存') await act (async () => { await save?.onSelect () }) @@ -63,7 +63,7 @@ describe ('PostImportRowForm', () => { await waitFor (() => expect (actions.length).toBe (2)) await act (async () => { - await actions.find (_1 => _1.label === '変更をリセット')?.onSelect () + await actions.find (action => action.label === '変更をリセット')?.onSelect () }) expect (screen.getByDisplayValue ('manual title')).toBeInTheDocument () @@ -86,7 +86,7 @@ describe ('PostImportRowForm', () => { expect (screen.getByText ('duration error')).toBeInTheDocument () expect (screen.getByText ('title warning')).toBeInTheDocument () expect (screen.getAllByRole ('textbox').filter ( - _1 => _1.getAttribute ('aria-invalid') === 'true')).toHaveLength (3) + textbox => textbox.getAttribute ('aria-invalid') === 'true')).toHaveLength (3) }) it ('keeps untouched original created values unchanged in the save payload', async () => { @@ -111,7 +111,7 @@ describe ('PostImportRowForm', () => { await waitFor (() => expect (actions.length).toBe (2)) await act (async () => { - await actions.find (_1 => _1.label === '編輯内容を保存')?.onSelect () + await actions.find (action => action.label === '編輯内容を保存')?.onSelect () }) expect (onSave).toHaveBeenCalledWith ({ @@ -120,4 +120,37 @@ describe ('PostImportRowForm', () => { originalCreatedBefore: '2024-01-01T12:35+09:00' }), resetRequested: false }) }) + + it ('keeps the shared duration and tags string contract without leaking file upload UI', async () => { + let actions: DialogueFormAction[] = [] + const controls: DialogueFormControls = { + close: vi.fn (), + confirm: vi.fn (), + setActions: next => { + actions = next + } } + const onSave = vi.fn ().mockResolvedValue ({ saved: true, row: null }) + + const { container } = render ( + ) + await waitFor (() => expect (actions.length).toBe (2)) + + expect (container.querySelector ('input[type="file"]')).toBeNull () + expect (screen.getByPlaceholderText ('例: 2 / 2.5 / 1:23')).toHaveValue ('2') + expect (screen.getByDisplayValue ('tag1')).toBeInTheDocument () + + await act (async () => { + await actions.find (action => action.label === '編輯内容を保存')?.onSelect () + }) + + expect (onSave).toHaveBeenCalledWith ({ + draft: expect.objectContaining ({ + duration: '2', + tags: 'tag1' }), + resetRequested: false }) + }) }) diff --git a/frontend/src/components/posts/import/PostImportRowForm.tsx b/frontend/src/components/posts/import/PostImportRowForm.tsx index 13e24b8..bdf7206 100644 --- a/frontend/src/components/posts/import/PostImportRowForm.tsx +++ b/frontend/src/components/posts/import/PostImportRowForm.tsx @@ -10,18 +10,10 @@ import PostThumbnailPreview from '@/components/posts/PostThumbnailPreview' import type { FC } from 'react' -import type { PostImportRow } from '@/lib/postImportSession' +import type { PostImportEditableDraft, PostImportRow } from '@/lib/postImportSession' import type { DialogueFormControls } from '@/lib/dialogues/useDialogue' -type Draft = { - url: string - title: string - thumbnailBase: string - originalCreatedFrom: string - originalCreatedBefore: string - duration: string - tags: string - parentPostIds: string } +type Draft = PostImportEditableDraft type Props = { row: PostImportRow diff --git a/frontend/src/components/posts/import/postImportRowStatus.ts b/frontend/src/components/posts/import/postImportRowStatus.ts index 03debd5..123ab23 100644 --- a/frontend/src/components/posts/import/postImportRowStatus.ts +++ b/frontend/src/components/posts/import/postImportRowStatus.ts @@ -5,7 +5,7 @@ export type PostImportDisplayStatus = 'ready' | 'skipped' | 'warning' export type PostImportBadgeValue = PostImportDisplayStatus const hasWarnings = (row: PostImportRow): boolean => - Object.values (row.fieldWarnings ?? { }).some (_1 => _1.length > 0) + Object.values (row.fieldWarnings ?? { }).some (messages => messages.length > 0) || row.baseWarnings.length > 0 export const displayPostImportStatus = ( diff --git a/frontend/src/lib/postImportRows.test.ts b/frontend/src/lib/postImportRows.test.ts index eaaeb65..5f52a5b 100644 --- a/frontend/src/lib/postImportRows.test.ts +++ b/frontend/src/lib/postImportRows.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { creatableImportRows, + buildNextEditedRow, canEditResultRow, canEditReviewRow, canRetryResultRow, @@ -250,4 +251,38 @@ describe ('post import row state', () => { expect (initialised.resetSnapshot.attributes.title).toBe ('') expect (initialised.resetSnapshot.fieldWarnings.title).toEqual (['warning']) }) + + it ('builds the next edited row with shared repair semantics', () => { + const row = buildPostImportRow ({ + url: 'https://example.com/original', + importStatus: 'failed', + recoverable: true, + importErrors: { base: ['failed'] }, + attributes: { title: 'old title', tags: 'old-tag', duration: '2' }, + provenance: { title: 'automatic', tags: 'automatic', url: 'manual' }, + tagSources: { automatic: 'old-tag', manual: '' } }) + + const nextRow = buildNextEditedRow ( + row, + { + url: 'https://example.com/edited', + title: 'edited title', + thumbnailBase: '', + originalCreatedFrom: '', + originalCreatedBefore: '', + duration: '2.5', + tags: 'edited-tag', + parentPostIds: '' }, + true) + + expect (nextRow.importStatus).toBe ('pending') + 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.tags).toBe ('edited-tag') + expect (nextRow.provenance.title).toBe ('manual') + expect (nextRow.provenance.url).toBe ('manual') + expect (nextRow.tagSources?.manual).toBe ('edited-tag') + }) }) diff --git a/frontend/src/lib/postImportRows.ts b/frontend/src/lib/postImportRows.ts index 6946c2e..2b811b0 100644 --- a/frontend/src/lib/postImportRows.ts +++ b/frontend/src/lib/postImportRows.ts @@ -1,4 +1,5 @@ -import type { PostImportResultRow, +import type { PostImportEditableDraft, + PostImportResultRow, PostImportRow } from '@/lib/postImportTypes' const hasSkipReason = (row: PostImportRow): boolean => @@ -112,6 +113,54 @@ export const resultRowWarnings = (row: PostImportRow): string[] => ...Object.values (row.fieldWarnings ?? { }).flat (), ...row.baseWarnings])] +export const buildNextEditedRow = ( + editingRow: PostImportRow, + draft: PostImportEditableDraft, + urlChanged: boolean, +): PostImportRow => { + const nextProvenance = { ...editingRow.provenance } + const nextAttributes = { ...editingRow.attributes } + const nextTagSources = { + automatic: editingRow.tagSources?.automatic ?? '', + manual: editingRow.tagSources?.manual ?? '' } + const draftFields = [ + ['title', draft.title], + ['thumbnailBase', draft.thumbnailBase], + ['originalCreatedFrom', draft.originalCreatedFrom], + ['originalCreatedBefore', draft.originalCreatedBefore], + ['duration', draft.duration], + ['parentPostIds', draft.parentPostIds]] as const + draftFields.forEach (([field, value]) => { + nextAttributes[field] = value + nextProvenance[field] = + value !== String (editingRow.attributes[field] ?? '') + ? 'manual' + : (editingRow.provenance[field] ?? 'automatic') + }) + nextAttributes.tags = draft.tags + if (draft.tags !== String (editingRow.attributes.tags ?? '')) + { + nextProvenance.tags = 'manual' + nextTagSources.manual = draft.tags + } + else + { + nextProvenance.tags = editingRow.provenance.tags ?? 'automatic' + nextTagSources.manual = editingRow.tagSources?.manual ?? '' + } + + return { + ...editingRow, + url: draft.url, + attributes: nextAttributes, + provenance: { + ...nextProvenance, + url: urlChanged ? 'manual' : (editingRow.provenance.url ?? 'manual') }, + tagSources: nextTagSources, + importStatus: editingRow.importStatus === 'created' ? 'created' : 'pending', + importErrors: undefined } +} + export const hasExactSourceRows = ( expected: number[], @@ -121,7 +170,7 @@ export const hasExactSourceRows = ( return false const expectedSorted = [...expected].sort ((a, b) => a - b) - const actualSorted = actual.map (_1 => _1.sourceRow).sort ((a, b) => a - b) + const actualSorted = actual.map (row => row.sourceRow).sort ((a, b) => a - b) return expectedSorted.every ((value, index) => value === actualSorted[index]) } diff --git a/frontend/src/lib/postImportSourceValidation.ts b/frontend/src/lib/postImportSourceValidation.ts index 1658216..812afd9 100644 --- a/frontend/src/lib/postImportSourceValidation.ts +++ b/frontend/src/lib/postImportSourceValidation.ts @@ -40,8 +40,8 @@ const normaliseImportUrl = (value: string): string | null => { export const countImportSourceLines = (source: string): number => source .split (/\r\n|\n|\r/) - .map (_1 => _1.trim ()) - .filter (_1 => _1 !== '') + .map (line => line.trim ()) + .filter (line => line !== '') .length diff --git a/frontend/src/lib/postImportStorage.ts b/frontend/src/lib/postImportStorage.ts index a50f1fd..1fec4a4 100644 --- a/frontend/src/lib/postImportStorage.ts +++ b/frontend/src/lib/postImportStorage.ts @@ -96,7 +96,7 @@ const ensureStringListRecord = (value: unknown): Record | null const result: Record = { } for (const [key, entry] of Object.entries (value)) { - if (!(Array.isArray (entry)) || !(entry.every (_1 => typeof _1 === 'string'))) + if (!(Array.isArray (entry)) || !(entry.every (item => typeof item === 'string'))) return null result[key] = entry } @@ -161,7 +161,7 @@ const sanitiseResetSnapshot = (value: unknown): PostImportResetSnapshot | null = return null if (!(hasOnlyKeys (value.tagSources, TAG_SOURCE_KEYS))) return null - if (!(Object.values (value.tagSources).every (_1 => typeof _1 === 'string'))) + if (!(Object.values (value.tagSources).every (entry => typeof entry === 'string'))) return null const fieldWarnings = ensureStringListRecord (value.fieldWarnings) if (fieldWarnings == null) @@ -169,7 +169,7 @@ const sanitiseResetSnapshot = (value: unknown): PostImportResetSnapshot | null = if (!(hasOnlyKeys (fieldWarnings, WARNING_KEYS))) return null if (!(Array.isArray (value.baseWarnings)) - || !(value.baseWarnings.every (_1 => typeof _1 === 'string'))) + || !(value.baseWarnings.every (warning => typeof warning === 'string'))) return null if (value.metadataUrl != null && typeof value.metadataUrl !== 'string') return null @@ -235,7 +235,7 @@ const sanitiseRow = (value: unknown): PostImportRow | null => { if (importErrors === null) return null if (!(Array.isArray (value.baseWarnings)) - || !(value.baseWarnings.every (_1 => typeof _1 === 'string'))) + || !(value.baseWarnings.every (warning => typeof warning === 'string'))) return null const provenanceEntries = Object.entries (value.provenance) @@ -255,7 +255,7 @@ const sanitiseRow = (value: unknown): PostImportRow | null => { return null if (!(hasOnlyKeys (value.tagSources, TAG_SOURCE_KEYS))) return null - if (!(Object.values (value.tagSources).every (_1 => typeof _1 === 'string'))) + if (!(Object.values (value.tagSources).every (entry => typeof entry === 'string'))) return null } @@ -401,7 +401,7 @@ export const loadPostImportSession = ( } const rows = value.rows.map (sanitiseRow) - if (rows.some (_1 => _1 == null)) + if (rows.some (row => row == null)) return null return { diff --git a/frontend/src/lib/postImportTypes.ts b/frontend/src/lib/postImportTypes.ts index dec6f9e..814374f 100644 --- a/frontend/src/lib/postImportTypes.ts +++ b/frontend/src/lib/postImportTypes.ts @@ -72,4 +72,14 @@ export type PostImportSourceIssue = { message: string url: string } +export type PostImportEditableDraft = { + url: string + title: string + thumbnailBase: string + originalCreatedFrom: string + originalCreatedBefore: string + duration: string + tags: string + parentPostIds: string } + export type StorageErrorHandler = (message: string) => void diff --git a/frontend/src/pages/posts/PostImportResultPage.test.tsx b/frontend/src/pages/posts/PostImportResultPage.test.tsx index 59b6d5a..0fe7f9a 100644 --- a/frontend/src/pages/posts/PostImportResultPage.test.tsx +++ b/frontend/src/pages/posts/PostImportResultPage.test.tsx @@ -263,7 +263,7 @@ describe ('PostImportResultPage', () => { fireEvent.change (screen.getByDisplayValue ('old title'), { target: { value: 'edited title' } }) await act (async () => { - await actions.find (_1 => _1.label === '編輯内容を保存')?.onSelect () + await actions.find (action => action.label === '編輯内容を保存')?.onSelect () }) }) diff --git a/frontend/src/pages/posts/PostImportResultPage.tsx b/frontend/src/pages/posts/PostImportResultPage.tsx index 499a74a..7521565 100644 --- a/frontend/src/pages/posts/PostImportResultPage.tsx +++ b/frontend/src/pages/posts/PostImportResultPage.tsx @@ -17,6 +17,7 @@ import { apiPost } from '@/lib/api' import useDialogue from '@/lib/dialogues/useDialogue' import { canEditContent } from '@/lib/users' import { clearPostImportSourceDraft, + buildNextEditedRow, canEditResultRow, canRetryResultRow, hasExactSourceRows, @@ -43,54 +44,6 @@ import type { User } from '@/types' type Props = { user: User | null } -const buildNextEditedRow = ( - editingRow: PostImportRow, - draft: PostImportRowDraft, - urlChanged: boolean, -): PostImportRow => { - const nextProvenance = { ...editingRow.provenance } - const nextAttributes = { ...editingRow.attributes } - const nextTagSources = { - automatic: editingRow.tagSources?.automatic ?? '', - manual: editingRow.tagSources?.manual ?? '' } - const draftFields = [ - ['title', draft.title], - ['thumbnailBase', draft.thumbnailBase], - ['originalCreatedFrom', draft.originalCreatedFrom], - ['originalCreatedBefore', draft.originalCreatedBefore], - ['duration', draft.duration], - ['parentPostIds', draft.parentPostIds]] as const - draftFields.forEach (([field, value]) => { - nextAttributes[field] = value - nextProvenance[field] = - value !== String (editingRow.attributes[field] ?? '') - ? 'manual' - : (editingRow.provenance[field] ?? 'automatic') - }) - nextAttributes.tags = draft.tags - if (draft.tags !== String (editingRow.attributes.tags ?? '')) - { - nextProvenance.tags = 'manual' - nextTagSources.manual = draft.tags - } - else - { - nextProvenance.tags = editingRow.provenance.tags ?? 'automatic' - nextTagSources.manual = editingRow.tagSources?.manual ?? '' - } - - return { - ...editingRow, - url: draft.url, - attributes: nextAttributes, - provenance: { - ...nextProvenance, - url: urlChanged ? 'manual' : (editingRow.provenance.url ?? 'manual') }, - tagSources: nextTagSources, - importStatus: editingRow.importStatus === 'created' ? 'created' : 'pending', - importErrors: undefined } -} - const PostImportResultPage: FC = ({ user }) => { const editable = canEditContent (user) @@ -186,7 +139,8 @@ const PostImportResultPage: FC = ({ user }) => { metadataUrl: row.metadataUrl })), changed_row: urlChanged ? baseRow.sourceRow : -1 }) const validatedRows = initialisePreviewRows (validated.rows) - const target = validatedRows.find (_1 => _1.sourceRow === baseRow.sourceRow) + const target = validatedRows.find ( + validatedRow => validatedRow.sourceRow === baseRow.sourceRow) const latestSession = sessionRef.current if (latestSession == null) return { saved: false, row: null } @@ -211,7 +165,9 @@ const PostImportResultPage: FC = ({ user }) => { sessionRef.current = nextSession setSession (nextSession) persistSession (nextSession) - const mergedTarget = rows.find (_1 => _1.sourceRow === baseRow.sourceRow) ?? target + const mergedTarget = + rows.find (mergedRow => mergedRow.sourceRow === baseRow.sourceRow) + ?? target if (Object.keys (mergedTarget.validationErrors).length > 0) return { saved: false, row: mergedTarget } return { saved: true, row: null } @@ -267,7 +223,7 @@ const PostImportResultPage: FC = ({ user }) => { return setLoadingRow (sourceRow) - const originalRow = initialSession.rows.find (_1 => _1.sourceRow === sourceRow) + const originalRow = initialSession.rows.find (row => row.sourceRow === sourceRow) if (originalRow == null) { setLoadingRow (null) @@ -305,7 +261,8 @@ const PostImportResultPage: FC = ({ user }) => { toast ({ title: '再検証結果が不完全でした' }) return } - const validatedTarget = validatedRows.find (_1 => _1.sourceRow === sourceRow) + const validatedTarget = validatedRows.find ( + row => row.sourceRow === sourceRow) if (validatedTarget == null) { const latest = sessionRef.current ?? pendingSession @@ -331,7 +288,7 @@ const PostImportResultPage: FC = ({ user }) => { sessionRef.current = nextSession setSession (nextSession) persistSession (nextSession) - const target = mergedValidatedRows.find (_1 => _1.sourceRow === sourceRow) + const target = mergedValidatedRows.find (row => row.sourceRow === sourceRow) if (target == null) { const restoredRows = replaceImportRow (latestAfterValidate.rows, originalRow) @@ -387,7 +344,8 @@ const PostImportResultPage: FC = ({ user }) => { && row.recoverable && Object.keys (row.errors ?? { }).length > 0) const nextRows = mergedRows.map ((row): PostImportRow => { - const recoverable = recoverableRows.find (_1 => _1.sourceRow === row.sourceRow) + const recoverable = recoverableRows.find ( + recoverableRow => recoverableRow.sourceRow === row.sourceRow) if (recoverable == null) return row return { @@ -397,7 +355,7 @@ const PostImportResultPage: FC = ({ user }) => { validationErrors: recoverable.errors ?? { }, importErrors: undefined } }) - const recoverableTarget = nextRows.find (_1 => _1.sourceRow === sourceRow) + const recoverableTarget = nextRows.find (row => row.sourceRow === sourceRow) const resultSession = { ...nextSession, rows: nextRows, @@ -449,7 +407,7 @@ const PostImportResultPage: FC = ({ user }) => { toast ({ title: '取込状態を保存できませんでした', description: message })) if (!(saved)) return - const row = nextSession.rows.find (_1 => _1.sourceRow === sourceRow) + const row = nextSession.rows.find (rowValue => rowValue.sourceRow === sourceRow) if (row != null) void editRow (row) } diff --git a/frontend/src/pages/posts/PostImportReviewPage.test.tsx b/frontend/src/pages/posts/PostImportReviewPage.test.tsx index 808f828..de16bca 100644 --- a/frontend/src/pages/posts/PostImportReviewPage.test.tsx +++ b/frontend/src/pages/posts/PostImportReviewPage.test.tsx @@ -3,11 +3,12 @@ import { HelmetProvider } from 'react-helmet-async' import { MemoryRouter, Route, Routes } from 'react-router-dom' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { savePostImportSession } from '@/lib/postImportSession' +import { loadPostImportSession, savePostImportSession } from '@/lib/postImportSession' import PostImportReviewPage from '@/pages/posts/PostImportReviewPage' import { buildUser } from '@/test/factories' import { buildPostImportRow } from '@/test/postImportFactories' +import type { DialogueFormAction, DialogueFormControls } from '@/lib/dialogues/useDialogue' import type { PostImportRow } from '@/lib/postImportSession' const api = vi.hoisted (() => ({ @@ -178,4 +179,82 @@ describe ('PostImportReviewPage', () => { }) expect (screen.queryByText ('RESULT ROUTE')).not.toBeInTheDocument () }) + + it ('keeps edited recoverable rows in session before the next batch submit', async () => { + savePostImportSession ('review-edit-retry', { + source: 'https://example.com/post', + 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: 0, + skipped: 0, + failed: 1, + rows: [{ + sourceRow: 1, + status: 'failed', + recoverable: true, + errors: { title: ['invalid'] } }] }) + 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 actions.find (action => action.label === '編輯内容を保存')?.onSelect () + }) + + render ( + + + + }/> + RESULT ROUTE}/> + + + ) + + fireEvent.click (screen.getByRole ('button', { name: '編輯' })) + + await waitFor (() => { + const saved = loadPostImportSession ('review-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[1]?.[1]?.rows?.[0]?.attributes?.title) + .toBe ('edited title') + }) + }) }) diff --git a/frontend/src/pages/posts/PostImportReviewPage.tsx b/frontend/src/pages/posts/PostImportReviewPage.tsx index 0eb8faf..fce3b22 100644 --- a/frontend/src/pages/posts/PostImportReviewPage.tsx +++ b/frontend/src/pages/posts/PostImportReviewPage.tsx @@ -13,6 +13,7 @@ import { apiPost } from '@/lib/api' import useDialogue from '@/lib/dialogues/useDialogue' import { canEditContent } from '@/lib/users' import { loadPostImportSession, + buildNextEditedRow, canEditReviewRow, creatableImportRows, hasExactSourceRows, @@ -145,7 +146,8 @@ const PostImportReviewPage: FC = ({ user }) => { metadataUrl: row.metadataUrl })), changed_row: urlChanged ? baseRow.sourceRow : -1 }) const validatedRows = initialisePreviewRows (validated.rows) - const target = validatedRows.find (_1 => _1.sourceRow === baseRow.sourceRow) + const target = validatedRows.find ( + validatedRow => validatedRow.sourceRow === baseRow.sourceRow) const latestSession = sessionRef.current if (latestSession == null) return { saved: false, row: null } @@ -161,14 +163,17 @@ const PostImportReviewPage: FC = ({ user }) => { toast ({ title: '行の再検証結果が不完全でした' }) return { saved: false, row: null } } - const rows = mergeValidatedImportRow (latestSession.rows, target) + const editedRows = replaceImportRow (latestSession.rows, nextRow) + const rows = mergeValidatedImportRow (editedRows, target) const nextSession = { ...latestSession, rows, repairMode: resultRepairMode (rows) } sessionRef.current = nextSession setSession (nextSession) - const mergedTarget = rows.find (_1 => _1.sourceRow === baseRow.sourceRow) ?? target + const mergedTarget = + rows.find (mergedRow => mergedRow.sourceRow === baseRow.sourceRow) + ?? target if (Object.keys (mergedTarget.validationErrors).length > 0) return { saved: false, row: mergedTarget } return { saved: true, row: null } @@ -411,52 +416,4 @@ const PostImportFooter = ( ) -const buildNextEditedRow = ( - editingRow: PostImportRow, - draft: PostImportRowDraft, - urlChanged: boolean, -): PostImportRow => { - const nextProvenance = { ...editingRow.provenance } - const nextAttributes = { ...editingRow.attributes } - const nextTagSources = { - automatic: editingRow.tagSources?.automatic ?? '', - manual: editingRow.tagSources?.manual ?? '' } - const draftFields = [ - ['title', draft.title], - ['thumbnailBase', draft.thumbnailBase], - ['originalCreatedFrom', draft.originalCreatedFrom], - ['originalCreatedBefore', draft.originalCreatedBefore], - ['duration', draft.duration], - ['parentPostIds', draft.parentPostIds]] as const - draftFields.forEach (([field, value]) => { - nextAttributes[field] = value - nextProvenance[field] = - value !== String (editingRow.attributes[field] ?? '') - ? 'manual' - : (editingRow.provenance[field] ?? 'automatic') - }) - nextAttributes.tags = draft.tags - if (draft.tags !== String (editingRow.attributes.tags ?? '')) - { - nextProvenance.tags = 'manual' - nextTagSources.manual = draft.tags - } - else - { - nextProvenance.tags = editingRow.provenance.tags ?? 'automatic' - nextTagSources.manual = editingRow.tagSources?.manual ?? '' - } - - return { - ...editingRow, - url: draft.url, - attributes: nextAttributes, - provenance: { - ...nextProvenance, - url: urlChanged ? 'manual' : (editingRow.provenance.url ?? 'manual') }, - tagSources: nextTagSources, - importStatus: editingRow.importStatus === 'created' ? 'created' : 'pending', - importErrors: undefined } -} - export default PostImportReviewPage diff --git a/frontend/src/pages/posts/PostImportSourcePage.test.tsx b/frontend/src/pages/posts/PostImportSourcePage.test.tsx index a8d5af5..f38eb57 100644 --- a/frontend/src/pages/posts/PostImportSourcePage.test.tsx +++ b/frontend/src/pages/posts/PostImportSourcePage.test.tsx @@ -86,7 +86,8 @@ describe ('PostImportSourcePage', () => { expect.stringMatching (/^\/posts\/import\/[^/]+\/review$/)) }) const sessionKeys = Array.from ({ length: sessionStorage.length }, (_, index) => - sessionStorage.key (index)).filter (_1 => _1?.startsWith ('post-import-session:')) + sessionStorage.key (index)).filter ( + key => key?.startsWith ('post-import-session:')) expect (sessionKeys).toHaveLength (1) }) }) diff --git a/frontend/src/pages/posts/PostImportSourcePage.tsx b/frontend/src/pages/posts/PostImportSourcePage.tsx index 8015b72..a5a8823 100644 --- a/frontend/src/pages/posts/PostImportSourcePage.tsx +++ b/frontend/src/pages/posts/PostImportSourcePage.tsx @@ -60,7 +60,7 @@ const PostImportSourcePage: FC = ({ user }) => { const sourceDescribedBy = [sourceError != null ? SOURCE_ERROR_ID : null, sourceIssues.length > 0 ? SOURCE_ISSUES_ID : null] - .filter (_1 => _1 != null) + .filter (value => value != null) .join (' ') useEffect (() => { diff --git a/frontend/src/pages/posts/PostNewPage.test.tsx b/frontend/src/pages/posts/PostNewPage.test.tsx index 61fecc2..31e8151 100644 --- a/frontend/src/pages/posts/PostNewPage.test.tsx +++ b/frontend/src/pages/posts/PostNewPage.test.tsx @@ -1,8 +1,7 @@ -import { readFileSync } from 'node:fs' - import { fireEvent, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { toMinutePrecisionIsoUtc } from '@/components/common/DateTimeField' import PostNewPage from '@/pages/posts/PostNewPage' import { buildUser } from '@/test/factories' import { renderWithProviders } from '@/test/render' @@ -108,15 +107,31 @@ describe ('PostNewPage', () => { expect (screen.getAllByRole ('textbox')[4]).toHaveAttribute ('aria-invalid', 'true') }) - it ('shares the common post field components with the import form', () => { - const newPage = readFileSync ('src/pages/posts/PostNewPage.tsx', 'utf8') - const importForm = readFileSync ('src/components/posts/import/PostImportRowForm.tsx', 'utf8') + it ('uses the shared duration, tags, and datetime value contract for post submission', async () => { + api.apiPost.mockResolvedValueOnce ({}) + api.apiGet.mockResolvedValue ([]) - expect (newPage).toContain ("@/components/posts/PostTextField") - expect (newPage).toContain ("@/components/posts/PostDurationField") - expect (newPage).toContain ("@/components/posts/PostTagsField") - expect (importForm).toContain ("@/components/posts/PostTextField") - expect (importForm).toContain ("@/components/posts/PostDurationField") - expect (importForm).toContain ("@/components/posts/PostTagsField") + const { container } = renderWithProviders () + + 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')) }) })