diff --git a/AGENTS.md b/AGENTS.md index 1b27081..6b17df2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -631,6 +631,19 @@ and layout reuse, follow `frontend/AGENTS.md`. ## Frontend rules +- 原則として、ユーザ向け UI 文言を TSX / TypeScript に直接記述しない。 + 新しい文言は既存の i18n システムで管理し、適切な既存キーを先に確認する。 + 不必要な重複定義を避け、新規機能固有の文言は適切な namespace にまとめる。 +- 対応 locale の辞書を整合させる。未翻訳項目には既存の日本語 fallback と + `_untranslated` 規約を使い、language metadata を維持する。 + 翻訳を捏造せず、未翻訳文字列を正式な翻訳として登録しない。 +- UI 文言の追加・変更時は、既存テストの実行、ビルド、静的検査、実画面の + 確認等で、辞書と SSR / hydration の整合及び翻訳キーの非露出を検証する。 + この規約はテストコードの追加・修正を義務付けない。 +- API の内部識別子、パラメータ、ユーザ入力データ、外部 URL は機械的に + 翻訳しない。ボタン、項目名、CSV ヘッダ、エラー、空状態、ツールチップ、 + アクセシビリティ用ラベルも UI 文言として扱う。 + - Use `frontend/src/lib/api.ts` for API calls so headers and camelCase conversion stay consistent. - Add or reuse TanStack Query keys through `frontend/src/lib/queryKeys.ts`; avoid ad hoc query key arrays. diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index ae68412..a7693dc 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -128,6 +128,19 @@ pass or the remaining failure is clearly blocked. ## Tailwind and UI +- 原則として、ユーザ向け UI 文言を TSX / TypeScript に直接記述しない。 + 新しい文言は既存の i18n システムで管理し、適切な既存キーを先に確認する。 + 不必要な重複定義を避け、新規機能固有の文言は適切な namespace にまとめる。 +- 対応 locale の辞書を整合させる。未翻訳項目には既存の日本語 fallback と + `_untranslated` 規約を使い、language metadata を維持する。 + 翻訳を捏造せず、未翻訳文字列を正式な翻訳として登録しない。 +- UI 文言の追加・変更時は、既存テストの実行、ビルド、静的検査、実画面の + 確認等で、辞書と SSR / hydration の整合及び翻訳キーの非露出を検証する。 + この規約はテストコードの追加・修正を義務付けない。 +- API の内部識別子、パラメータ、ユーザ入力データ、外部 URL は機械的に + 翻訳しない。ボタン、項目名、CSV ヘッダ、エラー、空状態、ツールチップ、 + アクセシビリティ用ラベルも UI 文言として扱う。 + - 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` @@ -144,7 +157,7 @@ pass or the remaining failure is clearly blocked. heavy borders, or nested panel decoration unless the surrounding screen already uses them. - Keep operational screens dense and direct; trim explanatory copy and use - short Japanese labels that fit the control. + short localised 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, diff --git a/frontend/server.ssr.test.mjs b/frontend/server.ssr.test.mjs index 07f1814..98a62b6 100644 --- a/frontend/server.ssr.test.mjs +++ b/frontend/server.ssr.test.mjs @@ -65,7 +65,8 @@ const responseFor = url => { if (path === '/counts/posts' || path === '/counts/tags') { const resource = path.endsWith ('posts') ? 'posts' : 'tags' return { count: 1, page: 1, limit: 20, group_count: 1, - groups: [{ key: 'create', name: null, count: 1 }], + groups: [{ key: url.searchParams.get ('axis') === 'user' ? 1 : '2024-01', + name: null, count: 1 }], versions: resource === 'posts' ? [{ post_id: 1, version_no: 1, title: { current: `SSR count ${locale}` } }] : [{ tag_id: 1, version_no: 1, name: { current: `SSR count ${locale}` } }] } @@ -317,6 +318,10 @@ test ('counts SSR renders history data and retains URL query state', async () => assert.equal (result.status, 200) assert.match (result.html, new RegExp (`SSR count ${locale}`)) assert.match (result.html, new RegExp (`/${locale}/${resource}/1`)) + assert.doesNotMatch (result.html, /counts:|columns\.changedAt|fields\.actor/) + assert.match (result.html, /aria-expanded="false"/) + if (locale === 'ain-Kana') + assert.match (result.html, /lang="ja"[^>]*>集計実行/) assert.ok (requests.some (request => request.url.includes (`/counts/${resource}`) && request.url.includes ('user_id=4') && request.url.includes (`locale=${locale}`))) } @@ -648,10 +653,44 @@ test ('hydrate every route with stored browser preferences', { if (ready) break } assert.ok (ready, 'history query results must render') + const layout = await evaluate (`(()=>{ + const nav=document.getElementById('counts-targets'); + const main=document.querySelector('main').getBoundingClientRect(); + return {visible:nav.getClientRects().length>0, + right:nav.getBoundingClientRect().right,left:main.left,width:main.width}; + })()`) + assert.ok (width >= 768 ? layout.visible && layout.right <= layout.left + : !layout.visible && layout.width >= width - 16, + 'the sidebar sits beside the table on desktop and collapses on mobile') + const toggle = `Array.from(document.querySelectorAll('button')) + .find(button=>button.textContent.includes('集計条件'))` + assert.equal (await evaluate (`(${toggle}).getAttribute('aria-expanded')`), 'false') + await evaluate (`(${toggle}).click()`) + const positions = await evaluate (`(()=>{ + const output=document.querySelector('[data-counts-output]').getBoundingClientRect(); + const filters=document.querySelector('[data-counts-filters]').getBoundingClientRect(); + return {left:output.left,right:filters.left,top:output.top,bottom:filters.top}; + })()`) + assert.ok (width >= 768 ? positions.left < positions.right + : positions.top < positions.bottom, 'output precedes filters in the layout') + assert.ok (await evaluate ('document.documentElement.scrollWidth<=innerWidth')) + if (resource === 'posts' && process.env.SSR_COUNTS_ONLY === '1') { + const { writeFile } = await import ('node:fs/promises') + const shot = await send ('Page.captureScreenshot', { format: 'png' }, sessionId) + await writeFile (`/tmp/counts-${width}-conditions.png`, + Buffer.from (shot.data, 'base64')) + } + await evaluate (`(${toggle}).click()`) for (const dark of [false, true]) { await evaluate (`document.documentElement.classList.toggle('dark',${dark})`) assert.ok (await evaluate ('document.documentElement.scrollWidth<=innerWidth'), 'counts controls must fit the viewport in both themes') + if (resource === 'posts' && process.env.SSR_COUNTS_ONLY === '1') { + const { writeFile } = await import ('node:fs/promises') + const shot = await send ('Page.captureScreenshot', { format: 'png' }, sessionId) + await writeFile (`/tmp/counts-${width}-${dark ? 'dark' : 'light'}.png`, + Buffer.from (shot.data, 'base64')) + } } }) } diff --git a/frontend/src/lib/counts.test.ts b/frontend/src/lib/counts.test.ts index c1d14a3..ea045c0 100644 --- a/frontend/src/lib/counts.test.ts +++ b/frontend/src/lib/counts.test.ts @@ -1,9 +1,30 @@ import { describe, expect, it } from 'vitest' -import { columnsForCounts, countsCSV } from '@/lib/counts' +import { columnsForCounts, countColumns, countsCSV, normaliseCountsParams } from '@/lib/counts' +import { createUII18n, translateUIMessage } from '@/lib/i18n' describe ('counts exports', () => { + it ('resolves every output and CSV header key in both locales', () => { + for (const locale of ['ja', 'ain-Kana']) + { + const instance = createUII18n (locale) + for (const key of Object.values (countColumns)) + { + const translated = translateUIMessage (key, 'counts', locale, { }, instance) + expect (translated.text).not.toBe (key) + expect (translated.text).not.toContain ('counts:') + } + const fallback = translateUIMessage ('run', 'counts', locale, { }, instance) + expect (fallback).toEqual ({ text: '集計実行', language: 'ja' }) + } + }) + + it ('makes the monthly default explicit for the existing API', () => { + expect (normaliseCountsParams ('posts', new URLSearchParams ()).get ('axis')) + .toBe ('month') + }) + it ('escapes quotes, newlines and spreadsheet formulas in UTF-8 CSV', () => { expect (countsCSV ([['日本語', 'a"b', 'a\nb', '=1+1']])) .toBe ('\uFEFF"日本語","a""b","a\nb","\'=1+1"') @@ -14,5 +35,7 @@ describe ('counts exports', () => { expect (columnsForCounts ('posts')).not.toContain ('aliases') expect (columnsForCounts ('tags')).toContain ('aliases') expect (columnsForCounts ('tags')).not.toContain ('url') + expect (columnsForCounts ('posts')).not.toContain ('event_type') + expect (columnsForCounts ('tags')).not.toContain ('event_type') }) }) diff --git a/frontend/src/lib/counts.ts b/frontend/src/lib/counts.ts index da9e9f6..f6e2730 100644 --- a/frontend/src/lib/counts.ts +++ b/frontend/src/lib/counts.ts @@ -13,22 +13,46 @@ export type CountsResult = { versions?: (PostVersion | TagVersion)[] } export const countColumns = { - created_at: '変更日時', resource_id: 'ID', version_no: '版番号', - event_type: '操作種別', user: '操作者', title: '投稿タイトル', url: '投稿 URL', - tags: '記載タグ', added_tags: '追加されたタグ', removed_tags: '削除されたタグ', - name: 'タグ名', category: 'カテゴリ', aliases: '別名', parents: '上位タグ', - deprecated: '廃止状態' } + created_at: 'counts:columns.changedAt', resource_id: 'counts:columns.resourceId', + version_no: 'history:version', user: 'history:actor', + title: 'counts:columns.postTitle', url: 'counts:columns.postURL', + tags: 'counts:columns.tags', added_tags: 'counts:columns.addedTags', + removed_tags: 'counts:columns.removedTags', name: 'history:tagName', + category: 'common:categoryLabel', aliases: 'tags:aliases', parents: 'tags:parents', + deprecated: 'counts:columns.deprecated' } export type CountColumn = keyof typeof countColumns export const columnsForCounts = (resource: CountsResource): CountColumn[] => [ - 'created_at', 'resource_id', 'version_no', 'event_type', 'user', + 'created_at', 'resource_id', 'version_no', 'user', ...(resource === 'posts' ? ['title', 'url', 'tags', 'added_tags', 'removed_tags'] as const : ['name', 'category', 'aliases', 'parents', 'deprecated'] as const)] +export const normaliseCountsParams = ( + resource: CountsResource, + source: URLSearchParams, +): URLSearchParams => { + const params = new URLSearchParams (source) + params.delete ('event_type') + if (!['month', 'user'].includes (params.get ('axis') ?? '')) + params.set ('axis', 'month') + if (params.has ('columns')) + { + const available = columnsForCounts (resource) + const columns = params.get ('columns')!.split (',') + .filter (column => available.includes (column as CountColumn)) + if (columns.length > 0) + params.set ('columns', [...new Set (columns)].join (',')) + else + params.delete ('columns') + } + return params +} + + export const fetchCounts = ( resource: CountsResource, params: URLSearchParams, diff --git a/frontend/src/locales/ain-Kana/counts.json b/frontend/src/locales/ain-Kana/counts.json new file mode 100644 index 0000000..ef28b0c --- /dev/null +++ b/frontend/src/locales/ain-Kana/counts.json @@ -0,0 +1,3 @@ +{ + "_untranslated": ["*"] +} diff --git a/frontend/src/locales/ja/counts.json b/frontend/src/locales/ja/counts.json new file mode 100644 index 0000000..97e005b --- /dev/null +++ b/frontend/src/locales/ja/counts.json @@ -0,0 +1,42 @@ +{ + "titles": { + "posts": "投稿の集計・明細", + "tags": "タグの集計・明細" + }, + "target": "集計対象", + "run": "集計実行", + "conditions": "集計条件", + "output": "出力項目", + "filters": "抽出条件", + "mode": "表示", + "summary": "集計", + "details": "明細", + "axis": "集計軸", + "axes": { + "month": "月別(UTC)", + "user": "$t(history:actor)" + }, + "metric": "履歴レコード件数", + "total": "履歴レコード件数:{{count}}", + "csv": "CSV", + "csvScope": "表示中のページ・選択列を CSV 出力", + "empty": "該当する履歴はありません。", + "columns": { + "changedAt": "変更日時", + "resourceId": "ID", + "postTitle": "投稿タイトル", + "postURL": "投稿 URL", + "tags": "記載タグ", + "addedTags": "追加されたタグ", + "removedTags": "削除されたタグ", + "deprecated": "廃止状態" + }, + "fields": { + "from": "開始(端末の時刻帯・以上)", + "before": "終了(端末の時刻帯・未満)", + "post": "投稿 ID", + "tag": "タグ名または ID", + "actor": "操作者 ID", + "limit": "表示件数" + } +} diff --git a/frontend/src/pages/CountsPage.tsx b/frontend/src/pages/CountsPage.tsx index 8dfe9e5..6be614d 100644 --- a/frontend/src/pages/CountsPage.tsx +++ b/frontend/src/pages/CountsPage.tsx @@ -1,17 +1,6 @@ -import PrefetchLink from '@/components/PrefetchLink' -import PageHead from '@/components/common/PageHead' -import PageTitle from '@/components/common/PageTitle' -import MainArea from '@/components/layout/MainArea' +import CountsResourcePage from '@/pages/CountsResourcePage' -const CountsPage = () => ( - - - 集計・明細照会 - - ) +const CountsPage = () => export default CountsPage diff --git a/frontend/src/pages/CountsResourcePage.test.tsx b/frontend/src/pages/CountsResourcePage.test.tsx index b30d926..5400e43 100644 --- a/frontend/src/pages/CountsResourcePage.test.tsx +++ b/frontend/src/pages/CountsResourcePage.test.tsx @@ -7,77 +7,151 @@ import { renderWithProviders } from '@/test/render' import type { CountsResult } from '@/lib/counts' - -const { fetchCounts } = vi.hoisted (() => ({ fetchCounts: vi.fn () })) +const { fetchCounts, fetchTagByName } = vi.hoisted (() => ({ + fetchCounts: vi.fn (), fetchTagByName: vi.fn () })) vi.mock ('@/lib/counts', async importOriginal => ({ ...await importOriginal (), fetchCounts })) +vi.mock ('@/lib/tags', async importOriginal => ({ + ...await importOriginal (), fetchTagByName })) beforeEach (() => { fetchCounts.mockReset () + fetchTagByName.mockReset () fetchCounts.mockResolvedValue ({ count: 0, groupCount: 0, groups: [], page: 1, limit: 20 }) }) -describe ('counts pages', () => { - it ('links to the two locale-aware resource screens', () => { +const conditions = () => screen.getByRole ('button', { name: '集計条件' }) + + +const execute = () => fireEvent.submit ( + screen.getByRole ('button', { name: '集計実行' }).closest ('form')!) + + +describe ('counts workspace', () => { + it ('opens the posts workspace at the root with a resource sidebar', async () => { renderWithProviders (, { route: '/ja/counts' }) + fireEvent.click (screen.getByRole ('button', { name: '集計対象' })) expect (screen.getByRole ('link', { name: '投稿' })) .toHaveAttribute ('href', '/ja/counts/posts') + expect (screen.getByRole ('link', { name: '投稿' })) + .toHaveAttribute ('aria-current', 'page') expect (screen.getByRole ('link', { name: 'タグ' })) .toHaveAttribute ('href', '/ja/counts/tags') + expect (conditions ()).toHaveAttribute ('aria-expanded', 'false') + await waitFor (() => expect (fetchCounts.mock.lastCall?.[1].get ('axis')).toBe ('month')) }) - it ('restores filters and selected columns and preserves them in page links', async () => { - fetchCounts.mockResolvedValue ({ - count: 21, page: 1, limit: 20, - versions: [{ postId: 7, versionNo: 2, - url: { current: 'https://example.com' }, - title: { current: 'Snapshot' } }] as CountsResult['versions'] }) - renderWithProviders (, { - route: '/ja/counts/posts?mode=details&columns=title&user_id=3' }) - expect (await screen.findByRole ('cell', { name: 'Snapshot' })).toBeInTheDocument () - expect (screen.getByRole ('checkbox', { name: '投稿タイトル' })).toBeChecked () - expect (screen.getByRole ('checkbox', { name: '投稿 URL' })).not.toBeChecked () - expect (screen.getByLabelText ('次のページ')).toHaveAttribute ( - 'href', '/ja/counts/posts?mode=details&columns=title&user_id=3&page=2') - expect (fetchCounts.mock.calls[0][1].get ('user_id')).toBe ('3') - fireEvent.click (screen.getByRole ('checkbox', { name: '投稿 URL' })) - await waitFor (() => { - expect (fetchCounts.mock.lastCall?.[1].get ('columns')).toBe ('title,url') - }) - }) - - it ('executes combined filters and resets the page', async () => { + it ('preserves draft filters on collapse without querying until execution', async () => { renderWithProviders (, { route: '/ja/counts/posts?page=3&mode=details' }) + await screen.findByText ('履歴レコード件数:0') + const calls = fetchCounts.mock.calls.length + fireEvent.click (conditions ()) + const output = document.querySelector ('[data-counts-output]')! + const filters = document.querySelector ('[data-counts-filters]')! + expect (output.compareDocumentPosition (filters) & Node.DOCUMENT_POSITION_FOLLOWING) + .toBeTruthy () fireEvent.change (screen.getByRole ('textbox', { name: '投稿 ID' }), { target: { value: '7' } }) fireEvent.change (screen.getByRole ('textbox', { name: '操作者 ID' }), { target: { value: '9' } }) - fireEvent.submit (screen.getByRole ('button', { name: '実行' }).closest ('form')!) + fireEvent.click (conditions ()) + expect (screen.getByRole ('table')).toBeInTheDocument () + fireEvent.click (conditions ()) + expect (screen.getByRole ('textbox', { name: '投稿 ID' })).toHaveValue ('7') + expect (fetchCounts).toHaveBeenCalledTimes (calls) + execute () await waitFor (() => { const params = fetchCounts.mock.lastCall?.[1] as URLSearchParams expect (params.get ('resource_id')).toBe ('7') expect (params.get ('user_id')).toBe ('9') expect (params.has ('page')).toBe (false) }) - const calls = fetchCounts.mock.calls.length - fireEvent.submit (screen.getByRole ('button', { name: '実行' }).closest ('form')!) - await waitFor (() => expect (fetchCounts.mock.calls.length).toBeGreaterThan (calls)) + const refreshed = fetchCounts.mock.calls.length + execute () + await waitFor (() => expect (fetchCounts.mock.calls.length).toBeGreaterThan (refreshed)) }) - it ('keeps a partial-month date range when drilling down into that month', async () => { + it ('applies selected columns only on execution and retains pagination state', async () => { + fetchCounts.mockResolvedValue ({ count: 21, page: 1, limit: 20, + versions: [{ postId: 7, versionNo: 2, url: { current: 'https://example.com' }, + title: { current: 'Snapshot' } }] as CountsResult['versions'] }) + renderWithProviders (, { + route: '/ja/counts/posts?axis=month&mode=details&columns=title&user_id=3' }) + expect (await screen.findByRole ('cell', { name: 'Snapshot' })).toBeInTheDocument () + expect (screen.getByLabelText ('次のページ')).toHaveAttribute ( + 'href', '/ja/counts/posts?axis=month&mode=details&columns=title&user_id=3&page=2') + fireEvent.click (conditions ()) + expect (screen.getByRole ('checkbox', { name: '投稿タイトル' })).toBeChecked () + fireEvent.click (screen.getByRole ('checkbox', { name: '投稿 URL' })) + expect (fetchCounts.mock.lastCall?.[1].get ('columns')).toBe ('title') + execute () + await waitFor (() => { + expect (fetchCounts.mock.lastCall?.[1].get ('columns')).toBe ('title,url') + }) + }) + + it ('removes legacy event fields and axes from public query state', async () => { + renderWithProviders (, { + route: '/ja/counts/tags?axis=event_type&event_type=update&columns=event_type,name' }) + await waitFor (() => { + const params = fetchCounts.mock.lastCall?.[1] as URLSearchParams + expect (params.get ('axis')).toBe ('month') + expect (params.has ('event_type')).toBe (false) + expect (params.get ('columns')).toBe ('name') + }) + fireEvent.click (conditions ()) + expect (screen.queryByText ('操作種別')).not.toBeInTheDocument () + expect (screen.getByRole ('combobox', { name: '集計軸' })).toHaveValue ('month') + }) + + it ('resolves a tag name with the existing lookup before running the query', async () => { + fetchTagByName.mockResolvedValue ({ id: 12 }) + renderWithProviders (, { + route: '/ja/counts/posts' }) + fireEvent.click (conditions ()) + fireEvent.change (screen.getByRole ('textbox', { name: 'タグ名または ID' }), { + target: { value: '日本語タグ' } }) + execute () + await waitFor (() => expect (fetchCounts.mock.lastCall?.[1].get ('tag')).toBe ('12')) + expect (fetchTagByName).toHaveBeenCalledWith ('日本語タグ', 'ja') + }) + + it ('uses Japanese fallback metadata in the Ainu workspace without exposing keys', async () => { + const { container } = renderWithProviders (, { + route: '/ain-Kana/counts/tags' }) + const title = screen.getByRole ('heading', { name: 'タグの集計・明細' }) + expect (title.querySelector ('[lang="ja"]')).toHaveTextContent ('タグの集計・明細') + expect (conditions ().querySelector ('[lang="ja"]')).toBeInTheDocument () + fireEvent.click (conditions ()) + await screen.findByRole ('table') + expect (container.textContent).not.toMatch (/counts:|columns\.|fields\.|axes\./) + }) + + it ('drills through an actor group while preserving the existing filters', async () => { + fetchCounts.mockResolvedValue ({ count: 3, groupCount: 1, page: 1, limit: 20, + groups: [{ key: 9, name: 'Actor name', count: 3 }] }) + renderWithProviders (, { + route: '/ja/counts/posts?axis=user&resource_id=7' }) + const link = await screen.findByRole ('link', { name: 'Actor name' }) + const url = new URL (link.getAttribute ('href')!, 'http://localhost') + expect (url.searchParams.get ('resource_id')).toBe ('7') + expect (url.searchParams.get ('user_id')).toBe ('9') + expect (url.searchParams.get ('mode')).toBe ('details') + }) + + it ('keeps partial-month boundaries when drilling through to details', async () => { fetchCounts.mockResolvedValue ({ count: 4, groupCount: 1, page: 1, limit: 20, groups: [{ key: '2024-01', name: null, count: 4 }] }) renderWithProviders (, { - route: '/ja/counts/tags?axis=month&from=2024-01-15T00:00Z&event_type=update' }) + route: '/ja/counts/tags?axis=month&from=2024-01-15T00:00Z' }) const link = await screen.findByRole ('link', { name: '2024-01' }) const url = new URL (link.getAttribute ('href')!, 'http://localhost') expect (url.searchParams.get ('from')).toBe ('2024-01-15T00:00Z') expect (url.searchParams.get ('before')).toBe ('2024-02-01T00:00Z') - expect (url.searchParams.get ('event_type')).toBe ('update') expect (url.searchParams.get ('mode')).toBe ('details') }) }) diff --git a/frontend/src/pages/CountsResourcePage.tsx b/frontend/src/pages/CountsResourcePage.tsx index 1c93591..07949e5 100644 --- a/frontend/src/pages/CountsResourcePage.tsx +++ b/frontend/src/pages/CountsResourcePage.tsx @@ -1,17 +1,22 @@ import { useQuery } from '@tanstack/react-query' +import { useEffect, useId, useState } from 'react' import { useSearchParams } from 'react-router-dom' +import LocalisedText from '@/components/LocalisedText' import PrefetchLink from '@/components/PrefetchLink' -import DateTimeField, { toMinutePrecisionIsoUtc } from '@/components/common/DateTimeField' +import DateTimeField from '@/components/common/DateTimeField' import FormField from '@/components/common/FormField' import PageHead from '@/components/common/PageHead' import PageTitle from '@/components/common/PageTitle' import Pagination from '@/components/common/Pagination' import MainArea from '@/components/layout/MainArea' +import SidebarComponent from '@/components/layout/SidebarComponent' import { Button } from '@/components/ui/button' -import { columnsForCounts, countColumns, countsCSV, fetchCounts } from '@/lib/counts' +import { columnsForCounts, countColumns, countsCSV, + fetchCounts, normaliseCountsParams } from '@/lib/counts' import { countsKeys } from '@/lib/queryKeys' -import { inputClass, localisedDateString } from '@/lib/utils' +import { fetchTagByName } from '@/lib/tags' +import { cn, inputClass, localisedDateString } from '@/lib/utils' import { useLocale } from '@/lib/useLocale' import { useUITranslation } from '@/lib/useUITranslation' @@ -19,41 +24,169 @@ import type { CountColumn, CountsResource } from '@/lib/counts' import type { PostVersion, TagVersion } from '@/types' -const CountsResourcePage = ({ resource }: { resource: CountsResource }) => { - const [params, setParams] = useSearchParams () +const CountsControls = ( + { resource, applied, open, setOpen, execute }: { + resource: CountsResource + applied: URLSearchParams + open: boolean + setOpen: (open: boolean) => void + execute: (params: URLSearchParams) => void }, +) => { + const { label, message } = useUITranslation ('counts') const locale = useLocale () - const { label, message } = useUITranslation ('history') - const mode = params.get ('mode') ?? 'summary' - const axis = params.get ('axis') ?? 'event_type' - const availableColumns = columnsForCounts (resource) - const selectedColumns = params.has ('columns') - ? params.get ('columns')!.split (',') as CountColumn[] - : availableColumns - const columns = selectedColumns.filter (column => availableColumns.includes (column)) - const title = `集計・明細照会:${resource === 'posts' ? '投稿' : 'タグ'}` - const { data, isPending, isError, refetch } = useQuery ({ - queryKey: countsKeys.index (resource, params.toString (), locale), - queryFn: () => fetchCounts (resource, params, locale), retry: false }) - + const id = useId () + const [draft, setDraft] = useState (() => new URLSearchParams (applied)) + const [error, setError] = useState (false) + const [pending, setPending] = useState (false) + const available = columnsForCounts (resource) + const columns = (draft.get ('columns')?.split (',') ?? available) as CountColumn[] + const mode = draft.get ('mode') ?? 'summary' const update = (key: string, value: string) => { - const next = new URLSearchParams (params) - if (value) - next.set (key, value) - else - next.delete (key) - next.delete ('page') - setParams (next) + setDraft (previous => { + const next = new URLSearchParams (previous) + if (value) next.set (key, value) + else next.delete (key) + return next + }) + } + const submit = async () => { + setError (false) + setPending (true) + try + { + const next = new URLSearchParams (draft) + for (const key of resource === 'posts' ? ['tag'] : ['resource_id']) + { + const value = next.get (key)?.trim () ?? '' + if (value && !/^[1-9][0-9]*$/.test (value)) + { + const tag = await fetchTagByName (value, locale) + if (tag == null) + throw new Error () + next.set (key, String (tag.id)) + } + } + next.delete ('page') + execute (normaliseCountsParams (resource, next)) + } + catch + { + setError (true) + } + finally + { + setPending (false) + } } - const eventName = (value: string) => value + return ( +
{ + event.preventDefault () + void submit () + }}> +
+ + + +
+ {error &&

{label ('shared:messages.checkInputModern')}

} + +
) +} + + +const CountsResourcePage = ({ resource }: { resource: CountsResource }) => { + const [source, setParams] = useSearchParams () + const params = normaliseCountsParams (resource, source) + const canonical = params.toString () + const [open, setOpen] = useState (false) + const [sidebarOpen, setSidebarOpen] = useState (false) + const locale = useLocale () + const { label, message } = useUITranslation ('counts') + const mode = params.get ('mode') ?? 'summary' + const axis = params.get ('axis') ?? 'month' + const columns = (params.get ('columns')?.split (',') + ?? columnsForCounts (resource)) as CountColumn[] + const { data, isPending, isError, refetch } = useQuery ({ + queryKey: countsKeys.index (resource, canonical, locale), + queryFn: () => fetchCounts (resource, params, locale), retry: false }) + + useEffect (() => { + if (source.toString () !== canonical) + setParams (canonical, { replace: true }) + }, [canonical, source, setParams]) + const cell = (row: PostVersion | TagVersion, column: CountColumn): string => { switch (column) { case 'created_at': return localisedDateString (row.createdAt, locale) case 'resource_id': return String ('postId' in row ? row.postId : row.tagId) case 'version_no': return String (row.versionNo) - case 'event_type': return eventName (row.eventType) - case 'user': return row.createdByUser?.name ?? '—' + case 'user': return row.createdByUser?.name ?? '' } if ('postId' in row) { @@ -74,15 +207,14 @@ const CountsResourcePage = ({ resource }: { resource: CountsResource }) => { case 'parents': return row.parentTags.filter (item => item.type !== 'removed') .map (item => item.tag.name).join (' ') - case 'deprecated': return row.deprecatedAt.current ?? '—' + case 'deprecated': return row.deprecatedAt.current ?? '' default: return '' } } const groups = data?.groups ?? [] const versions = data?.versions ?? [] - const groupName = (group: typeof groups[number]) => axis === 'event_type' - ? eventName (String (group.key)) - : (axis === 'user' ? group.name ?? String (group.key ?? '—') : String (group.key)) + const groupName = (group: typeof groups[number]) => + axis === 'user' ? group.name ?? String (group.key ?? '') : String (group.key) const drillDown = (key: string | number | null): string => { const next = new URLSearchParams (params) next.set ('mode', 'details') @@ -99,13 +231,16 @@ const CountsResourcePage = ({ resource }: { resource: CountsResource }) => { next.set ('before', before != null && before < last ? before : last) } else - next.set (axis === 'user' ? 'user_id' : 'event_type', String (key ?? 'none')) + next.set ('user_id', String (key ?? 'none')) return `?${next}` } + const groupText = (group: typeof groups[number]) => + group.key == null ? message ('history:bot').text : groupName (group) const exportCSV = () => { const rows = mode === 'summary' - ? [['集計軸', '件数'], ...groups.map (group => [groupName (group), String (group.count)])] - : [columns.map (column => countColumns[column]), + ? [[message (`axes.${axis}`).text, message ('metric').text], + ...groups.map (group => [groupText (group), String (group.count)])] + : [columns.map (column => message (countColumns[column]).text), ...versions.map (row => columns.map (column => cell (row, column)))] const url = URL.createObjectURL (new Blob ([countsCSV (rows)], { type: 'text/csv;charset=utf-8' })) @@ -117,136 +252,91 @@ const CountsResourcePage = ({ resource }: { resource: CountsResource }) => { } return ( - - - {title} - 集計・明細照会 -
{ - event.preventDefault () - const form = new FormData (event.currentTarget) - const next = new URLSearchParams (params) - for (const key of [ - 'resource_id', 'user_id', 'event_type', 'tag', 'limit', 'from', 'before']) - { - const raw = String (form.get (key) ?? '') - const value = (raw && ['from', 'before'].includes (key)) - ? toMinutePrecisionIsoUtc (raw) : raw - if (value) next.set (key, value) - else next.delete (key) - } - next.delete ('page') - if (next.toString () === params.toString ()) - void refetch () - else - setParams (next) - }}> -
- - {() => } - - - {() => } - - - {() => } - - {[['resource_id', resource === 'posts' ? '投稿 ID' : 'タグ ID'], - ['user_id', '操作者 ID'], - ...(resource === 'posts' ? [['tag', '記載・変更タグ ID']] : [])] - .map (([key, name]) => ( - - {() => } - ))} - - {() => } - - - {() => } - +
+ + + + + + + {label (`titles.${resource}`)} +
+ { + if (next.toString () === canonical) void refetch () + else setParams (next) + }}/> +
- {mode === 'summary' ? ( - - {() => } - ) : ( -
- 出力項目 - {availableColumns.map (column => ( - ))} -
)} -
- - -
- -

変更日時:開始以上・終了未満。入力は端末の時刻帯、月別は UTC。

-

件数は履歴レコード数です。

- {isPending &&

{label ('shared:messages.loadingEllipsis')}

} - {isError &&

{label ('common:fetchError')}

} - {data != null && <> -

総件数:{data.count}

-
- - - {mode === 'summary' - ? <> - : <> - {columns.map (column => )}} - - - {mode === 'summary' ? groups.map (group => ( - - - - )) : versions.map (row => { - const id = 'postId' in row ? row.postId : row.tagId - return - - {columns.map (column => )} - - })} - -
集計軸件数{resource === 'posts' ? '投稿' : 'タグ'} - {countColumns[column]}
- {groupName (group)}{group.count}
#{id} - {column === 'url' - ? - {cell (row, column)} - : cell (row, column)}
-
- - } -
) + {isPending &&

{label ('shared:messages.loadingEllipsis')}

} + {isError &&

{label ('common:fetchError')}

} + {data != null && <> +

{label ('total', { count: data.count })}

+
+ + + {mode === 'summary' + ? <> + + : <> + {columns.map (column => ( + ))}} + + + {mode === 'summary' ? groups.map (group => ( + + + + )) : versions.map (row => { + const id = 'postId' in row ? row.postId : row.tagId + return + + {columns.map (column => ( + ))} + + })} + +
{label (`axes.${axis}`)}{label ('metric')} + {label (resource === 'posts' ? 'history:post' : 'navigation:tags')} + {label (countColumns[column])}
+ {group.key == null ? label ('history:bot') : groupName (group)} + {group.count}
#{id} + {column === 'url' + ? + {cell (row, column)} + : (column === 'category' && 'tagId' in row + ? + {cell (row, column)} + : cell (row, column))}
+ {(mode === 'summary' ? groups.length : versions.length) === 0 + &&

{label ('empty')}

} +
+ + } + +
) }