diff --git a/backend/app/services/post_metadata_fetcher.rb b/backend/app/services/post_metadata_fetcher.rb index 2db41b4..5dbff94 100644 --- a/backend/app/services/post_metadata_fetcher.rb +++ b/backend/app/services/post_metadata_fetcher.rb @@ -108,7 +108,12 @@ class PostMetadataFetcher end from = timestamp.change(sec: 0, nsec: 0) - before = from + 1.minute + before = + if match[5].nil? + from + 1.hour + else + from + 1.minute + end [from, before] end diff --git a/backend/spec/services/post_metadata_fetcher_spec.rb b/backend/spec/services/post_metadata_fetcher_spec.rb index bbc6575..94da2bd 100644 --- a/backend/spec/services/post_metadata_fetcher_spec.rb +++ b/backend/spec/services/post_metadata_fetcher_spec.rb @@ -49,6 +49,20 @@ RSpec.describe PostMetadataFetcher do expect(result.fetch(:original_created_from)).not_to include('.') end + it 'treats hour-precision timestamps as one-hour ranges with and without offsets' do + offset_result = fetch_with_published_time('2024-02-03T12+02:30') + local_result = fetch_with_published_time('2024-02-03T12') + + expect(Time.iso8601(offset_result.fetch(:original_created_from))) + .to eq(Time.iso8601('2024-02-03T12:00:00+02:30')) + expect( + Time.iso8601(offset_result.fetch(:original_created_before)) - + Time.iso8601(offset_result.fetch(:original_created_from)) + ).to eq(3600) + expect(local_result.fetch(:original_created_from)).to eq('2024-02-03T12:00:00Z') + expect(local_result.fetch(:original_created_before)).to eq('2024-02-03T13:00:00Z') + end + it 'adds platform tags for known video URLs' do result = fetch_with_published_time('2024', url: 'https://youtu.be/abc123') diff --git a/frontend/src/components/PostEditForm.test.tsx b/frontend/src/components/PostEditForm.test.tsx index 0523b70..fe9eefc 100644 --- a/frontend/src/components/PostEditForm.test.tsx +++ b/frontend/src/components/PostEditForm.test.tsx @@ -89,4 +89,28 @@ describe ('PostEditForm', () => { }) expect (screen.getByPlaceholderText ('例: 2 / 2.5 / 1:23')).toHaveValue ('180.5') }) + + it ('shows deduplicated original-created endpoint errors on the shared datetime field', async () => { + const post = buildPost () + api.isApiError.mockReturnValue (true) + postsApi.updatePost.mockRejectedValueOnce ({ + response: { + status: 422, + data: { + type: 'validation_error', + errors: { + original_created_at: ['日時を確認してください.'], + original_created_from: ['日時を確認してください.'], + original_created_before: ['終了を確認してください.'] }, + }, + }, + }) + + render () + fireEvent.submit (screen.getByRole ('button', { name: '更新' }).closest ('form')!) + + expect (await screen.findByText ('日時を確認してください.')).toBeInTheDocument () + expect (screen.getByText ('終了を確認してください.')).toBeInTheDocument () + expect (screen.getAllByText ('日時を確認してください.')).toHaveLength (1) + }) }) diff --git a/frontend/src/components/PostEditForm.tsx b/frontend/src/components/PostEditForm.tsx index 62e8d4b..6d3fe3f 100644 --- a/frontend/src/components/PostEditForm.tsx +++ b/frontend/src/components/PostEditForm.tsx @@ -18,7 +18,11 @@ import type { FC, FormEvent } from 'react' import type { Post, TagWithSections } from '@/types' type PostFormField = - 'parentPostIds' | 'tags' | 'videoMs' | 'originalCreatedAt' + 'parentPostIds' | 'tags' | 'videoMs' + | 'originalCreatedAt' | 'originalCreatedFrom' | 'originalCreatedBefore' + +const groupedMessages = (...values: (string[] | undefined)[]): string[] => + [...new Set (values.flatMap (value => value ?? []))] const videoMsToDurationValue = (videoMs: number | null): string => videoMs == null ? '' : String (videoMs / 1_000) @@ -184,7 +188,10 @@ const PostEditForm: FC = ({ post, onSave }) => { setOriginalCreatedFrom={setOriginalCreatedFrom} originalCreatedBefore={originalCreatedBefore} setOriginalCreatedBefore={setOriginalCreatedBefore} - errors={fieldErrors.originalCreatedAt}/> + errors={groupedMessages ( + fieldErrors.originalCreatedAt, + fieldErrors.originalCreatedFrom, + fieldErrors.originalCreatedBefore)}/> {videoFlg && ( { + it ('keeps an existing blob preview URL unchanged for normal post forms', () => { + render () + + expect (screen.getByRole ('img')).toHaveAttribute ('src', 'blob:preview') + }) +}) diff --git a/frontend/src/components/posts/import/PostImportRowForm.test.tsx b/frontend/src/components/posts/import/PostImportRowForm.test.tsx index e4f3d08..bb7fb8a 100644 --- a/frontend/src/components/posts/import/PostImportRowForm.test.tsx +++ b/frontend/src/components/posts/import/PostImportRowForm.test.tsx @@ -1,12 +1,25 @@ import { act, render, screen, waitFor } from '@testing-library/react' -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import PostImportRowForm from '@/components/posts/import/PostImportRowForm' import { buildPostImportRow } from '@/test/postImportFactories' import type { DialogueFormAction, DialogueFormControls } from '@/lib/dialogues/useDialogue' +const api = vi.hoisted (() => ({ + apiGet: vi.fn (), +})) + +vi.mock ('@/lib/api', () => api) + describe ('PostImportRowForm', () => { + beforeEach (() => { + vi.clearAllMocks () + globalThis.URL.createObjectURL = vi.fn (() => 'blob:preview') + globalThis.URL.revokeObjectURL = vi.fn () + api.apiGet.mockResolvedValue (new Blob (['img'], { type: 'image/png' })) + }) + it ('resets only the draft, then saves with resetRequested', async () => { const row = buildPostImportRow () row.attributes.title = 'manual title' @@ -111,7 +124,7 @@ describe ('PostImportRowForm', () => { await waitFor (() => expect (actions.length).toBe (2)) await act (async () => { - await actions.find (action => action.label === '編輯内容を保存')?.onSelect () + void actions.find (action => action.label === '編輯内容を保存')?.onSelect () }) expect (onSave).toHaveBeenCalledWith ({ @@ -153,4 +166,96 @@ describe ('PostImportRowForm', () => { tags: 'tag1' }), resetRequested: false }) }) + + it ('keeps reset enabled when the value matches but provenance still differs', async () => { + const row = buildPostImportRow ({ + attributes: { title: 'same title' }, + provenance: { title: 'manual' }, + resetSnapshot: { + url: 'https://example.com/post', + attributes: { + title: 'same title', + thumbnailBase: '', + originalCreatedFrom: '', + originalCreatedBefore: '', + duration: '', + tags: '', + parentPostIds: '' }, + provenance: { + url: 'manual', + title: 'automatic', + thumbnailBase: 'automatic', + originalCreatedFrom: 'automatic', + originalCreatedBefore: 'automatic', + duration: 'automatic', + tags: 'automatic', + parentPostIds: 'automatic' }, + tagSources: { automatic: '', manual: '' }, + fieldWarnings: { }, + baseWarnings: [] } }) + let actions: DialogueFormAction[] = [] + + render ( + { + actions = next + } }} + onSave={vi.fn ()}/>) + await waitFor (() => expect (actions.length).toBe (2)) + + expect (actions.find (action => action.label === '変更をリセット')?.disabled).toBe (false) + }) + + it ('disables every field while save validation is pending and re-enables them afterwards', async () => { + let actions: DialogueFormAction[] = [] + let resolveSave: + ((value: { saved: boolean + row: ReturnType | null }) => void) | null + = null + const onSave = vi.fn (() => + new Promise<{ saved: boolean + row: ReturnType | null }> (resolve => { + resolveSave = resolve + })) + + render ( + { + actions = next + } }} + onSave={onSave}/>) + await waitFor (() => expect (actions.length).toBe (2)) + + await act (async () => { + await actions.find (action => action.label === '編輯内容を保存')?.onSelect () + }) + + await waitFor (() => { + screen.getAllByRole ('textbox').forEach (textbox => { + expect (textbox).toBeDisabled () + }) + }) + + resolveSave?.({ + saved: false, + row: buildPostImportRow ({ + attributes: { title: 'draft title' }, + validationErrors: { title: ['タイトルを確認してください.'] } }) }) + + await waitFor (() => { + screen.getAllByRole ('textbox').forEach (textbox => { + expect (textbox).not.toBeDisabled () + }) + }) + 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 bdf7206..8a9da16 100644 --- a/frontend/src/components/posts/import/PostImportRowForm.tsx +++ b/frontend/src/components/posts/import/PostImportRowForm.tsx @@ -6,7 +6,7 @@ import FieldWarning from '@/components/common/FieldWarning' import PostDurationField from '@/components/posts/PostDurationField' import PostTagsField from '@/components/posts/PostTagsField' import PostTextField from '@/components/posts/PostTextField' -import PostThumbnailPreview from '@/components/posts/PostThumbnailPreview' +import PostImportThumbnailPreview from '@/components/posts/import/PostImportThumbnailPreview' import type { FC } from 'react' @@ -56,6 +56,19 @@ const sameDraft = (left: Draft, right: Draft): boolean => && left.tags === right.tags && left.parentPostIds === right.parentPostIds +const sameProvenance = ( + current: PostImportRow['provenance'], + reset: PostImportRow['resetSnapshot']['provenance'], +): boolean => + Object.keys (reset).every (field => current[field] === reset[field]) + +const sameTagSources = ( + current: PostImportRow['tagSources'], + reset: PostImportRow['resetSnapshot']['tagSources'], +): boolean => + (current?.automatic ?? '') === reset.automatic + && (current?.manual ?? '') === reset.manual + const PostImportRowForm: FC = ( { row, @@ -78,7 +91,12 @@ const PostImportRowForm: FC = ( const resetDraft = useMemo ( () => buildResetDraft (row), [row]) - const resetDisabled = saving || sameDraft (draft, resetDraft) + const resetDisabled = + saving + || (sameDraft (draft, resetDraft) + && sameProvenance (row.provenance, row.resetSnapshot.provenance) + && sameTagSources (row.tagSources, row.resetSnapshot.tagSources) + && row.metadataUrl === row.resetSnapshot.metadataUrl) const update = ( key: Key, @@ -145,7 +163,7 @@ const PostImportRowForm: FC = (
-
@@ -154,6 +172,7 @@ const PostImportRowForm: FC = ( = ( = ( update ('thumbnailBase', value)}/> update ('originalCreatedFrom', value ?? '')} originalCreatedBefore={draft.originalCreatedBefore || null} @@ -189,6 +211,7 @@ const PostImportRowForm: FC = ( displayRow.importErrors?.originalCreatedBefore)}/> = ( onChange={value => update ('duration', value)}/> update ('tags', value)} warnings={displayRow.fieldWarnings.tags} errors={groupedMessages ( @@ -206,6 +230,7 @@ const PostImportRowForm: FC = ( = ({ row, onEdit, editDisabled }) => {
#{row.sourceRow}
-
@@ -80,7 +80,7 @@ const PostImportRowSummary: FC = ({ row, onEdit, editDisabled }) => { 'space-y-3 rounded-lg border p-4 md:hidden', 'transition-shadow hover:shadow-sm')}>
-
diff --git a/frontend/src/components/posts/import/PostImportThumbnailPreview.test.tsx b/frontend/src/components/posts/import/PostImportThumbnailPreview.test.tsx new file mode 100644 index 0000000..21cdd0c --- /dev/null +++ b/frontend/src/components/posts/import/PostImportThumbnailPreview.test.tsx @@ -0,0 +1,78 @@ +import { render, screen, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import PostImportThumbnailPreview from '@/components/posts/import/PostImportThumbnailPreview' + +const api = vi.hoisted (() => ({ + apiGet: vi.fn (), +})) + +vi.mock ('@/lib/api', () => api) + +describe ('PostImportThumbnailPreview', () => { + beforeEach (() => { + vi.clearAllMocks () + globalThis.URL.createObjectURL = vi.fn (() => 'blob:preview') + globalThis.URL.revokeObjectURL = vi.fn () + }) + + it ('uses a backend-fetched blob URL instead of the external thumbnail URL directly', async () => { + api.apiGet.mockResolvedValueOnce (new Blob (['img'], { type: 'image/png' })) + + render ( + ) + + await waitFor (() => { + expect (screen.getByRole ('img')).toHaveAttribute ('src', 'blob:preview') + }) + expect (screen.getByRole ('img')).not.toHaveAttribute ( + 'src', + 'https://example.com/thumbnail.jpg') + expect (api.apiGet).toHaveBeenCalledWith ('/preview/thumbnail', { + params: { url: 'https://example.com/thumbnail.jpg' }, + responseType: 'blob' }) + }) + + it ('does not render the unsafe URL directly when preview fetching fails', async () => { + api.apiGet.mockRejectedValueOnce (new Error ('unsafe')) + + render ( + ) + + expect (await screen.findByText ('サムネールを表示できません')).toBeInTheDocument () + expect (screen.queryByRole ('img')).toBeNull () + }) + + it ('revokes the old object URL when the source URL changes', async () => { + const createObjectUrlMock = + globalThis.URL.createObjectURL as unknown as ReturnType + createObjectUrlMock + .mockReturnValueOnce ('blob:first') + .mockReturnValueOnce ('blob:second') + api.apiGet + .mockResolvedValueOnce (new Blob (['first'], { type: 'image/png' })) + .mockResolvedValueOnce (new Blob (['second'], { type: 'image/png' })) + + const { rerender } = render ( + ) + await waitFor (() => { + expect (screen.getByRole ('img')).toHaveAttribute ('src', 'blob:first') + }) + + rerender ( + ) + + await waitFor (() => { + expect (screen.getByRole ('img')).toHaveAttribute ('src', 'blob:second') + }) + expect (globalThis.URL.revokeObjectURL).toHaveBeenCalledWith ('blob:first') + }) +}) diff --git a/frontend/src/components/posts/import/PostImportThumbnailPreview.tsx b/frontend/src/components/posts/import/PostImportThumbnailPreview.tsx new file mode 100644 index 0000000..8fb3980 --- /dev/null +++ b/frontend/src/components/posts/import/PostImportThumbnailPreview.tsx @@ -0,0 +1,89 @@ +import { useEffect, useRef, useState } from 'react' + +import PostThumbnailPreview from '@/components/posts/PostThumbnailPreview' +import { apiGet } from '@/lib/api' +import { cn } from '@/lib/utils' + +import type { FC } from 'react' + +type Props = { + url: string + alt?: string + className?: string } + + +const PostImportThumbnailPreview: FC = ( + { url, + alt = 'サムネール', + className = 'h-16 w-16' }, +) => { + const [previewUrl, setPreviewUrl] = useState ('') + const [unavailable, setUnavailable] = useState (false) + const previewUrlRef = useRef ('') + + useEffect (() => { + if (previewUrlRef.current) + { + URL.revokeObjectURL (previewUrlRef.current) + previewUrlRef.current = '' + } + setPreviewUrl ('') + setUnavailable (false) + + if (!(url)) + return + + let active = true + + const loadPreview = async () => { + try + { + const blob = await apiGet ('/preview/thumbnail', { + params: { url }, + responseType: 'blob' }) + if (!(active)) + return + + const nextPreviewUrl = URL.createObjectURL (blob) + previewUrlRef.current = nextPreviewUrl + setPreviewUrl (nextPreviewUrl) + } + catch + { + if (active) + setUnavailable (true) + } + } + + void loadPreview () + + return () => { + active = false + if (previewUrlRef.current) + { + URL.revokeObjectURL (previewUrlRef.current) + previewUrlRef.current = '' + } + } + }, [url]) + + if (unavailable) + { + return ( +
+ サムネールを表示できません +
) + } + + return ( + ) +} + +export default PostImportThumbnailPreview diff --git a/frontend/src/pages/posts/PostImportResultPage.test.tsx b/frontend/src/pages/posts/PostImportResultPage.test.tsx index 0fe7f9a..6f72603 100644 --- a/frontend/src/pages/posts/PostImportResultPage.test.tsx +++ b/frontend/src/pages/posts/PostImportResultPage.test.tsx @@ -16,6 +16,7 @@ import type { PostImportRow } from '@/lib/postImportSession' import type { DialogueFormAction, DialogueFormControls } from '@/lib/dialogues/useDialogue' const api = vi.hoisted (() => ({ + apiGet: vi.fn (), apiPost: vi.fn (), })) @@ -37,6 +38,9 @@ describe ('PostImportResultPage', () => { beforeEach (() => { sessionStorage.clear () vi.clearAllMocks () + globalThis.URL.createObjectURL = vi.fn (() => 'blob:preview') + globalThis.URL.revokeObjectURL = vi.fn () + api.apiGet.mockResolvedValue (new Blob (['img'], { type: 'image/png' })) }) it ('shows edit and retry only for recoverable failures and shows warnings', () => { @@ -86,6 +90,35 @@ describe ('PostImportResultPage', () => { )).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 } })] }) + + render ( + + + + }/> + + + ) + + expect (screen.getByText (originalCreatedAtString (from, before))).toBeInTheDocument () + }) + it ('keeps recoverable pending rows actionable and hides actions for hard failures', () => { savePostImportSession ('result-pending', { source: '', diff --git a/frontend/src/pages/posts/PostImportReviewPage.test.tsx b/frontend/src/pages/posts/PostImportReviewPage.test.tsx index de16bca..51a44eb 100644 --- a/frontend/src/pages/posts/PostImportReviewPage.test.tsx +++ b/frontend/src/pages/posts/PostImportReviewPage.test.tsx @@ -12,6 +12,7 @@ import type { DialogueFormAction, DialogueFormControls } from '@/lib/dialogues/u import type { PostImportRow } from '@/lib/postImportSession' const api = vi.hoisted (() => ({ + apiGet: vi.fn (), apiPost: vi.fn (), })) @@ -33,6 +34,9 @@ describe ('PostImportReviewPage', () => { beforeEach (() => { sessionStorage.clear () vi.clearAllMocks () + globalThis.URL.createObjectURL = vi.fn (() => 'blob:preview') + globalThis.URL.revokeObjectURL = vi.fn () + api.apiGet.mockResolvedValue (new Blob (['img'], { type: 'image/png' })) }) it ('navigates to the result route after an initial recoverable failure', async () => { @@ -257,4 +261,35 @@ describe ('PostImportReviewPage', () => { .toBe ('edited title') }) }) + + it ('sorts recoverable pending validation rows to the top in repair mode', () => { + savePostImportSession ('review-repair-sort', { + source: '', + repairMode: 'failed', + rows: [ + buildPostImportRow ({ + sourceRow: 2, + attributes: { title: 'normal row' } }), + buildPostImportRow ({ + sourceRow: 1, + attributes: { title: 'repair row' }, + importStatus: 'pending', + recoverable: true, + validationErrors: { title: ['invalid'] } })] }) + + const { container } = render ( + + + + }/> + + + ) + + const titles = Array.from (container.querySelectorAll ('.line-clamp-2')).map ( + node => node.textContent) + expect (titles[0]).toBe ('repair row') + }) }) diff --git a/frontend/src/pages/posts/PostImportReviewPage.tsx b/frontend/src/pages/posts/PostImportReviewPage.tsx index fce3b22..13e6db2 100644 --- a/frontend/src/pages/posts/PostImportReviewPage.tsx +++ b/frontend/src/pages/posts/PostImportReviewPage.tsx @@ -38,6 +38,12 @@ import type { User } from '@/types' type Props = { user: User | null } +const isRepairRow = (row: PostImportRow): boolean => + row.recoverable === true + && (row.importStatus === 'failed' + || (row.importStatus === 'pending' + && Object.keys (row.validationErrors).length > 0)) + const PostImportReviewPage: FC = ({ user }) => { const editable = canEditContent (user) @@ -85,9 +91,9 @@ const PostImportReviewPage: FC = ({ user }) => { session?.repairMode === 'failed' ? ( [...rows].sort ((a, b) => { - const aFailed = a.importStatus === 'failed' ? 0 : 1 - const bFailed = b.importStatus === 'failed' ? 0 : 1 - return aFailed - bFailed || a.sourceRow - b.sourceRow + const aRepair = isRepairRow (a) ? 0 : 1 + const bRepair = isRepairRow (b) ? 0 : 1 + return aRepair - bRepair || a.sourceRow - b.sourceRow })) : rows @@ -228,21 +234,19 @@ const PostImportReviewPage: FC = ({ user }) => { setLoading (true) try { + const validatableRows = + currentSession.rows.filter (row => row.importStatus !== 'created') const validated = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/validate', { rows: - currentSession.rows - .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 })), + 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 = currentSession.rows - .filter (row => row.importStatus !== 'created') - .map (row => row.sourceRow) + const expectedSourceRows = validatableRows.map (row => row.sourceRow) if (!(hasExactSourceRows (expectedSourceRows, validatedRows))) { toast ({ title: '再検証結果が不完全でした' }) diff --git a/frontend/src/pages/posts/PostNewPage.test.tsx b/frontend/src/pages/posts/PostNewPage.test.tsx index 31e8151..e14975f 100644 --- a/frontend/src/pages/posts/PostNewPage.test.tsx +++ b/frontend/src/pages/posts/PostNewPage.test.tsx @@ -134,4 +134,29 @@ describe ('PostNewPage', () => { expect (formData.get ('original_created_from')).toBe ( toMinutePrecisionIsoUtc ('2024-01-01T12:34')) }) + + it ('shows deduplicated original-created endpoint errors on the shared datetime field', async () => { + api.apiGet.mockResolvedValue ([]) + api.isApiError.mockReturnValue (true) + api.apiPost.mockRejectedValueOnce ({ + response: { + status: 422, + data: { + type: 'validation_error', + errors: { + original_created_at: ['日時を確認してください.'], + original_created_from: ['日時を確認してください.'], + original_created_before: ['終了を確認してください.'] }, + }, + }, + }) + + renderWithProviders () + + fireEvent.click (screen.getByRole ('button', { name: '追加' })) + + 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 4d9e42b..196b555 100644 --- a/frontend/src/pages/posts/PostNewPage.tsx +++ b/frontend/src/pages/posts/PostNewPage.tsx @@ -27,7 +27,11 @@ type Props = { user: User | null } type PostFormField = 'url' | 'title' | 'tags' | 'parentPostIds' - | 'videoMs' | 'originalCreatedAt' | 'thumbnail' + | 'videoMs' | 'originalCreatedAt' | 'originalCreatedFrom' + | 'originalCreatedBefore' | 'thumbnail' + +const groupedMessages = (...values: (string[] | undefined)[]): string[] => + [...new Set (values.flatMap (value => value ?? []))] const PostNewPage: FC = ({ user }) => { @@ -216,7 +220,10 @@ const PostNewPage: FC = ({ user }) => { setOriginalCreatedFrom={setOriginalCreatedFrom} originalCreatedBefore={originalCreatedBefore} setOriginalCreatedBefore={setOriginalCreatedBefore} - errors={fieldErrors.originalCreatedAt}/> + errors={groupedMessages ( + fieldErrors.originalCreatedAt, + fieldErrors.originalCreatedFrom, + fieldErrors.originalCreatedBefore)}/> {videoFlg && (