このコミットが含まれているのは:
@@ -20,8 +20,10 @@ describe ('PostOriginalCreatedTimeField', () => {
|
||||
fireEvent.change (inputs[0], { target: { value: '2026-01-02T03:04' } })
|
||||
fireEvent.change (inputs[1], { target: { value: '2026-01-03T03:04' } })
|
||||
|
||||
expect (setFrom).toHaveBeenCalledWith (expect.any (String))
|
||||
expect (setBefore).toHaveBeenCalledWith (expect.any (String))
|
||||
expect (setFrom).toHaveBeenCalledWith (expect.stringMatching (
|
||||
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}Z$/))
|
||||
expect (setBefore).toHaveBeenCalledWith (expect.stringMatching (
|
||||
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}Z$/))
|
||||
})
|
||||
|
||||
it ('infers an exclusive before value on blur', () => {
|
||||
@@ -38,7 +40,26 @@ describe ('PostOriginalCreatedTimeField', () => {
|
||||
const input = screen.getAllByDisplayValue ('')[0]
|
||||
fireEvent.blur (input, { target: { value: '2026-01-02T03:04' } })
|
||||
|
||||
expect (setBefore).toHaveBeenCalledWith (expect.any (String))
|
||||
const value = setBefore.mock.calls.at (-1)?.[0]
|
||||
expect (value).toMatch (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}Z$/)
|
||||
expect (new Date (value).getTime () - new Date ('2026-01-02T03:04').getTime ())
|
||||
.toBe (60_000)
|
||||
})
|
||||
|
||||
it ('normalises second-bearing values to minute precision', () => {
|
||||
const setFrom = vi.fn ()
|
||||
const setBefore = vi.fn ()
|
||||
|
||||
render (
|
||||
<PostOriginalCreatedTimeField
|
||||
originalCreatedFrom="2026-01-01T00:00:05Z"
|
||||
setOriginalCreatedFrom={setFrom}
|
||||
originalCreatedBefore="2026-01-02T00:00:00.123Z"
|
||||
setOriginalCreatedBefore={setBefore}/>,
|
||||
)
|
||||
|
||||
expect (setFrom).toHaveBeenCalledWith ('2026-01-01T00:00Z')
|
||||
expect (setBefore).toHaveBeenCalledWith ('2026-01-02T00:00Z')
|
||||
})
|
||||
|
||||
it ('resets both values', () => {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import DateTimeField from '@/components/common/DateTimeField'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
import DateTimeField, { toMinutePrecisionIsoUtc } from '@/components/common/DateTimeField'
|
||||
import FormField from '@/components/common/FormField'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
@@ -19,72 +21,92 @@ const PostOriginalCreatedTimeField: FC<Props> = (
|
||||
setOriginalCreatedFrom,
|
||||
originalCreatedBefore,
|
||||
setOriginalCreatedBefore,
|
||||
errors }: Props) => (
|
||||
<FormField label="オリジナルの作成日時" messages={errors}>
|
||||
{({ describedBy, invalid }) => (
|
||||
<>
|
||||
<div className="my-1 flex flex-col gap-2 sm:flex-row sm:items-start">
|
||||
<div className="min-w-0 flex-1">
|
||||
<DateTimeField
|
||||
className="w-full"
|
||||
disabled={disabled ?? false}
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={invalid}
|
||||
invalid={invalid}
|
||||
value={originalCreatedFrom ?? undefined}
|
||||
onChange={setOriginalCreatedFrom}
|
||||
onBlur={ev => {
|
||||
const v = ev.target.value
|
||||
if (!(v))
|
||||
return
|
||||
errors }: Props) => {
|
||||
useEffect (() => {
|
||||
if (originalCreatedFrom == null)
|
||||
return
|
||||
|
||||
const d = new Date (v)
|
||||
if (d.getMinutes () === 0 && d.getHours () === 0)
|
||||
d.setDate (d.getDate () + 1)
|
||||
else
|
||||
d.setMinutes (d.getMinutes () + 1)
|
||||
setOriginalCreatedBefore (d.toISOString ())
|
||||
}}/>
|
||||
以降
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
type="button"
|
||||
className="bg-gray-600 text-white rounded"
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
setOriginalCreatedFrom (null)
|
||||
}}>
|
||||
リセット
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
const normalised = toMinutePrecisionIsoUtc (originalCreatedFrom)
|
||||
if (normalised !== originalCreatedFrom)
|
||||
setOriginalCreatedFrom (normalised)
|
||||
}, [originalCreatedFrom, setOriginalCreatedFrom])
|
||||
|
||||
<div className="my-1 flex flex-col gap-2 sm:flex-row sm:items-start">
|
||||
<div className="min-w-0 flex-1">
|
||||
<DateTimeField
|
||||
className="w-full"
|
||||
disabled={disabled}
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={invalid}
|
||||
invalid={invalid}
|
||||
value={originalCreatedBefore ?? undefined}
|
||||
onChange={setOriginalCreatedBefore}/>
|
||||
より前
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
type="button"
|
||||
className="bg-gray-600 text-white rounded"
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
setOriginalCreatedBefore (null)
|
||||
}}>
|
||||
リセット
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>)}
|
||||
</FormField>)
|
||||
useEffect (() => {
|
||||
if (originalCreatedBefore == null)
|
||||
return
|
||||
|
||||
const normalised = toMinutePrecisionIsoUtc (originalCreatedBefore)
|
||||
if (normalised !== originalCreatedBefore)
|
||||
setOriginalCreatedBefore (normalised)
|
||||
}, [originalCreatedBefore, setOriginalCreatedBefore])
|
||||
|
||||
return (
|
||||
<FormField label="オリジナルの作成日時" messages={errors}>
|
||||
{({ describedBy, invalid }) => (
|
||||
<>
|
||||
<div className="my-1 flex flex-col gap-2 sm:flex-row sm:items-start">
|
||||
<div className="min-w-0 flex-1">
|
||||
<DateTimeField
|
||||
className="w-full"
|
||||
disabled={disabled ?? false}
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={invalid}
|
||||
invalid={invalid}
|
||||
value={originalCreatedFrom ?? undefined}
|
||||
onChange={setOriginalCreatedFrom}
|
||||
onBlur={ev => {
|
||||
const v = ev.target.value
|
||||
if (!(v))
|
||||
return
|
||||
|
||||
const d = new Date (v)
|
||||
if (d.getMinutes () === 0 && d.getHours () === 0)
|
||||
d.setDate (d.getDate () + 1)
|
||||
else
|
||||
d.setMinutes (d.getMinutes () + 1)
|
||||
setOriginalCreatedBefore (toMinutePrecisionIsoUtc (d.toISOString ()))
|
||||
}}/>
|
||||
以降
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
type="button"
|
||||
className="bg-gray-600 text-white rounded"
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
setOriginalCreatedFrom (null)
|
||||
}}>
|
||||
リセット
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="my-1 flex flex-col gap-2 sm:flex-row sm:items-start">
|
||||
<div className="min-w-0 flex-1">
|
||||
<DateTimeField
|
||||
className="w-full"
|
||||
disabled={disabled}
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={invalid}
|
||||
invalid={invalid}
|
||||
value={originalCreatedBefore ?? undefined}
|
||||
onChange={setOriginalCreatedBefore}/>
|
||||
より前
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
type="button"
|
||||
className="bg-gray-600 text-white rounded"
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
setOriginalCreatedBefore (null)
|
||||
}}>
|
||||
リセット
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>)}
|
||||
</FormField>)
|
||||
}
|
||||
|
||||
export default PostOriginalCreatedTimeField
|
||||
|
||||
@@ -21,7 +21,8 @@ describe ('DateTimeField', () => {
|
||||
fireEvent.change (input, { target: { value: '' } })
|
||||
|
||||
const first = handleChange.mock.calls[0]?.[0]
|
||||
expect (new Date (first).getFullYear ()).toBe (2026)
|
||||
expect (first).toMatch (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}Z$/)
|
||||
expect (new Date (first).getUTCSeconds ()).toBe (0)
|
||||
expect (handleChange).toHaveBeenLastCalledWith (null)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,13 +8,27 @@ import type { ComponentPropsWithoutRef, FC, FocusEvent } from 'react'
|
||||
const pad = (n: number): string => n.toString ().padStart (2, '0')
|
||||
|
||||
|
||||
const toDateTimeLocalValue = (d: Date) => {
|
||||
const y = d.getFullYear ()
|
||||
const m = pad (d.getMonth () + 1)
|
||||
const day = pad (d.getDate ())
|
||||
const h = pad (d.getHours ())
|
||||
const min = pad (d.getMinutes ())
|
||||
return `${ y }-${ m }-${ day }T${ h }:${ min }:00`
|
||||
const toDateTimeLocalValue = (value: Date) => {
|
||||
const y = value.getFullYear ()
|
||||
const m = pad (value.getMonth () + 1)
|
||||
const day = pad (value.getDate ())
|
||||
const h = pad (value.getHours ())
|
||||
const min = pad (value.getMinutes ())
|
||||
return `${ y }-${ m }-${ day }T${ h }:${ min }`
|
||||
}
|
||||
|
||||
|
||||
const toMinutePrecisionIsoUtc = (value: string) => {
|
||||
const date = new Date (value)
|
||||
if (Number.isNaN (date.getTime ()))
|
||||
return value
|
||||
|
||||
const y = date.getUTCFullYear ()
|
||||
const m = pad (date.getUTCMonth () + 1)
|
||||
const day = pad (date.getUTCDate ())
|
||||
const h = pad (date.getUTCHours ())
|
||||
const min = pad (date.getUTCMinutes ())
|
||||
return `${ y }-${ m }-${ day }T${ h }:${ min }Z`
|
||||
}
|
||||
|
||||
|
||||
@@ -47,14 +61,16 @@ const DateTimeField: FC<Props> = ({ value, onChange, className, onBlur, invalid,
|
||||
'focus:ring-2 focus:ring-blue-200']),
|
||||
className)}
|
||||
type="datetime-local"
|
||||
step={60}
|
||||
value={local}
|
||||
aria-invalid={invalid}
|
||||
onChange={ev => {
|
||||
const v = ev.target.value
|
||||
setLocal (v)
|
||||
onChange?.(v ? (new Date (v)).toISOString () : null)
|
||||
onChange?.(v ? toMinutePrecisionIsoUtc (v) : null)
|
||||
}}
|
||||
onBlur={onBlur}/>)
|
||||
}
|
||||
|
||||
export default DateTimeField
|
||||
export { toMinutePrecisionIsoUtc }
|
||||
|
||||
@@ -52,7 +52,7 @@ const buildResetDraft = (row: PostImportRow): Draft => ({
|
||||
parentPostIds: String (row.resetSnapshot.attributes.parentPostIds ?? '') })
|
||||
|
||||
const groupedMessages = (...values: (string[] | undefined)[]): string[] =>
|
||||
values.flatMap (value => value ?? [])
|
||||
[...new Set (values.flatMap (value => value ?? []))]
|
||||
|
||||
const sameDraft = (left: Draft, right: Draft): boolean =>
|
||||
left.url === right.url
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { cn, originalCreatedAtString } from '@/lib/utils'
|
||||
|
||||
import PostImportStatusBadge from '@/components/posts/import/PostImportStatusBadge'
|
||||
import ThumbnailPreview from '@/components/posts/import/ThumbnailPreview'
|
||||
@@ -19,9 +19,9 @@ const summaryWarning = (row: PostImportRow): string | null =>
|
||||
?? null
|
||||
|
||||
const summaryDate = (row: PostImportRow): string =>
|
||||
[row.attributes.originalCreatedFrom, row.attributes.originalCreatedBefore]
|
||||
.filter (_1 => typeof _1 === 'string' && _1 !== '')
|
||||
.join (' ~ ')
|
||||
originalCreatedAtString (
|
||||
row.attributes.originalCreatedFrom?.toString () ?? null,
|
||||
row.attributes.originalCreatedBefore?.toString () ?? null)
|
||||
|
||||
|
||||
const PostImportRowSummary: FC<Props> = ({ row, onEdit }) => {
|
||||
@@ -52,7 +52,7 @@ const PostImportRowSummary: FC<Props> = ({ row, onEdit }) => {
|
||||
{String (row.attributes.tags ?? '') || 'タグなし'}
|
||||
</div>
|
||||
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{summaryDate (row) || '日時未取得'}
|
||||
{summaryDate (row)}
|
||||
{row.attributes.duration ? ` / ${ row.attributes.duration }` : ''}
|
||||
</div>
|
||||
{warning && (
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { fireEvent, screen } from '@testing-library/react'
|
||||
import { Route, Routes, useLocation } from 'react-router-dom'
|
||||
import { Route, Routes } from 'react-router-dom'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { savePostImportSession } from '@/lib/postImportSession'
|
||||
import PostImportResultPage from '@/pages/posts/PostImportResultPage'
|
||||
import PageTitle from '@/components/common/PageTitle'
|
||||
import { buildUser } from '@/test/factories'
|
||||
import { buildPostImportRow } from '@/test/postImportFactories'
|
||||
import { renderWithProviders } from '@/test/render'
|
||||
@@ -14,17 +15,12 @@ const toastApi = vi.hoisted (() => ({ toast: vi.fn () }))
|
||||
vi.mock ('@/lib/api', () => api)
|
||||
vi.mock ('@/components/ui/use-toast', () => toastApi)
|
||||
|
||||
const ReviewLocation = () => {
|
||||
const location = useLocation ()
|
||||
return <div>{`review route ${ location.search }`}</div>
|
||||
}
|
||||
|
||||
const renderPage = () => renderWithProviders (
|
||||
<Routes>
|
||||
<Route
|
||||
path="/posts/import/:sessionId/result"
|
||||
element={<PostImportResultPage user={buildUser ()}/>}/>
|
||||
<Route path="/posts/import/:sessionId/review" element={<ReviewLocation/>}/>
|
||||
<Route path="/posts/import/:sessionId/review" element={<PageTitle>review route</PageTitle>}/>
|
||||
</Routes>,
|
||||
{ route: '/posts/import/session/result' })
|
||||
|
||||
@@ -54,7 +50,7 @@ describe ('PostImportResultPage', () => {
|
||||
expect (screen.getByRole ('button', { name: '再試行' })).toBeInTheDocument ()
|
||||
})
|
||||
|
||||
it ('returns a retry validation error to the review dialogue route', async () => {
|
||||
it ('keeps the result route and opens the dialogue on retry validation error', async () => {
|
||||
const failed = buildPostImportRow ({
|
||||
importStatus: 'failed',
|
||||
importErrors: { base: ['old error'] } })
|
||||
@@ -71,7 +67,8 @@ describe ('PostImportResultPage', () => {
|
||||
renderPage ()
|
||||
fireEvent.click (await screen.findByRole ('button', { name: '再試行' }))
|
||||
|
||||
expect (await screen.findByText ('review route ?edit=1')).toBeInTheDocument ()
|
||||
expect (await screen.findByText ('投稿を編輯')).toBeInTheDocument ()
|
||||
expect (screen.getByText ('タイトルを確認してください.')).toBeInTheDocument ()
|
||||
expect (api.apiPost).toHaveBeenCalledTimes (1)
|
||||
const saved = JSON.parse (
|
||||
sessionStorage.getItem ('post-import-session:session') ?? '{}')
|
||||
@@ -79,4 +76,25 @@ describe ('PostImportResultPage', () => {
|
||||
importStatus: 'pending',
|
||||
validationErrors: { title: ['タイトルを確認してください.'] } })
|
||||
})
|
||||
|
||||
it ('keeps the dialogue open when result-page save validation fails', async () => {
|
||||
const row = buildPostImportRow ({
|
||||
importStatus: 'failed',
|
||||
importErrors: { base: ['old error'] } })
|
||||
savePostImportSession ('session', { source: row.url, rows: [row], repairMode: 'failed' })
|
||||
api.apiPost.mockResolvedValue ({
|
||||
rows: [buildPostImportRow ({
|
||||
sourceRow: row.sourceRow,
|
||||
validationErrors: {
|
||||
originalCreatedFrom: ['オリジナルの作成日時は分単位で入力してください.'] } })] })
|
||||
|
||||
renderPage ()
|
||||
fireEvent.click (await screen.findByRole ('button', { name: '編輯' }))
|
||||
fireEvent.click (await screen.findByRole ('button', { name: '編輯内容を保存' }))
|
||||
|
||||
expect (await screen.findByText ('投稿を編輯')).toBeInTheDocument ()
|
||||
expect (screen.getByText ('オリジナルの作成日時は分単位で入力してください.'))
|
||||
.toBeInTheDocument ()
|
||||
expect (api.apiPost).toHaveBeenCalledTimes (1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -75,4 +75,23 @@ describe ('PostImportReviewPage', () => {
|
||||
skipReason: 'existing' })
|
||||
})
|
||||
})
|
||||
|
||||
it ('keeps the dialogue open when save validation fails', async () => {
|
||||
const row = buildPostImportRow ({ attributes: { title: 'title' } })
|
||||
savePostImportSession ('session', { source: row.url, rows: [row], repairMode: 'all' })
|
||||
api.apiPost.mockResolvedValue ({
|
||||
rows: [buildPostImportRow ({
|
||||
sourceRow: row.sourceRow,
|
||||
validationErrors: {
|
||||
originalCreatedAt: ['オリジナルの作成日時の範囲は1分以上必要です.'] } })] })
|
||||
|
||||
renderPage ()
|
||||
fireEvent.click (await screen.findByRole ('button', { name: '編輯' }))
|
||||
fireEvent.click (await screen.findByRole ('button', { name: '編輯内容を保存' }))
|
||||
|
||||
expect (await screen.findByText ('投稿を編輯')).toBeInTheDocument ()
|
||||
expect (screen.getByText ('オリジナルの作成日時の範囲は1分以上必要です.'))
|
||||
.toBeInTheDocument ()
|
||||
expect (api.apiPost).toHaveBeenCalledTimes (1)
|
||||
})
|
||||
})
|
||||
|
||||
新しい課題から参照
ユーザをブロックする