From 6afbab8eee6c74c5be7b48b28f4faea53ffa9064 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sat, 31 Oct 2026 23:30:45 +0900 Subject: [PATCH] #434 --- frontend/src/components/TagSidebar.tsx | 51 +++++++----- .../src/components/common/SortableColumns.tsx | 6 +- .../src/components/common/SortableList.tsx | 6 +- frontend/src/locales/ja/shared.json | 53 ++++++------ .../src/pages/CountsResourcePage.test.tsx | 82 ++++++++++++++----- frontend/src/pages/CountsResourcePage.tsx | 4 +- .../src/pages/help/HelpChangesPage.test.tsx | 11 ++- frontend/src/pages/posts/PostListPage.tsx | 3 +- 8 files changed, 137 insertions(+), 79 deletions(-) diff --git a/frontend/src/components/TagSidebar.tsx b/frontend/src/components/TagSidebar.tsx index 7e8550a..a061854 100644 --- a/frontend/src/components/TagSidebar.tsx +++ b/frontend/src/components/TagSidebar.tsx @@ -4,6 +4,7 @@ import { useLocation } from 'react-router-dom' import { useLocale, useLocaleNavigate } from '@/lib/useLocale' import { useUITranslation } from '@/lib/useUITranslation' +import PrefetchLink from '@/components/PrefetchLink' import TagLink from '@/components/TagLink' import TagSearch from '@/components/TagSearch' import SectionTitle from '@/components/common/SectionTitle' @@ -93,25 +94,37 @@ const TagSidebar: FC = ({ posts, onClick }) => { ))) : [])} {label ('labels.relations')} - { - ev.preventDefault () - void ((async () => { - try - { - const data = await apiGet ('/posts/random', - { locale, params: { tags: tagsQuery.split (' ').filter (e => e !== '').join (' '), - match: (anyFlg ? 'any' : 'all') } }) - navigate (`/posts/${ data.id }`) - } - catch - { - ; - } - }) ()) - }}> - {label ('actions.random')} - + ) return ( diff --git a/frontend/src/components/common/SortableColumns.tsx b/frontend/src/components/common/SortableColumns.tsx index 8c6482e..ba87d3a 100644 --- a/frontend/src/components/common/SortableColumns.tsx +++ b/frontend/src/components/common/SortableColumns.tsx @@ -2,7 +2,7 @@ import { DndContext, DragOverlay, KeyboardSensor, MouseSensor, TouchSensor, closestCenter, useSensor, useSensors } from '@dnd-kit/core' import { SortableContext, arrayMove, horizontalListSortingStrategy, sortableKeyboardCoordinates } from '@dnd-kit/sortable' -import { useRef, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { createPortal } from 'react-dom' import { CSS } from '@dnd-kit/utilities' @@ -15,6 +15,8 @@ const SortableColumns = ({ ids, fixed = [], name, onChange, children }: { ids: string[]; fixed?: string[]; name: (id: string) => { text: string; language: string } onChange: (ids: string[]) => void; children: (ids: string[]) => ReactNode }, ) => { + const [mounted, setMounted] = useState (false) + useEffect (() => setMounted (true), []) const [active, setActive] = useState (null) const drag = useRef<{ id: string; ids: string[] } | null> (null) const motion = useClientMotion () @@ -56,7 +58,7 @@ const SortableColumns = ({ ids, fixed = [], name, onChange, children }: { {children (ids)} - {typeof document !== 'undefined' && createPortal ( ReactNode onChange: (groups: Groups) => void; fixed?: string[] }, ) => { + const [mounted, setMounted] = useState (false) + useEffect (() => setMounted (true), []) const drag = useRef<{ id: string; groups: Groups } | null> (null) const scope = useId () const [active, setActive] = useState (null) @@ -139,7 +141,7 @@ const SortableList = ( , overGroup === group)} )} - {typeof document !== 'undefined' && createPortal ( + {mounted && typeof document !== 'undefined' && createPortal ( ", "oldest": "(最古)", "latest": "(最新)", - "diff": "差分", "restore": "復元", "show": "表示" - }, + "update": "更新", "edit": "編輯", "add": "追加", "reset": "リセット", + "cancel": "取消", "retry": "再試行", "skip": "スキップ", "random": "ランダム", + "count": "件数", + "old": "< 古", "new": "新 >", "oldest": "(最古)", "latest": "(最新)", + "diff": "差分", "restore": "復元", "show": "表示" }, "messages": { - "loading": "読み込み中...", "loadingEllipsis": "読込中……", - "updated": "更新しました.", "updateFailed": "更新失敗", "failed": "失敗……", - "checkInput": "入力を確認してください.", "checkInputModern": "入力を確認してください。", - "checkInputContent": "入力内容を確認してください.", "reviewInput": "入力を見直してください.", - "postSuccess": "投稿成功!", "postFailed": "投稿失敗", "resetChanges": "変更をリセットしますか?" - } -} + "loading": "読込中……", "loadingEllipsis": "読込中……", + "updated": "更新しました.", "updateFailed": "更新失敗", "failed": "失敗……", + "checkInput": "入力を確認してください.", "checkInputModern": "入力を確認してください。", + "checkInputContent": "入力内容を確認してください.", "reviewInput": "入力を見直してください.", + "postSuccess": "投稿成功!", "postFailed": "投稿失敗", "resetChanges": "変更をリセットしますか?" } } diff --git a/frontend/src/pages/CountsResourcePage.test.tsx b/frontend/src/pages/CountsResourcePage.test.tsx index d840000..93824b1 100644 --- a/frontend/src/pages/CountsResourcePage.test.tsx +++ b/frontend/src/pages/CountsResourcePage.test.tsx @@ -3,12 +3,14 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { hydrateRoot } from 'react-dom/client' import { renderToString } from 'react-dom/server' import { HelmetProvider } from 'react-helmet-async' -import { MemoryRouter, useLocation } from 'react-router-dom' +import { createMemoryRouter, RouterProvider, useLocation } from 'react-router-dom' import { beforeEach, describe, expect, it, vi } from 'vitest' import CountsResourcePage from '@/pages/CountsResourcePage' import DialogueProvider from '@/components/dialogues/DialogueProvider' import { countsKeys } from '@/lib/queryKeys' +import SortableColumns from '@/components/common/SortableColumns' +import SortableList from '@/components/common/SortableList' import { KeyboardShortcutsProvider } from '@/lib/useKeyboardShortcuts' import { UnsavedChangesGuardProvider } from '@/lib/useUnsavedChangesGuard' import { detailResult, executionAt, postDefinition } from '@/test/fixtures/counts' @@ -19,6 +21,10 @@ import type { CountsResult } from '@/lib/counts' const { fetchCounts, fetchCountsDefinition } = vi.hoisted (() => ({ fetchCounts: vi.fn (), fetchCountsDefinition: vi.fn () })) +// Layout animation is outside this state/URL contract and adds asynchronous frames. +vi.mock ('@/lib/useClientBehaviourSettings', () => ({ + useClientBehaviourSettings: () => ({ animation: 'off', linkPreload: 'off' }) })) + vi.mock ('@/lib/counts', async original => ({ ...await original (), fetchCounts })) vi.mock ('@/lib/countsDefinitions', async original => ({ @@ -27,12 +33,27 @@ vi.mock ('@/lib/countsDefinitions', async original => ({ const Location = () => {useLocation ().search} const currentParams = () => new URLSearchParams (screen.getByTestId ('location').textContent ?? '') const execute = () => fireEvent.submit ( - screen.getByRole ('button', { name: '集計実行' }).closest ('form')!) -const openConditions = () => fireEvent.click (screen.getByRole ('button', { name: '集計条件' })) + screen.getByText ('集計実行').closest ('form')!) +const openConditions = () => fireEvent.click ( + screen.getByText ('集計条件').closest ('button')!) const renderPage = (extra = '') => renderWithProviders (<> , { route: `/ja/counts/posts?mode=details&timezone=UTC${extra}` }) +const waitForResult = async (client: QueryClient) => { + await waitFor (() => { + expect (fetchCounts).toHaveBeenCalled () + const queries = client.getQueryCache ().getAll ().filter (query => + query.queryKey[0] === 'counts' && query.queryKey[1] === 'posts') + expect (queries.map (query => ({ + key: query.queryKey, status: query.state.status, data: query.state.data, + error: query.state.error }))).toEqual (expect.arrayContaining ([ + expect.objectContaining ({ status: 'success', data: detailResult })])) + }) + await waitFor (() => expect (currentParams ().get ('mode')).toBe ('details')) + await screen.findByText ('Recorded title') +} + beforeEach (() => { fetchCounts.mockReset ().mockResolvedValue (detailResult) fetchCountsDefinition.mockReset ().mockResolvedValue (postDefinition) @@ -40,19 +61,30 @@ beforeEach (() => { describe ('Counts execution and URL state', () => { + it ('renders sortable shells on the server even when JSDOM provides document', () => { + const name = () => ({ text: 'Title', language: 'en' }) + expect (() => renderToString ( {}}> + {() =>
Title
} +
)).not.toThrow () + expect (() => renderToString ( {}} renderItem={() =>
Title
} + renderGroup={(_, content) =>
{content}
}/>)).not.toThrow () + }) + it ('hydrates the server shell before fetching results with the URL conditions', async () => { const client = new QueryClient ({ defaultOptions: { queries: { retry: false } } }) client.setQueryData (countsKeys.definition ('posts', 'ja'), postDefinition) - const page = - + const router = createMemoryRouter ([{ path: '*', element: ( - + - - + ) }], { + initialEntries: ['/ja/counts/posts?mode=details&timezone=UTC&resource_id=7'] }) + const page = + const html = renderToString (page) expect (fetchCounts).not.toHaveBeenCalled () @@ -71,23 +103,26 @@ describe ('Counts execution and URL state', () => { const root = hydrateRoot (container, page, { onRecoverableError: recoverable }) try { - await screen.findByText ('Recorded title') + await waitForResult (client) expect (fetchCounts).toHaveBeenCalledTimes (1) expect (fetchCounts.mock.calls[0][1].get ('resource_id')).toBe ('7') + await waitFor (() => expect (currentParams ().get ('execution_at')).toBe (executionAt)) + expect (currentParams ().get ('resource_id')).toBe ('7') expect (recoverable).not.toHaveBeenCalled () } finally { await act (async () => root.unmount ()) container.remove () + router.dispose () client.clear () } }) it ('enriches the initial URL without a second query or removing the page shell', async () => { - renderPage () + const { queryClient } = renderPage () const heading = screen.getByRole ('heading', { name: '広場集計' }) - await screen.findByText ('Recorded title') + await waitForResult (queryClient) await waitFor (() => expect (currentParams ().get ('execution_at')).toBe (executionAt)) expect (fetchCounts).toHaveBeenCalledTimes (1) expect (screen.getByRole ('heading', { name: '広場集計' })).toBe (heading) @@ -98,24 +133,25 @@ describe ('Counts execution and URL state', () => { it ('retries an initial CSR failure with the URL filter intact', async () => { fetchCounts.mockRejectedValueOnce (new Error ('offline')) - renderPage ('&resource_id=7') + const { queryClient } = renderPage ('&resource_id=7') await screen.findByRole ('alert') expect (screen.queryByText ('Recorded title')).not.toBeInTheDocument () expect (currentParams ().get ('resource_id')).toBe ('7') execute () - await screen.findByText ('Recorded title') + await waitForResult (queryClient) expect (fetchCounts).toHaveBeenCalledTimes (2) expect (fetchCounts.mock.calls[1][1].get ('resource_id')).toBe ('7') }) it ('keeps edits local until execution then commits the server timestamp and filters together', async () => { - renderPage () + const { queryClient } = renderPage () + await waitForResult (queryClient) await waitFor (() => expect (currentParams ().get ('execution_at')).toBe (executionAt)) openConditions () const input = screen.getByRole ('textbox', { name: 'Id.' }) fireEvent.change (input, { target: { value: '7' } }) - fireEvent.change (screen.getByRole ('combobox', { name: '表示件数' }), { + fireEvent.change (screen.getByLabelText ('表示件数', { selector: 'select' }), { target: { value: '20' } }) openConditions () openConditions () @@ -129,6 +165,7 @@ describe ('Counts execution and URL state', () => { const oldURL = currentParams ().toString () execute () await waitFor (() => expect (fetchCounts).toHaveBeenCalledTimes (2)) + expect (fetchCounts.mock.calls[1][1].get ('resource_id')).toBe ('7') expect (currentParams ().toString ()).toBe (oldURL) expect (screen.getByRole ('textbox', { name: 'Id.' })).toBe (input) await act (async () => complete ({ ...detailResult, @@ -141,7 +178,8 @@ describe ('Counts execution and URL state', () => { }) it ('keeps the filter and identifier after a failed execution', async () => { - renderPage () + const { queryClient } = renderPage () + await waitForResult (queryClient) await waitFor (() => expect (currentParams ().get ('execution_at')).toBe (executionAt)) openConditions () fireEvent.change (screen.getByRole ('textbox', { name: 'Id.' }), { @@ -149,6 +187,8 @@ describe ('Counts execution and URL state', () => { fetchCounts.mockRejectedValueOnce (new Error ('offline')) execute () await screen.findByRole ('alert') + expect (fetchCounts).toHaveBeenCalledTimes (2) + expect (fetchCounts.mock.calls[1][1].get ('resource_id')).toBe ('99') expect (screen.getByRole ('textbox', { name: 'Id.' })).toHaveValue ('99') expect (currentParams ().has ('resource_id')).toBe (false) }) @@ -170,17 +210,17 @@ describe ('Counts execution and URL state', () => { it ('can execute after an initial column-limit failure with no execution timestamp', async () => { fetchCounts.mockRejectedValueOnce ({ isAxiosError: true, response: { status: 422, data: { error: 'too_many_columns', max_columns: 73 } } }) - renderPage () + const { queryClient } = renderPage () await screen.findByRole ('alert') execute () - await screen.findByText ('Recorded title') + await waitForResult (queryClient) await waitFor (() => expect (currentParams ().get ('execution_at')).toBe (executionAt)) expect (fetchCounts).toHaveBeenCalledTimes (2) }) it ('keeps the page heading and common site title consistent', async () => { - renderPage () - await screen.findByText ('Recorded title') + const { queryClient } = renderPage () + await waitForResult (queryClient) await waitFor (() => expect (document.title).toContain ('広場集計 | ')) expect (screen.getByRole ('heading', { name: '広場集計' })).toBeInTheDocument () }) diff --git a/frontend/src/pages/CountsResourcePage.tsx b/frontend/src/pages/CountsResourcePage.tsx index 8e5f6c7..fb200b7 100644 --- a/frontend/src/pages/CountsResourcePage.tsx +++ b/frontend/src/pages/CountsResourcePage.tsx @@ -53,7 +53,9 @@ const tooManyColumns = (failure: unknown): number | null => { const CountsResourceContent = ( { resource, definition, definitionError }: { - resource: CountsResource; definition?: CountsDefinition; definitionError: boolean }, + resource: CountsResource + definition?: CountsDefinition + definitionError: boolean }, ) => { const queryClient = useQueryClient () const [mounted, setMounted] = useState (false) diff --git a/frontend/src/pages/help/HelpChangesPage.test.tsx b/frontend/src/pages/help/HelpChangesPage.test.tsx index 5d8cb6d..872fdf8 100644 --- a/frontend/src/pages/help/HelpChangesPage.test.tsx +++ b/frontend/src/pages/help/HelpChangesPage.test.tsx @@ -1,10 +1,10 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { render, screen } from '@testing-library/react' +import { act, render, screen, within } from '@testing-library/react' import { HelmetProvider } from 'react-helmet-async' import { beforeEach, describe, expect, it, vi } from 'vitest' import { apiGet } from '@/lib/api' -import App from '@/App' +import App, { pageRoutes } from '@/App' import { helpFixture } from '@/test/helpFixtures' vi.hoisted (() => { @@ -28,12 +28,17 @@ describe ('Help global edition history', () => { }) it ('routes /help/changes to published editions in date and numeric order', async () => { + // Resolve the real lazy route before waiting for its asynchronously loaded data. + const route = pageRoutes (null, () => {}).find (route => route.path === 'help/changes')! + expect (route.handle?.preload).toBeTypeOf ('function') + await act (async () => { await route.handle!.preload! ('ja') }) const client = new QueryClient ({ defaultOptions: { queries: { retry: false } } }) const { container } = render ( ) - const links = await screen.findAllByRole ('link') + const table = await screen.findByRole ('table') + const links = within (table).getAllByRole ('link') const editions = links.map (link => link.getAttribute ('href')) .filter (href => href?.startsWith ('/ja/help/') && href.includes ('edition=')) expect (editions).toEqual ([ diff --git a/frontend/src/pages/posts/PostListPage.tsx b/frontend/src/pages/posts/PostListPage.tsx index f9a4519..a0ee1ab 100644 --- a/frontend/src/pages/posts/PostListPage.tsx +++ b/frontend/src/pages/posts/PostListPage.tsx @@ -106,8 +106,7 @@ const PostListPage: FC = () => { updatePage (Math.min (page + 1, totalPages)) }, } - : { }), - }), [page, totalPages, location.search])) + : { }) }), [page, totalPages, location.search])) useLayoutEffect (() => { scroll (0, 0)