このコミットが含まれているのは:
@@ -3,6 +3,8 @@ class CountsTablesController < ApplicationController
|
|||||||
resolve_locale!
|
resolve_locale!
|
||||||
definition = CountsResourceDefinition.new(params[:resource])
|
definition = CountsResourceDefinition.new(params[:resource])
|
||||||
render json: { fields: definition.metadata,
|
render json: { fields: definition.metadata,
|
||||||
|
output_fields: definition.output_fields,
|
||||||
|
filter_fields: definition.filter_metadata,
|
||||||
axes: { latest: definition.axes(false), history: definition.axes(true) },
|
axes: { latest: definition.axes(false), history: definition.axes(true) },
|
||||||
grains: CountsAxes::GRAINS, defaults: CountsDefaults.metadata(params[:resource]) }
|
grains: CountsAxes::GRAINS, defaults: CountsDefaults.metadata(params[:resource]) }
|
||||||
rescue ArgumentError
|
rescue ArgumentError
|
||||||
|
|||||||
@@ -148,4 +148,13 @@ class HistoryAccess < ActiveSupport::CurrentAttributes
|
|||||||
|
|
||||||
# This number deliberately ignores time, identity and content filters to prevent count probes.
|
# This number deliberately ignores time, identity and content filters to prevent count probes.
|
||||||
def self.population_count(model) = model.unscoped.count
|
def self.population_count(model) = model.unscoped.count
|
||||||
|
|
||||||
|
# Only the resource-wide denied count is public, never a condition-dependent denied count.
|
||||||
|
def self.population_counts(model)
|
||||||
|
total, excluded = model.unscoped.optimizer_hints('MAX_EXECUTION_TIME(10000)').pick(
|
||||||
|
Arel.sql('COUNT(*)'),
|
||||||
|
Arel.sql("COALESCE(SUM(CASE WHEN #{ sql(model) } THEN 0 ELSE 1 END), 0)"))
|
||||||
|
{ population_count: total.to_i, excluded_count: excluded.to_i,
|
||||||
|
excluded_count_scope: 'all_recorded_versions' }
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,330 @@
|
|||||||
|
# Typed predicates over the same recorded fields used by Counts output.
|
||||||
|
# Multi-valued comparisons use EXISTS; inequality means no matching element.
|
||||||
|
class CountsCondition
|
||||||
|
CHILD_TYPES = { 'id' => :integer, 'versionNo' => :integer, 'tagId' => :integer,
|
||||||
|
'tagGroupId' => :integer, 'autoGenerated' => :boolean,
|
||||||
|
'category' => :enum, 'kind' => :enum, 'sections' => :interval }.freeze
|
||||||
|
|
||||||
|
def self.fields(definition)
|
||||||
|
result = definition.metadata.map { _1.merge(labels: [definition.field_label(_1[:key])]) }
|
||||||
|
visit = lambda do |fields, prefix, labels|
|
||||||
|
fields.each do |field|
|
||||||
|
key = [prefix, field[:key]].compact.join('.')
|
||||||
|
path_labels = labels + [field[:label]]
|
||||||
|
type = if prefix
|
||||||
|
CHILD_TYPES.fetch(field[:key], :string)
|
||||||
|
elsif key == 'is_latest'
|
||||||
|
:boolean
|
||||||
|
elsif key == 'parents'
|
||||||
|
:integer
|
||||||
|
elsif key == 'deprecated'
|
||||||
|
:datetime
|
||||||
|
elsif key == 'thumbnail'
|
||||||
|
:string
|
||||||
|
else
|
||||||
|
definition.fields.fetch(key, :string)
|
||||||
|
end
|
||||||
|
type = :string if field[:children] || type == :json
|
||||||
|
type = :integer if ['parents', 'members'].include?(key)
|
||||||
|
physical = result.find { _1[:key] == key }
|
||||||
|
nullable = prefix.present? || (physical ? physical[:nullable] : key != 'is_latest')
|
||||||
|
nullable = false if field[:children]
|
||||||
|
entry = { key:, type:, labels: path_labels, nullable:,
|
||||||
|
multiple: prefix.present? || field[:children].present?,
|
||||||
|
collection: field[:children].present? || type == :interval,
|
||||||
|
operators: CountsResourceDefinition::OPERATORS.fetch(type) }
|
||||||
|
result.reject! { _1[:key] == key }
|
||||||
|
result << entry
|
||||||
|
visit.call(field[:children] || [], key, path_labels)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
visit.call(definition.output_fields, nil, [])
|
||||||
|
# The raw JSON alias has the same membership semantics as the public tag field.
|
||||||
|
if definition.resource == 'posts'
|
||||||
|
result.find { _1[:key] == 'tags_json' }.merge!(type: :string, multiple: true, collection: true,
|
||||||
|
operators: CountsResourceDefinition::OPERATORS.fetch(:string))
|
||||||
|
end
|
||||||
|
result + [{ key: 'user.id', type: :integer, nullable: true,
|
||||||
|
labels: ['history:actor', 'counts:columns.resourceId'],
|
||||||
|
operators: CountsResourceDefinition::OPERATORS.fetch(:integer) },
|
||||||
|
{ key: 'user.name', type: :string, nullable: true,
|
||||||
|
labels: ['history:actor', 'history:name'],
|
||||||
|
operators: CountsResourceDefinition::OPERATORS.fetch(:string) }]
|
||||||
|
end
|
||||||
|
|
||||||
|
def initialize(population)
|
||||||
|
@population = population
|
||||||
|
@definition = population.definition
|
||||||
|
@connection = @definition.model.connection
|
||||||
|
@language = Locale.find_by!(code: population.params.fetch('locale', 'ja')).language_code
|
||||||
|
end
|
||||||
|
|
||||||
|
def apply(scope, entry)
|
||||||
|
raise ArgumentError unless entry.is_a?(Hash)
|
||||||
|
raise ArgumentError if (entry.keys - ['field', 'operator', 'value', 'end', 'blank']).any?
|
||||||
|
|
||||||
|
field = @definition.filter_metadata.find { _1[:key] == entry.fetch('field') }
|
||||||
|
raise ArgumentError unless field && field[:operators].include?(entry.fetch('operator'))
|
||||||
|
raise ArgumentError unless [true, false, nil].include?(entry['blank'])
|
||||||
|
values = [entry['value'], entry['end']]
|
||||||
|
raise ArgumentError unless values.all? { _1.nil? || (_1.is_a?(String) && _1.bytesize <= 8192) }
|
||||||
|
if entry['operator'] != 'range' && !values[1].nil? && values[1] != ''
|
||||||
|
raise ArgumentError
|
||||||
|
end
|
||||||
|
|
||||||
|
source, expression = source_for(field[:key])
|
||||||
|
type = field[:type]
|
||||||
|
blank = blank_sql(expression, type)
|
||||||
|
if entry['blank']
|
||||||
|
sql = if source && field[:collection]
|
||||||
|
"NOT EXISTS (SELECT 1 FROM #{ source })"
|
||||||
|
elsif source
|
||||||
|
"NOT EXISTS (SELECT 1 FROM #{ source } WHERE NOT (#{ blank }))"
|
||||||
|
else blank
|
||||||
|
end
|
||||||
|
return scope.where(Arel.sql(sql))
|
||||||
|
end
|
||||||
|
return scope if values.all? { _1.nil? || _1 == '' }
|
||||||
|
|
||||||
|
parsed = values.map { parse(_1, type) }
|
||||||
|
validate_enum!(field[:key], parsed.first) if type == :enum
|
||||||
|
operator = entry['operator']
|
||||||
|
if type == :interval
|
||||||
|
return interval(scope, source, expression, parsed)
|
||||||
|
end
|
||||||
|
clauses = []
|
||||||
|
binds = []
|
||||||
|
if operator == 'range'
|
||||||
|
['>=', '<='].each_with_index do |comparison, index|
|
||||||
|
next if parsed[index].nil?
|
||||||
|
|
||||||
|
clauses << "#{ expression } #{ comparison } ?"
|
||||||
|
binds << parsed[index]
|
||||||
|
end
|
||||||
|
raise ArgumentError if parsed.all? && parsed.first > parsed.last
|
||||||
|
elsif !parsed.first.nil?
|
||||||
|
comparison = { 'eq' => '=', 'ne' => '=', 'gt' => '>', 'lt' => '<',
|
||||||
|
'ge' => '>=', 'le' => '<=', 'like' => 'LIKE' }.fetch(operator)
|
||||||
|
clauses << "#{ expression } #{ comparison } ?"
|
||||||
|
binds << parsed.first
|
||||||
|
end
|
||||||
|
return scope if clauses.empty?
|
||||||
|
|
||||||
|
predicate = clauses.join(' AND ')
|
||||||
|
if source
|
||||||
|
predicate = "EXISTS (SELECT 1 FROM #{ source } WHERE #{ predicate })"
|
||||||
|
predicate = "NOT (#{ predicate })" if operator == 'ne'
|
||||||
|
elsif operator == 'ne'
|
||||||
|
predicate = "(NOT (#{ predicate }) OR #{ blank })"
|
||||||
|
end
|
||||||
|
scope.where(predicate, *binds)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def parse(value, type)
|
||||||
|
return nil if value.nil? || value == ''
|
||||||
|
|
||||||
|
case type
|
||||||
|
when :integer, :interval
|
||||||
|
raise ArgumentError unless value.match?(/\A-?[0-9]{1,19}\z/)
|
||||||
|
|
||||||
|
number = Integer(value, 10)
|
||||||
|
raise ArgumentError unless number.between?(-9_223_372_036_854_775_808, 9_223_372_036_854_775_807)
|
||||||
|
|
||||||
|
number
|
||||||
|
when :boolean
|
||||||
|
raise ArgumentError unless ['true', 'false'].include?(value)
|
||||||
|
|
||||||
|
value == 'true' ? 1 : 0
|
||||||
|
when :datetime then @population.timestamp(value)
|
||||||
|
else value
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate_enum!(key, value)
|
||||||
|
allowed = case key.split('.').last
|
||||||
|
when 'event_type' then VersionRecord::DEFAULT_EVENT_TYPE_MAP.values
|
||||||
|
when 'category', 'tag_category' then TagVersion.categories.keys
|
||||||
|
when 'kind' then ['internal', 'external']
|
||||||
|
when 'source_kind' then Material::SOURCE_KINDS
|
||||||
|
end
|
||||||
|
raise ArgumentError if value && allowed && !allowed.include?(value)
|
||||||
|
end
|
||||||
|
|
||||||
|
def blank_sql(expression, type)
|
||||||
|
if [:string, :enum].include?(type)
|
||||||
|
"(#{ expression } IS NULL OR #{ expression } = '')"
|
||||||
|
else
|
||||||
|
"#{ expression } IS NULL"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def scalar(document, key, type = :string)
|
||||||
|
path = @connection.quote("$.#{ key }")
|
||||||
|
value = "JSON_EXTRACT(#{ document }, #{ path })"
|
||||||
|
text = "JSON_UNQUOTE(NULLIF(#{ value }, CAST('null' AS JSON)))"
|
||||||
|
return "CAST(#{ text } AS SIGNED)" if type == :integer
|
||||||
|
return "CASE #{ text } WHEN 'true' THEN 1 WHEN 'false' THEN 0 END" if type == :boolean
|
||||||
|
|
||||||
|
text
|
||||||
|
end
|
||||||
|
|
||||||
|
def json_table(document, path, name)
|
||||||
|
"JSON_TABLE(COALESCE(#{ document }, JSON_ARRAY()), #{ @connection.quote(path) } " +
|
||||||
|
"COLUMNS (item JSON PATH '$')) #{ name }"
|
||||||
|
end
|
||||||
|
|
||||||
|
def names_source(document, alias_name)
|
||||||
|
source = "JSON_TABLE(COALESCE(JSON_KEYS(#{ document }), JSON_ARRAY()), '$[*]' " +
|
||||||
|
"COLUMNS (language_code TEXT PATH '$')) #{ alias_name }"
|
||||||
|
path = "CONCAT('$.', JSON_QUOTE(#{ alias_name }.language_code))"
|
||||||
|
[source, "JSON_EXTRACT(#{ document }, #{ path })", "#{ alias_name }.language_code"]
|
||||||
|
end
|
||||||
|
|
||||||
|
def point
|
||||||
|
@point ||= @population.history ? @population.at :
|
||||||
|
@population.timestamp(@population.params['at']) || @population.at
|
||||||
|
end
|
||||||
|
|
||||||
|
def name_expression(row)
|
||||||
|
language_path = @connection.quote("$.#{ @language.to_json }.name")
|
||||||
|
name = "JSON_UNQUOTE(JSON_EXTRACT(#{ row }.names, #{ language_path }))"
|
||||||
|
"COALESCE(#{ name }, #{ row }.name)"
|
||||||
|
end
|
||||||
|
|
||||||
|
def parent_source
|
||||||
|
column = @definition.resource == 'posts' ? 'parent_post_ids' : 'parent_tag_ids'
|
||||||
|
ids = "TRIM(REGEXP_REPLACE(COALESCE(v.#{ column }, ''), '[[:space:]]+', ' '))"
|
||||||
|
document = "CONCAT('[', REPLACE(#{ ids }, ' ', ','), ']')"
|
||||||
|
source = json_table(document, '$[*]', 'parent_entry')
|
||||||
|
id = 'CAST(parent_entry.item AS UNSIGNED)'
|
||||||
|
return [source, id] if @definition.resource == 'posts'
|
||||||
|
|
||||||
|
bound = @connection.quote(point)
|
||||||
|
source += ' LEFT JOIN tag_versions parent_version ON parent_version.tag_id = ' + id +
|
||||||
|
" AND parent_version.created_at <= #{ bound } AND " +
|
||||||
|
HistoryAccess.sql(TagVersion, 'parent_version') +
|
||||||
|
' AND NOT EXISTS (SELECT 1 FROM tag_versions newer_parent ' +
|
||||||
|
'WHERE newer_parent.tag_id = parent_version.tag_id ' +
|
||||||
|
"AND newer_parent.created_at <= #{ bound } " +
|
||||||
|
'AND newer_parent.version_no > parent_version.version_no)'
|
||||||
|
[source, id]
|
||||||
|
end
|
||||||
|
|
||||||
|
def post_tag_source
|
||||||
|
source = json_table('v.tags_json', '$[*]', 'tag_snapshot')
|
||||||
|
source += ' LEFT JOIN tag_versions referenced_tag ON referenced_tag.tag_id = ' +
|
||||||
|
scalar('tag_snapshot.item', 'tag_id', :integer) +
|
||||||
|
' AND referenced_tag.version_no = ' + scalar('tag_snapshot.item', 'version_no', :integer) +
|
||||||
|
" AND referenced_tag.created_at <= #{ @connection.quote(point) } AND " +
|
||||||
|
HistoryAccess.sql(TagVersion, 'referenced_tag')
|
||||||
|
source
|
||||||
|
end
|
||||||
|
|
||||||
|
def language_field(source, document, parts, inherited_language = nil)
|
||||||
|
return [source, scalar(document, 'name')] if parts.empty?
|
||||||
|
return [source, inherited_language] if parts == ['languageCode'] && inherited_language
|
||||||
|
|
||||||
|
if parts.first == 'aliases'
|
||||||
|
aliases = "JSON_EXTRACT(#{ document }, '$.aliases')"
|
||||||
|
source += ' JOIN ' + json_table(aliases, '$[*]', 'alias_entry')
|
||||||
|
child = parts[1] || 'name'
|
||||||
|
return [source, inherited_language] if child == 'languageCode' && inherited_language
|
||||||
|
columns = { 'scriptCode' => 'script_code', 'name' => 'name' }
|
||||||
|
key = columns.fetch(child) { raise ArgumentError }
|
||||||
|
return [source, scalar('alias_entry.item', key)]
|
||||||
|
end
|
||||||
|
key = { 'name' => 'name', 'scriptCode' => 'script_code',
|
||||||
|
'autoGenerated' => 'auto_generated' }.fetch(parts.first) { raise ArgumentError }
|
||||||
|
[source, scalar(document, key, CHILD_TYPES.fetch(parts.first, :string))]
|
||||||
|
end
|
||||||
|
|
||||||
|
def source_for(key)
|
||||||
|
root, *parts = key.split('.')
|
||||||
|
return [nil, "(#{ @population.latest_sql })"] if root == 'is_latest'
|
||||||
|
if root == 'user' && parts.any?
|
||||||
|
expression = parts.first == 'id' ? 'v.created_by_user_id' :
|
||||||
|
'(SELECT actor.name FROM users actor WHERE actor.id = v.created_by_user_id)'
|
||||||
|
return [nil, expression]
|
||||||
|
end
|
||||||
|
return [nil, 'v.thumbnail_base'] if root == 'thumbnail'
|
||||||
|
return [nil, 'v.deprecated_at'] if root == 'deprecated'
|
||||||
|
if root == 'parents'
|
||||||
|
source, id = parent_source
|
||||||
|
return [source, parts.first == 'name' ? name_expression('parent_version') : id]
|
||||||
|
end
|
||||||
|
if root == 'tags' || root == 'tags_json'
|
||||||
|
return post_field(parts)
|
||||||
|
end
|
||||||
|
if root == 'names' || root == 'aliases'
|
||||||
|
source, document, language = names_source('v.names', 'name_language')
|
||||||
|
parts = ['aliases', *parts] if root == 'aliases'
|
||||||
|
return language_field(source, document, parts, language)
|
||||||
|
end
|
||||||
|
if root == 'members' || root == 'placements'
|
||||||
|
path = '$[*]' + parts.take_while { _1 == 'children' }.map { '.children[*]' }.join
|
||||||
|
source = json_table("v.#{ root }", path, 'structure_entry')
|
||||||
|
if root == 'members' && parts.empty?
|
||||||
|
value = "COALESCE(#{ scalar('structure_entry.item', 'tag_id', :integer) }, " +
|
||||||
|
"#{ scalar('structure_entry.item', 'tag_group_id', :integer) })"
|
||||||
|
return [source, value]
|
||||||
|
end
|
||||||
|
leaf = parts.last || 'sectionName'
|
||||||
|
leaf = 'sectionName' if leaf == 'children'
|
||||||
|
column = leaf.gsub(/[A-Z]/) { "_#{ _1.downcase }" }
|
||||||
|
return [source, scalar('structure_entry.item', column, CHILD_TYPES.fetch(leaf, :string))]
|
||||||
|
end
|
||||||
|
if root == 'export_paths_json'
|
||||||
|
source, value, profile = names_source('v.export_paths_json', 'export_profile')
|
||||||
|
return [source, parts.first == 'profile' ? profile : "JSON_UNQUOTE(#{ value })"]
|
||||||
|
end
|
||||||
|
return [nil, name_expression('v')] if root == 'name' && @definition.resource == 'tags'
|
||||||
|
|
||||||
|
[nil, @definition.expression(root)]
|
||||||
|
end
|
||||||
|
|
||||||
|
def post_field(parts)
|
||||||
|
source = post_tag_source
|
||||||
|
document = 'tag_snapshot.item'
|
||||||
|
case parts.first
|
||||||
|
when 'names'
|
||||||
|
names, entry, language = names_source('referenced_tag.names', 'tag_language')
|
||||||
|
language_field("#{ source } JOIN #{ names }", entry, parts.drop(1), language)
|
||||||
|
when 'sections'
|
||||||
|
sections = "JSON_EXTRACT(#{ document }, '$.sections')"
|
||||||
|
source += ' JOIN ' + json_table(sections, '$[*]', 'section_entry')
|
||||||
|
[source, 'section_entry.item']
|
||||||
|
when 'id'
|
||||||
|
[source, "COALESCE(#{ scalar(document, 'tag_id', :integer) }, " +
|
||||||
|
"#{ scalar(document, 'external_tag_id', :integer) })"]
|
||||||
|
when 'versionNo' then [source, scalar(document, 'version_no', :integer)]
|
||||||
|
when 'kind'
|
||||||
|
[source, "CASE WHEN #{ scalar(document, 'tag_id') } IS NULL " +
|
||||||
|
"THEN 'external' ELSE 'internal' END"]
|
||||||
|
when 'category'
|
||||||
|
[source, "COALESCE(#{ scalar(document, 'category') }, referenced_tag.category)"]
|
||||||
|
else
|
||||||
|
[source, "COALESCE(#{ scalar(document, 'name') }, referenced_tag.name)"]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def interval(scope, source, document, bounds)
|
||||||
|
raise ArgumentError if bounds.compact.any?(&:negative?)
|
||||||
|
raise ArgumentError if bounds.all? && bounds.first > bounds.last
|
||||||
|
|
||||||
|
first = scalar(document, 'begin_ms', :integer)
|
||||||
|
last = scalar(document, 'end_ms', :integer)
|
||||||
|
clauses = []
|
||||||
|
values = []
|
||||||
|
if bounds.first
|
||||||
|
clauses << "(#{ last } IS NULL OR #{ last } > ?)"
|
||||||
|
values << bounds.first
|
||||||
|
end
|
||||||
|
if bounds.last
|
||||||
|
clauses << "#{ first } < ?"
|
||||||
|
values << bounds.last
|
||||||
|
end
|
||||||
|
scope.where("EXISTS (SELECT 1 FROM #{ source } WHERE #{ clauses.join(' AND ') })", *values)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -11,22 +11,32 @@ class CountsDefaults
|
|||||||
'sort' => 'a0:desc', 'conditions' => '' } }]
|
'sort' => 'a0:desc', 'conditions' => '' } }]
|
||||||
result << { definition: 'latest', mode: 'summary', params: {
|
result << { definition: 'latest', mode: 'summary', params: {
|
||||||
'rows' => '', 'cols' => '', 'conditions' => '', 'tags' => '' } }
|
'rows' => '', 'cols' => '', 'conditions' => '', 'tags' => '' } }
|
||||||
if resource == 'posts'
|
details = detail_params(resource)
|
||||||
result += [{ definition: 'latest', mode: 'details', params: {
|
if details
|
||||||
'columns' => POST_COLUMNS.join(' '), 'order' => POST_COLUMNS.join(' '),
|
['latest', 'history'].each do |definition|
|
||||||
'children' => 'tags.name parents.id', 'nested_json' => '0',
|
result << { definition:, mode: 'details', params: details }
|
||||||
'conditions' => '', 'tags' => '' } }]
|
end
|
||||||
end
|
|
||||||
if resource == 'tags'
|
|
||||||
columns = ['resource_id', 'version_no', 'is_latest', 'name', 'category',
|
|
||||||
'aliases', 'parents', 'deprecated', 'event_type', 'user', 'created_at']
|
|
||||||
result << { definition: 'latest', mode: 'details', params: {
|
|
||||||
'columns' => columns.join(' '), 'order' => columns.join(' '),
|
|
||||||
'children' => 'aliases.name parents.name', 'nested_json' => '0' } }
|
|
||||||
end
|
end
|
||||||
result
|
result
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def self.detail_params(resource)
|
||||||
|
if resource == 'posts'
|
||||||
|
return { 'columns' => POST_COLUMNS.join(' '), 'order' => POST_COLUMNS.join(' '),
|
||||||
|
'children' => 'tags.name parents.id', 'nested_json' => '0',
|
||||||
|
'conditions' => '', 'tags' => '' }
|
||||||
|
end
|
||||||
|
unless resource == 'tags'
|
||||||
|
return { 'columns' => CountsExportProjection.default_columns(resource).join(' '),
|
||||||
|
'children' => CountsExportProjection.default_children(resource).join(' ') }
|
||||||
|
end
|
||||||
|
|
||||||
|
columns = ['resource_id', 'version_no', 'is_latest', 'name', 'category',
|
||||||
|
'aliases', 'parents', 'deprecated', 'event_type', 'user', 'created_at']
|
||||||
|
{ 'columns' => columns.join(' '), 'order' => columns.join(' '),
|
||||||
|
'children' => 'aliases.name parents.name', 'nested_json' => '0' }
|
||||||
|
end
|
||||||
|
|
||||||
def self.for(params)
|
def self.for(params)
|
||||||
settings = entries(params.fetch('resource', 'posts')).find do |entry|
|
settings = entries(params.fetch('resource', 'posts')).find do |entry|
|
||||||
entry[:definition] == params.fetch('definition', 'latest') &&
|
entry[:definition] == params.fetch('definition', 'latest') &&
|
||||||
|
|||||||
@@ -125,50 +125,8 @@ class CountsPopulation
|
|||||||
end
|
end
|
||||||
|
|
||||||
def apply_condition(entry)
|
def apply_condition(entry)
|
||||||
raise ArgumentError unless entry.is_a?(Hash)
|
@condition ||= CountsCondition.new(self)
|
||||||
raise ArgumentError if (entry.keys - ['field', 'operator', 'value', 'end', 'blank']).any?
|
@scope = @condition.apply(@scope, entry)
|
||||||
|
|
||||||
key = entry.fetch('field')
|
|
||||||
type = definition.fields.fetch(key) { raise ArgumentError }
|
|
||||||
operator = entry.fetch('operator')
|
|
||||||
raise ArgumentError unless CountsResourceDefinition::OPERATORS.fetch(type, []).include?(operator)
|
|
||||||
raise ArgumentError unless [true, false, nil].include?(entry['blank'])
|
|
||||||
|
|
||||||
expression = definition.expression(key)
|
|
||||||
blank = type == :string || type == :enum ?
|
|
||||||
"(#{ expression } IS NULL OR #{ expression } = '')" : "#{ expression } IS NULL"
|
|
||||||
if entry['blank']
|
|
||||||
@scope = @scope.where(Arel.sql(blank))
|
|
||||||
return
|
|
||||||
end
|
|
||||||
values = [entry['value'], entry['end']]
|
|
||||||
raise ArgumentError unless values.all? { _1.nil? || _1.is_a?(String) }
|
|
||||||
return if values.all? { _1.nil? || _1 == '' }
|
|
||||||
|
|
||||||
parsed = values.map do |value|
|
|
||||||
next nil if value.nil? || value == ''
|
|
||||||
|
|
||||||
case type
|
|
||||||
when :integer
|
|
||||||
raise ArgumentError unless value.match?(/\A-?\d+\z/)
|
|
||||||
|
|
||||||
value.to_i
|
|
||||||
when :datetime then timestamp(value)
|
|
||||||
else value
|
|
||||||
end
|
|
||||||
end
|
|
||||||
if operator == 'range'
|
|
||||||
@scope = @scope.where("#{ expression } >= ?", parsed[0]) if parsed[0]
|
|
||||||
@scope = @scope.where("#{ expression } <= ?", parsed[1]) if parsed[1]
|
|
||||||
return
|
|
||||||
end
|
|
||||||
return unless parsed[0]
|
|
||||||
|
|
||||||
sql_operator = { 'eq' => '=', 'ne' => '<>', 'gt' => '>', 'lt' => '<',
|
|
||||||
'ge' => '>=', 'le' => '<=', 'like' => 'LIKE' }.fetch(operator)
|
|
||||||
sql = "#{ expression } #{ sql_operator } ?"
|
|
||||||
sql = "(#{ sql } OR #{ blank })" if operator == 'ne'
|
|
||||||
@scope = @scope.where(sql, parsed[0])
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def snapshot_membership(value)
|
def snapshot_membership(value)
|
||||||
|
|||||||
@@ -31,7 +31,35 @@ class CountsResourceDefinition
|
|||||||
OPERATORS = {
|
OPERATORS = {
|
||||||
string: ['eq', 'ne', 'like'], enum: ['eq', 'ne'], boolean: ['eq', 'ne'],
|
string: ['eq', 'ne', 'like'], enum: ['eq', 'ne'], boolean: ['eq', 'ne'],
|
||||||
integer: ['eq', 'ne', 'gt', 'lt', 'ge', 'le', 'range'],
|
integer: ['eq', 'ne', 'gt', 'lt', 'ge', 'le', 'range'],
|
||||||
datetime: ['eq', 'ne', 'gt', 'lt', 'ge', 'le', 'range'] }.freeze
|
datetime: ['eq', 'ne', 'gt', 'lt', 'ge', 'le', 'range'],
|
||||||
|
interval: ['range'] }.freeze
|
||||||
|
LABELS = {
|
||||||
|
'resource_id' => 'counts:columns.resourceId', 'version_no' => 'counts:columns.version',
|
||||||
|
'is_latest' => 'counts:columns.latest', 'event_type' => 'counts:eventType',
|
||||||
|
'user' => 'history:actor', 'created_at' => 'counts:columns.changedAt',
|
||||||
|
'thumbnail' => 'counts:columns.thumbnailDisplay',
|
||||||
|
'thumbnail_base' => 'counts:columns.thumbnailURL',
|
||||||
|
'title' => 'counts:columns.postTitle', 'url' => 'counts:columns.postURL',
|
||||||
|
'tags' => 'counts:columns.tags', 'tags_json' => 'counts:columns.tags',
|
||||||
|
'video_ms' => 'shared:labels.duration', 'name' => 'counts:columns.name',
|
||||||
|
'names' => 'counts:columns.names', 'category' => 'common:categoryLabel',
|
||||||
|
'aliases' => 'tags:aliases', 'deprecated' => 'counts:columns.deprecated',
|
||||||
|
'original_created_from' => 'counts:columns.originalFrom',
|
||||||
|
'original_created_before' => 'counts:columns.originalBefore',
|
||||||
|
'language_code' => 'counts:columns.language' }.freeze
|
||||||
|
CHILD_LABELS = {
|
||||||
|
'id' => 'counts:columns.resourceId', 'versionNo' => 'history:version',
|
||||||
|
'kind' => 'counts:columns.kind', 'name' => 'history:name',
|
||||||
|
'category' => 'common:categoryLabel', 'sections' => 'counts:columns.sections',
|
||||||
|
'names' => 'counts:columns.names', 'aliases' => 'tags:aliases',
|
||||||
|
'languageCode' => 'counts:columns.language',
|
||||||
|
'scriptCode' => 'history:nameChanges.script',
|
||||||
|
'autoGenerated' => 'history:nameChanges.automatic',
|
||||||
|
'tagId' => 'counts:attributes.tag_id', 'tagGroupId' => 'counts:resources.tag_groups',
|
||||||
|
'sectionName' => 'counts:attributes.section_name',
|
||||||
|
'description' => 'counts:attributes.description',
|
||||||
|
'children' => 'counts:attributes.children', 'profile' => 'counts:attributes.profile',
|
||||||
|
'path' => 'counts:attributes.path' }.freeze
|
||||||
|
|
||||||
attr_reader :model, :keys, :fields, :resource
|
attr_reader :model, :keys, :fields, :resource
|
||||||
|
|
||||||
@@ -62,4 +90,57 @@ class CountsResourceDefinition
|
|||||||
operators: OPERATORS.fetch(type, []) }
|
operators: OPERATORS.fetch(type, []) }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def field_label(key) = LABELS.fetch(key, "counts:attributes.#{ key }")
|
||||||
|
|
||||||
|
def output_fields
|
||||||
|
content = case resource
|
||||||
|
when 'posts'
|
||||||
|
['thumbnail', 'title', 'url', 'thumbnail_base', 'tags', 'parents', 'video_ms',
|
||||||
|
'original_created_from', 'original_created_before']
|
||||||
|
when 'tags' then ['name', 'names', 'category', 'aliases', 'parents', 'deprecated']
|
||||||
|
else fields.keys - COMMON.keys
|
||||||
|
end
|
||||||
|
keys = ['resource_id', 'version_no', 'is_latest'] + (content - ['thumbnail']) +
|
||||||
|
['event_type', 'user', 'created_at']
|
||||||
|
keys.unshift('thumbnail') if content.include?('thumbnail')
|
||||||
|
keys.map do |key|
|
||||||
|
label = if key == 'parents'
|
||||||
|
resource == 'tags' ? 'tags:parents' : 'shared:labels.parentPosts'
|
||||||
|
else field_label(key)
|
||||||
|
end
|
||||||
|
field = { key:, label: }
|
||||||
|
children = child_fields(key)
|
||||||
|
field[:children] = children if children.any?
|
||||||
|
field
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def child_fields(key, depth = 4)
|
||||||
|
keys = case key
|
||||||
|
when 'tags' then ['id', 'versionNo', 'kind', 'name', 'category', 'sections', 'names']
|
||||||
|
when 'names' then ['languageCode', 'scriptCode', 'name', 'aliases', 'autoGenerated']
|
||||||
|
when 'aliases'
|
||||||
|
resource == 'tags' ? ['languageCode', 'name', 'scriptCode'] : ['name', 'scriptCode']
|
||||||
|
when 'parents' then resource == 'tags' ? ['id', 'name'] : ['id']
|
||||||
|
when 'members' then ['tagId', 'tagGroupId', 'versionNo']
|
||||||
|
when 'export_paths_json' then ['profile', 'path']
|
||||||
|
when 'placements', 'children'
|
||||||
|
scalar = ['sectionName', 'description', 'tagId', 'tagGroupId']
|
||||||
|
scalar + (depth > 0 ? ['children'] : [])
|
||||||
|
else []
|
||||||
|
end
|
||||||
|
keys.map do |child|
|
||||||
|
label = key == 'members' && child == 'tagId' ?
|
||||||
|
'counts:columns.resourceId' : CHILD_LABELS.fetch(child)
|
||||||
|
field = { key: child, label: }
|
||||||
|
children = child_fields(child, child == 'children' ? depth - 1 : depth)
|
||||||
|
# Language is attached to top-level aliases, not to aliases inside a language entry.
|
||||||
|
children = children.reject { _1[:key] == 'languageCode' } if child == 'aliases'
|
||||||
|
field[:children] = children if children.any?
|
||||||
|
field
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def filter_metadata = @filter_metadata ||= CountsCondition.fields(self)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -67,7 +67,9 @@ class CountsTableExport
|
|||||||
unless started
|
unless started
|
||||||
metadata = page.except(:versions, :cells, :axis_labels, :column_values,
|
metadata = page.except(:versions, :cells, :axis_labels, :column_values,
|
||||||
:row_headers, :column_headers, :pivot_totals).merge(
|
:row_headers, :column_headers, :pivot_totals).merge(
|
||||||
resource:, filters: @params.select { |key, _value| CountsTableQuery::KEYS.include?(key) ||
|
resource:, record_unit: @summary && format == 'json' ? 'pivot_cell' : 'row',
|
||||||
|
body_row_totals: 'once_per_logical_group', page_grand_total_repeated: false,
|
||||||
|
filters: @params.select { |key, _value| CountsTableQuery::KEYS.include?(key) ||
|
||||||
key == 'measures' || QueryState.valid_key?(key) },
|
key == 'measures' || QueryState.valid_key?(key) },
|
||||||
fields: @query.population.definition.metadata,
|
fields: @query.population.definition.metadata,
|
||||||
columns: @columns, column_labels: @headers,
|
columns: @columns, column_labels: @headers,
|
||||||
|
|||||||
@@ -41,17 +41,29 @@ class CountsTableQuery
|
|||||||
|
|
||||||
def call
|
def call
|
||||||
ApplicationRecord.transaction do
|
ApplicationRecord.transaction do
|
||||||
metadata.merge(mode == 'summary' ? summary : details)
|
result = metadata.merge(mode == 'summary' ? summary : details)
|
||||||
|
visible = mode == 'summary' ? result[:visible_output_count] : result[:count]
|
||||||
|
# A scalar result has one row regardless of private attributes or predicates.
|
||||||
|
public_count = result[:excluded_count].zero? || (mode == 'summary' && axes.rows.empty?)
|
||||||
|
result.merge(visible_output_count: visible,
|
||||||
|
output_count: public_count ? visible : nil,
|
||||||
|
output_count_scope: public_count ?
|
||||||
|
'before_authorisation' : 'unavailable_private_history',
|
||||||
|
output_count_unit: 'table_body_rows',
|
||||||
|
page_grand_total_repeated: mode == 'summary' && axes.rows.any? &&
|
||||||
|
result[:group_count] > limit)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def metadata
|
def metadata
|
||||||
{ population_count_scope: 'all_recorded_versions', population_count: HistoryAccess.population_count(@definition.model),
|
@population_counts ||= HistoryAccess.population_counts(@definition.model)
|
||||||
page:, limit:, execution_at: population.at.iso8601(6), at: @params['at'].presence,
|
@population_counts.merge(
|
||||||
definition: population.history ? 'history' : 'latest',
|
population_count_scope: 'all_recorded_versions',
|
||||||
timezone: population.timezone.identifier, rows: axes.rows, cols: axes.columns,
|
page:, limit:, execution_at: population.at.iso8601(6), at: @params['at'].presence,
|
||||||
fields: @definition.metadata,
|
definition: population.history ? 'history' : 'latest',
|
||||||
count: population.scope.except(:select).optimizer_hints('MAX_EXECUTION_TIME(10000)').count }
|
timezone: population.timezone.identifier, rows: axes.rows, cols: axes.columns,
|
||||||
|
fields: @definition.metadata,
|
||||||
|
count: population.scope.except(:select).optimizer_hints('MAX_EXECUTION_TIME(10000)').count)
|
||||||
end
|
end
|
||||||
|
|
||||||
def summary
|
def summary
|
||||||
@@ -143,7 +155,9 @@ class CountsTableQuery
|
|||||||
column_headers = [{ values: [], kind: 'grand_total' }]
|
column_headers = [{ values: [], kind: 'grand_total' }]
|
||||||
end
|
end
|
||||||
totals = CountsPivotTotals.new(population, axes, row_headers, column_headers).call
|
totals = CountsPivotTotals.new(population, axes, row_headers, column_headers).call
|
||||||
{ group_count: total, column_values:, row_headers:, column_headers:, pivot_totals: totals,
|
{ group_count: total,
|
||||||
|
visible_output_count: output_row_count(prefix, row_keys, row_depths, total),
|
||||||
|
column_values:, row_headers:, column_headers:, pivot_totals: totals,
|
||||||
cells: records.map do |row|
|
cells: records.map do |row|
|
||||||
{ values: axes.all.each_index.map { row["a#{ _1 }"] }, count: row['count'].to_i }
|
{ values: axes.all.each_index.map { row["a#{ _1 }"] }, count: row['count'].to_i }
|
||||||
end, axis_labels: axis_labels(records, column_values) }
|
end, axis_labels: axis_labels(records, column_values) }
|
||||||
@@ -192,6 +206,21 @@ class CountsTableQuery
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def output_row_count(prefix, row_keys, depths, total)
|
||||||
|
# With no row axes the table has exactly one body row, including the zero-count case.
|
||||||
|
return 1 if row_keys.empty?
|
||||||
|
|
||||||
|
subtotals = depths.map do |depth|
|
||||||
|
keys = row_keys.first(depth).join(', ')
|
||||||
|
"(SELECT COUNT(*) FROM (SELECT #{ keys } FROM cells GROUP BY #{ keys }) subtotal_rows)"
|
||||||
|
end
|
||||||
|
expressions = [total.to_s, '1'] + subtotals
|
||||||
|
@output_row_count ||= @connection.select_value(<<~SQL).to_i
|
||||||
|
#{ prefix }
|
||||||
|
SELECT /*+ MAX_EXECUTION_TIME(10000) */ #{ expressions.join(' + ') } AS output_rows
|
||||||
|
SQL
|
||||||
|
end
|
||||||
|
|
||||||
def locale = @locale ||= Locale.find_by!(code: @params.fetch('locale', 'ja'))
|
def locale = @locale ||= Locale.find_by!(code: @params.fetch('locale', 'ja'))
|
||||||
|
|
||||||
def limited_columns(prefix)
|
def limited_columns(prefix)
|
||||||
|
|||||||
+3
-1
@@ -261,6 +261,8 @@ const RoutedApp = ({ locales }: { locales: AvailableLocale[] }) => {
|
|||||||
const locale = useLocale ()
|
const locale = useLocale ()
|
||||||
const route = resolvePageRoute (location.pathname)
|
const route = resolvePageRoute (location.pathname)
|
||||||
const robots = route?.route.handle?.robots
|
const robots = route?.route.handle?.robots
|
||||||
|
const pagePath = unlocalisedPath (location.pathname)
|
||||||
|
const countsPage = pagePath === '/counts' || pagePath.startsWith ('/counts/')
|
||||||
const switchLocale = async (targetLocale: LocaleCode): Promise<string> => {
|
const switchLocale = async (targetLocale: LocaleCode): Promise<string> => {
|
||||||
const resolve = route?.route.handle?.switchLocale
|
const resolve = route?.route.handle?.switchLocale
|
||||||
return (resolve != null && route != null)
|
return (resolve != null && route != null)
|
||||||
@@ -377,7 +379,7 @@ const RoutedApp = ({ locales }: { locales: AvailableLocale[] }) => {
|
|||||||
}>
|
}>
|
||||||
<LayoutGroup>
|
<LayoutGroup>
|
||||||
<motion.div
|
<motion.div
|
||||||
layout={animationMode === 'off' ? false : 'position'}
|
layout={(animationMode === 'off' || countsPage) ? false : 'position'}
|
||||||
transition={{ layout: appLayoutTransition }}
|
transition={{ layout: appLayoutTransition }}
|
||||||
className="relative flex h-dvh w-full flex-col overflow-y-hidden">
|
className="relative flex h-dvh w-full flex-col overflow-y-hidden">
|
||||||
<TopNav user={user} locales={locales} resolveLocaleSwitch={switchLocale}/>
|
<TopNav user={user} locales={locales} resolveLocaleSwitch={switchLocale}/>
|
||||||
|
|||||||
@@ -22,7 +22,9 @@ const ConditionEditor = (
|
|||||||
save: (condition: CountsCondition) => void },
|
save: (condition: CountsCondition) => void },
|
||||||
) => {
|
) => {
|
||||||
const { label, message } = useUITranslation ('counts')
|
const { label, message } = useUITranslation ('counts')
|
||||||
const fields = countsFilterFields (definition)
|
const fields = definition.filterFields ?? definition.fields
|
||||||
|
const fieldName = (key: string) => (fields.find (item => item.key === key)?.labels
|
||||||
|
?? [countsFieldLabel (key)]).map (key => message (key).text).join (' › ')
|
||||||
const [condition, setCondition] = useState<CountsCondition> (() => {
|
const [condition, setCondition] = useState<CountsCondition> (() => {
|
||||||
const next = { ...initial, field: initial?.field ?? 'resource_id',
|
const next = { ...initial, field: initial?.field ?? 'resource_id',
|
||||||
operator: initial?.operator ?? 'eq' }
|
operator: initial?.operator ?? 'eq' }
|
||||||
@@ -30,7 +32,7 @@ const ConditionEditor = (
|
|||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
const { field, operator } = condition
|
const { field, operator } = condition
|
||||||
const operators = definition.fields.find (item => item.key === field)?.operators ?? []
|
const operators = fields.find (item => item.key === field)?.operators ?? []
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
controls.setActions ([{ label: label (initial == null ? 'add' : 'shared:actions.edit'),
|
controls.setActions ([{ label: label (initial == null ? 'add' : 'shared:actions.edit'),
|
||||||
onSelect: () => save (condition) }])
|
onSelect: () => save (condition) }])
|
||||||
@@ -39,11 +41,11 @@ const ConditionEditor = (
|
|||||||
<select value={field} aria-label={message ('field').text} className={inputClass ()}
|
<select value={field} aria-label={message ('field').text} className={inputClass ()}
|
||||||
onChange={event => {
|
onChange={event => {
|
||||||
const field = event.target.value
|
const field = event.target.value
|
||||||
const operator = definition.fields.find (item => item.key === field)?.operators[0]
|
const operator = fields.find (item => item.key === field)?.operators[0]
|
||||||
if (operator != null) setCondition ({ field, operator })
|
if (operator != null) setCondition ({ field, operator })
|
||||||
}}>
|
}}>
|
||||||
{Object.keys (fields).filter (key => fields[key] !== 'json').map (key => (
|
{fields.filter (field => field.operators.length > 0).map (field => (
|
||||||
<option key={key} value={key}>{message (countsFieldLabel (key)).text}</option>))}
|
<option key={field.key} value={field.key}>{fieldName (field.key)}</option>))}
|
||||||
</select>
|
</select>
|
||||||
<select value={operator} aria-label={message ('operator').text} className={inputClass ()}
|
<select value={operator} aria-label={message ('operator').text} className={inputClass ()}
|
||||||
onChange={event => setCondition (previous => {
|
onChange={event => setCondition (previous => {
|
||||||
@@ -68,6 +70,9 @@ const CountsAdditionalConditions = ({ params, update }: {
|
|||||||
const definition = useCountsDefinition ()
|
const definition = useCountsDefinition ()
|
||||||
const dialogue = useDialogue ()
|
const dialogue = useDialogue ()
|
||||||
const conditions = readCountState<CountsCondition[]> (params, 'conditions', [])
|
const conditions = readCountState<CountsCondition[]> (params, 'conditions', [])
|
||||||
|
const metadata = definition.filterFields ?? definition.fields
|
||||||
|
const fieldName = (key: string) => (metadata.find (item => item.key === key)?.labels
|
||||||
|
?? [countsFieldLabel (key)]).map (key => message (key).text).join (' › ')
|
||||||
const write = (next: CountsCondition[]) => update ('conditions', encodeCountState ('conditions', next))
|
const write = (next: CountsCondition[]) => update ('conditions', encodeCountState ('conditions', next))
|
||||||
const edit = (index?: number) => {
|
const edit = (index?: number) => {
|
||||||
const initial = index == null ? undefined : conditions[index]
|
const initial = index == null ? undefined : conditions[index]
|
||||||
@@ -81,37 +86,45 @@ const CountsAdditionalConditions = ({ params, update }: {
|
|||||||
}
|
}
|
||||||
const patch = (index: number, change: Partial<CountsCondition>) => write (
|
const patch = (index: number, change: Partial<CountsCondition>) => write (
|
||||||
conditions.map ((item, position) => position === index ? { ...item, ...change } : item))
|
conditions.map ((item, position) => position === index ? { ...item, ...change } : item))
|
||||||
|
const valueInput = (condition: CountsCondition, index: number, key: 'value' | 'end') => {
|
||||||
|
const type = countsFilterFields (definition)[condition.field]
|
||||||
|
const aria = message (key === 'end' ? 'through' : 'value')
|
||||||
|
if (type === 'boolean' || condition.field === 'event_type')
|
||||||
|
{
|
||||||
|
const options: string[] = type === 'boolean' ? ['true', 'false'] : [...countsEventTypes]
|
||||||
|
const namespace = type === 'boolean' ? 'truthValues' : 'events'
|
||||||
|
return <select key={key} value={condition[key] ?? ''} disabled={condition.blank}
|
||||||
|
className={inputClass (false, 'w-36 py-1')} aria-label={aria.text}
|
||||||
|
onChange={event => patch (index, { [key]: event.target.value })}>
|
||||||
|
<option value=""/>
|
||||||
|
{options.map (value => <option key={value} value={value}
|
||||||
|
lang={message (`${namespace}.${value}`).language}>
|
||||||
|
{message (`${namespace}.${value}`).text}</option>)}
|
||||||
|
</select>
|
||||||
|
}
|
||||||
|
if (type === 'datetime')
|
||||||
|
return <DateTimeField precision="second" key={key}
|
||||||
|
value={condition[key] ?? ''} disabled={condition.blank}
|
||||||
|
aria-label={aria.text} lang={aria.language}
|
||||||
|
onChange={value => patch (index, { [key]: value ?? '' })}/>
|
||||||
|
return <input key={key} value={condition[key] ?? ''} disabled={condition.blank}
|
||||||
|
type={(type === 'integer' || type === 'interval') ? 'number' : 'text'}
|
||||||
|
className={inputClass (false, 'w-36 py-1')}
|
||||||
|
aria-label={aria.text} lang={aria.language}
|
||||||
|
onChange={event => patch (index, { [key]: event.target.value })}/>
|
||||||
|
}
|
||||||
return <fieldset className="space-y-1">
|
return <fieldset className="space-y-1">
|
||||||
<legend className="flex items-center gap-2">{label ('additionalConditions')}
|
<legend className="flex items-center gap-2">{label ('additionalConditions')}
|
||||||
<Button type="button" variant="outline" size="sm" onClick={() => edit ()}>
|
<Button type="button" variant="outline" size="sm" onClick={() => edit ()}>
|
||||||
{label ('add')}</Button></legend>
|
{label ('add')}</Button></legend>
|
||||||
{conditions.map ((condition, index) => {
|
{conditions.map ((condition, index) => {
|
||||||
const datetime = countsFilterFields (definition)[condition.field] === 'datetime'
|
|
||||||
return <div key={index} className="flex flex-wrap items-center gap-1">
|
return <div key={index} className="flex flex-wrap items-center gap-1">
|
||||||
<button type="button" onClick={() => edit (index)}>
|
<button type="button" onClick={() => edit (index)}>
|
||||||
{label (countsFieldLabel (condition.field))}
|
{fieldName (condition.field)}
|
||||||
{' · '}{label (`operators.${condition.operator}`)}
|
{' · '}{label (`operators.${condition.operator}`)}
|
||||||
</button>
|
</button>
|
||||||
{(condition.operator === 'range' ? ['value', 'end'] as const : ['value'] as const)
|
{(condition.operator === 'range' ? ['value', 'end'] as const : ['value'] as const)
|
||||||
.map (key => condition.field === 'event_type' ? (
|
.map (key => valueInput (condition, index, key))}
|
||||||
<select key={key} value={condition[key] ?? ''} disabled={condition.blank}
|
|
||||||
className={inputClass (false, 'w-36 py-1')}
|
|
||||||
aria-label={message ('value').text}
|
|
||||||
onChange={event => patch (index, { [key]: event.target.value })}>
|
|
||||||
<option value=""/>
|
|
||||||
{condition[key] && !countsEventTypes.some (event => event === condition[key])
|
|
||||||
&& <option value={condition[key]}>{condition[key]}</option>}
|
|
||||||
{countsEventTypes.map (event => <option key={event} value={event}
|
|
||||||
lang={message (`events.${event}`).language}>
|
|
||||||
{message (`events.${event}`).text}</option>)}
|
|
||||||
</select>) : (datetime ? <DateTimeField precision="second" key={key}
|
|
||||||
value={condition[key] ?? ''} disabled={condition.blank}
|
|
||||||
aria-label={message (key === 'end' ? 'through' : 'value').text}
|
|
||||||
onChange={value => patch (index, { [key]: value ?? '' })}/> : (
|
|
||||||
<input key={key} value={condition[key] ?? ''} disabled={condition.blank}
|
|
||||||
className={inputClass (false, 'w-36 py-1')}
|
|
||||||
aria-label={message ('value').text}
|
|
||||||
onChange={event => patch (index, { [key]: event.target.value })}/>)))}
|
|
||||||
<label className="inline-flex items-center gap-1">
|
<label className="inline-flex items-center gap-1">
|
||||||
<input type="checkbox" checked={condition.blank ?? false}
|
<input type="checkbox" checked={condition.blank ?? false}
|
||||||
onChange={event => patch (index, { blank: event.target.checked })}/>
|
onChange={event => patch (index, { blank: event.target.checked })}/>
|
||||||
|
|||||||
@@ -26,8 +26,9 @@ import type { CountsResource } from '@/lib/counts'
|
|||||||
|
|
||||||
const CountsControls = (
|
const CountsControls = (
|
||||||
{ resource, applied, open, setOpen, execute, displayJSON, displayRowNumbers,
|
{ resource, applied, open, setOpen, execute, displayJSON, displayRowNumbers,
|
||||||
displayThumbnail, reorder, actions }: {
|
displayThumbnail, reorder, actions, pending = false }: {
|
||||||
resource: CountsResource; applied: URLSearchParams; open: boolean
|
resource: CountsResource; applied: URLSearchParams; open: boolean
|
||||||
|
pending?: boolean
|
||||||
setOpen: (open: boolean) => void
|
setOpen: (open: boolean) => void
|
||||||
execute: (params: URLSearchParams) => void
|
execute: (params: URLSearchParams) => void
|
||||||
displayJSON: (enabled: boolean) => void
|
displayJSON: (enabled: boolean) => void
|
||||||
@@ -106,9 +107,9 @@ const CountsControls = (
|
|||||||
})
|
})
|
||||||
return <form className="shrink-0" onSubmit={event => {
|
return <form className="shrink-0" onSubmit={event => {
|
||||||
event.preventDefault ()
|
event.preventDefault ()
|
||||||
|
if (pending) return
|
||||||
const next = new URLSearchParams (draft)
|
const next = new URLSearchParams (draft)
|
||||||
next.delete ('page')
|
next.delete ('page')
|
||||||
next.delete ('execution_at')
|
|
||||||
execute (normaliseCountsParams (resource, next, definition))
|
execute (normaliseCountsParams (resource, next, definition))
|
||||||
}}>
|
}}>
|
||||||
{!history && <div className="flex flex-wrap items-center gap-2 border-b pb-1
|
{!history && <div className="flex flex-wrap items-center gap-2 border-b pb-1
|
||||||
@@ -130,7 +131,8 @@ const CountsControls = (
|
|||||||
</div>
|
</div>
|
||||||
</div>}
|
</div>}
|
||||||
<div className="flex flex-wrap items-center gap-2 border-b py-1 dark:border-slate-700">
|
<div className="flex flex-wrap items-center gap-2 border-b py-1 dark:border-slate-700">
|
||||||
<Button type="submit" size="sm">{label ('run')}</Button>
|
<Button type="submit" size="sm" aria-disabled={pending}
|
||||||
|
className="aria-disabled:opacity-50">{label ('run')}</Button>
|
||||||
<Button type="button" size="sm" variant="outline" aria-expanded={open}
|
<Button type="button" size="sm" variant="outline" aria-expanded={open}
|
||||||
aria-controls={id} onClick={() => setOpen (!open)}>
|
aria-controls={id} onClick={() => setOpen (!open)}>
|
||||||
{label ('conditions')}<span aria-hidden className="inline-block" style={{
|
{label ('conditions')}<span aria-hidden className="inline-block" style={{
|
||||||
|
|||||||
@@ -8,10 +8,11 @@ import type { FC, ReactNode } from 'react'
|
|||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
className?: string }
|
className?: string
|
||||||
|
animateLayout?: boolean }
|
||||||
|
|
||||||
|
|
||||||
const MainArea: FC<Props> = ({ children, className }) => {
|
const MainArea: FC<Props> = ({ children, className, animateLayout = true }) => {
|
||||||
const behaviourSettings = useClientBehaviourSettings ()
|
const behaviourSettings = useClientBehaviourSettings ()
|
||||||
const animationMode = behaviourSettings.animation ?? 'normal'
|
const animationMode = behaviourSettings.animation ?? 'normal'
|
||||||
const layoutTransition = clientAnimationTransition (
|
const layoutTransition = clientAnimationTransition (
|
||||||
@@ -23,7 +24,7 @@ const MainArea: FC<Props> = ({ children, className }) => {
|
|||||||
<motion.main
|
<motion.main
|
||||||
transition={{ layout: layoutTransition }}
|
transition={{ layout: layoutTransition }}
|
||||||
className={cn ('flex-1 overflow-y-auto p-4', className)}
|
className={cn ('flex-1 overflow-y-auto p-4', className)}
|
||||||
layout={animationMode === 'off' ? false : 'position'}>
|
layout={(animationMode === 'off' || !animateLayout) ? false : 'position'}>
|
||||||
{children}
|
{children}
|
||||||
</motion.main>)
|
</motion.main>)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ type Props = {
|
|||||||
className?: string
|
className?: string
|
||||||
maxWidth?: number
|
maxWidth?: number
|
||||||
maxWidthRatio?: number
|
maxWidthRatio?: number
|
||||||
|
animateLayout?: boolean
|
||||||
onWidthChange?: (width: number) => void
|
onWidthChange?: (width: number) => void
|
||||||
sidebarKey: string
|
sidebarKey: string
|
||||||
side?: SidebarSide }
|
side?: SidebarSide }
|
||||||
@@ -69,6 +70,7 @@ const SidebarComponent: FC<Props> = ({
|
|||||||
className,
|
className,
|
||||||
maxWidth,
|
maxWidth,
|
||||||
maxWidthRatio,
|
maxWidthRatio,
|
||||||
|
animateLayout = true,
|
||||||
onWidthChange,
|
onWidthChange,
|
||||||
sidebarKey,
|
sidebarKey,
|
||||||
side = 'left',
|
side = 'left',
|
||||||
@@ -316,7 +318,7 @@ const SidebarComponent: FC<Props> = ({
|
|||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
ref={rootRef}
|
ref={rootRef}
|
||||||
layout={animationMode === 'off' ? false : 'position'}
|
layout={(animationMode === 'off' || !animateLayout) ? false : 'position'}
|
||||||
transition={{ layout: layoutTransition }}
|
transition={{ layout: layoutTransition }}
|
||||||
style={style}
|
style={style}
|
||||||
className={cn (
|
className={cn (
|
||||||
|
|||||||
@@ -61,6 +61,10 @@ export type CountsPivotHeader = { values: (string | number | null)[]
|
|||||||
kind: 'data' | 'subtotal' | 'grand_total' }
|
kind: 'data' | 'subtotal' | 'grand_total' }
|
||||||
export type CountsResult = {
|
export type CountsResult = {
|
||||||
populationCount?: number
|
populationCount?: number
|
||||||
|
excludedCount?: number
|
||||||
|
outputCount?: number | null
|
||||||
|
visibleOutputCount?: number
|
||||||
|
outputCountScope?: 'before_authorisation' | 'unavailable_private_history'
|
||||||
populationCountScope?: string
|
populationCountScope?: string
|
||||||
rowHeaders?: CountsPivotHeader[]
|
rowHeaders?: CountsPivotHeader[]
|
||||||
columnHeaders?: CountsPivotHeader[]
|
columnHeaders?: CountsPivotHeader[]
|
||||||
@@ -148,7 +152,7 @@ const nested: Partial<Record<CountColumn, CountField[]>> = {
|
|||||||
export const columnsForCounts = (
|
export const columnsForCounts = (
|
||||||
resource: CountsResource,
|
resource: CountsResource,
|
||||||
definition?: CountsDefinition,
|
definition?: CountsDefinition,
|
||||||
): CountColumn[] => [
|
): CountColumn[] => definition?.outputFields?.map (field => field.key) ?? [
|
||||||
...(resource === 'posts' ? ['thumbnail'] : []),
|
...(resource === 'posts' ? ['thumbnail'] : []),
|
||||||
'resource_id', 'version_no', 'is_latest',
|
'resource_id', 'version_no', 'is_latest',
|
||||||
...(resource === 'posts'
|
...(resource === 'posts'
|
||||||
@@ -165,7 +169,8 @@ export const columnsForCounts = (
|
|||||||
export const fieldsForCounts = (
|
export const fieldsForCounts = (
|
||||||
resource: CountsResource,
|
resource: CountsResource,
|
||||||
definition?: CountsDefinition,
|
definition?: CountsDefinition,
|
||||||
): CountField[] => columnsForCounts (resource, definition).map (key => ({
|
): CountField[] => definition?.outputFields
|
||||||
|
?? columnsForCounts (resource, definition).map (key => ({
|
||||||
key,
|
key,
|
||||||
label: (key === 'parents' && resource === 'tags') ? 'tags:parents'
|
label: (key === 'parents' && resource === 'tags') ? 'tags:parents'
|
||||||
: (countColumns[key] ?? countsFieldLabel (key)),
|
: (countColumns[key] ?? countsFieldLabel (key)),
|
||||||
@@ -303,6 +308,17 @@ export const defaultCountsMode = (definition: string | null): 'summary' | 'detai
|
|||||||
definition === 'history' ? 'details' : 'summary'
|
definition === 'history' ? 'details' : 'summary'
|
||||||
|
|
||||||
|
|
||||||
|
export const countsDefinitionParams = (
|
||||||
|
definition: 'latest' | 'history',
|
||||||
|
previous: URLSearchParams,
|
||||||
|
): URLSearchParams => {
|
||||||
|
const next = new URLSearchParams ({ definition, mode: defaultCountsMode (definition) })
|
||||||
|
for (const key of ['timezone', 'row_numbers'])
|
||||||
|
if (previous.has (key)) next.set (key, previous.get (key)!)
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export const normaliseCountsParams = (
|
export const normaliseCountsParams = (
|
||||||
resource: CountsResource,
|
resource: CountsResource,
|
||||||
source: URLSearchParams,
|
source: URLSearchParams,
|
||||||
@@ -311,7 +327,8 @@ export const normaliseCountsParams = (
|
|||||||
const params = new URLSearchParams (source)
|
const params = new URLSearchParams (source)
|
||||||
if (!params.has ('definition'))
|
if (!params.has ('definition'))
|
||||||
params.set ('definition', params.get ('basis') === 'history' ? 'history' : 'latest')
|
params.set ('definition', params.get ('basis') === 'history' ? 'history' : 'latest')
|
||||||
if (!params.has ('current')) params.set ('current', params.get ('at') ? '0' : '1')
|
if (params.get ('definition') === 'latest' && !params.has ('current'))
|
||||||
|
params.set ('current', params.get ('at') ? '0' : '1')
|
||||||
const legacy = params.get ('mode')
|
const legacy = params.get ('mode')
|
||||||
if (['state', 'trend', 'snapshot'].includes (legacy ?? ''))
|
if (['state', 'trend', 'snapshot'].includes (legacy ?? ''))
|
||||||
params.set ('mode', legacy === 'snapshot' ? 'details' : 'summary')
|
params.set ('mode', legacy === 'snapshot' ? 'details' : 'summary')
|
||||||
@@ -392,8 +409,9 @@ export const fetchCounts = (
|
|||||||
resource: CountsResource,
|
resource: CountsResource,
|
||||||
params: URLSearchParams,
|
params: URLSearchParams,
|
||||||
locale: LocaleCode,
|
locale: LocaleCode,
|
||||||
|
signal?: AbortSignal,
|
||||||
): Promise<CountsResult> => apiGet ('/counts/query', {
|
): Promise<CountsResult> => apiGet ('/counts/query', {
|
||||||
locale, params: { ...Object.fromEntries (countsRequestParams (params)), resource } })
|
locale, signal, params: { ...Object.fromEntries (countsRequestParams (params)), resource } })
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -9,14 +9,18 @@ import type { CountField, CountsResource } from '@/lib/counts'
|
|||||||
export type CountsAxis = { field: string; grain?: string }
|
export type CountsAxis = { field: string; grain?: string }
|
||||||
export type CountsCondition = {
|
export type CountsCondition = {
|
||||||
field: string; operator: string; value?: string; end?: string; blank?: boolean }
|
field: string; operator: string; value?: string; end?: string; blank?: boolean }
|
||||||
export type CountsFieldType = 'string' | 'integer' | 'datetime' | 'enum' | 'json' | 'boolean'
|
export type CountsFieldType = 'string' | 'integer' | 'datetime' | 'enum'
|
||||||
|
| 'json' | 'boolean' | 'interval'
|
||||||
|
|
||||||
export const countsResources: CountsResource[] = [
|
export const countsResources: CountsResource[] = [
|
||||||
'posts', 'tags', 'nico_tags', 'tag_descriptions', 'tag_groups', 'materials']
|
'posts', 'tags', 'nico_tags', 'tag_descriptions', 'tag_groups', 'materials']
|
||||||
export type CountsFieldMetadata = {
|
export type CountsFieldMetadata = {
|
||||||
key: string; type: CountsFieldType; nullable: boolean; operators: string[] }
|
key: string; type: CountsFieldType; nullable: boolean; operators: string[]
|
||||||
|
labels?: string[]; multiple?: boolean }
|
||||||
export type CountsDefinition = {
|
export type CountsDefinition = {
|
||||||
fields: CountsFieldMetadata[]
|
fields: CountsFieldMetadata[]
|
||||||
|
outputFields?: CountField[]
|
||||||
|
filterFields?: CountsFieldMetadata[]
|
||||||
defaults?: { definition: string; mode: string; params: { key: string; value: string }[] }[]
|
defaults?: { definition: string; mode: string; params: { key: string; value: string }[] }[]
|
||||||
axes: { latest: string[]; history: string[] }
|
axes: { latest: string[]; history: string[] }
|
||||||
grains: string[] }
|
grains: string[] }
|
||||||
@@ -38,7 +42,7 @@ export const fetchCountsDefinition = (resource: CountsResource): Promise<CountsD
|
|||||||
export const countsFilterFields = (
|
export const countsFilterFields = (
|
||||||
definition: CountsDefinition,
|
definition: CountsDefinition,
|
||||||
): Record<string, CountsFieldType> => Object.fromEntries (
|
): Record<string, CountsFieldType> => Object.fromEntries (
|
||||||
definition.fields.map (field => [field.key, field.type]))
|
(definition.filterFields ?? definition.fields).map (field => [field.key, field.type]))
|
||||||
|
|
||||||
|
|
||||||
export const countsFieldLabel = (key: string): string => {
|
export const countsFieldLabel = (key: string): string => {
|
||||||
|
|||||||
@@ -68,6 +68,10 @@
|
|||||||
"limit": "表示件数"
|
"limit": "表示件数"
|
||||||
},
|
},
|
||||||
"latestOnly": "最新のみ",
|
"latestOnly": "最新のみ",
|
||||||
|
"truthValues": {
|
||||||
|
"true": "真",
|
||||||
|
"false": "偽"
|
||||||
|
},
|
||||||
"boolean": {
|
"boolean": {
|
||||||
"true": "はい",
|
"true": "はい",
|
||||||
"false": "いいえ"
|
"false": "いいえ"
|
||||||
@@ -168,6 +172,8 @@
|
|||||||
"changedField": "変更項目",
|
"changedField": "変更項目",
|
||||||
"eventType": "異動区分",
|
"eventType": "異動区分",
|
||||||
"count": "件数",
|
"count": "件数",
|
||||||
|
"outputTotal": "綜件数:{{value}} 件",
|
||||||
|
"excludedRecords": "(閲覧権限のないレコード {{count}} 件が除外されました)",
|
||||||
"recordTotal": "閲覧可能件数:{{count}}",
|
"recordTotal": "閲覧可能件数:{{count}}",
|
||||||
"grain": "粒度",
|
"grain": "粒度",
|
||||||
"grains": {
|
"grains": {
|
||||||
|
|||||||
@@ -15,12 +15,12 @@ import Pagination from '@/components/common/Pagination'
|
|||||||
import MainArea from '@/components/layout/MainArea'
|
import MainArea from '@/components/layout/MainArea'
|
||||||
import SidebarComponent from '@/components/layout/SidebarComponent'
|
import SidebarComponent from '@/components/layout/SidebarComponent'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { countsRequestParams, defaultCountsMode, countsExportPayload, fieldsForCounts, fetchCounts,
|
import { countsRequestParams, countsDefinitionParams, countsExportPayload, fieldsForCounts, fetchCounts,
|
||||||
isCountsState, normaliseCountsParams, selectedCountColumns } from '@/lib/counts'
|
isCountsState, normaliseCountsParams, selectedCountColumns } from '@/lib/counts'
|
||||||
import { countsExportLabels } from '@/lib/countsDisplay'
|
import { countsExportLabels } from '@/lib/countsDisplay'
|
||||||
import { CountsDefinitionContext, countsAxisFields, countsAxisLabel,
|
import { CountsDefinitionContext, countsAxisFields, countsAxisLabel,
|
||||||
countsResources, countsFieldLabel,
|
countsResources, countsFieldLabel,
|
||||||
fetchCountsDefinition, useCountsDefinition }
|
fetchCountsDefinition }
|
||||||
from '@/lib/countsDefinitions'
|
from '@/lib/countsDefinitions'
|
||||||
import { countsKeys } from '@/lib/queryKeys'
|
import { countsKeys } from '@/lib/queryKeys'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
@@ -31,19 +31,44 @@ import { useUITranslation } from '@/lib/useUITranslation'
|
|||||||
|
|
||||||
import { setCountState, readCountState } from '@/lib/queryState'
|
import { setCountState, readCountState } from '@/lib/queryState'
|
||||||
|
|
||||||
import type { CountsAxis } from '@/lib/countsDefinitions'
|
import type { CountsAxis, CountsDefinition } from '@/lib/countsDefinitions'
|
||||||
|
|
||||||
import type { CountsResource } from '@/lib/counts'
|
import type { CountsResource } from '@/lib/counts'
|
||||||
|
|
||||||
|
type PendingCountsRun = {
|
||||||
|
key: ReturnType<typeof countsKeys.index>
|
||||||
|
origin: string
|
||||||
|
params: URLSearchParams
|
||||||
|
target?: string }
|
||||||
|
|
||||||
const CountsResourceContent = ({ resource }: { resource: CountsResource }) => {
|
|
||||||
const definition = useCountsDefinition ()
|
const CountsResourceContent = (
|
||||||
|
{ resource, definition, definitionError }: {
|
||||||
|
resource: CountsResource; definition?: CountsDefinition; definitionError: boolean },
|
||||||
|
) => {
|
||||||
const queryClient = useQueryClient ()
|
const queryClient = useQueryClient ()
|
||||||
const [source, setParams] = useSearchParams ()
|
const [source, setParams] = useSearchParams ()
|
||||||
|
const [timezone, setTimezone] = useState<string | null> (null)
|
||||||
|
useEffect (() => {
|
||||||
|
setTimezone (Intl.DateTimeFormat ().resolvedOptions ().timeZone)
|
||||||
|
}, [])
|
||||||
const params = normaliseCountsParams (resource, source, definition)
|
const params = normaliseCountsParams (resource, source, definition)
|
||||||
|
if (!params.has ('timezone') && timezone != null) params.set ('timezone', timezone)
|
||||||
const canonical = params.toString ()
|
const canonical = params.toString ()
|
||||||
const request = countsRequestParams (params)
|
const request = countsRequestParams (params)
|
||||||
const [open, setOpen] = useState (false)
|
const [open, setOpen] = useState (false)
|
||||||
|
const controlsArea = useRef<HTMLDivElement> (null)
|
||||||
|
const [controlsHeight, setControlsHeight] = useState (80)
|
||||||
|
useEffect (() => {
|
||||||
|
const node = controlsArea.current
|
||||||
|
if (definition == null || node == null) return
|
||||||
|
const measure = () => setControlsHeight (node.getBoundingClientRect ().height)
|
||||||
|
measure ()
|
||||||
|
if (typeof ResizeObserver === 'undefined') return
|
||||||
|
const observer = new ResizeObserver (measure)
|
||||||
|
observer.observe (node)
|
||||||
|
return () => observer.disconnect ()
|
||||||
|
}, [definition])
|
||||||
const [sidebarOpen, setSidebarOpen] = useState (false)
|
const [sidebarOpen, setSidebarOpen] = useState (false)
|
||||||
const [expanded, setExpanded] = useState (() => new Set<string> ([resource]))
|
const [expanded, setExpanded] = useState (() => new Set<string> ([resource]))
|
||||||
const motion = useClientMotion ()
|
const motion = useClientMotion ()
|
||||||
@@ -57,42 +82,138 @@ const CountsResourceContent = ({ resource }: { resource: CountsResource }) => {
|
|||||||
const state = isCountsState (params)
|
const state = isCountsState (params)
|
||||||
const cacheParams = new URLSearchParams (request)
|
const cacheParams = new URLSearchParams (request)
|
||||||
cacheParams.set ('definition', state ? 'latest' : 'history')
|
cacheParams.set ('definition', state ? 'latest' : 'history')
|
||||||
|
const [running, setRunning] = useState (false)
|
||||||
|
const [runError, setRunError] = useState (false)
|
||||||
|
const pendingRun = useRef<PendingCountsRun | null> (null)
|
||||||
|
const applied = useRef (params)
|
||||||
|
const retainedParams = useRef (params)
|
||||||
|
const requestKey = request.toString ()
|
||||||
const [exporting, setExporting] = useState (false)
|
const [exporting, setExporting] = useState (false)
|
||||||
const exportInFlight = useRef (false)
|
const exportInFlight = useRef (false)
|
||||||
const [exportError, setExportError] = useState (false)
|
const [exportError, setExportError] = useState (false)
|
||||||
const { data, dataUpdatedAt, isFetching, isPending, isError,
|
const { data, dataUpdatedAt, isFetching, isPending, isError,
|
||||||
isPlaceholderData, refetch } = useQuery ({
|
isPlaceholderData } = useQuery ({
|
||||||
enabled: params.has ('timezone'),
|
enabled: definition != null && params.has ('timezone') && !running,
|
||||||
staleTime: params.has ('execution_at') ? 5 * 60 * 1000 : 0,
|
staleTime: params.has ('execution_at') ? 5 * 60 * 1000 : 0,
|
||||||
queryKey: countsKeys.index (resource, cacheParams.toString (), locale),
|
queryKey: countsKeys.index (resource, cacheParams.toString (), locale),
|
||||||
queryFn: () => fetchCounts (resource, params, locale), retry: false,
|
queryFn: ({ signal }) => fetchCounts (resource, params, locale, signal), retry: false,
|
||||||
placeholderData: (previous, query) => {
|
placeholderData: (previous, query) =>
|
||||||
const previousParams = new URLSearchParams (String (query?.queryKey[3] ?? ''))
|
(query?.queryKey[1] === resource && query?.queryKey[2] === locale
|
||||||
return (query?.queryKey[1] === resource && query?.queryKey[2] === locale
|
&& queryClient.getQueryData (query.queryKey) === previous)
|
||||||
&& previousParams.get ('mode') === mode
|
? previous : undefined })
|
||||||
&& previousParams.get ('rows') === params.get ('rows')
|
|
||||||
&& previousParams.get ('cols') === params.get ('cols')
|
|
||||||
&& previousParams.get ('definition') === cacheParams.get ('definition'))
|
|
||||||
? previous : undefined
|
|
||||||
} })
|
|
||||||
|
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
|
applied.current = params
|
||||||
|
if (data != null && !isPlaceholderData && !isError) retainedParams.current = params
|
||||||
|
const pending = pendingRun.current
|
||||||
|
if (pending == null) return
|
||||||
|
if (pending.target === canonical)
|
||||||
|
{
|
||||||
|
pendingRun.current = null
|
||||||
|
setRunning (false)
|
||||||
|
}
|
||||||
|
else if (pending.origin !== requestKey)
|
||||||
|
{
|
||||||
|
pendingRun.current = null
|
||||||
|
void queryClient.cancelQueries ({ queryKey: pending.key, exact: true })
|
||||||
|
setRunning (false)
|
||||||
|
}
|
||||||
|
}, [canonical, data, isError, isPlaceholderData, params, queryClient, requestKey])
|
||||||
|
|
||||||
|
useEffect (() => {
|
||||||
|
setRunning (false)
|
||||||
|
setRunError (false)
|
||||||
|
return () => {
|
||||||
|
const pending = pendingRun.current
|
||||||
|
pendingRun.current = null
|
||||||
|
if (pending != null)
|
||||||
|
void queryClient.cancelQueries ({ queryKey: pending.key, exact: true })
|
||||||
|
}
|
||||||
|
}, [locale, queryClient, resource])
|
||||||
|
|
||||||
|
useEffect (() => {
|
||||||
|
if (running || data == null || isPlaceholderData || isFetching || isError
|
||||||
|
|| !params.has ('timezone')) return
|
||||||
const next = new URLSearchParams (canonical)
|
const next = new URLSearchParams (canonical)
|
||||||
if (!next.has ('timezone'))
|
if (data.executionAt != null && !params.has ('execution_at'))
|
||||||
next.set ('timezone', Intl.DateTimeFormat ().resolvedOptions ().timeZone)
|
|
||||||
if (data?.executionAt != null && !isPlaceholderData && !isFetching && !isError
|
|
||||||
&& params.has ('timezone') && !params.has ('execution_at'))
|
|
||||||
{
|
{
|
||||||
next.set ('execution_at', data.executionAt)
|
next.set ('execution_at', data.executionAt)
|
||||||
// Pin the response under its server-confirmed time before changing the URL.
|
// Commit initial defaults, timezone and server time together after acquisition.
|
||||||
queryClient.setQueryData (
|
queryClient.setQueryData (
|
||||||
countsKeys.index (resource, countsRequestParams (next).toString (), locale),
|
countsKeys.index (resource, countsRequestParams (next).toString (), locale),
|
||||||
data, { updatedAt: dataUpdatedAt })
|
data, { updatedAt: dataUpdatedAt })
|
||||||
}
|
}
|
||||||
if (source.toString () !== next.toString ())
|
if (source.toString () !== next.toString ())
|
||||||
setParams (next, { replace: true })
|
setParams (next, { replace: true, preventScrollReset: true })
|
||||||
}, [canonical, data, dataUpdatedAt, isError, isFetching, isPlaceholderData,
|
}, [canonical, data, dataUpdatedAt, isError, isFetching, isPlaceholderData,
|
||||||
locale, params, queryClient, resource, setParams, source])
|
locale, params, queryClient, resource, running, setParams, source])
|
||||||
|
|
||||||
|
|
||||||
|
const execute = async (draft: URLSearchParams) => {
|
||||||
|
if (pendingRun.current != null || definition == null) return
|
||||||
|
const next = normaliseCountsParams (resource, draft, definition)
|
||||||
|
next.delete ('execution_at')
|
||||||
|
if (!next.has ('timezone'))
|
||||||
|
next.set ('timezone', Intl.DateTimeFormat ().resolvedOptions ().timeZone)
|
||||||
|
const pending: PendingCountsRun = {
|
||||||
|
key: countsKeys.index (resource, countsRequestParams (next).toString (), locale),
|
||||||
|
origin: requestKey, params: new URLSearchParams (next) }
|
||||||
|
const previous = new URLSearchParams (params)
|
||||||
|
pendingRun.current = pending
|
||||||
|
setRunError (false)
|
||||||
|
setRunning (true)
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const result = await queryClient.fetchQuery ({ queryKey: pending.key,
|
||||||
|
queryFn: ({ signal }) => fetchCounts (resource, next, locale, signal),
|
||||||
|
staleTime: 0, retry: false })
|
||||||
|
if (pendingRun.current !== pending) return
|
||||||
|
if (result.executionAt == null) throw new Error ('Missing Counts execution time')
|
||||||
|
// Preserve presentation changes made while this execution was in flight.
|
||||||
|
for (const key of ['columns', 'children', 'child_sort', 'order', 'nested_json', 'row_numbers'])
|
||||||
|
{
|
||||||
|
if (previous.get (key) === applied.current.get (key)) continue
|
||||||
|
if (key === 'columns')
|
||||||
|
{
|
||||||
|
const columns = selectedCountColumns (resource, next, definition)
|
||||||
|
.filter (column => column !== 'thumbnail')
|
||||||
|
const thumbnail = selectedCountColumns (resource, applied.current, definition)
|
||||||
|
.includes ('thumbnail')
|
||||||
|
next.set ('columns', (thumbnail ? ['thumbnail', ...columns] : columns).join (' '))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
next.delete (key)
|
||||||
|
for (const value of applied.current.getAll (key)) next.append (key, value)
|
||||||
|
}
|
||||||
|
const committed = normaliseCountsParams (resource, next, definition)
|
||||||
|
committed.set ('execution_at', result.executionAt)
|
||||||
|
queryClient.setQueryData (
|
||||||
|
countsKeys.index (resource, countsRequestParams (committed).toString (), locale), result)
|
||||||
|
pending.target = committed.toString ()
|
||||||
|
// Commit only after the complete result is available under the final URL's key.
|
||||||
|
if (pending.target === canonical)
|
||||||
|
{
|
||||||
|
pendingRun.current = null
|
||||||
|
setRunning (false)
|
||||||
|
}
|
||||||
|
else setParams (committed, { preventScrollReset: true })
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
if (pendingRun.current !== pending) return
|
||||||
|
pendingRun.current = null
|
||||||
|
setRunning (false)
|
||||||
|
setRunError (true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const resultParams = isPlaceholderData ? retainedParams.current : params
|
||||||
|
const resultMode = resultParams.get ('mode') ?? 'summary'
|
||||||
|
const waiting = running || (!definitionError && (definition == null
|
||||||
|
|| isPending || isFetching || isPlaceholderData
|
||||||
|
|| (!params.has ('execution_at') && !isError)))
|
||||||
|
const loadingParams = pendingRun.current?.params ?? params
|
||||||
|
const compatible = data != null && ['definition', 'mode', 'rows', 'cols'].every (
|
||||||
|
key => loadingParams.get (key) === resultParams.get (key))
|
||||||
|
|
||||||
|
|
||||||
const update = (key: string, value: string, extra?: Record<string, string>) => {
|
const update = (key: string, value: string, extra?: Record<string, string>) => {
|
||||||
@@ -110,7 +231,7 @@ const CountsResourceContent = ({ resource }: { resource: CountsResource }) => {
|
|||||||
setParams (next)
|
setParams (next)
|
||||||
}
|
}
|
||||||
const download = async (format: 'csv' | 'json') => {
|
const download = async (format: 'csv' | 'json') => {
|
||||||
if (exportInFlight.current) return
|
if (exportInFlight.current || pendingRun.current != null || definition == null) return
|
||||||
exportInFlight.current = true
|
exportInFlight.current = true
|
||||||
setExporting (true)
|
setExporting (true)
|
||||||
setExportError (false)
|
setExportError (false)
|
||||||
@@ -160,7 +281,7 @@ const CountsResourceContent = ({ resource }: { resource: CountsResource }) => {
|
|||||||
<Button type="button" variant="ghost" size="sm" className="shrink-0 md:hidden"
|
<Button type="button" variant="ghost" size="sm" className="shrink-0 md:hidden"
|
||||||
aria-expanded={sidebarOpen} aria-controls="counts-targets"
|
aria-expanded={sidebarOpen} aria-controls="counts-targets"
|
||||||
onClick={() => setSidebarOpen (!sidebarOpen)}>{label ('target')}</Button>
|
onClick={() => setSidebarOpen (!sidebarOpen)}>{label ('target')}</Button>
|
||||||
<SidebarComponent sidebarKey="counts" maxWidthRatio={.5}
|
<SidebarComponent sidebarKey="counts" maxWidthRatio={.5} animateLayout={false}
|
||||||
className={cn ('md:block', !sidebarOpen && 'hidden')}>
|
className={cn ('md:block', !sidebarOpen && 'hidden')}>
|
||||||
<nav id="counts-targets" aria-label={message ('target').text}
|
<nav id="counts-targets" aria-label={message ('target').text}
|
||||||
lang={message ('target').language}>
|
lang={message ('target').language}>
|
||||||
@@ -184,11 +305,8 @@ const CountsResourceContent = ({ resource }: { resource: CountsResource }) => {
|
|||||||
</button>
|
</button>
|
||||||
<AnimatedCollapse open={expanded.has (target)} id={`counts-definition-${target}`}>
|
<AnimatedCollapse open={expanded.has (target)} id={`counts-definition-${target}`}>
|
||||||
<ul className="ml-3">
|
<ul className="ml-3">
|
||||||
{['latest', 'history'].map (definition => {
|
{(['latest', 'history'] as const).map (definition => {
|
||||||
const next = new URLSearchParams ({
|
const next = countsDefinitionParams (definition, params)
|
||||||
definition, mode: defaultCountsMode (definition) })
|
|
||||||
for (const key of ['timezone', 'at', 'current', 'row_numbers'])
|
|
||||||
if (params.get (key)) next.set (key, params.get (key)!)
|
|
||||||
const active = target === resource
|
const active = target === resource
|
||||||
&& params.get ('definition') === definition
|
&& params.get ('definition') === definition
|
||||||
return <li key={definition}><PrefetchLink
|
return <li key={definition}><PrefetchLink
|
||||||
@@ -203,11 +321,13 @@ const CountsResourceContent = ({ resource }: { resource: CountsResource }) => {
|
|||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
</SidebarComponent>
|
</SidebarComponent>
|
||||||
<MainArea className="flex min-h-0 min-w-0 flex-col p-2">
|
<MainArea animateLayout={false} className="flex min-h-0 min-w-0 flex-col p-2">
|
||||||
<PageTitle className="mb-1 text-base">{label (`titles.${resource}`)}</PageTitle>
|
<PageTitle className="mb-1 text-base">{label (`titles.${resource}`)}</PageTitle>
|
||||||
<div className="shrink-0">
|
<div ref={controlsArea} className="shrink-0"
|
||||||
<CountsControls
|
style={{ minHeight: definition == null ? controlsHeight : undefined }}>
|
||||||
|
{definition != null && <CountsControls key={`${resource}:${params.get ('definition')}`}
|
||||||
resource={resource} applied={params}
|
resource={resource} applied={params}
|
||||||
|
pending={running || isFetching || !params.has ('execution_at')}
|
||||||
open={open} setOpen={setOpen}
|
open={open} setOpen={setOpen}
|
||||||
reorder={value => update ('order', value)}
|
reorder={value => update ('order', value)}
|
||||||
displayJSON={enabled => update ('nested_json', enabled ? '1' : '0')}
|
displayJSON={enabled => update ('nested_json', enabled ? '1' : '0')}
|
||||||
@@ -217,36 +337,48 @@ const CountsResourceContent = ({ resource }: { resource: CountsResource }) => {
|
|||||||
.filter (column => column !== 'thumbnail')
|
.filter (column => column !== 'thumbnail')
|
||||||
update ('columns', (enabled ? ['thumbnail', ...columns] : columns).join (' '))
|
update ('columns', (enabled ? ['thumbnail', ...columns] : columns).join (' '))
|
||||||
}}
|
}}
|
||||||
execute={next => {
|
execute={next => void execute (next)}
|
||||||
if (next.toString () === canonical) void refetch ()
|
actions={<div className="ml-auto flex shrink-0 items-center gap-2">
|
||||||
else setParams (next)
|
|
||||||
}} actions={<div className="ml-auto flex shrink-0 items-center gap-2">
|
|
||||||
{(['csv', 'json'] as const).map (format => <Button key={format}
|
{(['csv', 'json'] as const).map (format => <Button key={format}
|
||||||
type="button" variant="outline" size="sm"
|
type="button" variant="outline" size="sm"
|
||||||
disabled={data == null || isError || isPlaceholderData
|
disabled={data == null || isError || isPlaceholderData
|
||||||
|| exporting || !params.get ('execution_at')}
|
|| running || isFetching || exporting || !params.get ('execution_at')}
|
||||||
onClick={() => void download (format)}>
|
onClick={() => void download (format)}>
|
||||||
{label (`exportFormats.${format}`)}</Button>)}
|
{label (`exportFormats.${format}`)}</Button>)}
|
||||||
</div>}/>
|
</div>}/>}
|
||||||
|
|
||||||
{exportError && <p role="alert">{label ('common:fetchError')}</p>}
|
{exportError && <p role="alert">{label ('common:fetchError')}</p>}
|
||||||
</div>
|
</div>
|
||||||
{isPending && <p>{label ('shared:messages.loadingEllipsis')}</p>}
|
{(definitionError || isError || runError) && <p role="alert">{label ('common:fetchError')}</p>}
|
||||||
{isError && <p role="alert">{label ('common:fetchError')}</p>}
|
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden"
|
||||||
{data != null && <>
|
aria-busy={waiting}>
|
||||||
<p className="my-1 flex flex-wrap gap-x-3 text-xs">
|
<div inert={waiting} aria-hidden={waiting || undefined}
|
||||||
<span>{label ('recordTotal', { count: data.count })}</span>
|
className={cn ('flex min-h-0 flex-1 flex-col',
|
||||||
{data.populationCount != null && <span>{label ('total', {
|
waiting && (compatible ? 'pointer-events-none opacity-40' : 'invisible'))}>
|
||||||
count: data.populationCount })}</span>}
|
{definition != null && data != null && <>
|
||||||
</p>
|
{data.excludedCount != null && (
|
||||||
{mode === 'summary' ? <CountsSummaryResults resource={resource}
|
<p className="my-1 flex flex-wrap items-baseline gap-x-2 text-sm">
|
||||||
data={data} params={params}
|
<span>{label ('outputTotal', { value: data.outputCount ?? '—' })}</span>
|
||||||
pending={isPlaceholderData} update={update}/> : (
|
<span className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
<CountsResults resource={resource} data={data} params={params}
|
{label ('excludedRecords', { count: data.excludedCount })}</span>
|
||||||
pending={isPlaceholderData} update={update}/>)}
|
</p>)}
|
||||||
|
{resultMode === 'summary' ? <CountsSummaryResults resource={resource}
|
||||||
|
data={data} params={resultParams}
|
||||||
|
pending={waiting} update={update}/> : (
|
||||||
|
<CountsResults resource={resource} data={data} params={resultParams}
|
||||||
|
pending={waiting} update={update}/>)}
|
||||||
<Pagination page={data.page} totalPages={Math.ceil (
|
<Pagination page={data.page} totalPages={Math.ceil (
|
||||||
(mode === 'summary' ? data.groupCount ?? 0 : data.count) / data.limit)}/>
|
(resultMode === 'summary' ? data.groupCount ?? 0 : data.count) / data.limit)}/>
|
||||||
</>}
|
</>}
|
||||||
|
</div>
|
||||||
|
{waiting && <div role="status" aria-live="polite"
|
||||||
|
className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||||
|
<span className="rounded border border-slate-300 bg-white px-2 py-1
|
||||||
|
text-sm text-slate-900 dark:border-slate-600
|
||||||
|
dark:bg-slate-950 dark:text-slate-100">
|
||||||
|
{label ('shared:messages.loadingEllipsis')}</span>
|
||||||
|
</div>}
|
||||||
|
</div>
|
||||||
</MainArea>
|
</MainArea>
|
||||||
</div>
|
</div>
|
||||||
</CountsTableLayout>)
|
</CountsTableLayout>)
|
||||||
@@ -254,14 +386,11 @@ const CountsResourceContent = ({ resource }: { resource: CountsResource }) => {
|
|||||||
|
|
||||||
|
|
||||||
const CountsResourcePage = ({ resource }: { resource: CountsResource }) => {
|
const CountsResourcePage = ({ resource }: { resource: CountsResource }) => {
|
||||||
const { label } = useUITranslation ('counts')
|
|
||||||
const { data, isError } = useQuery ({
|
const { data, isError } = useQuery ({
|
||||||
queryKey: countsKeys.definition (resource),
|
queryKey: countsKeys.definition (resource),
|
||||||
queryFn: () => fetchCountsDefinition (resource), staleTime: Infinity })
|
queryFn: () => fetchCountsDefinition (resource), staleTime: Infinity })
|
||||||
if (data == null) return <p>{label (
|
return <CountsDefinitionContext.Provider value={data ?? null}>
|
||||||
isError ? 'common:fetchError' : 'shared:messages.loadingEllipsis')}</p>
|
<CountsResourceContent resource={resource} definition={data} definitionError={isError}/>
|
||||||
return <CountsDefinitionContext.Provider value={data}>
|
|
||||||
<CountsResourceContent resource={resource}/>
|
|
||||||
</CountsDefinitionContext.Provider>
|
</CountsDefinitionContext.Provider>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
新しいイシューから参照
ユーザーをブロックする