このコミットが含まれているのは:
@@ -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,
|
||||||
|
|||||||
@@ -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'))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 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
|
||||||
|
|||||||
@@ -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')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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>)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
新しいイシューから参照
ユーザーをブロックする