このコミットが含まれているのは:
+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,
|
||||
|
||||
@@ -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'))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"_untranslated": ["*"]
|
||||
}
|
||||
@@ -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": "表示件数"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 (
|
||||
<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">
|
||||
<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>)}</>}
|
||||
</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>
|
||||
</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
|
||||
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>)
|
||||
{isPending && <p>{label ('shared:messages.loadingEllipsis')}</p>}
|
||||
{isError && <p role="alert">{label ('common:fetchError')}</p>}
|
||||
{data != null && <>
|
||||
<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="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="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="px-2 py-1"><PrefetchLink
|
||||
to={`/${resource}/${encodeURIComponent (id)}`}>#{id}</PrefetchLink></td>
|
||||
{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>
|
||||
: (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>
|
||||
</div>)
|
||||
}
|
||||
|
||||
|
||||
|
||||
新しいイシューから参照
ユーザーをブロックする