このコミットが含まれているのは:
@@ -62,6 +62,14 @@ const responseFor = url => {
|
||||
return { locales: [{ code: 'ja', name: '日本語' }, { code: 'ain-Kana', name: 'アイヌ' }] }
|
||||
if (path === '/users/settings') return { theme: 'system', wiki_editor_mode: 'split' }
|
||||
if (path === '/users/theme_slots') return []
|
||||
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 }],
|
||||
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}` } }] }
|
||||
}
|
||||
if (path === '/posts') return { posts: [post (locale)], count: 1 }
|
||||
if (path === '/gekanator/posts') return { posts: [post (locale)] }
|
||||
if (path === '/gekanator/questions') return { questions: [] }
|
||||
@@ -300,6 +308,21 @@ after (async () => {
|
||||
if (errors.length) throw new AggregateError (errors, 'SSR teardown failed')
|
||||
})
|
||||
|
||||
test ('counts SSR renders history data and retains URL query state', async () => {
|
||||
for (const locale of ['ja', 'ain-Kana']) {
|
||||
for (const resource of ['posts', 'tags']) {
|
||||
const column = resource === 'posts' ? 'title' : 'name'
|
||||
const query = `mode=details&columns=${column}&user_id=4`
|
||||
const result = await renderPage (`/${locale}/counts/${resource}?${query}`, origin, apiURL)
|
||||
assert.equal (result.status, 200)
|
||||
assert.match (result.html, new RegExp (`SSR count ${locale}`))
|
||||
assert.match (result.html, new RegExp (`/${locale}/${resource}/1`))
|
||||
assert.ok (requests.some (request => request.url.includes (`/counts/${resource}`)
|
||||
&& request.url.includes ('user_id=4') && request.url.includes (`locale=${locale}`)))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test ('every canonical screen route returns page HTML without JavaScript', async t => {
|
||||
const cookie = await authenticatedCookie ()
|
||||
for (const route of routes) {
|
||||
@@ -582,7 +605,8 @@ test ('hydrate every route with stored browser preferences', {
|
||||
}` }, sessionId)
|
||||
const evaluate = async expression => (await send ('Runtime.evaluate', {
|
||||
expression, returnByValue: true }, sessionId)).result.value
|
||||
for (const route of routes.filter (entry => !entry.handle?.redirectTo)) {
|
||||
for (const route of routes.filter (entry => !entry.handle?.redirectTo
|
||||
&& (process.env.SSR_COUNTS_ONLY !== '1' || entry.path.startsWith ('counts')))) {
|
||||
for (const locale of ['ja', 'ain-Kana']) {
|
||||
await t.test (`hydrate ${locale} ${route.path}`, async () => {
|
||||
const path = route.path.replace (':identifier', 'home').replace (/:id|:title/g, '1')
|
||||
@@ -608,6 +632,31 @@ test ('hydrate every route with stored browser preferences', {
|
||||
})
|
||||
}
|
||||
}
|
||||
for (const width of [375, 1240]) {
|
||||
await send ('Emulation.setDeviceMetricsOverride', {
|
||||
width, height: 900, deviceScaleFactor: 1, mobile: width < 768 }, sessionId)
|
||||
for (const resource of ['posts', 'tags']) {
|
||||
await t.test (`counts layout ${width} ${resource}`, async () => {
|
||||
await send ('Page.navigate', {
|
||||
url: `${httpOrigin}/ja/counts/${resource}` }, sessionId)
|
||||
let ready = false
|
||||
for (let attempt = 0; attempt < 100; ++attempt) {
|
||||
await new Promise (resolve => setTimeout (resolve, 75))
|
||||
try {
|
||||
ready = await evaluate (`document.querySelector('tbody tr')!=null`)
|
||||
} catch {}
|
||||
if (ready) break
|
||||
}
|
||||
assert.ok (ready, 'history query results must render')
|
||||
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 (process.env.SSR_COUNTS_ONLY === '1') return
|
||||
probeActive = true
|
||||
for (const width of [375, 1240]) {
|
||||
await send ('Emulation.setDeviceMetricsOverride', {
|
||||
|
||||
@@ -77,6 +77,8 @@ const MaterialListPage = lazyPage (() => import ('@/pages/materials/MaterialList
|
||||
const MaterialNewPage = lazyPage (() => import ('@/pages/materials/MaterialNewPage'))
|
||||
const MaterialSyncSuppressionsPage = lazyPage (
|
||||
() => import ('@/pages/materials/MaterialSyncSuppressionsPage'))
|
||||
const CountsPage = lazyPage (() => import ('@/pages/CountsPage'))
|
||||
const CountsResourcePage = lazyPage (() => import ('@/pages/CountsResourcePage'))
|
||||
const MorePage = lazyPage (() => import ('@/pages/MorePage'))
|
||||
const GekanatorUnavailablePage = lazyPage (() => import ('@/pages/GekanatorUnavailablePage'))
|
||||
const NicoTagListPage = lazyPage (() => import ('@/pages/tags/NicoTagListPage'))
|
||||
@@ -194,6 +196,12 @@ export const pageRoutes = (
|
||||
{ id: 'gekanator', path: 'gekanator', element: <GekanatorRoute user={user}/>,
|
||||
handle: { locale: 'ja', sitemap: ['static'], preload: locale => locale === 'ja'
|
||||
? GekanatorPage.preload () : GekanatorUnavailablePage.preload () } },
|
||||
{ path: 'counts', element: <CountsPage/>,
|
||||
handle: { preload: CountsPage.preload } },
|
||||
{ path: 'counts/posts', element: <CountsResourcePage resource="posts"/>,
|
||||
handle: { preload: CountsResourcePage.preload } },
|
||||
{ path: 'counts/tags', element: <CountsResourcePage resource="tags"/>,
|
||||
handle: { preload: CountsResourcePage.preload } },
|
||||
{ path: 'more', element: <MorePage/>,
|
||||
handle: { preload: MorePage.preload, sitemap: ['static'] } },
|
||||
{ path: '*', element: <NotFound/>, handle: { notFound: true } }]
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { columnsForCounts, countsCSV } from '@/lib/counts'
|
||||
|
||||
|
||||
describe ('counts exports', () => {
|
||||
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"')
|
||||
})
|
||||
|
||||
it ('keeps resource-specific columns out of the other resource', () => {
|
||||
expect (columnsForCounts ('posts')).toContain ('added_tags')
|
||||
expect (columnsForCounts ('posts')).not.toContain ('aliases')
|
||||
expect (columnsForCounts ('tags')).toContain ('aliases')
|
||||
expect (columnsForCounts ('tags')).not.toContain ('url')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
import { apiGet } from '@/lib/api'
|
||||
|
||||
import type { LocaleCode } from '@/lib/locales'
|
||||
import type { PostVersion, TagVersion } from '@/types'
|
||||
|
||||
export type CountsResource = 'posts' | 'tags'
|
||||
export type CountsResult = {
|
||||
count: number
|
||||
page: number
|
||||
limit: number
|
||||
groupCount?: number
|
||||
groups?: { key: string | number | null; name: string | null; count: number }[]
|
||||
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: '廃止状態' }
|
||||
|
||||
export type CountColumn = keyof typeof countColumns
|
||||
|
||||
|
||||
export const columnsForCounts = (resource: CountsResource): CountColumn[] => [
|
||||
'created_at', 'resource_id', 'version_no', 'event_type', 'user',
|
||||
...(resource === 'posts'
|
||||
? ['title', 'url', 'tags', 'added_tags', 'removed_tags'] as const
|
||||
: ['name', 'category', 'aliases', 'parents', 'deprecated'] as const)]
|
||||
|
||||
|
||||
export const fetchCounts = (
|
||||
resource: CountsResource,
|
||||
params: URLSearchParams,
|
||||
locale: LocaleCode,
|
||||
): Promise<CountsResult> => apiGet (`/counts/${resource}`, {
|
||||
locale, params: Object.fromEntries (params) })
|
||||
|
||||
|
||||
export const countsCSV = (rows: string[][]): string =>
|
||||
'\uFEFF' + rows.map (row => row.map (value => {
|
||||
const safe = /^[=+@\-\t\r]/.test (value) ? `'${value}` : value
|
||||
return `"${safe.replaceAll ('"', '""')}"`
|
||||
}).join (',')).join ('\r\n')
|
||||
@@ -161,3 +161,7 @@ export const theatresKeys = {
|
||||
show: (id: string, locale: LocaleCode) => ['theatres', locale, id] as const,
|
||||
programmes: (id: string, locale: LocaleCode) =>
|
||||
['theatres', locale, id, 'programmes'] as const }
|
||||
|
||||
export const countsKeys = {
|
||||
index: (resource: string, params: string, locale: LocaleCode) =>
|
||||
['counts', resource, locale, params] as const }
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import PrefetchLink from '@/components/PrefetchLink'
|
||||
import PageHead from '@/components/common/PageHead'
|
||||
import PageTitle from '@/components/common/PageTitle'
|
||||
import MainArea from '@/components/layout/MainArea'
|
||||
|
||||
|
||||
const CountsPage = () => (
|
||||
<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
|
||||
@@ -0,0 +1,83 @@
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import CountsPage from '@/pages/CountsPage'
|
||||
import CountsResourcePage from '@/pages/CountsResourcePage'
|
||||
import { renderWithProviders } from '@/test/render'
|
||||
|
||||
import type { CountsResult } from '@/lib/counts'
|
||||
|
||||
|
||||
const { fetchCounts } = vi.hoisted (() => ({ fetchCounts: vi.fn () }))
|
||||
|
||||
vi.mock ('@/lib/counts', async importOriginal => ({
|
||||
...await importOriginal<typeof import ('@/lib/counts')> (), fetchCounts }))
|
||||
|
||||
|
||||
beforeEach (() => {
|
||||
fetchCounts.mockReset ()
|
||||
fetchCounts.mockResolvedValue ({ count: 0, groupCount: 0, groups: [], page: 1, limit: 20 })
|
||||
})
|
||||
|
||||
|
||||
describe ('counts pages', () => {
|
||||
it ('links to the two locale-aware resource screens', () => {
|
||||
renderWithProviders (<CountsPage/>, { route: '/ja/counts' })
|
||||
expect (screen.getByRole ('link', { name: '投稿' }))
|
||||
.toHaveAttribute ('href', '/ja/counts/posts')
|
||||
expect (screen.getByRole ('link', { name: 'タグ' }))
|
||||
.toHaveAttribute ('href', '/ja/counts/tags')
|
||||
})
|
||||
|
||||
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 () => {
|
||||
renderWithProviders (<CountsResourcePage resource="posts"/>, {
|
||||
route: '/ja/counts/posts?page=3&mode=details' })
|
||||
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')!)
|
||||
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))
|
||||
})
|
||||
|
||||
it ('keeps a partial-month date range when drilling down into that month', 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' })
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,253 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
|
||||
import PrefetchLink from '@/components/PrefetchLink'
|
||||
import DateTimeField, { toMinutePrecisionIsoUtc } 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 { Button } from '@/components/ui/button'
|
||||
import { columnsForCounts, countColumns, countsCSV, fetchCounts } from '@/lib/counts'
|
||||
import { countsKeys } from '@/lib/queryKeys'
|
||||
import { inputClass, localisedDateString } from '@/lib/utils'
|
||||
import { useLocale } from '@/lib/useLocale'
|
||||
import { useUITranslation } from '@/lib/useUITranslation'
|
||||
|
||||
import type { CountColumn, CountsResource } from '@/lib/counts'
|
||||
import type { PostVersion, TagVersion } from '@/types'
|
||||
|
||||
|
||||
const CountsResourcePage = ({ resource }: { resource: CountsResource }) => {
|
||||
const [params, setParams] = useSearchParams ()
|
||||
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 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)
|
||||
}
|
||||
|
||||
const eventName = (value: string) => value
|
||||
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 ?? '—'
|
||||
}
|
||||
if ('postId' in row)
|
||||
{
|
||||
if (column === 'title') return row.title.current ?? ''
|
||||
if (column === 'url') return row.url.current
|
||||
const tags = row.tags.filter (tag => column === 'added_tags'
|
||||
? tag.type === 'added'
|
||||
: (column === 'removed_tags' ? tag.type === 'removed' : tag.type !== 'removed'))
|
||||
return tags.map (tag => tag.name).join (' ')
|
||||
}
|
||||
switch (column)
|
||||
{
|
||||
case 'name': return row.name.current ?? ''
|
||||
case 'category': return message (`common:categories.${row.category.current}`).text
|
||||
case 'aliases':
|
||||
return row.aliases.filter (item => item.type !== 'removed')
|
||||
.map (item => item.name).join (' ')
|
||||
case 'parents':
|
||||
return row.parentTags.filter (item => item.type !== 'removed')
|
||||
.map (item => item.tag.name).join (' ')
|
||||
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 drillDown = (key: string | number | null): string => {
|
||||
const next = new URLSearchParams (params)
|
||||
next.set ('mode', 'details')
|
||||
next.delete ('page')
|
||||
if (axis === 'month')
|
||||
{
|
||||
const first = `${key}-01T00:00Z`
|
||||
const date = new Date (first)
|
||||
date.setUTCMonth (date.getUTCMonth () + 1)
|
||||
const last = date.toISOString ().slice (0, 16) + 'Z'
|
||||
const from = next.get ('from')
|
||||
const before = next.get ('before')
|
||||
next.set ('from', from != null && from > first ? from : first)
|
||||
next.set ('before', before != null && before < last ? before : last)
|
||||
}
|
||||
else
|
||||
next.set (axis === 'user' ? 'user_id' : 'event_type', String (key ?? 'none'))
|
||||
return `?${next}`
|
||||
}
|
||||
const exportCSV = () => {
|
||||
const rows = mode === 'summary'
|
||||
? [['集計軸', '件数'], ...groups.map (group => [groupName (group), String (group.count)])]
|
||||
: [columns.map (column => countColumns[column]),
|
||||
...versions.map (row => columns.map (column => cell (row, column)))]
|
||||
const url = URL.createObjectURL (new Blob ([countsCSV (rows)], {
|
||||
type: 'text/csv;charset=utf-8' }))
|
||||
const link = document.createElement ('a')
|
||||
link.href = url
|
||||
link.download = `counts-${resource}.csv`
|
||||
link.click ()
|
||||
URL.revokeObjectURL (url)
|
||||
}
|
||||
|
||||
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>
|
||||
{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>)
|
||||
}
|
||||
|
||||
|
||||
export default CountsResourcePage
|
||||
新しいイシューから参照
ユーザーをブロックする