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