このコミットが含まれているのは:
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
class CountsPostsController < PostVersionsController
|
||||
include CountsListing
|
||||
end
|
||||
@@ -0,0 +1,3 @@
|
||||
class CountsTagsController < TagVersionsController
|
||||
include CountsListing
|
||||
end
|
||||
@@ -14,21 +14,7 @@ class PostVersionsController < ApplicationController
|
||||
|
||||
offset = (page - 1) * limit
|
||||
|
||||
q = 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')
|
||||
q = version_scope
|
||||
q = q.where('post_versions.post_id = ?', post_id) if post_id
|
||||
if external_tag_id || (tag_id && !(Tag.exists?(id: tag_id)))
|
||||
q = q.where('JSON_CONTAINS(post_versions.tags_json,' +
|
||||
@@ -51,6 +37,24 @@ class PostVersionsController < ApplicationController
|
||||
|
||||
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
|
||||
rows = rows.to_a
|
||||
user_ids = rows.map(&:created_by_user_id).compact.uniq
|
||||
|
||||
@@ -10,17 +10,7 @@ class TagVersionsController < ApplicationController
|
||||
|
||||
offset = (page - 1) * limit
|
||||
|
||||
q = 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')
|
||||
q = version_scope
|
||||
q = q.where('tag_versions.tag_id = ?', tag_id) if tag_id
|
||||
|
||||
count = q.except(:select, :order, :limit, :offset).count
|
||||
@@ -34,6 +24,20 @@ class TagVersionsController < ApplicationController
|
||||
|
||||
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
|
||||
rows = rows.to_a
|
||||
tag_ids = rows.map(&:tag_id).uniq
|
||||
|
||||
@@ -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
|
||||
@@ -1,4 +1,7 @@
|
||||
Rails.application.routes.draw do
|
||||
get 'counts/posts', to: 'counts_posts#index'
|
||||
get 'counts/tags', to: 'counts_tags#index'
|
||||
|
||||
resources :locales, only: [:index]
|
||||
resources :sitemap_entries, only: [:index, :show], param: :resource
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
新しいイシューから参照
ユーザーをブロックする