このコミットが含まれているのは:
2026-10-25 14:53:35 +09:00
コミット d411b3b40f
10個のファイルの変更、528行の追加、218行の削除
+13
ファイルの表示
@@ -631,6 +631,19 @@ and layout reuse, follow `frontend/AGENTS.md`.
## Frontend rules ## 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. - 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`; - Add or reuse TanStack Query keys through `frontend/src/lib/queryKeys.ts`;
avoid ad hoc query key arrays. avoid ad hoc query key arrays.
+14 -1
ファイルの表示
@@ -128,6 +128,19 @@ pass or the remaining failure is clearly blocked.
## Tailwind and UI ## 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}`. - Tailwind scans `src/**/*.{html,js,ts,jsx,tsx,mdx}`.
- Use `cn` from `src/lib/utils.ts` for conditional class names and class merging. - 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` - 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 heavy borders, or nested panel decoration unless the surrounding screen
already uses them. already uses them.
- Keep operational screens dense and direct; trim explanatory copy and use - 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 - Preserve existing Japanese tone and orthography in nearby UI text, including
old-kana wording where the file already uses it. old-kana wording where the file already uses it.
- Do not add user-facing copy, helper text, descriptions, notes, tooltips, - Do not add user-facing copy, helper text, descriptions, notes, tooltips,
+40 -1
ファイルの表示
@@ -65,7 +65,8 @@ const responseFor = url => {
if (path === '/counts/posts' || path === '/counts/tags') { if (path === '/counts/posts' || path === '/counts/tags') {
const resource = path.endsWith ('posts') ? 'posts' : 'tags' const resource = path.endsWith ('posts') ? 'posts' : 'tags'
return { count: 1, page: 1, limit: 20, group_count: 1, 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' versions: resource === 'posts'
? [{ post_id: 1, version_no: 1, title: { current: `SSR count ${locale}` } }] ? [{ post_id: 1, version_no: 1, title: { current: `SSR count ${locale}` } }]
: [{ tag_id: 1, version_no: 1, name: { 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.equal (result.status, 200)
assert.match (result.html, new RegExp (`SSR count ${locale}`)) assert.match (result.html, new RegExp (`SSR count ${locale}`))
assert.match (result.html, new RegExp (`/${locale}/${resource}/1`)) 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}`) assert.ok (requests.some (request => request.url.includes (`/counts/${resource}`)
&& request.url.includes ('user_id=4') && request.url.includes (`locale=${locale}`))) && 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 if (ready) break
} }
assert.ok (ready, 'history query results must render') 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]) { for (const dark of [false, true]) {
await evaluate (`document.documentElement.classList.toggle('dark',${dark})`) await evaluate (`document.documentElement.classList.toggle('dark',${dark})`)
assert.ok (await evaluate ('document.documentElement.scrollWidth<=innerWidth'), assert.ok (await evaluate ('document.documentElement.scrollWidth<=innerWidth'),
'counts controls must fit the viewport in both themes') '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'))
}
} }
}) })
} }
+24 -1
ファイルの表示
@@ -1,9 +1,30 @@
import { describe, expect, it } from 'vitest' 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', () => { 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', () => { it ('escapes quotes, newlines and spreadsheet formulas in UTF-8 CSV', () => {
expect (countsCSV ([['日本語', 'a"b', 'a\nb', '=1+1']])) expect (countsCSV ([['日本語', 'a"b', 'a\nb', '=1+1']]))
.toBe ('\uFEFF"日本語","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 ('posts')).not.toContain ('aliases')
expect (columnsForCounts ('tags')).toContain ('aliases') expect (columnsForCounts ('tags')).toContain ('aliases')
expect (columnsForCounts ('tags')).not.toContain ('url') expect (columnsForCounts ('tags')).not.toContain ('url')
expect (columnsForCounts ('posts')).not.toContain ('event_type')
expect (columnsForCounts ('tags')).not.toContain ('event_type')
}) })
}) })
+30 -6
ファイルの表示
@@ -13,22 +13,46 @@ export type CountsResult = {
versions?: (PostVersion | TagVersion)[] } versions?: (PostVersion | TagVersion)[] }
export const countColumns = { export const countColumns = {
created_at: '変更日時', resource_id: 'ID', version_no: '版番号', created_at: 'counts:columns.changedAt', resource_id: 'counts:columns.resourceId',
event_type: '操作種別', user: '操作者', title: '投稿タイトル', url: '投稿 URL', version_no: 'history:version', user: 'history:actor',
tags: '記載タグ', added_tags: '追加されたタグ', removed_tags: '削除されたタグ', title: 'counts:columns.postTitle', url: 'counts:columns.postURL',
name: 'タグ名', category: 'カテゴリ', aliases: '別名', parents: '上位タグ', tags: 'counts:columns.tags', added_tags: 'counts:columns.addedTags',
deprecated: '廃止状態' } 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 type CountColumn = keyof typeof countColumns
export const columnsForCounts = (resource: CountsResource): CountColumn[] => [ export const columnsForCounts = (resource: CountsResource): CountColumn[] => [
'created_at', 'resource_id', 'version_no', 'event_type', 'user', 'created_at', 'resource_id', 'version_no', 'user',
...(resource === 'posts' ...(resource === 'posts'
? ['title', 'url', 'tags', 'added_tags', 'removed_tags'] as const ? ['title', 'url', 'tags', 'added_tags', 'removed_tags'] as const
: ['name', 'category', 'aliases', 'parents', 'deprecated'] 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 = ( export const fetchCounts = (
resource: CountsResource, resource: CountsResource,
params: URLSearchParams, params: URLSearchParams,
+3
ファイルの表示
@@ -0,0 +1,3 @@
{
"_untranslated": ["*"]
}
+42
ファイルの表示
@@ -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": "表示件数"
}
}
+2 -13
ファイルの表示
@@ -1,17 +1,6 @@
import PrefetchLink from '@/components/PrefetchLink' import CountsResourcePage from '@/pages/CountsResourcePage'
import PageHead from '@/components/common/PageHead'
import PageTitle from '@/components/common/PageTitle'
import MainArea from '@/components/layout/MainArea'
const CountsPage = () => ( const CountsPage = () => <CountsResourcePage resource="posts"/>
<MainArea>
<PageHead title="集計・明細照会"/>
<PageTitle>集計・明細照会</PageTitle>
<ul>
<li><PrefetchLink to="/counts/posts">投稿</PrefetchLink></li>
<li><PrefetchLink to="/counts/tags">タグ</PrefetchLink></li>
</ul>
</MainArea>)
export default CountsPage export default CountsPage
+106 -32
ファイルの表示
@@ -7,77 +7,151 @@ import { renderWithProviders } from '@/test/render'
import type { CountsResult } from '@/lib/counts' import type { CountsResult } from '@/lib/counts'
const { fetchCounts, fetchTagByName } = vi.hoisted (() => ({
const { fetchCounts } = vi.hoisted (() => ({ fetchCounts: vi.fn () })) fetchCounts: vi.fn (), fetchTagByName: vi.fn () }))
vi.mock ('@/lib/counts', async importOriginal => ({ vi.mock ('@/lib/counts', async importOriginal => ({
...await importOriginal<typeof import ('@/lib/counts')> (), fetchCounts })) ...await importOriginal<typeof import ('@/lib/counts')> (), fetchCounts }))
vi.mock ('@/lib/tags', async importOriginal => ({
...await importOriginal<typeof import ('@/lib/tags')> (), fetchTagByName }))
beforeEach (() => { beforeEach (() => {
fetchCounts.mockReset () fetchCounts.mockReset ()
fetchTagByName.mockReset ()
fetchCounts.mockResolvedValue ({ count: 0, groupCount: 0, groups: [], page: 1, limit: 20 }) fetchCounts.mockResolvedValue ({ count: 0, groupCount: 0, groups: [], page: 1, limit: 20 })
}) })
describe ('counts pages', () => { const conditions = () => screen.getByRole ('button', { name: '集計条件' })
it ('links to the two locale-aware resource screens', () => {
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 (<CountsPage/>, { route: '/ja/counts' }) renderWithProviders (<CountsPage/>, { route: '/ja/counts' })
fireEvent.click (screen.getByRole ('button', { name: '集計対象' }))
expect (screen.getByRole ('link', { name: '投稿' })) expect (screen.getByRole ('link', { name: '投稿' }))
.toHaveAttribute ('href', '/ja/counts/posts') .toHaveAttribute ('href', '/ja/counts/posts')
expect (screen.getByRole ('link', { name: '投稿' }))
.toHaveAttribute ('aria-current', 'page')
expect (screen.getByRole ('link', { name: 'タグ' })) expect (screen.getByRole ('link', { name: 'タグ' }))
.toHaveAttribute ('href', '/ja/counts/tags') .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 () => { it ('preserves draft filters on collapse without querying until execution', 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 (<CountsResourcePage resource="posts"/>, {
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 () => {
renderWithProviders (<CountsResourcePage resource="posts"/>, { renderWithProviders (<CountsResourcePage resource="posts"/>, {
route: '/ja/counts/posts?page=3&mode=details' }) 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' }), { fireEvent.change (screen.getByRole ('textbox', { name: '投稿 ID' }), {
target: { value: '7' } }) target: { value: '7' } })
fireEvent.change (screen.getByRole ('textbox', { name: '操作者 ID' }), { fireEvent.change (screen.getByRole ('textbox', { name: '操作者 ID' }), {
target: { value: '9' } }) 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 (() => { await waitFor (() => {
const params = fetchCounts.mock.lastCall?.[1] as URLSearchParams const params = fetchCounts.mock.lastCall?.[1] as URLSearchParams
expect (params.get ('resource_id')).toBe ('7') expect (params.get ('resource_id')).toBe ('7')
expect (params.get ('user_id')).toBe ('9') expect (params.get ('user_id')).toBe ('9')
expect (params.has ('page')).toBe (false) expect (params.has ('page')).toBe (false)
}) })
const calls = fetchCounts.mock.calls.length const refreshed = fetchCounts.mock.calls.length
fireEvent.submit (screen.getByRole ('button', { name: '実行' }).closest ('form')!) execute ()
await waitFor (() => expect (fetchCounts.mock.calls.length).toBeGreaterThan (calls)) 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 (<CountsResourcePage resource="posts"/>, {
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 (<CountsResourcePage resource="tags"/>, {
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 (<CountsResourcePage resource="posts"/>, {
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 (<CountsResourcePage resource="tags"/>, {
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 (<CountsResourcePage resource="posts"/>, {
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, fetchCounts.mockResolvedValue ({ count: 4, groupCount: 1, page: 1, limit: 20,
groups: [{ key: '2024-01', name: null, count: 4 }] }) groups: [{ key: '2024-01', name: null, count: 4 }] })
renderWithProviders (<CountsResourcePage resource="tags"/>, { renderWithProviders (<CountsResourcePage resource="tags"/>, {
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 link = await screen.findByRole ('link', { name: '2024-01' })
const url = new URL (link.getAttribute ('href')!, 'http://localhost') const url = new URL (link.getAttribute ('href')!, 'http://localhost')
expect (url.searchParams.get ('from')).toBe ('2024-01-15T00:00Z') expect (url.searchParams.get ('from')).toBe ('2024-01-15T00:00Z')
expect (url.searchParams.get ('before')).toBe ('2024-02-01T00: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') expect (url.searchParams.get ('mode')).toBe ('details')
}) })
}) })
+254 -164
ファイルの表示
@@ -1,17 +1,22 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { useEffect, useId, useState } from 'react'
import { useSearchParams } from 'react-router-dom' import { useSearchParams } from 'react-router-dom'
import LocalisedText from '@/components/LocalisedText'
import PrefetchLink from '@/components/PrefetchLink' 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 FormField from '@/components/common/FormField'
import PageHead from '@/components/common/PageHead' import PageHead from '@/components/common/PageHead'
import PageTitle from '@/components/common/PageTitle' import PageTitle from '@/components/common/PageTitle'
import Pagination from '@/components/common/Pagination' import Pagination from '@/components/common/Pagination'
import MainArea from '@/components/layout/MainArea' import MainArea from '@/components/layout/MainArea'
import SidebarComponent from '@/components/layout/SidebarComponent'
import { Button } from '@/components/ui/button' 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 { 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 { useLocale } from '@/lib/useLocale'
import { useUITranslation } from '@/lib/useUITranslation' import { useUITranslation } from '@/lib/useUITranslation'
@@ -19,41 +24,169 @@ import type { CountColumn, CountsResource } from '@/lib/counts'
import type { PostVersion, TagVersion } from '@/types' import type { PostVersion, TagVersion } from '@/types'
const CountsResourcePage = ({ resource }: { resource: CountsResource }) => { const CountsControls = (
const [params, setParams] = useSearchParams () { 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 locale = useLocale ()
const { label, message } = useUITranslation ('history') const id = useId ()
const mode = params.get ('mode') ?? 'summary' const [draft, setDraft] = useState (() => new URLSearchParams (applied))
const axis = params.get ('axis') ?? 'event_type' const [error, setError] = useState (false)
const availableColumns = columnsForCounts (resource) const [pending, setPending] = useState (false)
const selectedColumns = params.has ('columns') const available = columnsForCounts (resource)
? params.get ('columns')!.split (',') as CountColumn[] const columns = (draft.get ('columns')?.split (',') ?? available) as CountColumn[]
: availableColumns const mode = draft.get ('mode') ?? 'summary'
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 update = (key: string, value: string) => { const update = (key: string, value: string) => {
const next = new URLSearchParams (params) setDraft (previous => {
if (value) const next = new URLSearchParams (previous)
next.set (key, value) if (value) next.set (key, value)
else else next.delete (key)
next.delete (key) return next
next.delete ('page') })
setParams (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 (
<form className="shrink-0" onSubmit={event => {
event.preventDefault ()
void submit ()
}}>
<div className="flex flex-wrap items-center gap-2 border-b pb-2 dark:border-slate-700">
<Button type="submit" size="sm" disabled={pending}>{label ('run')}</Button>
<Button type="button" size="sm" variant="outline" aria-expanded={open}
aria-controls={id} onClick={() => setOpen (!open)}>
{label ('conditions')}<span aria-hidden>{open ? '▴' : '▾'}</span>
</Button>
<select className={inputClass (false, 'w-auto py-1')}
aria-label={message ('mode').text} lang={message ('mode').language}
value={mode} onChange={event => update ('mode', event.target.value)}>
<option value="summary" lang={message ('summary').language}>
{message ('summary').text}</option>
<option value="details" lang={message ('details').language}>
{message ('details').text}</option>
</select>
</div>
{error && <p role="alert">{label ('shared:messages.checkInputModern')}</p>}
<div id={id} hidden={!open} className="border-b py-2 dark:border-slate-700">
<div className="grid gap-4 md:grid-cols-2">
<fieldset className="min-w-0" data-counts-output>
<legend className="mb-2 font-semibold">{label ('output')}</legend>
{mode === 'summary' ? (
<label className="flex flex-wrap items-center gap-2">
{label ('axis')}
<select className={inputClass (false, 'w-auto py-1')}
value={draft.get ('axis') ?? 'month'}
aria-label={message ('axis').text} lang={message ('axis').language}
onChange={event => update ('axis', event.target.value)}>
{['month', 'user'].map (axis => (
<option key={axis} value={axis} lang={message (`axes.${axis}`).language}>
{message (`axes.${axis}`).text}</option>))}
</select>
</label>) : (
<div className="grid gap-1 md:grid-cols-2">
{available.map (column => (
<label key={column} className="flex items-center gap-1">
<input type="checkbox" checked={columns.includes (column)}
disabled={columns.length === 1 && columns.includes (column)}
onChange={event => update ('columns', (event.target.checked
? [...columns, column]
: columns.filter (item => item !== column)).join (','))}/>
{label (countColumns[column])}
</label>))}
</div>)}
</fieldset>
<fieldset className="min-w-0 space-y-2" data-counts-filters>
<legend className="mb-2 font-semibold">{label ('filters')}</legend>
<div className="grid gap-2 md:grid-cols-2">
{['from', 'before'].map (key => (
<FormField key={key} label={label (`fields.${key}`)}>
{() => <DateTimeField value={draft.get (key) ?? ''}
aria-label={message (`fields.${key}`).text}
lang={message (`fields.${key}`).language}
onChange={value => update (key, value ?? '')}/>}
</FormField>))}
</div>
<div className="grid gap-2 md:grid-cols-2">
{[['resource_id', resource === 'posts' ? 'post' : 'tag'], ['user_id', 'actor'],
...(resource === 'posts' ? [['tag', 'tag']] : []), ['limit', 'limit']]
.map (([key, field]) => (
<FormField key={key} label={label (`fields.${field}`)}>
{() => <input aria-label={message (`fields.${field}`).text}
lang={message (`fields.${field}`).language}
type={key === 'limit' ? 'number' : 'text'} min={1} max={100}
value={draft.get (key) ?? (key === 'limit' ? '20' : '')}
onChange={event => update (key, event.target.value)}
className={inputClass (false, 'py-1')}/>}
</FormField>))}
</div>
</fieldset>
</div>
</div>
</form>)
}
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 => { const cell = (row: PostVersion | TagVersion, column: CountColumn): string => {
switch (column) switch (column)
{ {
case 'created_at': return localisedDateString (row.createdAt, locale) case 'created_at': return localisedDateString (row.createdAt, locale)
case 'resource_id': return String ('postId' in row ? row.postId : row.tagId) case 'resource_id': return String ('postId' in row ? row.postId : row.tagId)
case 'version_no': return String (row.versionNo) 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) if ('postId' in row)
{ {
@@ -74,15 +207,14 @@ const CountsResourcePage = ({ resource }: { resource: CountsResource }) => {
case 'parents': case 'parents':
return row.parentTags.filter (item => item.type !== 'removed') return row.parentTags.filter (item => item.type !== 'removed')
.map (item => item.tag.name).join (' ') .map (item => item.tag.name).join (' ')
case 'deprecated': return row.deprecatedAt.current ?? '—' case 'deprecated': return row.deprecatedAt.current ?? ''
default: return '' default: return ''
} }
} }
const groups = data?.groups ?? [] const groups = data?.groups ?? []
const versions = data?.versions ?? [] const versions = data?.versions ?? []
const groupName = (group: typeof groups[number]) => axis === 'event_type' const groupName = (group: typeof groups[number]) =>
? eventName (String (group.key)) axis === 'user' ? group.name ?? String (group.key ?? '') : String (group.key)
: (axis === 'user' ? group.name ?? String (group.key ?? '—') : String (group.key))
const drillDown = (key: string | number | null): string => { const drillDown = (key: string | number | null): string => {
const next = new URLSearchParams (params) const next = new URLSearchParams (params)
next.set ('mode', 'details') next.set ('mode', 'details')
@@ -99,13 +231,16 @@ const CountsResourcePage = ({ resource }: { resource: CountsResource }) => {
next.set ('before', before != null && before < last ? before : last) next.set ('before', before != null && before < last ? before : last)
} }
else else
next.set (axis === 'user' ? 'user_id' : 'event_type', String (key ?? 'none')) next.set ('user_id', String (key ?? 'none'))
return `?${next}` return `?${next}`
} }
const groupText = (group: typeof groups[number]) =>
group.key == null ? message ('history:bot').text : groupName (group)
const exportCSV = () => { const exportCSV = () => {
const rows = mode === 'summary' const rows = mode === 'summary'
? [['集計軸', '件数'], ...groups.map (group => [groupName (group), String (group.count)])] ? [[message (`axes.${axis}`).text, message ('metric').text],
: [columns.map (column => countColumns[column]), ...groups.map (group => [groupText (group), String (group.count)])]
: [columns.map (column => message (countColumns[column]).text),
...versions.map (row => columns.map (column => cell (row, column)))] ...versions.map (row => columns.map (column => cell (row, column)))]
const url = URL.createObjectURL (new Blob ([countsCSV (rows)], { const url = URL.createObjectURL (new Blob ([countsCSV (rows)], {
type: 'text/csv;charset=utf-8' })) type: 'text/csv;charset=utf-8' }))
@@ -117,136 +252,91 @@ const CountsResourcePage = ({ resource }: { resource: CountsResource }) => {
} }
return ( return (
<MainArea> <div data-sidebar-container
<PageHead title={title}/> className="flex min-h-0 flex-1 flex-col overflow-hidden md:flex-row">
<PageTitle>{title}</PageTitle> <PageHead title={message (`titles.${resource}`)}/>
<PrefetchLink to="/counts">集計・明細照会</PrefetchLink> <Button type="button" variant="ghost" size="sm" className="shrink-0 md:hidden"
<form key={params.toString ()} className="my-3 space-y-3" aria-expanded={sidebarOpen} aria-controls="counts-targets"
onSubmit={event => { onClick={() => setSidebarOpen (!sidebarOpen)}>{label ('target')}</Button>
event.preventDefault () <SidebarComponent sidebarKey="counts" maxWidth={224}
const form = new FormData (event.currentTarget) className={cn ('md:block md:max-w-56', !sidebarOpen && 'hidden')}>
const next = new URLSearchParams (params) <nav id="counts-targets" aria-label={message ('target').text}
for (const key of [ lang={message ('target').language}>
'resource_id', 'user_id', 'event_type', 'tag', 'limit', 'from', 'before']) <h2 className="mb-2 font-semibold">{label ('target')}</h2>
{ <ul className="space-y-1">
const raw = String (form.get (key) ?? '') {(['posts', 'tags'] as const).map (target => (
const value = (raw && ['from', 'before'].includes (key)) <li key={target}><PrefetchLink to={`/counts/${target}`}
? toMinutePrecisionIsoUtc (raw) : raw aria-current={target === resource ? 'page' : undefined}
if (value) next.set (key, value) className={cn ('block py-1', target === resource && 'font-bold underline')}>
else next.delete (key) {label (target === 'posts' ? 'history:post' : 'navigation:tags')}
} </PrefetchLink></li>))}
next.delete ('page') </ul>
if (next.toString () === params.toString ()) </nav>
void refetch () </SidebarComponent>
else <MainArea className="flex min-h-0 min-w-0 flex-col p-2">
setParams (next) <PageTitle className="mb-1 text-base">{label (`titles.${resource}`)}</PageTitle>
}}> <div className="relative pr-16">
<div className="flex flex-wrap gap-3"> <CountsControls key={`${resource}:${canonical}`} resource={resource} applied={params}
<FormField label="表示"> open={open} setOpen={setOpen} execute={next => {
{() => <select className={inputClass ()} value={mode} if (next.toString () === canonical) void refetch ()
onChange={event => update ('mode', event.target.value)}> else setParams (next)
<option value="summary">集計</option> }}/>
<option value="details">明細</option> <Button type="button" variant="outline" size="sm"
</select>} className="absolute right-0 top-0"
</FormField> title={message ('csvScope').text} lang={message ('csvScope').language}
<FormField label="変更日時の開始"> disabled={data == null || isError} onClick={exportCSV}>{label ('csv')}</Button>
{() => <DateTimeField name="from" value={params.get ('from') ?? ''}/>}
</FormField>
<FormField label="変更日時の終了">
{() => <DateTimeField name="before" value={params.get ('before') ?? ''}/>}
</FormField>
{[['resource_id', resource === 'posts' ? '投稿 ID' : 'タグ ID'],
['user_id', '操作者 ID'],
...(resource === 'posts' ? [['tag', '記載・変更タグ ID']] : [])]
.map (([key, name]) => (
<FormField key={key} label={name}>
{() => <input name={key} aria-label={name}
defaultValue={params.get (key) ?? ''}
className={inputClass ()} size={10}/>}
</FormField>))}
<FormField label="操作種別">
{() => <select name="event_type" className={inputClass ()}
defaultValue={params.get ('event_type') ?? ''}>
<option value="">—</option>
{['create', 'update', 'discard', 'restore'].map (event => (
<option key={event} value={event}>{eventName (event)}</option>))}
</select>}
</FormField>
<FormField label="表示件数">
{() => <input name="limit" type="number" min={1} max={100}
defaultValue={params.get ('limit') ?? '20'}
className={inputClass ()}/>}
</FormField>
</div> </div>
{mode === 'summary' ? ( {isPending && <p>{label ('shared:messages.loadingEllipsis')}</p>}
<FormField label="集計軸"> {isError && <p role="alert">{label ('common:fetchError')}</p>}
{() => <select className={inputClass ()} value={axis} {data != null && <>
onChange={event => update ('axis', event.target.value)}> <p className="my-1 text-xs">{label ('total', { count: data.count })}</p>
<option value="event_type">操作種別</option> <div className="min-h-0 flex-1 overflow-auto" data-counts-results>
<option value="month">月別(UTC)</option> <table className="w-full text-left text-sm">
<option value="user">操作者</option> <thead><tr>
</select>} {mode === 'summary'
</FormField>) : ( ? <><th className="px-2 py-1">{label (`axes.${axis}`)}</th>
<fieldset className="flex flex-wrap gap-3"> <th className="px-2 py-1">{label ('metric')}</th></>
<legend>出力項目</legend> : <><th className="px-2 py-1">
{availableColumns.map (column => ( {label (resource === 'posts' ? 'history:post' : 'navigation:tags')}</th>
<label key={column} className="flex items-center gap-1"> {columns.map (column => (
<input type="checkbox" checked={columns.includes (column)} <th key={column} className="whitespace-nowrap px-2 py-1">
disabled={columns.length === 1 && columns.includes (column)} {label (countColumns[column])}</th>))}</>}
onChange={event => update ('columns', (event.target.checked </tr></thead>
? [...columns, column] <tbody>
: columns.filter (item => item !== column)).join (','))}/> {mode === 'summary' ? groups.map (group => (
{countColumns[column]} <tr key={String (group.key)} className="border-b dark:border-slate-700">
</label>))} <td className="px-2 py-1"><PrefetchLink to={drillDown (group.key)}>
</fieldset>)} {group.key == null ? label ('history:bot') : groupName (group)}
<div className="flex flex-col gap-2 md:flex-row"> </PrefetchLink></td>
<Button type="submit">実行</Button> <td className="px-2 py-1">{group.count}</td>
<Button type="button" variant="outline" className="h-auto min-h-10 whitespace-normal" </tr>)) : versions.map (row => {
disabled={data == null || isError} const id = 'postId' in row ? row.postId : row.tagId
onClick={exportCSV}>CSV 出力(表示中のページ・選択列)</Button> return <tr key={`${id}-${row.versionNo}`}
</div> className="border-b dark:border-slate-700">
</form> <td className="px-2 py-1"><PrefetchLink
<p>変更日時:開始以上・終了未満。入力は端末の時刻帯、月別は UTC。</p> to={`/${resource}/${encodeURIComponent (id)}`}>#{id}</PrefetchLink></td>
<p>件数は履歴レコード数です。</p> {columns.map (column => (
{isPending && <p>{label ('shared:messages.loadingEllipsis')}</p>} <td key={column} className="max-w-md break-words px-2 py-1">
{isError && <p role="alert">{label ('common:fetchError')}</p>} {column === 'url'
{data != null && <> ? <a href={cell (row, column)} target="_blank" rel="noreferrer">
<p>総件数:{data.count}</p> {cell (row, column)}</a>
<div className="overflow-x-auto"> : (column === 'category' && 'tagId' in row
<table className="w-full text-sm text-left"> ? <LocalisedText language={message (
<thead><tr> `common:categories.${row.category.current}`).language}>
{mode === 'summary' {cell (row, column)}</LocalisedText>
? <><th className="p-2">集計軸</th><th className="p-2">件数</th></> : cell (row, column))}</td>))}
: <><th className="p-2">{resource === 'posts' ? '投稿' : 'タグ'}</th> </tr>
{columns.map (column => <th key={column} className="p-2 whitespace-nowrap"> })}
{countColumns[column]}</th>)}</>} </tbody>
</tr></thead> </table>
<tbody> {(mode === 'summary' ? groups.length : versions.length) === 0
{mode === 'summary' ? groups.map (group => ( && <p className="p-2">{label ('empty')}</p>}
<tr key={String (group.key)} className="border-b dark:border-slate-700"> </div>
<td className="p-2"><PrefetchLink to={drillDown (group.key)}> <Pagination page={data.page} totalPages={Math.ceil (
{groupName (group)}</PrefetchLink></td> (mode === 'summary' ? data.groupCount ?? 0 : data.count) / data.limit)}/>
<td className="p-2">{group.count}</td> </>}
</tr>)) : versions.map (row => { </MainArea>
const id = 'postId' in row ? row.postId : row.tagId </div>)
return <tr key={`${id}-${row.versionNo}`}
className="border-b dark:border-slate-700">
<td className="p-2"><PrefetchLink
to={`/${resource}/${encodeURIComponent (id)}`}>#{id}</PrefetchLink></td>
{columns.map (column => <td key={column} className="p-2 max-w-md break-words">
{column === 'url'
? <a href={cell (row, column)} target="_blank" rel="noreferrer">
{cell (row, column)}</a>
: cell (row, column)}</td>)}
</tr>
})}
</tbody>
</table>
</div>
<Pagination page={data.page} totalPages={Math.ceil (
(mode === 'summary' ? data.groupCount ?? 0 : data.count) / data.limit)}/>
</>}
</MainArea>)
} }