このコミットが含まれているのは:
2026-10-25 14:14:55 +09:00
コミット 304090f717
16個のファイルの変更、745行の追加、27行の削除
+19
ファイルの表示
@@ -0,0 +1,19 @@
module CountsListing
extend ActiveSupport::Concern
def index
locale = resolve_locale!
resource = is_a?(CountsPostsController) ? 'posts' : 'tags'
query = CountsQuery.new(version_scope, resource, request.query_parameters.except('locale')
.merge('locale' => locale.code))
result = { count: query.count, page: query.page, limit: query.limit }
if query.mode == 'summary'
result.merge!(query.summary)
else
result[:versions] = serialise_versions(query.details(version_scope), locale.language_code)
end
render json: result
rescue ArgumentError, TypeError
head :bad_request
end
end
+3
ファイルの表示
@@ -0,0 +1,3 @@
class CountsPostsController < PostVersionsController
include CountsListing
end
+3
ファイルの表示
@@ -0,0 +1,3 @@
class CountsTagsController < TagVersionsController
include CountsListing
end
+19 -15
ファイルの表示
@@ -14,21 +14,7 @@ class PostVersionsController < ApplicationController
offset = (page - 1) * limit offset = (page - 1) * limit
q = PostVersion.joins(<<~SQL.squish) q = version_scope
LEFT JOIN
post_versions prev
ON
prev.post_id = post_versions.post_id
AND prev.version_no = post_versions.version_no - 1
SQL
.select('post_versions.*',
'prev.title AS prev_title',
'prev.url AS prev_url',
'prev.thumbnail_base AS prev_thumbnail_base',
'prev.tags_json AS prev_tags_json',
'prev.video_ms AS prev_video_ms',
'prev.original_created_from AS prev_original_created_from',
'prev.original_created_before AS prev_original_created_before')
q = q.where('post_versions.post_id = ?', post_id) if post_id q = q.where('post_versions.post_id = ?', post_id) if post_id
if external_tag_id || (tag_id && !(Tag.exists?(id: tag_id))) if external_tag_id || (tag_id && !(Tag.exists?(id: tag_id)))
q = q.where('JSON_CONTAINS(post_versions.tags_json,' + q = q.where('JSON_CONTAINS(post_versions.tags_json,' +
@@ -51,6 +37,24 @@ class PostVersionsController < ApplicationController
private private
def version_scope
PostVersion.joins(<<~SQL.squish)
LEFT JOIN
post_versions prev
ON
prev.post_id = post_versions.post_id
AND prev.version_no = post_versions.version_no - 1
SQL
.select('post_versions.*',
'prev.title AS prev_title',
'prev.url AS prev_url',
'prev.thumbnail_base AS prev_thumbnail_base',
'prev.tags_json AS prev_tags_json',
'prev.video_ms AS prev_video_ms',
'prev.original_created_from AS prev_original_created_from',
'prev.original_created_before AS prev_original_created_before')
end
def serialise_versions rows, language_code def serialise_versions rows, language_code
rows = rows.to_a rows = rows.to_a
user_ids = rows.map(&:created_by_user_id).compact.uniq user_ids = rows.map(&:created_by_user_id).compact.uniq
+15 -11
ファイルの表示
@@ -10,17 +10,7 @@ class TagVersionsController < ApplicationController
offset = (page - 1) * limit offset = (page - 1) * limit
q = TagVersion.joins(<<~SQL.squish) q = version_scope
LEFT JOIN
tag_versions prev
ON
prev.tag_id = tag_versions.tag_id
AND prev.version_no = tag_versions.version_no - 1
SQL
.select('tag_versions.*', 'prev.name AS prev_name', 'prev.category AS prev_category',
'prev.deprecated_at AS prev_deprecated_at',
'prev.aliases AS prev_aliases', 'prev.names AS prev_names',
'prev.parent_tag_ids AS prev_parent_tag_ids')
q = q.where('tag_versions.tag_id = ?', tag_id) if tag_id q = q.where('tag_versions.tag_id = ?', tag_id) if tag_id
count = q.except(:select, :order, :limit, :offset).count count = q.except(:select, :order, :limit, :offset).count
@@ -34,6 +24,20 @@ class TagVersionsController < ApplicationController
private private
def version_scope
TagVersion.joins(<<~SQL.squish)
LEFT JOIN
tag_versions prev
ON
prev.tag_id = tag_versions.tag_id
AND prev.version_no = tag_versions.version_no - 1
SQL
.select('tag_versions.*', 'prev.name AS prev_name', 'prev.category AS prev_category',
'prev.deprecated_at AS prev_deprecated_at',
'prev.aliases AS prev_aliases', 'prev.names AS prev_names',
'prev.parent_tag_ids AS prev_parent_tag_ids')
end
def serialise_versions rows, language_code def serialise_versions rows, language_code
rows = rows.to_a rows = rows.to_a
tag_ids = rows.map(&:tag_id).uniq tag_ids = rows.map(&:tag_id).uniq
+110
ファイルの表示
@@ -0,0 +1,110 @@
# Read-only history queries. All identifiers and SQL expressions are server-owned.
class CountsQuery
EVENTS = ['create', 'update', 'discard', 'restore'].freeze
AXES = ['event_type', 'month', 'user'].freeze
COMMON_COLUMNS = ['created_at', 'resource_id', 'version_no', 'event_type', 'user'].freeze
COLUMNS = {
'posts' => COMMON_COLUMNS + ['title', 'url', 'tags', 'added_tags', 'removed_tags'],
'tags' => COMMON_COLUMNS + ['name', 'category', 'aliases', 'parents', 'deprecated'] }.freeze
KEYS = [
'locale', 'mode', 'axis', 'page', 'limit', 'from', 'before',
'user_id', 'event_type', 'resource_id', 'tag', 'columns'].freeze
attr_reader :page, :limit, :mode, :axis, :columns
def initialize(scope, resource, params)
@scope = scope.except(:select, :order)
@resource = resource
@table = scope.klass.table_name
@params = params
raise ArgumentError unless (params.keys - KEYS).empty?
raise ArgumentError unless params.values.all? { _1.is_a?(String) }
@mode = params.fetch('mode', 'summary')
@axis = params.fetch('axis', 'event_type')
raise ArgumentError unless ['summary', 'details'].include?(mode) && AXES.include?(axis)
@page = integer('page', 1)
@limit = integer('limit', 20)
raise ArgumentError if limit > 100 || page > 1_000_000
@columns = params.fetch('columns', COLUMNS.fetch(resource).join(',')).split(',')
if columns.empty? || columns.uniq != columns || (columns - COLUMNS.fetch(resource)).any?
raise ArgumentError
end
apply_filters
end
def count = @scope.count
def details(scope)
scope.where(id: @scope.select("#{ @table }.id"))
.order(Arel.sql("#{ @table }.created_at DESC, #{ @table }.id DESC"))
.limit(limit).offset((page - 1) * limit)
end
def summary
expression = case axis
when 'event_type' then "#{ @table }.event_type"
when 'user' then "#{ @table }.created_by_user_id"
when 'month' then "DATE_FORMAT(#{ @table }.created_at, '%Y-%m')"
end
grouped = @scope.group(Arel.sql(expression))
# A derived table counts groups without materialising every group in Ruby.
sql = grouped.select(Arel.sql("#{ expression } AS group_key")).to_sql
groups = @scope.klass.connection.select_value("SELECT COUNT(*) FROM (#{ sql }) counts_groups")
rows = grouped.order(Arel.sql("#{ expression } ASC"))
.limit(limit).offset((page - 1) * limit).count
names = axis == 'user' ? User.where(id: rows.keys.compact).pluck(:id, :name).to_h : {}
{ group_count: groups.to_i,
groups: rows.map { |key, value| { key:, name: names[key], count: value } } }
end
private
def integer(key, default = nil)
value = @params[key].presence
return default if value.nil? && !default.nil?
raise ArgumentError unless value&.match?(/\A[1-9][0-9]{0,9}\z/)
value.to_i
end
def timestamp(key)
value = @params[key].presence
return nil unless value
raise ArgumentError unless value.match?(/\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2})?Z\z/)
Date.iso8601(value.first(10))
Time.iso8601(value.length == 17 ? value.sub(/Z\z/, ':00Z') : value)
end
def apply_filters
first = timestamp('from')
last = timestamp('before')
raise ArgumentError if first && last && first >= last
@scope = @scope.where("#{ @table }.created_at >= ?", first) if first
@scope = @scope.where("#{ @table }.created_at < ?", last) if last
if @params['resource_id'].present?
id_column = @resource == 'posts' ? 'post_id' : 'tag_id'
@scope = @scope.where(@table => { id_column => integer('resource_id') })
end
if @params['user_id'].present?
user = @params['user_id'] == 'none' ? nil : integer('user_id')
@scope = @scope.where(@table => { created_by_user_id: user })
end
if @params['event_type'].present?
raise ArgumentError unless EVENTS.include?(@params['event_type'])
@scope = @scope.where(@table => { event_type: @params['event_type'] })
end
return unless @params['tag'].present?
raise ArgumentError unless @resource == 'posts'
id = integer('tag')
@scope = @scope.where(
"JSON_CONTAINS(post_versions.tags_json, JSON_OBJECT('tag_id', CAST(? AS UNSIGNED))) OR " +
"JSON_CONTAINS(prev.tags_json, JSON_OBJECT('tag_id', CAST(? AS UNSIGNED)))", id, id)
end
end
+3
ファイルの表示
@@ -1,4 +1,7 @@
Rails.application.routes.draw do Rails.application.routes.draw do
get 'counts/posts', to: 'counts_posts#index'
get 'counts/tags', to: 'counts_tags#index'
resources :locales, only: [:index] resources :locales, only: [:index]
resources :sitemap_entries, only: [:index, :show], param: :resource resources :sitemap_entries, only: [:index, :show], param: :resource
+96
ファイルの表示
@@ -0,0 +1,96 @@
require 'rails_helper'
RSpec.describe 'Counts API', type: :request do
let!(:actor) { create(:user, :member) }
let!(:post) { create(:post) }
let!(:tag) { create(:tag, primary_name: 'counts tag', category: :general) }
before do
snapshot = { tag_id: tag.id, version_no: 1, name: 'counts tag',
category: 'general', sections: [] }
[1, 2, 3].each do |number|
time = Time.utc(2024, number == 3 ? 2 : 1, number, 0, 0)
PostVersion.create!(post:, version_no: number,
event_type: number == 1 ? 'create' : 'update',
tags: '', url: post.url, title: "Snapshot #{ number }", parent_post_ids: '',
tags_json: number == 1 ? [snapshot] : [],
created_by_user: number == 3 ? nil : actor, created_at: time)
TagVersion.create!(tag:, version_no: number,
event_type: number == 1 ? 'create' : 'update',
name: 'counts tag', category: 'general', aliases: '', parent_tag_ids: '',
names: { ja: { name: 'counts tag', script_code: 'Jpan',
auto_generated: false, aliases: [] } },
created_by_user: number == 3 ? nil : actor, created_at: time)
end
end
['posts', 'tags'].each do |resource|
context resource do
let(:path) { "/counts/#{ resource }" }
let(:id) { resource == 'posts' ? post.id : tag.id }
let(:filters) { { resource_id: id, user_id: actor.id,
from: '2024-01-01T00:00Z', before: '2024-02-01T00:00Z' } }
it 'uses the same AND filters for SQL totals, groups and details' do
get path, params: filters.merge(axis: 'event_type')
expect(response).to have_http_status(:ok)
summary = response.parsed_body
expect(summary['count']).to eq(2)
expect(summary['groups'].sum { _1['count'] }).to eq(2)
expect(summary['groups'].map { _1['key'] }).to eq(['create', 'update'])
get path, params: filters.merge(mode: 'details')
expect(response.parsed_body['count']).to eq(2)
expect(response.parsed_body['versions'].map { _1['version_no'] }).to eq([2, 1])
end
it 'paginates details and groups independently from totals' do
get path, params: { resource_id: id, mode: 'details', limit: 1, page: 2 }
expect(response.parsed_body['count']).to eq(3)
expect(response.parsed_body['versions'].sole['version_no']).to eq(2)
get path, params: { resource_id: id, axis: 'month', limit: 1, page: 2 }
expect(response.parsed_body['group_count']).to eq(2)
expect(response.parsed_body['groups'].sole).to include('key' => '2024-02', 'count' => 1)
end
it 'groups null actors and supports their drill-down filter' do
get path, params: { resource_id: id, axis: 'user' }
expect(response.parsed_body['groups']).to include(
{ 'key' => nil, 'name' => nil, 'count' => 1 })
get path, params: { resource_id: id, mode: 'details', user_id: 'none' }
expect(response.parsed_body['count']).to eq(1)
end
it 'uses an inclusive start and exclusive end' do
get path, params: { resource_id: id,
from: '2024-01-01T00:00Z', before: '2024-01-02T00:00Z' }
expect(response.parsed_body['count']).to eq(1)
end
it 'rejects unsupported or malformed query values' do
[
{ axis: 'SQL' }, { limit: 101 }, { page: '1x' }, { user_id: '-1' },
{ event_type: 'invalid' }, { columns: 'password' }, { order: 'id' },
{ from: '2024-02-30T00:00Z' }, { from: '2024-01-01' },
{ user_id: ['1'] }, { mode: 'invalid' }].each do |invalid|
get path, params: invalid
expect(response).to have_http_status(:bad_request)
end
end
it 'retains the existing banned-user boundary' do
actor.update!(banned_at: Time.current)
sign_in_as(actor)
get path
expect(response).to have_http_status(:forbidden)
end
end
end
it 'matches removed tags from the previous snapshot, not current post_tags' do
get '/counts/posts', params: { tag: tag.id, mode: 'details' }
expect(response.parsed_body['count']).to eq(2)
expect(response.parsed_body['versions'].map { _1['version_no'] }).to eq([2, 1])
expect(response.parsed_body['versions'].first['tags'].sole['type']).to eq('removed')
end
end
+50 -1
ファイルの表示
@@ -62,6 +62,14 @@ const responseFor = url => {
return { locales: [{ code: 'ja', name: '日本語' }, { code: 'ain-Kana', name: 'アイヌ' }] } return { locales: [{ code: 'ja', name: '日本語' }, { code: 'ain-Kana', name: 'アイヌ' }] }
if (path === '/users/settings') return { theme: 'system', wiki_editor_mode: 'split' } if (path === '/users/settings') return { theme: 'system', wiki_editor_mode: 'split' }
if (path === '/users/theme_slots') return [] 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 === '/posts') return { posts: [post (locale)], count: 1 }
if (path === '/gekanator/posts') return { posts: [post (locale)] } if (path === '/gekanator/posts') return { posts: [post (locale)] }
if (path === '/gekanator/questions') return { questions: [] } if (path === '/gekanator/questions') return { questions: [] }
@@ -300,6 +308,21 @@ after (async () => {
if (errors.length) throw new AggregateError (errors, 'SSR teardown failed') 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 => { test ('every canonical screen route returns page HTML without JavaScript', async t => {
const cookie = await authenticatedCookie () const cookie = await authenticatedCookie ()
for (const route of routes) { for (const route of routes) {
@@ -582,7 +605,8 @@ test ('hydrate every route with stored browser preferences', {
}` }, sessionId) }` }, sessionId)
const evaluate = async expression => (await send ('Runtime.evaluate', { const evaluate = async expression => (await send ('Runtime.evaluate', {
expression, returnByValue: true }, sessionId)).result.value 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']) { for (const locale of ['ja', 'ain-Kana']) {
await t.test (`hydrate ${locale} ${route.path}`, async () => { await t.test (`hydrate ${locale} ${route.path}`, async () => {
const path = route.path.replace (':identifier', 'home').replace (/:id|:title/g, '1') 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 probeActive = true
for (const width of [375, 1240]) { for (const width of [375, 1240]) {
await send ('Emulation.setDeviceMetricsOverride', { await send ('Emulation.setDeviceMetricsOverride', {
+8
ファイルの表示
@@ -77,6 +77,8 @@ const MaterialListPage = lazyPage (() => import ('@/pages/materials/MaterialList
const MaterialNewPage = lazyPage (() => import ('@/pages/materials/MaterialNewPage')) const MaterialNewPage = lazyPage (() => import ('@/pages/materials/MaterialNewPage'))
const MaterialSyncSuppressionsPage = lazyPage ( const MaterialSyncSuppressionsPage = lazyPage (
() => import ('@/pages/materials/MaterialSyncSuppressionsPage')) () => import ('@/pages/materials/MaterialSyncSuppressionsPage'))
const CountsPage = lazyPage (() => import ('@/pages/CountsPage'))
const CountsResourcePage = lazyPage (() => import ('@/pages/CountsResourcePage'))
const MorePage = lazyPage (() => import ('@/pages/MorePage')) const MorePage = lazyPage (() => import ('@/pages/MorePage'))
const GekanatorUnavailablePage = lazyPage (() => import ('@/pages/GekanatorUnavailablePage')) const GekanatorUnavailablePage = lazyPage (() => import ('@/pages/GekanatorUnavailablePage'))
const NicoTagListPage = lazyPage (() => import ('@/pages/tags/NicoTagListPage')) const NicoTagListPage = lazyPage (() => import ('@/pages/tags/NicoTagListPage'))
@@ -194,6 +196,12 @@ export const pageRoutes = (
{ id: 'gekanator', path: 'gekanator', element: <GekanatorRoute user={user}/>, { id: 'gekanator', path: 'gekanator', element: <GekanatorRoute user={user}/>,
handle: { locale: 'ja', sitemap: ['static'], preload: locale => locale === 'ja' handle: { locale: 'ja', sitemap: ['static'], preload: locale => locale === 'ja'
? GekanatorPage.preload () : GekanatorUnavailablePage.preload () } }, ? 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/>, { path: 'more', element: <MorePage/>,
handle: { preload: MorePage.preload, sitemap: ['static'] } }, handle: { preload: MorePage.preload, sitemap: ['static'] } },
{ path: '*', element: <NotFound/>, handle: { notFound: true } }] { path: '*', element: <NotFound/>, handle: { notFound: true } }]
+18
ファイルの表示
@@ -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')
})
})
+44
ファイルの表示
@@ -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')
+4
ファイルの表示
@@ -161,3 +161,7 @@ export const theatresKeys = {
show: (id: string, locale: LocaleCode) => ['theatres', locale, id] as const, show: (id: string, locale: LocaleCode) => ['theatres', locale, id] as const,
programmes: (id: string, locale: LocaleCode) => programmes: (id: string, locale: LocaleCode) =>
['theatres', locale, id, 'programmes'] as const } ['theatres', locale, id, 'programmes'] as const }
export const countsKeys = {
index: (resource: string, params: string, locale: LocaleCode) =>
['counts', resource, locale, params] as const }
+17
ファイルの表示
@@ -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
+83
ファイルの表示
@@ -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')
})
})
+253
ファイルの表示
@@ -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