このコミットが含まれているのは:
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
- 原則として、ユーザ向け 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.
+14 -1
ファイルの表示
@@ -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,
+40 -1
ファイルの表示
@@ -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'))
}
}
})
}
+24 -1
ファイルの表示
@@ -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')
})
})
+30 -6
ファイルの表示
@@ -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,
+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 PageHead from '@/components/common/PageHead'
import PageTitle from '@/components/common/PageTitle'
import MainArea from '@/components/layout/MainArea'
import CountsResourcePage from '@/pages/CountsResourcePage'
const CountsPage = () => (
<MainArea>
<PageHead title="集計・明細照会"/>
<PageTitle>集計・明細照会</PageTitle>
<ul>
<li><PrefetchLink to="/counts/posts">投稿</PrefetchLink></li>
<li><PrefetchLink to="/counts/tags">タグ</PrefetchLink></li>
</ul>
</MainArea>)
const CountsPage = () => <CountsResourcePage resource="posts"/>
export default CountsPage
+106 -32
ファイルの表示
@@ -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<typeof import ('@/lib/counts')> (), fetchCounts }))
vi.mock ('@/lib/tags', async importOriginal => ({
...await importOriginal<typeof import ('@/lib/tags')> (), 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 (<CountsPage/>, { 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 (<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 () => {
it ('preserves draft filters on collapse without querying until execution', async () => {
renderWithProviders (<CountsResourcePage resource="posts"/>, {
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 (<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,
groups: [{ key: '2024-01', name: null, count: 4 }] })
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 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')
})
})
+228 -138
ファイルの表示
@@ -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)
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')
setParams (next)
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 => {
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 (
<MainArea>
<PageHead title={title}/>
<PageTitle>{title}</PageTitle>
<PrefetchLink to="/counts">集計・明細照会</PrefetchLink>
<form key={params.toString ()} className="my-3 space-y-3"
onSubmit={event => {
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)
}}>
<div className="flex flex-wrap gap-3">
<FormField label="表示">
{() => <select className={inputClass ()} value={mode}
onChange={event => update ('mode', event.target.value)}>
<option value="summary">集計</option>
<option value="details">明細</option>
</select>}
</FormField>
<FormField label="変更日時の開始">
{() => <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 data-sidebar-container
className="flex min-h-0 flex-1 flex-col overflow-hidden md:flex-row">
<PageHead title={message (`titles.${resource}`)}/>
<Button type="button" variant="ghost" size="sm" className="shrink-0 md:hidden"
aria-expanded={sidebarOpen} aria-controls="counts-targets"
onClick={() => setSidebarOpen (!sidebarOpen)}>{label ('target')}</Button>
<SidebarComponent sidebarKey="counts" maxWidth={224}
className={cn ('md:block md:max-w-56', !sidebarOpen && 'hidden')}>
<nav id="counts-targets" aria-label={message ('target').text}
lang={message ('target').language}>
<h2 className="mb-2 font-semibold">{label ('target')}</h2>
<ul className="space-y-1">
{(['posts', 'tags'] as const).map (target => (
<li key={target}><PrefetchLink to={`/counts/${target}`}
aria-current={target === resource ? 'page' : undefined}
className={cn ('block py-1', target === resource && 'font-bold underline')}>
{label (target === 'posts' ? 'history:post' : 'navigation:tags')}
</PrefetchLink></li>))}
</ul>
</nav>
</SidebarComponent>
<MainArea className="flex min-h-0 min-w-0 flex-col p-2">
<PageTitle className="mb-1 text-base">{label (`titles.${resource}`)}</PageTitle>
<div className="relative pr-16">
<CountsControls key={`${resource}:${canonical}`} resource={resource} applied={params}
open={open} setOpen={setOpen} execute={next => {
if (next.toString () === canonical) void refetch ()
else setParams (next)
}}/>
<Button type="button" variant="outline" size="sm"
className="absolute right-0 top-0"
title={message ('csvScope').text} lang={message ('csvScope').language}
disabled={data == null || isError} onClick={exportCSV}>{label ('csv')}</Button>
</div>
{mode === 'summary' ? (
<FormField label="集計軸">
{() => <select className={inputClass ()} value={axis}
onChange={event => update ('axis', event.target.value)}>
<option value="event_type">操作種別</option>
<option value="month">月別(UTC)</option>
<option value="user">操作者</option>
</select>}
</FormField>) : (
<fieldset className="flex flex-wrap gap-3">
<legend>出力項目</legend>
{availableColumns.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 (','))}/>
{countColumns[column]}
</label>))}
</fieldset>)}
<div className="flex flex-col gap-2 md:flex-row">
<Button type="submit">実行</Button>
<Button type="button" variant="outline" className="h-auto min-h-10 whitespace-normal"
disabled={data == null || isError}
onClick={exportCSV}>CSV 出力(表示中のページ・選択列)</Button>
</div>
</form>
<p>変更日時:開始以上・終了未満。入力は端末の時刻帯、月別は UTC。</p>
<p>件数は履歴レコード数です。</p>
{isPending && <p>{label ('shared:messages.loadingEllipsis')}</p>}
{isError && <p role="alert">{label ('common:fetchError')}</p>}
{data != null && <>
<p>総件数:{data.count}</p>
<div className="overflow-x-auto">
<table className="w-full text-sm text-left">
<p className="my-1 text-xs">{label ('total', { count: data.count })}</p>
<div className="min-h-0 flex-1 overflow-auto" data-counts-results>
<table className="w-full text-left text-sm">
<thead><tr>
{mode === 'summary'
? <><th className="p-2">集計軸</th><th className="p-2">件数</th></>
: <><th className="p-2">{resource === 'posts' ? '投稿' : 'タグ'}</th>
{columns.map (column => <th key={column} className="p-2 whitespace-nowrap">
{countColumns[column]}</th>)}</>}
? <><th className="px-2 py-1">{label (`axes.${axis}`)}</th>
<th className="px-2 py-1">{label ('metric')}</th></>
: <><th className="px-2 py-1">
{label (resource === 'posts' ? 'history:post' : 'navigation:tags')}</th>
{columns.map (column => (
<th key={column} className="whitespace-nowrap px-2 py-1">
{label (countColumns[column])}</th>))}</>}
</tr></thead>
<tbody>
{mode === 'summary' ? groups.map (group => (
<tr key={String (group.key)} className="border-b dark:border-slate-700">
<td className="p-2"><PrefetchLink to={drillDown (group.key)}>
{groupName (group)}</PrefetchLink></td>
<td className="p-2">{group.count}</td>
<td className="px-2 py-1"><PrefetchLink to={drillDown (group.key)}>
{group.key == null ? label ('history:bot') : groupName (group)}
</PrefetchLink></td>
<td className="px-2 py-1">{group.count}</td>
</tr>)) : versions.map (row => {
const id = 'postId' in row ? row.postId : row.tagId
return <tr key={`${id}-${row.versionNo}`}
className="border-b dark:border-slate-700">
<td className="p-2"><PrefetchLink
<td className="px-2 py-1"><PrefetchLink
to={`/${resource}/${encodeURIComponent (id)}`}>#{id}</PrefetchLink></td>
{columns.map (column => <td key={column} className="p-2 max-w-md break-words">
{columns.map (column => (
<td key={column} className="max-w-md break-words px-2 py-1">
{column === 'url'
? <a href={cell (row, column)} target="_blank" rel="noreferrer">
{cell (row, column)}</a>
: cell (row, column)}</td>)}
: (column === 'category' && 'tagId' in row
? <LocalisedText language={message (
`common:categories.${row.category.current}`).language}>
{cell (row, column)}</LocalisedText>
: cell (row, column))}</td>))}
</tr>
})}
</tbody>
</table>
{(mode === 'summary' ? groups.length : versions.length) === 0
&& <p className="p-2">{label ('empty')}</p>}
</div>
<Pagination page={data.page} totalPages={Math.ceil (
(mode === 'summary' ? data.groupCount ?? 0 : data.count) / data.limit)}/>
</>}
</MainArea>)
</MainArea>
</div>)
}