このコミットが含まれているのは:
@@ -20,4 +20,12 @@ class CountsTablesController < ApplicationController
|
||||
rescue ArgumentError, TypeError, KeyError, JSON::ParserError
|
||||
head :bad_request
|
||||
end
|
||||
|
||||
def candidates
|
||||
resolve_locale!
|
||||
render json: CountsCandidates.new(request.query_parameters.merge(
|
||||
'resource' => request.path_parameters[:resource])).call
|
||||
rescue ArgumentError, TypeError, KeyError
|
||||
head :bad_request
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
# Bounded, authorised candidates at the same recorded point as the Counts query.
|
||||
class CountsCandidates
|
||||
KEYS = ['resource', 'field', 'query', 'category', 'page', 'locale', 'at',
|
||||
'execution_at', 'timezone'].freeze
|
||||
LIMIT = 20
|
||||
|
||||
def initialize(params)
|
||||
QueryState.validate!(params, KEYS)
|
||||
raise ArgumentError unless params.fetch('resource') == 'posts'
|
||||
raise ArgumentError unless ['tags', 'tags_json'].include?(params.fetch('field'))
|
||||
|
||||
@params = params
|
||||
@page = Integer(params.fetch('page', '1'), 10)
|
||||
raise ArgumentError unless @page.between?(1, 100_000)
|
||||
raise ArgumentError if params.fetch('query', '').bytesize > 1000
|
||||
category = params['category']
|
||||
raise ArgumentError if category.present? && !Tag.categories.key?(category)
|
||||
end
|
||||
|
||||
def call
|
||||
options = @params.slice('locale', 'at', 'execution_at', 'timezone')
|
||||
.merge('definition' => 'latest', 'resource' => 'tags')
|
||||
internal = CountsPopulation.new(options)
|
||||
external = CountsPopulation.new(options.merge('resource' => 'nico_tags',
|
||||
'execution_at' => internal.at.iso8601(6)))
|
||||
language = Locale.find_by!(code: @params.fetch('locale', 'ja')).language_code
|
||||
connection = TagVersion.connection
|
||||
path = connection.quote("$.#{ language.to_json }.name")
|
||||
name = "COALESCE(JSON_UNQUOTE(JSON_EXTRACT(v.names, #{ path })), v.name)"
|
||||
sources = [[internal, 'internal', name, 'v.category'],
|
||||
[external, 'external', 'v.name', 'NULL']].map do |population, kind, title, category|
|
||||
population.scope.except(:select).select(Arel.sql(
|
||||
"CONCAT('#{ kind }:', v.tag_id) AS value, v.tag_id AS id, " +
|
||||
"#{ title } AS name, #{ category } AS category, '#{ kind }' AS kind")).to_sql
|
||||
end
|
||||
clauses = []
|
||||
binds = []
|
||||
if @params['query'].present?
|
||||
clauses << '(name LIKE ? OR CAST(id AS CHAR) = ?)'
|
||||
binds += ["%#{ ApplicationRecord.sanitize_sql_like(@params['query']) }%", @params['query']]
|
||||
end
|
||||
if @params['category'].present?
|
||||
clauses << 'category = ?'
|
||||
binds << @params['category']
|
||||
end
|
||||
where = clauses.empty? ? '' : "WHERE #{ clauses.join(' AND ') }"
|
||||
sql = <<~SQL
|
||||
SELECT /*+ MAX_EXECUTION_TIME(10000) */ * FROM (#{ sources.join(' UNION ALL ') }) choices
|
||||
#{ where } ORDER BY kind, id LIMIT #{ LIMIT + 1 } OFFSET #{ (@page - 1) * LIMIT }
|
||||
SQL
|
||||
rows = connection.select_all(ApplicationRecord.sanitize_sql_array([sql, *binds])).to_a
|
||||
{ rows: rows.first(LIMIT), page: @page, more: rows.length > LIMIT,
|
||||
execution_at: internal.at.iso8601(6) }
|
||||
end
|
||||
end
|
||||
@@ -44,12 +44,31 @@ class CountsCondition
|
||||
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,
|
||||
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) }]
|
||||
result.each do |field|
|
||||
next if field[:type] == :interval
|
||||
|
||||
if field[:multiple]
|
||||
field[:operators] = field[:operators] - ['in', 'not_in'] + ['any', 'all', 'none']
|
||||
end
|
||||
field[:input_types] = ['single', 'multiple']
|
||||
if ['category', 'tag_category'].include?(field[:key].split('.').last)
|
||||
field[:candidate_source] = 'categories'
|
||||
field[:choices] = Tag.categories.keys.map { |value|
|
||||
{ value:, label: "common:categories.#{ value }" }
|
||||
}
|
||||
elsif definition.resource == 'posts' && ['tags', 'tags_json'].include?(field[:key])
|
||||
field[:candidate_source] = 'tags'
|
||||
field[:set_type] = 'tag_reference'
|
||||
end
|
||||
field[:input_types] << 'list' if field[:candidate_source]
|
||||
end
|
||||
result
|
||||
end
|
||||
|
||||
def initialize(population)
|
||||
@@ -61,11 +80,17 @@ class CountsCondition
|
||||
|
||||
def apply(scope, entry)
|
||||
raise ArgumentError unless entry.is_a?(Hash)
|
||||
raise ArgumentError if (entry.keys - ['field', 'operator', 'value', 'end', 'blank']).any?
|
||||
allowed = ['field', 'operator', 'value', 'end', 'blank', 'input', 'values']
|
||||
raise ArgumentError if (entry.keys - allowed).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'])
|
||||
input = entry.fetch('input', 'single')
|
||||
raise ArgumentError unless ['single', 'multiple', 'list'].include?(input)
|
||||
if input == 'list' && !field[:candidate_source]
|
||||
raise ArgumentError
|
||||
end
|
||||
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] != ''
|
||||
@@ -84,6 +109,10 @@ class CountsCondition
|
||||
end
|
||||
return scope.where(Arel.sql(sql))
|
||||
end
|
||||
if ['in', 'not_in', 'any', 'all', 'none'].include?(entry['operator'])
|
||||
return set_condition(scope, entry, field)
|
||||
end
|
||||
raise ArgumentError if entry.key?('values') || input != 'single'
|
||||
return scope if values.all? { _1.nil? || _1 == '' }
|
||||
|
||||
parsed = values.map { parse(_1, type) }
|
||||
@@ -122,6 +151,58 @@ class CountsCondition
|
||||
|
||||
private
|
||||
|
||||
def set_condition(scope, entry, field)
|
||||
return scope unless entry.key?('values')
|
||||
|
||||
values = entry['values']
|
||||
raise ArgumentError unless values.is_a?(Array) && values.length <= 100
|
||||
raise ArgumentError if [entry['value'], entry['end']].any? { !_1.nil? && _1 != '' }
|
||||
|
||||
parsed = values.map do |value|
|
||||
unless (value.is_a?(String) || value.is_a?(Numeric) || [true, false].include?(value))
|
||||
raise ArgumentError
|
||||
end
|
||||
raise ArgumentError if value.to_s.bytesize > 8192
|
||||
|
||||
if field[:set_type] == 'tag_reference'
|
||||
raise ArgumentError unless value.is_a?(String) &&
|
||||
value.match?(/\A(?:internal|external):[1-9][0-9]{0,17}\z/)
|
||||
value
|
||||
else
|
||||
raise ArgumentError if value == '' && ![:string, :enum].include?(field[:type])
|
||||
result = value == '' ? '' : parse(value.to_s, field[:type])
|
||||
validate_enum!(field[:key], result) if field[:type] == :enum
|
||||
result
|
||||
end
|
||||
end
|
||||
raise ArgumentError unless parsed.uniq == parsed
|
||||
|
||||
operator = entry['operator']
|
||||
if parsed.empty?
|
||||
return ['in', 'any'].include?(operator) ? scope.none : scope
|
||||
end
|
||||
source, expression = source_for(field[:key])
|
||||
if field[:set_type] == 'tag_reference'
|
||||
source = post_tag_source
|
||||
id = "COALESCE(#{ scalar('tag_snapshot.item', 'tag_id', :integer) }, " +
|
||||
"#{ scalar('tag_snapshot.item', 'external_tag_id', :integer) })"
|
||||
expression = "CONCAT(CASE WHEN #{ scalar('tag_snapshot.item', 'tag_id') } IS NULL " +
|
||||
"THEN 'external:' ELSE 'internal:' END, #{ id })"
|
||||
end
|
||||
predicates = parsed.map do |_value|
|
||||
clause = "#{ expression } = ?"
|
||||
clause = "EXISTS (SELECT 1 FROM #{ source } WHERE #{ clause })" if source
|
||||
clause
|
||||
end
|
||||
conjunction = operator == 'all' ? ' AND ' : ' OR '
|
||||
predicate = "(#{ predicates.join(conjunction) })"
|
||||
if ['not_in', 'none'].include?(operator)
|
||||
predicate = source ? "NOT #{ predicate }" :
|
||||
"(NOT #{ predicate } OR #{ blank_sql(expression, field[:type]) })"
|
||||
end
|
||||
scope.where(predicate, *parsed)
|
||||
end
|
||||
|
||||
def parse(value, type)
|
||||
return nil if value.nil? || value == ''
|
||||
|
||||
|
||||
@@ -10,7 +10,21 @@ class CountsPivotLayout
|
||||
(1...keys.length).select { order.first(_1).sort == keys.first(_1).sort }
|
||||
end
|
||||
|
||||
def self.headers(values, depths, next_values: nil, grand: false)
|
||||
def self.label_key(row_count, column_count, row_depth, column_depth)
|
||||
total = row_count + column_count
|
||||
collapsed = total - row_depth - column_depth
|
||||
return nil if collapsed.zero?
|
||||
return 'grandTotal' if total >= 3 && collapsed == total
|
||||
if (collapsed == 1 &&
|
||||
((row_count > 1 && row_depth < row_count) ||
|
||||
(column_count > 1 && column_depth < column_count)))
|
||||
return 'subtotal'
|
||||
end
|
||||
'singleTotal'
|
||||
end
|
||||
|
||||
def self.headers(values, depths, next_values: nil, grand: false, axis_count: nil,
|
||||
other_count: 0, direction: :row)
|
||||
result = []
|
||||
values.each_with_index do |tuple, index|
|
||||
result << { values: tuple, kind: 'data' }
|
||||
@@ -23,6 +37,15 @@ class CountsPivotLayout
|
||||
end
|
||||
end
|
||||
result << { values: [], kind: 'grand_total' } if grand
|
||||
count = axis_count || values.first&.length || 0
|
||||
result.each do |header|
|
||||
depth = header[:values].length
|
||||
label = if header[:kind] != 'data'
|
||||
direction == :row ? label_key(count, other_count, depth, other_count) :
|
||||
label_key(other_count, count, other_count, depth)
|
||||
end
|
||||
header.merge!(depth:, axis_count: count, label_key: label)
|
||||
end
|
||||
result
|
||||
end
|
||||
end
|
||||
|
||||
@@ -53,6 +53,9 @@ class CountsPivotTotals
|
||||
column_depth = record['column_depth'].to_i
|
||||
{ row: (0...row_depth).map { record["v#{ _1 }"] },
|
||||
column: (0...column_depth).map { record["v#{ row_depth + _1 }"] },
|
||||
row_depth:, column_depth:,
|
||||
label_key: CountsPivotLayout.label_key(@axes.rows.length, @axes.columns.length,
|
||||
row_depth, column_depth),
|
||||
count: record['count'].to_i }
|
||||
end
|
||||
end
|
||||
|
||||
@@ -103,7 +103,21 @@ class CountsPopulation
|
||||
conditions = QueryState.records(params, 'conditions')
|
||||
raise ArgumentError unless conditions.is_a?(Array) && conditions.length <= 100
|
||||
|
||||
conditions.each { apply_condition(_1) }
|
||||
values = QueryState.records(params, 'condition_values')
|
||||
values.each do |entry|
|
||||
index = entry.fetch('condition')
|
||||
raise ArgumentError unless index.between?(0, conditions.length - 1)
|
||||
raise ArgumentError unless conditions[index]['values'] == true
|
||||
end
|
||||
conditions.each_with_index do |entry, index|
|
||||
if entry.key?('values')
|
||||
raise ArgumentError unless entry['values'] == true
|
||||
|
||||
entry = entry.merge('values' => values.select { _1['condition'] == index }
|
||||
.map { _1.fetch('value') })
|
||||
end
|
||||
apply_condition(entry)
|
||||
end
|
||||
drills = QueryState.records(params, 'drill')
|
||||
raise ArgumentError unless drills.is_a?(Array) && drills.length <= 100
|
||||
|
||||
|
||||
@@ -46,9 +46,10 @@ class CountsResourceDefinition
|
||||
'source_kind' => :enum, 'source_uri' => :string, 'source_path' => :string,
|
||||
'source_file_id' => :string, 'normalized_source_key' => :string }] }.freeze
|
||||
OPERATORS = {
|
||||
string: ['eq', 'ne', 'like'], enum: ['eq', 'ne'], boolean: ['eq', 'ne'],
|
||||
integer: ['eq', 'ne', 'gt', 'lt', 'ge', 'le', 'range'],
|
||||
datetime: ['eq', 'ne', 'gt', 'lt', 'ge', 'le', 'range'],
|
||||
string: ['eq', 'ne', 'like', 'in', 'not_in'],
|
||||
enum: ['eq', 'ne', 'in', 'not_in'], boolean: ['eq', 'ne', 'in', 'not_in'],
|
||||
integer: ['eq', 'ne', 'gt', 'lt', 'ge', 'le', 'range', 'in', 'not_in'],
|
||||
datetime: ['eq', 'ne', 'gt', 'lt', 'ge', 'le', 'range', 'in', 'not_in'],
|
||||
interval: ['range'] }.freeze
|
||||
LABELS = {
|
||||
'resource_id' => 'counts:columns.resourceId', 'version_no' => 'counts:columns.version',
|
||||
|
||||
@@ -143,8 +143,8 @@ class CountsTableExport
|
||||
end
|
||||
|
||||
def total_label(header)
|
||||
key = header[:kind] == 'grand_total' ?
|
||||
(@query.axes.all.length == 1 ? 'singleTotal' : 'grandTotal') : 'subtotal'
|
||||
key = header[:label_key] || (header[:kind] == 'grand_total' ?
|
||||
(@query.axes.all.length == 1 ? 'singleTotal' : 'grandTotal') : 'subtotal')
|
||||
@labels.fetch(key, '')
|
||||
end
|
||||
|
||||
@@ -152,9 +152,10 @@ class CountsTableExport
|
||||
values = header[:values].each_with_index.map { |value, index|
|
||||
summary_value(value, index, page)
|
||||
}
|
||||
if header[:kind] == 'data' && @previous_pivot_row&.fetch(:kind) == 'data'
|
||||
if @previous_pivot_row
|
||||
values.each_index do |index|
|
||||
if header[:values].first(index + 1) == @previous_pivot_row[:values].first(index + 1)
|
||||
if (@previous_pivot_row[:values].length > index &&
|
||||
header[:values].first(index + 1) == @previous_pivot_row[:values].first(index + 1))
|
||||
values[index] = ''
|
||||
end
|
||||
end
|
||||
@@ -234,6 +235,8 @@ class CountsTableExport
|
||||
{ row: row[:values], column: column[:values], kind:,
|
||||
row_kind: row[:kind], column_kind: column[:kind],
|
||||
row_depth: row[:values].length, column_depth: column[:values].length,
|
||||
label_key: CountsPivotLayout.label_key(@query.axes.rows.length, @query.axes.columns.length,
|
||||
row[:values].length, column[:values].length),
|
||||
count: @pivot_lookup.fetch([row[:values], column[:values]], 0) }
|
||||
end
|
||||
|
||||
|
||||
@@ -5,7 +5,8 @@ class CountsTableQuery
|
||||
|
||||
KEYS = ['resource', 'definition', 'mode', 'at', 'execution_at', 'from', 'through',
|
||||
'timezone', 'rows', 'cols', 'conditions', 'drill', 'tags', 'tag_refs', 'match',
|
||||
'tag', 'kind', 'resource_id', 'user_id', 'page', 'limit', 'sort', 'locale', 'measures', 'column_order'].freeze
|
||||
'tag', 'kind', 'resource_id', 'user_id', 'page', 'limit', 'sort', 'locale',
|
||||
'measures', 'column_order', 'condition_values'].freeze
|
||||
attr_reader :population, :axes, :page, :limit, :mode
|
||||
|
||||
def initialize(params)
|
||||
@@ -145,14 +146,19 @@ class CountsTableQuery
|
||||
end
|
||||
column_depths = CountsPivotLayout.depths(column_keys, column_sort, measures: false)
|
||||
row_headers = CountsPivotLayout.headers(row_values, row_depths,
|
||||
next_values: next_row && row_keys.map { next_row[_1] }, grand: axes.rows.any?)
|
||||
next_values: next_row && row_keys.map { next_row[_1] }, grand: axes.rows.any?,
|
||||
axis_count: axes.rows.length, other_count: axes.columns.length)
|
||||
if axes.rows.empty? && axes.columns.any?
|
||||
row_headers = [{ values: [], kind: 'grand_total' }]
|
||||
row_headers = CountsPivotLayout.headers([[]], [], axis_count: 0,
|
||||
other_count: axes.columns.length).map { _1.merge(kind: 'grand_total') }
|
||||
end
|
||||
column_headers = CountsPivotLayout.headers(column_values, column_depths,
|
||||
grand: axes.columns.any?)
|
||||
grand: axes.columns.any?, axis_count: axes.columns.length,
|
||||
other_count: axes.rows.length, direction: :column)
|
||||
if axes.columns.empty? && axes.rows.any?
|
||||
column_headers = [{ values: [], kind: 'grand_total' }]
|
||||
column_headers = CountsPivotLayout.headers([[]], [], axis_count: 0,
|
||||
other_count: axes.rows.length, direction: :column)
|
||||
.map { _1.merge(kind: 'grand_total') }
|
||||
end
|
||||
totals = CountsPivotTotals.new(population, axes, row_headers, column_headers).call
|
||||
{ group_count: total,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
# Public query grammar shared with frontend/src/lib/queryState.ts.
|
||||
class QueryState
|
||||
SCHEMAS = {
|
||||
'conditions' => ['field', 'operator', 'value', 'end', 'blank'],
|
||||
'conditions' => ['field', 'operator', 'value', 'end', 'blank', 'input', 'values'],
|
||||
'condition_values' => ['condition', 'value', 'value_type'],
|
||||
'drill' => ['field', 'grain', 'value', 'value_type'],
|
||||
'tag_refs' => ['name', 'tag_id', 'external_tag_id', 'negative', 'missing'],
|
||||
'column_order' => ['value', 'value_type'],
|
||||
@@ -40,11 +41,11 @@ class QueryState
|
||||
|
||||
_root, index, field = name.split('.')
|
||||
record = records[index.to_i] ||= {}
|
||||
if ['blank', 'negative', 'missing'].include?(field)
|
||||
if ['blank', 'negative', 'missing', 'values'].include?(field)
|
||||
raise ArgumentError unless ['0', '1'].include?(value)
|
||||
|
||||
value = value == '1'
|
||||
elsif ['tag_id', 'external_tag_id', 'axis'].include?(field)
|
||||
elsif ['tag_id', 'external_tag_id', 'axis', 'condition'].include?(field)
|
||||
raise ArgumentError unless value.match?(/\A[0-9]{1,18}\z/)
|
||||
|
||||
value = value.to_i
|
||||
@@ -56,7 +57,7 @@ class QueryState
|
||||
|
||||
records.sort.map do |_index, record|
|
||||
type = record.delete('value_type')
|
||||
if ['drill', 'measures', 'column_order'].include?(key)
|
||||
if ['drill', 'measures', 'column_order', 'condition_values'].include?(key)
|
||||
raise ArgumentError unless ['null', 'string', 'number', 'boolean'].include?(type)
|
||||
|
||||
raise ArgumentError unless type == 'null' || record.key?('value')
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
Rails.application.routes.draw do
|
||||
get 'counts/definitions/:resource', to: 'counts_tables#definition'
|
||||
get 'counts/definitions/:resource/candidates', to: 'counts_tables#candidates'
|
||||
get 'counts/query', to: 'counts_tables#index'
|
||||
post 'counts/export/jobs', to: 'counts_exports#create_job'
|
||||
get 'counts/export/jobs/:token', to: 'counts_exports#job_status'
|
||||
|
||||
+4
-2
@@ -5,7 +5,7 @@ import { request as requestHTTPS } from 'node:https'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { render, renderSitemap } from './dist/server/entry-server.js'
|
||||
import { isCountsPath, render, renderSitemap } from './dist/server/entry-server.js'
|
||||
|
||||
const root = fileURLToPath (new URL ('./dist/client/', import.meta.url))
|
||||
const template = await readFile (path.join (root, 'index.html'), 'utf8')
|
||||
@@ -61,6 +61,8 @@ const server = createServer (async (request, response) => {
|
||||
{
|
||||
const url = new URL (request.url, origin)
|
||||
const pathname = decodeURIComponent (url.pathname)
|
||||
const countsPage = isCountsPath (pathname)
|
||||
if (countsPage) response.setHeader ('X-Robots-Tag', 'noindex')
|
||||
if (pathname === '/sitemap.xml' || pathname.startsWith ('/sitemap-'))
|
||||
{
|
||||
const result = await renderSitemap (url.pathname, origin, apiBaseURL)
|
||||
@@ -108,7 +110,7 @@ const server = createServer (async (request, response) => {
|
||||
return
|
||||
}
|
||||
const html = result?.html == null
|
||||
? template.replace ('<!-- helmet -->', result?.status === 404
|
||||
? template.replace ('<!-- helmet -->', (result?.status === 404 || countsPage)
|
||||
? '<meta name="robots" content="noindex" data-rh="true">' : '')
|
||||
: template
|
||||
.replace ('lang="ja"', `lang="${result.locale}"`)
|
||||
|
||||
+12
-8
@@ -23,6 +23,7 @@ import { prepareHelpPage } from '@/lib/help'
|
||||
import { fetchLocales, isLocaleCode, legacyLocalePath,
|
||||
switchLocalePath, unlocalisedPath } from '@/lib/locales'
|
||||
import { isAuthorityDependentQuery, localesKeys, sessionKeys } from '@/lib/queryKeys'
|
||||
import { isCountsPath } from '@/lib/routeLocales'
|
||||
import { switchPostFilterLocale } from '@/lib/posts'
|
||||
import { switchWikiLocale } from '@/lib/wiki'
|
||||
import { useLocale } from '@/lib/useLocale'
|
||||
@@ -199,19 +200,19 @@ export const pageRoutes = (
|
||||
handle: { locale: 'ja', sitemap: ['static'], preload: locale => locale === 'ja'
|
||||
? GekanatorPage.preload () : GekanatorUnavailablePage.preload () } },
|
||||
{ path: 'counts', element: <CountsResourcePage resource="posts"/>,
|
||||
handle: { preload: CountsResourcePage.preload } },
|
||||
handle: { preload: CountsResourcePage.preload, prefetch: false, robots: 'noindex' } },
|
||||
{ path: 'counts/posts', element: <CountsResourcePage resource="posts"/>,
|
||||
handle: { preload: CountsResourcePage.preload } },
|
||||
handle: { preload: CountsResourcePage.preload, prefetch: false, robots: 'noindex' } },
|
||||
{ path: 'counts/tags', element: <CountsResourcePage resource="tags"/>,
|
||||
handle: { preload: CountsResourcePage.preload } },
|
||||
handle: { preload: CountsResourcePage.preload, prefetch: false, robots: 'noindex' } },
|
||||
{ path: 'counts/nico_tags', element: <CountsResourcePage resource="nico_tags"/>,
|
||||
handle: { preload: CountsResourcePage.preload } },
|
||||
handle: { preload: CountsResourcePage.preload, prefetch: false, robots: 'noindex' } },
|
||||
{ path: 'counts/tag_descriptions', element: <CountsResourcePage resource="tag_descriptions"/>,
|
||||
handle: { preload: CountsResourcePage.preload } },
|
||||
handle: { preload: CountsResourcePage.preload, prefetch: false, robots: 'noindex' } },
|
||||
{ path: 'counts/tag_groups', element: <CountsResourcePage resource="tag_groups"/>,
|
||||
handle: { preload: CountsResourcePage.preload } },
|
||||
handle: { preload: CountsResourcePage.preload, prefetch: false, robots: 'noindex' } },
|
||||
{ path: 'counts/materials', element: <CountsResourcePage resource="materials"/>,
|
||||
handle: { preload: CountsResourcePage.preload } },
|
||||
handle: { preload: CountsResourcePage.preload, prefetch: false, robots: 'noindex' } },
|
||||
{ path: 'more', element: <MorePage/>,
|
||||
handle: { preload: MorePage.preload, sitemap: ['static'] } },
|
||||
{ path: '*', element: <NotFound/>, handle: { notFound: true } }]
|
||||
@@ -224,8 +225,11 @@ const matchPageRoutes = (pathname: string) =>
|
||||
export const preloadPageRoute = async (
|
||||
pathname: string,
|
||||
locale: LocaleCode,
|
||||
intent: 'navigation' | 'prefetch' = 'navigation',
|
||||
): Promise<void> => {
|
||||
if (intent === 'prefetch' && isCountsPath (pathname)) return
|
||||
await Promise.all ((matchPageRoutes (pathname) ?? [])
|
||||
.filter (match => intent === 'navigation' || match.route.handle?.prefetch !== false)
|
||||
.map (match => match.route.handle?.preload?.(locale)))
|
||||
}
|
||||
|
||||
@@ -262,7 +266,7 @@ const RoutedApp = ({ locales }: { locales: AvailableLocale[] }) => {
|
||||
const location = useLocation ()
|
||||
const locale = useLocale ()
|
||||
const route = resolvePageRoute (location.pathname)
|
||||
const robots = route?.route.handle?.robots
|
||||
const robots = isCountsPath (location.pathname) ? 'noindex' : route?.route.handle?.robots
|
||||
const pagePath = unlocalisedPath (location.pathname)
|
||||
const countsPage = pagePath === '/counts' || pagePath.startsWith ('/counts/')
|
||||
const switchLocale = async (targetLocale: LocaleCode): Promise<string> => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { localiseTo } from '@/lib/locales'
|
||||
import { useLocale, useLocaleNavigate } from '@/lib/useLocale'
|
||||
import { useOverlayStore } from '@/components/RouteBlockerOverlay'
|
||||
import { prefetchForURL } from '@/lib/prefetchers'
|
||||
import { isCountsPath } from '@/lib/routeLocales'
|
||||
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { currentRenderContext } from '@/lib/renderContext'
|
||||
@@ -53,6 +54,7 @@ export default forwardRef<HTMLAnchorElement, Props> (({
|
||||
const setOverlay = useOverlayStore (s => s.setActive)
|
||||
|
||||
const doPrefetch = async () => {
|
||||
if (isCountsPath (new URL (url).pathname)) return true
|
||||
try
|
||||
{
|
||||
await prefetchForURL (qc, url)
|
||||
@@ -94,6 +96,12 @@ export default forwardRef<HTMLAnchorElement, Props> (({
|
||||
|
||||
ev.preventDefault ()
|
||||
|
||||
if (isCountsPath (new URL (url).pathname))
|
||||
{
|
||||
navigate (path, { replace, ...(state && { state }) })
|
||||
return
|
||||
}
|
||||
|
||||
flushSync (() => {
|
||||
setOverlay (true)
|
||||
})
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import CountsConditionValues from '@/components/counts/CountsConditionValues'
|
||||
import DateTimeField from '@/components/common/DateTimeField'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { countsEventTypes, countsFieldLabel, countsFilterFields,
|
||||
useCountsDefinition }
|
||||
import { countsConditionInputs, countsEventTypes, countsFieldLabel, countsFilterFields,
|
||||
countsSetOperators, useCountsDefinition }
|
||||
from '@/lib/countsDefinitions'
|
||||
import useDialogue from '@/lib/dialogues/useDialogue'
|
||||
import { inputClass } from '@/lib/utils'
|
||||
@@ -33,12 +34,14 @@ const ConditionEditor = (
|
||||
})
|
||||
const { field, operator } = condition
|
||||
const operators = fields.find (item => item.key === field)?.operators ?? []
|
||||
const inputs = countsConditionInputs (fields.find (item => item.key === field), operator)
|
||||
useEffect (() => {
|
||||
controls.setActions ([{ label: label (initial == null ? 'add' : 'shared:actions.edit'),
|
||||
onSelect: () => save (condition) }])
|
||||
}, [controls, condition, initial, label, save])
|
||||
return <div className="space-y-2">
|
||||
<select value={field} aria-label={message ('field').text} className={inputClass ()}
|
||||
<select value={field} aria-label={message ('field').text}
|
||||
className={inputClass (false, 'h-9 w-full')}
|
||||
onChange={event => {
|
||||
const field = event.target.value
|
||||
const operator = fields.find (item => item.key === field)?.operators[0]
|
||||
@@ -47,17 +50,39 @@ const ConditionEditor = (
|
||||
{fields.filter (field => field.operators.length > 0).map (field => (
|
||||
<option key={field.key} value={field.key}>{fieldName (field.key)}</option>))}
|
||||
</select>
|
||||
<select value={operator} aria-label={message ('operator').text} className={inputClass ()}
|
||||
<select value={operator} aria-label={message ('operator').text}
|
||||
className={inputClass (false, 'h-9 w-full')}
|
||||
onChange={event => setCondition (previous => {
|
||||
const operator = event.target.value
|
||||
const next = { ...previous, operator }
|
||||
if (operator !== 'range') delete next.end
|
||||
if ((operator === 'like') !== (previous.operator === 'like')) delete next.value
|
||||
const choices = countsConditionInputs (fields.find (item => item.key === field), operator)
|
||||
if (countsSetOperators.includes (operator))
|
||||
{
|
||||
delete next.value
|
||||
delete next.end
|
||||
next.input = choices.includes (previous.input ?? 'single') ? previous.input : choices[0]
|
||||
}
|
||||
else
|
||||
{
|
||||
delete next.values
|
||||
delete next.input
|
||||
}
|
||||
return next
|
||||
})}>
|
||||
{operators.map (key => <option key={key} value={key}>
|
||||
{message (`operators.${key}`).text}</option>)}
|
||||
</select>
|
||||
<label className="block text-sm">{label ('pattern.input')}
|
||||
<select value={condition.input ?? inputs[0]}
|
||||
className={inputClass (false, 'h-9 w-full')}
|
||||
onChange={event => setCondition (previous => ({ ...previous,
|
||||
input: event.target.value as CountsCondition['input'] }))}>
|
||||
{inputs.map (input => <option key={input} value={input}>
|
||||
{message (`pattern.${input}`).text}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -99,7 +124,7 @@ const CountsAdditionalConditions = ({ params, update }: {
|
||||
const options: string[] = type === 'boolean' ? ['true', 'false'] : [...countsEventTypes]
|
||||
const namespace = type === 'boolean' ? 'truthValues' : 'events'
|
||||
return <select value={condition[key] ?? ''} disabled={condition.blank}
|
||||
className={inputClass (false, 'h-7 w-36 min-w-0 max-w-full py-1')}
|
||||
className={inputClass (false, 'h-9 w-36 min-w-0 max-w-full py-1')}
|
||||
aria-label={aria.text}
|
||||
lang={aria.language}
|
||||
onChange={event => patch (index, { [key]: event.target.value })}>
|
||||
@@ -110,13 +135,13 @@ const CountsAdditionalConditions = ({ params, update }: {
|
||||
</select>
|
||||
}
|
||||
if (type === 'datetime')
|
||||
return <DateTimeField precision="second" className="h-7 w-48 min-w-0 max-w-full py-1"
|
||||
return <DateTimeField precision="second" className="h-9 w-48 min-w-0 max-w-full py-1"
|
||||
value={condition[key] ?? ''} disabled={condition.blank}
|
||||
aria-label={aria.text} lang={aria.language}
|
||||
onChange={value => patch (index, { [key]: value ?? '' })}/>
|
||||
return <input value={condition[key] ?? ''} disabled={condition.blank}
|
||||
type={(type === 'integer' || type === 'interval') ? 'number' : 'text'}
|
||||
className={inputClass (false, 'h-7 w-36 min-w-0 max-w-full py-1')}
|
||||
className={inputClass (false, 'h-9 w-36 min-w-0 max-w-full py-1')}
|
||||
aria-label={aria.text} lang={aria.language}
|
||||
onChange={event => patch (index, { [key]: event.target.value })}/>
|
||||
}
|
||||
@@ -130,24 +155,32 @@ const CountsAdditionalConditions = ({ params, update }: {
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => edit (index)}
|
||||
aria-label={`${fieldName (condition.field)} ${message (
|
||||
`operators.${condition.operator}`).text}`}
|
||||
className="h-7 min-w-0 max-w-full justify-start gap-2 whitespace-normal px-1 text-left">
|
||||
className="min-w-0 max-w-full justify-start gap-2 whitespace-normal px-1 text-left">
|
||||
<span className="min-w-0 break-words">{fieldName (condition.field)}</span>
|
||||
{condition.operator !== 'range' && <span aria-hidden="true" className="shrink-0">
|
||||
{label (`operatorSymbols.${condition.operator}`)}</span>}
|
||||
</Button>
|
||||
{countsSetOperators.includes (condition.operator) ? (
|
||||
<CountsConditionValues key={`${condition.field}:${condition.operator}`}
|
||||
condition={condition}
|
||||
field={metadata.find (field => field.key === condition.field)!}
|
||||
definition={definition} params={params}
|
||||
maximum={Math.max (0, 100 - conditions.reduce ((sum, item, position) =>
|
||||
sum + (position === index ? 0 : item.values?.length ?? 0), 0))}
|
||||
change={values => patch (index, { values })}/>) : (
|
||||
<div className="inline-flex min-w-0 max-w-full items-center gap-1">
|
||||
{valueInput (condition, index, 'value')}
|
||||
{condition.operator === 'range' && <>
|
||||
<span aria-hidden="true" className="shrink-0">{label ('operatorSymbols.range')}</span>
|
||||
{valueInput (condition, index, 'end')}
|
||||
</>}
|
||||
</div>
|
||||
</div>)}
|
||||
<div className="inline-flex shrink-0 items-center gap-1">
|
||||
<label className="inline-flex items-center gap-1 text-sm">
|
||||
<input type="checkbox" checked={condition.blank ?? false}
|
||||
onChange={event => patch (index, { blank: event.target.checked })}/>
|
||||
{label ('blank')}</label>
|
||||
<Button type="button" variant="ghost" size="sm" className="h-7 px-1"
|
||||
<Button type="button" variant="ghost" size="sm" className="px-1"
|
||||
aria-label={message ('remove').text}
|
||||
onClick={() => write (conditions.filter ((_, position) => index !== position))}>
|
||||
×</Button>
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { countsUseLatest } from '@/lib/counts'
|
||||
import { fetchCountsCandidates } from '@/lib/countsDefinitions'
|
||||
import { countsKeys } from '@/lib/queryKeys'
|
||||
import { useLocale } from '@/lib/useLocale'
|
||||
import { useUITranslation } from '@/lib/useUITranslation'
|
||||
import { inputClass } from '@/lib/utils'
|
||||
|
||||
import type { DialogueFormControls } from '@/lib/dialogues/useDialogue'
|
||||
import type { CountsCandidate, CountsDefinition, CountsFieldMetadata }
|
||||
from '@/lib/countsDefinitions'
|
||||
|
||||
|
||||
const CountsCandidatePicker = (
|
||||
{ field, definition, params, initial, maximum, controls, save }: {
|
||||
field: CountsFieldMetadata; params: URLSearchParams
|
||||
definition: CountsDefinition
|
||||
initial: (string | number | boolean)[]; maximum: number
|
||||
controls: DialogueFormControls; save: (values: string[]) => void },
|
||||
) => {
|
||||
const { label, message } = useUITranslation ('counts')
|
||||
const locale = useLocale ()
|
||||
const [selected, setSelected] = useState (() => new Set (initial.map (String)))
|
||||
const [columns, setColumns] = useState (['id', 'name', 'category', 'kind'])
|
||||
const [draft, setDraft] = useState ('')
|
||||
const [search, setSearch] = useState ('')
|
||||
const [category, setCategory] = useState ('')
|
||||
const [page, setPage] = useState (1)
|
||||
const point = useRef (params.get ('execution_at'))
|
||||
const request = new URLSearchParams ({ field: field.key, query: search, page: String (page) })
|
||||
for (const key of ['timezone', 'at', 'execution_at'])
|
||||
if (params.has (key)) request.set (key, params.get (key)!)
|
||||
if (countsUseLatest (params)) request.delete ('at')
|
||||
if (category) request.set ('category', category)
|
||||
const dynamic = field.candidateSource === 'tags'
|
||||
const { data, isFetching: fetching, isError: errorFlg } = useQuery ({
|
||||
queryKey: countsKeys.candidates (request.toString (), locale), enabled: dynamic,
|
||||
queryFn: ({ signal }) => {
|
||||
const pinned = new URLSearchParams (request)
|
||||
if (point.current != null) pinned.set ('execution_at', point.current)
|
||||
return fetchCountsCandidates (pinned, locale, signal)
|
||||
}, retry: false })
|
||||
useEffect (() => {
|
||||
if (data != null) point.current = data.executionAt
|
||||
}, [data])
|
||||
useEffect (() => {
|
||||
controls.setActions ([{ label: label ('pattern.selection'),
|
||||
onSelect: () => save ([...selected]) }])
|
||||
}, [controls, label, save, selected])
|
||||
const fixed: CountsCandidate[] = (field.choices ?? []).map (choice => ({
|
||||
value: choice.value, name: message (choice.label).text }))
|
||||
const rows = dynamic ? data?.rows ?? [] : fixed.filter (row =>
|
||||
row.name?.includes (search))
|
||||
const headings: Record<string, string> = {
|
||||
id: 'columns.resourceId', name: 'columns.name', category: 'common:categoryLabel',
|
||||
kind: 'columns.kind' }
|
||||
const available = dynamic ? ['id', 'name', 'category', 'kind'] : ['name']
|
||||
const visible = available.filter (column => columns.includes (column))
|
||||
const categories = definition.filterFields?.find (field =>
|
||||
field.candidateSource === 'categories')?.choices ?? []
|
||||
const cell = (row: CountsCandidate, column: string) => {
|
||||
if (column === 'category') return row.category == null ? ''
|
||||
: message (`common:categories.${row.category}`).text
|
||||
if (column === 'kind') return row.kind == null ? '' : message (`kinds.${row.kind}`).text
|
||||
return column === 'id' ? row.id : row.name
|
||||
}
|
||||
return <div className="min-w-0 space-y-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<input className={inputClass (false, 'h-9 min-w-0 flex-1')}
|
||||
aria-label={message ('common:search').text} value={draft}
|
||||
onChange={event => setDraft (event.target.value)}
|
||||
onKeyDown={event => {
|
||||
if (event.key !== 'Enter') return
|
||||
event.preventDefault ()
|
||||
setSearch (draft)
|
||||
setPage (1)
|
||||
}}/>
|
||||
<Button type="button" onClick={() => { setSearch (draft); setPage (1) }}>
|
||||
{label ('common:search')}</Button>
|
||||
</div>
|
||||
{dynamic && <select className={inputClass (false, 'h-9 w-full')}
|
||||
aria-label={message ('common:categoryLabel').text} value={category}
|
||||
onChange={event => { setCategory (event.target.value); setPage (1) }}>
|
||||
<option value=""/>
|
||||
{categories.map (choice => <option key={choice.value} value={choice.value}>
|
||||
{message (choice.label).text}</option>)}
|
||||
</select>}
|
||||
<fieldset className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<legend>{label ('pattern.columns')}</legend>
|
||||
{available.map (column => <label key={column} className="inline-flex items-center gap-1">
|
||||
<input type="checkbox" checked={columns.includes (column)} onChange={event =>
|
||||
setColumns (previous => event.target.checked ? [...previous, column]
|
||||
: previous.filter (value => value !== column))}/>{label (headings[column])}
|
||||
</label>)}
|
||||
</fieldset>
|
||||
<p className="text-sm">{label ('pattern.value')}{':'}
|
||||
{label (dynamic ? 'columns.resourceId' : 'common:categoryLabel')}
|
||||
</p>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => setSelected (new Set ())}>
|
||||
{label ('shared:actions.reset')}</Button>
|
||||
{errorFlg && <p role="alert">{label ('common:fetchError')}</p>}
|
||||
<div className="max-h-[40dvh] overflow-auto" aria-busy={fetching}>
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead><tr><th className="p-2">{label ('pattern.selection')}</th>
|
||||
{visible.map (column => <th key={column} className="p-2">
|
||||
{label (headings[column])}</th>)}
|
||||
</tr></thead>
|
||||
<tbody>{rows.map (row => <tr key={row.value} className="border-t dark:border-slate-700">
|
||||
<td className="p-2"><input type="checkbox" checked={selected.has (row.value)}
|
||||
aria-label={row.name ?? String (row.id ?? '')}
|
||||
disabled={!selected.has (row.value) && selected.size >= maximum}
|
||||
onChange={event => setSelected (previous => {
|
||||
const next = new Set (previous)
|
||||
if (event.target.checked) next.add (row.value)
|
||||
else next.delete (row.value)
|
||||
return next
|
||||
})}/></td>
|
||||
{visible.map (column => <td key={column} className="break-words p-2">
|
||||
{cell (row, column)}</td>)}
|
||||
</tr>)}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{dynamic && <div className="flex items-center justify-between gap-2">
|
||||
<Button type="button" variant="outline" disabled={page === 1 || fetching}
|
||||
onClick={() => setPage (previous => previous - 1)}>
|
||||
{label ('common:pagination.previous')}</Button>
|
||||
<span>{page}</span>
|
||||
<Button type="button" variant="outline" disabled={!data?.more || fetching}
|
||||
onClick={() => setPage (previous => previous + 1)}>
|
||||
{label ('common:pagination.next')}</Button>
|
||||
</div>}
|
||||
</div>
|
||||
}
|
||||
|
||||
|
||||
export default CountsCandidatePicker
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
import CountsCandidatePicker from '@/components/counts/CountsCandidatePicker'
|
||||
import DateTimeField from '@/components/common/DateTimeField'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import useDialogue from '@/lib/dialogues/useDialogue'
|
||||
import { inputClass } from '@/lib/utils'
|
||||
import { useUITranslation } from '@/lib/useUITranslation'
|
||||
|
||||
import type { CountsCondition, CountsDefinition, CountsFieldMetadata }
|
||||
from '@/lib/countsDefinitions'
|
||||
|
||||
|
||||
const CountsConditionValues = (
|
||||
{ condition, field, definition, params, maximum, change }: {
|
||||
condition: CountsCondition; field: CountsFieldMetadata; definition: CountsDefinition
|
||||
params: URLSearchParams; maximum: number
|
||||
change: (values: (string | number | boolean)[]) => void },
|
||||
) => {
|
||||
const { label, message } = useUITranslation ('counts')
|
||||
const dialogue = useDialogue ()
|
||||
const [draft, setDraft] = useState ('')
|
||||
const values = condition.values ?? []
|
||||
const pick = () => {
|
||||
void dialogue.form ({ title: label (field.candidateSource === 'tags'
|
||||
? 'pattern.link' : 'pattern.pattern'), size: 'large', footerLayout: 'inline',
|
||||
body: controls => <CountsCandidatePicker field={field} definition={definition}
|
||||
params={params} initial={values} maximum={maximum} controls={controls} save={change}/> })
|
||||
}
|
||||
if (condition.input === 'list' || field.candidateSource === 'tags')
|
||||
return <Button type="button" variant="outline" disabled={condition.blank} onClick={pick}>
|
||||
{label ('pattern.list')}{' ('}{values.length}{')'}</Button>
|
||||
const append = () => {
|
||||
if (draft === '' || values.length >= maximum
|
||||
|| values.some (value => String (value) === draft))
|
||||
return
|
||||
change ([...values, draft])
|
||||
setDraft ('')
|
||||
}
|
||||
const input = () => {
|
||||
if (field.type === 'datetime')
|
||||
return <DateTimeField precision="second" value={draft} disabled={condition.blank}
|
||||
className="h-9 w-48 min-w-0 max-w-full" aria-label={message ('value').text}
|
||||
onChange={value => setDraft (value ?? '')}/>
|
||||
if (field.type === 'boolean' || field.choices != null)
|
||||
{
|
||||
const choices = field.choices ?? ['true', 'false'].map (value => ({
|
||||
value, label: `counts:truthValues.${value}` }))
|
||||
return <select value={draft} disabled={condition.blank}
|
||||
className={inputClass (false, 'h-9 w-36 min-w-0 max-w-full')}
|
||||
aria-label={message ('value').text} onChange={event => setDraft (event.target.value)}>
|
||||
<option value=""/>
|
||||
{choices.map (choice => <option key={choice.value} value={choice.value}>
|
||||
{message (choice.label).text}</option>)}
|
||||
</select>
|
||||
}
|
||||
return <input value={draft} disabled={condition.blank}
|
||||
type={field.type === 'integer' ? 'number' : 'text'} aria-label={message ('value').text}
|
||||
className={inputClass (false, 'h-9 w-36 min-w-0 max-w-full')}
|
||||
onChange={event => setDraft (event.target.value)}/>
|
||||
}
|
||||
const display = (value: string | number | boolean) => {
|
||||
const choice = field.choices?.find (choice => choice.value === String (value))
|
||||
if (choice != null) return message (choice.label).text
|
||||
if (field.type === 'boolean') return message (`truthValues.${value}`).text
|
||||
return String (value)
|
||||
}
|
||||
return <div className="flex min-w-0 max-w-full flex-wrap items-center gap-1">
|
||||
{values.map ((value, index) => <span key={`${index}:${value}`}
|
||||
className="inline-flex items-center gap-1">
|
||||
{display (value)}<Button type="button" size="sm" variant="ghost"
|
||||
aria-label={message ('remove').text} disabled={condition.blank}
|
||||
onClick={() => change (values.filter ((_, position) => position !== index))}>×</Button>
|
||||
</span>)}
|
||||
{input ()}
|
||||
<Button type="button" variant="outline" disabled={condition.blank || draft === ''
|
||||
|| values.length >= maximum} onClick={append}>{label ('add')}</Button>
|
||||
<Button type="button" variant="ghost" disabled={condition.blank}
|
||||
onClick={() => change ([])}>{label ('shared:actions.reset')}</Button>
|
||||
</div>
|
||||
}
|
||||
|
||||
|
||||
export default CountsConditionValues
|
||||
@@ -41,8 +41,8 @@ const CountsSummaryTable = (
|
||||
const display = (value: string | number | null, axis: CountsAxis, index: number) =>
|
||||
countsSummaryValue (value, axis, index, data, locale)
|
||||
const aggregateLabel = (header: CountsPivotHeader) => message (
|
||||
header.kind === 'grand_total' ? (rows.length + cols.length === 1
|
||||
? 'singleTotal' : 'grandTotal') : 'subtotal').text
|
||||
header.labelKey ?? (header.kind === 'grand_total' ? (rows.length + cols.length === 1
|
||||
? 'singleTotal' : 'grandTotal') : 'subtotal')).text
|
||||
const leading = Math.max (rows.length, cols.length > 0 ? 1 : 0)
|
||||
const levels = Math.max (1, cols.length)
|
||||
const headingLevels = cols.length === 0 ? 1 : levels * 2
|
||||
@@ -77,6 +77,8 @@ const CountsSummaryTable = (
|
||||
const full = new Map (cells.map (cell => [JSON.stringify (cell.values), cell.count]))
|
||||
const totals = new Map ((data.pivotTotals ?? []).map (cell => [
|
||||
JSON.stringify ([cell.row, cell.column]), cell.count]))
|
||||
const totalLabels = new Map ((data.pivotTotals ?? []).map (cell => [
|
||||
JSON.stringify ([cell.row, cell.column]), cell.labelKey]))
|
||||
const count = (row: CountsPivotHeader, column: CountsPivotHeader) =>
|
||||
(row.values.length === rows.length && column.values.length === cols.length)
|
||||
? full.get (JSON.stringify ([...row.values, ...column.values])) ?? 0
|
||||
@@ -101,16 +103,16 @@ const CountsSummaryTable = (
|
||||
...Array.from ({ length: leading }, (_, index) => labels[index] ?? '')]
|
||||
})
|
||||
const spans = rowHeaders.map ((row, index) => rows.map ((_, axis) => {
|
||||
if (row.kind !== 'data') return 1
|
||||
if (row.values.length <= axis) return 1
|
||||
const prefix = JSON.stringify (row.values.slice (0, axis + 1))
|
||||
const previous = rowHeaders[index - 1]
|
||||
if (previous?.kind === 'data'
|
||||
if (previous != null && previous.values.length > axis
|
||||
&& JSON.stringify (previous.values.slice (0, axis + 1)) === prefix) return 0
|
||||
let span = 1
|
||||
while (index + span < rowHeaders.length)
|
||||
{
|
||||
const next = rowHeaders[index + span]
|
||||
if (next.kind !== 'data'
|
||||
if (next.values.length <= axis
|
||||
|| JSON.stringify (next.values.slice (0, axis + 1)) !== prefix) break
|
||||
++span
|
||||
}
|
||||
@@ -246,8 +248,11 @@ const CountsSummaryTable = (
|
||||
<tbody>{rowHeaders.map ((row, index) => <tr key={columnKey (row)}>
|
||||
{rowLabels[index].map ((value, offset) => {
|
||||
const axis = offset - (numbered ? 1 : 0)
|
||||
if (axis >= 0 && row.kind !== 'data' && axis > row.values.length) return null
|
||||
const aggregate = axis >= 0 && row.kind !== 'data' && axis === row.values.length
|
||||
const span = axis < 0 ? 1 : spans[index][axis] ?? 1
|
||||
return span === 0 ? null : <td key={offset} rowSpan={span}
|
||||
colSpan={aggregate ? leading - axis : 1}
|
||||
role={axis >= 0 ? 'rowheader' : undefined} data-counts-copy={value}
|
||||
data-counts-aggregate={row.kind !== 'data' ? '' : undefined}
|
||||
data-counts-total={row.kind !== 'data' ? row.kind : undefined}
|
||||
@@ -264,7 +269,11 @@ const CountsSummaryTable = (
|
||||
data-counts-aggregate={(row.kind !== 'data' || column.kind !== 'data')
|
||||
? '' : undefined}
|
||||
className="border px-2 py-1 dark:border-slate-700">
|
||||
<PrefetchLink to={drill (row, column)}>{count (row, column)}</PrefetchLink>
|
||||
<PrefetchLink to={drill (row, column)} aria-label={
|
||||
totalLabels.get (JSON.stringify ([row.values, column.values])) == null ? undefined
|
||||
: `${message (totalLabels.get (
|
||||
JSON.stringify ([row.values, column.values]))!).text}: ${count (row, column)}`}>
|
||||
{count (row, column)}</PrefetchLink>
|
||||
</td>)}
|
||||
</tr>)}</tbody>
|
||||
</CountsSelectableTable>
|
||||
|
||||
@@ -20,6 +20,7 @@ import type { RenderContext } from '@/lib/renderContext'
|
||||
import type { User } from '@/types'
|
||||
|
||||
export { renderSitemap } from '@/lib/sitemap.server'
|
||||
export { isCountsPath } from '@/lib/routeLocales'
|
||||
|
||||
type RenderedPage = {
|
||||
status: number
|
||||
|
||||
@@ -58,7 +58,8 @@ export type CountsStateTotals = {
|
||||
deprecatedTags: number | null
|
||||
externalAssignments: number | null }
|
||||
export type CountsPivotHeader = { values: (string | number | null)[]
|
||||
kind: 'data' | 'subtotal' | 'grand_total' }
|
||||
kind: 'data' | 'subtotal' | 'grand_total'
|
||||
depth?: number; axisCount?: number; labelKey?: string | null }
|
||||
export type CountsResult = {
|
||||
populationCount?: number
|
||||
excludedCount?: number | null
|
||||
@@ -70,7 +71,7 @@ export type CountsResult = {
|
||||
rowHeaders?: CountsPivotHeader[]
|
||||
columnHeaders?: CountsPivotHeader[]
|
||||
pivotTotals?: { row: (string | number | null)[]; column: (string | number | null)[]
|
||||
count: number }[]
|
||||
count: number; rowDepth?: number; columnDepth?: number; labelKey?: string | null }[]
|
||||
executionAt?: string
|
||||
columnValues?: (string | number | null)[][]
|
||||
cells?: { values: (string | number | null)[]; count: number }[]
|
||||
|
||||
@@ -8,7 +8,8 @@ import type { CountField, CountsResource } from '@/lib/counts'
|
||||
|
||||
export type CountsAxis = { field: string; grain?: string }
|
||||
export type CountsCondition = {
|
||||
field: string; operator: string; value?: string; end?: string; blank?: boolean }
|
||||
field: string; operator: string; value?: string; end?: string; blank?: boolean
|
||||
input?: 'single' | 'multiple' | 'list'; values?: (string | number | boolean)[] }
|
||||
export type CountsFieldType = 'string' | 'integer' | 'datetime' | 'enum'
|
||||
| 'json' | 'boolean' | 'interval'
|
||||
|
||||
@@ -22,7 +23,9 @@ export const visibleCountsResources = [
|
||||
|
||||
export type CountsFieldMetadata = {
|
||||
key: string; type: CountsFieldType; nullable: boolean; operators: string[]
|
||||
labels?: string[]; multiple?: boolean }
|
||||
labels?: string[]; multiple?: boolean; candidateSource?: 'categories' | 'tags'
|
||||
setType?: 'tag_reference'; inputTypes?: string[]
|
||||
choices?: { value: string; label: string }[] }
|
||||
export type CountsDefinition = {
|
||||
fields: CountsFieldMetadata[]
|
||||
outputFields?: CountField[]
|
||||
@@ -44,6 +47,31 @@ export const useCountsDefinition = (): CountsDefinition => {
|
||||
export const fetchCountsDefinition = (resource: CountsResource): Promise<CountsDefinition> =>
|
||||
apiGet (`/counts/definitions/${encodeURIComponent (resource)}`)
|
||||
|
||||
export type CountsCandidate = {
|
||||
value: string; id?: number; name: string | null; category?: string | null
|
||||
kind?: 'internal' | 'external' }
|
||||
export type CountsCandidatesResult = {
|
||||
rows: CountsCandidate[]; page: number; more: boolean; executionAt: string }
|
||||
|
||||
export const fetchCountsCandidates = (
|
||||
params: URLSearchParams,
|
||||
locale: LocaleCode,
|
||||
signal?: AbortSignal,
|
||||
): Promise<CountsCandidatesResult> => apiGet (
|
||||
'/counts/definitions/posts/candidates', {
|
||||
params: Object.fromEntries (params), locale, signal })
|
||||
|
||||
export const countsSetOperators = ['in', 'not_in', 'any', 'all', 'none']
|
||||
|
||||
export const countsConditionInputs = (
|
||||
field: CountsFieldMetadata | undefined,
|
||||
operator: string,
|
||||
): ('single' | 'multiple' | 'list')[] => {
|
||||
if (!countsSetOperators.includes (operator)) return ['single']
|
||||
if (field?.candidateSource === 'tags') return ['list']
|
||||
return field?.candidateSource == null ? ['multiple'] : ['list', 'multiple']
|
||||
}
|
||||
|
||||
|
||||
export const countsFilterFields = (
|
||||
definition: CountsDefinition,
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { queryRecordParams } from '@/lib/queryState'
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
import { match } from 'path-to-regexp'
|
||||
|
||||
import { getHelpArticle,
|
||||
@@ -11,12 +9,16 @@ import { getHelpArticle,
|
||||
import { localeFromPath, localeLanguage, unlocalisedPath } from '@/lib/locales'
|
||||
import { fetchPost, fetchPosts, fetchPostChanges } from '@/lib/posts'
|
||||
import { helpKeys, postsKeys, tagsKeys, wikiKeys } from '@/lib/queryKeys'
|
||||
import { queryRecordParams } from '@/lib/queryState'
|
||||
import { isCountsPath } from '@/lib/routeLocales'
|
||||
import { fetchNicoTags, fetchTag, fetchTagChanges, fetchTags } from '@/lib/tags'
|
||||
import { fetchWikiAutolinkCandidates,
|
||||
fetchWikiPageByReference,
|
||||
fetchWikiTag,
|
||||
fetchWikiPages } from '@/lib/wiki'
|
||||
|
||||
import type { QueryClient } from '@tanstack/react-query'
|
||||
|
||||
import type { LocaleCode } from '@/lib/locales'
|
||||
import type { Category, FetchPostsOrder, FetchTagsOrder, WikiTagReference } from '@/types'
|
||||
|
||||
@@ -326,6 +328,7 @@ export const routePrefetchers: { test: (u: URL) => boolean; run: Prefetcher }[]
|
||||
|
||||
export const prefetchForURL = async (qc: QueryClient, urlLike: string): Promise<void> => {
|
||||
const u = new URL (urlLike, location.origin)
|
||||
if (isCountsPath (u.pathname)) return
|
||||
if (u.origin !== location.origin)
|
||||
return
|
||||
|
||||
|
||||
@@ -173,5 +173,6 @@ export const theatresKeys = {
|
||||
|
||||
export const countsKeys = {
|
||||
definition: (resource: string) => ['counts', 'definition', resource] as const,
|
||||
candidates: (params: string, locale: LocaleCode) => ['counts', 'candidates', locale, params] as const,
|
||||
index: (resource: string, params: string, locale: LocaleCode) =>
|
||||
['counts', resource, locale, params] as const }
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// Public URL grammar: space-delimited tokens and indexed scalar fields, never JSON.
|
||||
export type QueryRecord = Record<string, string | number | boolean | null | undefined>
|
||||
const schemas: Record<string, string[]> = {
|
||||
conditions: ['field', 'operator', 'value', 'end', 'blank'],
|
||||
conditions: ['field', 'operator', 'value', 'end', 'blank', 'input', 'values'],
|
||||
condition_values: ['condition', 'value', 'value_type'],
|
||||
drill: ['field', 'grain', 'value', 'value_type'],
|
||||
tag_refs: ['name', 'tag_id', 'external_tag_id', 'negative', 'missing'],
|
||||
column_order: ['value', 'value_type'],
|
||||
@@ -23,8 +24,8 @@ export const queryRecords = (params: URLSearchParams, key: string): QueryRecord[
|
||||
const index = Number (parts[0])
|
||||
const record = indexed.get (index) ?? { }
|
||||
const field = parts[1]
|
||||
record[field] = ['blank', 'negative', 'missing'].includes (field)
|
||||
? value === '1' : (['tag_id', 'external_tag_id', 'axis'].includes (field)
|
||||
record[field] = ['blank', 'negative', 'missing', 'values'].includes (field)
|
||||
? value === '1' : (['tag_id', 'external_tag_id', 'axis', 'condition'].includes (field)
|
||||
? Number (value) : value)
|
||||
indexed.set (index, record)
|
||||
}
|
||||
@@ -52,11 +53,13 @@ export const setQueryRecords = (
|
||||
if (value == null && field !== 'value') continue
|
||||
if (!schemas[key]?.includes (field)) continue
|
||||
const name = `${key}.${index}.${field}`
|
||||
if (field === 'value' && ['drill', 'measures', 'column_order'].includes (key))
|
||||
if (field === 'value'
|
||||
&& ['drill', 'measures', 'column_order', 'condition_values'].includes (key))
|
||||
params.set (`${key}.${index}.value_type`, value == null ? 'null' : typeof value)
|
||||
if (value == null) continue
|
||||
params.set (name, typeof value === 'boolean'
|
||||
? (['blank', 'negative', 'missing'].includes (field) ? (value ? '1' : '0') : String (value))
|
||||
? (['blank', 'negative', 'missing', 'values'].includes (field)
|
||||
? (value ? '1' : '0') : String (value))
|
||||
: String (value))
|
||||
}
|
||||
})
|
||||
@@ -74,16 +77,34 @@ export const readCountState = <T,> (
|
||||
const [field, grain] = token.split (':')
|
||||
return grain == null ? { field } : { field, grain }
|
||||
}) as T
|
||||
return queryRecords (params, key) as T
|
||||
const records = queryRecords (params, key)
|
||||
if (key === 'conditions')
|
||||
{
|
||||
const values = queryRecords (params, 'condition_values')
|
||||
return records.map ((record, index) => record.values === true
|
||||
? { ...record, values: values.filter (entry => entry.condition === index)
|
||||
.map (entry => entry.value) } : record) as T
|
||||
}
|
||||
return records as T
|
||||
}
|
||||
|
||||
|
||||
export const encodeCountState = (key: string, records: QueryRecord[]): string => {
|
||||
export const encodeCountState = (key: string, records: Record<string, unknown>[]): string => {
|
||||
if (key === 'rows' || key === 'cols')
|
||||
return records.map (record => record.grain == null
|
||||
? String (record.field) : `${record.field}:${record.grain}`).join (' ')
|
||||
const params = new URLSearchParams ()
|
||||
setQueryRecords (params, key, records)
|
||||
const values: QueryRecord[] = []
|
||||
const flat = records.map ((record, condition) => {
|
||||
if (key !== 'conditions' || !Array.isArray (record.values)) return record as QueryRecord
|
||||
const { values: selected, ...fields } = record
|
||||
for (const value of selected as unknown[])
|
||||
values.push ({ condition, value: value as string | number | boolean })
|
||||
return { ...fields, values: true } as QueryRecord
|
||||
})
|
||||
setQueryRecords (params, key, flat)
|
||||
if (key === 'conditions' && flat.some (record => record.values === true))
|
||||
setQueryRecords (params, 'condition_values', values)
|
||||
return params.toString ()
|
||||
}
|
||||
|
||||
@@ -93,6 +114,14 @@ export const setCountState = (params: URLSearchParams, key: string, value: strin
|
||||
{
|
||||
const fields = new URLSearchParams (value)
|
||||
setQueryRecords (params, key, queryRecords (fields, key))
|
||||
if (key === 'conditions')
|
||||
{
|
||||
for (const name of [...params.keys ()])
|
||||
if (name === 'condition_values' || name.startsWith ('condition_values.'))
|
||||
params.delete (name)
|
||||
if (fields.has ('condition_values'))
|
||||
setQueryRecords (params, 'condition_values', queryRecords (fields, 'condition_values'))
|
||||
}
|
||||
}
|
||||
else params.set (key, value)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { unlocalisedPath } from '@/lib/locales'
|
||||
|
||||
import type { QueryClient } from '@tanstack/react-query'
|
||||
import type { IndexRouteObject, Location, NonIndexRouteObject, Params } from 'react-router-dom'
|
||||
|
||||
@@ -20,6 +22,7 @@ export type SitemapSource =
|
||||
| 'descriptions' | 'tag_groups' | 'theatres'
|
||||
|
||||
export type PageRouteHandle = {
|
||||
prefetch?: false
|
||||
preload?: (locale: LocaleCode) => Promise<unknown>
|
||||
prepare?: (request: PagePreparation) => Promise<number>
|
||||
switchLocale?: (request: LocaleSwitchRequest) => Promise<string>
|
||||
@@ -33,3 +36,9 @@ export type PageRouteHandle = {
|
||||
export type PageRoute = { handle?: PageRouteHandle } & (
|
||||
Omit<IndexRouteObject, 'handle'>
|
||||
| (Omit<NonIndexRouteObject, 'handle' | 'children'> & { children?: PageRoute[] }))
|
||||
|
||||
|
||||
export const isCountsPath = (pathname: string): boolean => {
|
||||
const path = unlocalisedPath (pathname).toLowerCase ()
|
||||
return path === '/counts' || path.startsWith ('/counts/')
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { apiGet, isApiError } from '@/lib/api'
|
||||
import { parseHelpEdition } from '@/lib/help'
|
||||
import { fetchLocales, isLocaleCode, localeLanguage } from '@/lib/locales'
|
||||
import { withRenderContext } from '@/lib/renderContext.server'
|
||||
import { isCountsPath } from '@/lib/routeLocales'
|
||||
|
||||
import type { LocaleCode } from '@/lib/locales'
|
||||
import type { PageRoute, SitemapSource } from '@/lib/routeLocales'
|
||||
@@ -27,6 +28,7 @@ const sitemapRoutes = (locale: LocaleCode): SitemapRoute[] => {
|
||||
|| (handle?.locale != null && handle.locale !== locale))
|
||||
return []
|
||||
const path = [parent, route.path].filter (Boolean).join ('/')
|
||||
if (isCountsPath (`/${path}`)) return []
|
||||
return [
|
||||
...(handle?.sitemap ?? []).map (source => ({ path, source })),
|
||||
...collect (route.children ?? [], path)]
|
||||
|
||||
@@ -186,6 +186,11 @@
|
||||
"hour": "時"
|
||||
},
|
||||
"operatorSymbols": {
|
||||
"in": "IN",
|
||||
"not_in": "NOT IN",
|
||||
"any": "ANY",
|
||||
"all": "ALL",
|
||||
"none": "NONE",
|
||||
"eq": "=",
|
||||
"ne": "≠",
|
||||
"gt": ">",
|
||||
@@ -196,6 +201,11 @@
|
||||
"range": "~"
|
||||
},
|
||||
"operators": {
|
||||
"in": "IN",
|
||||
"not_in": "NOT IN",
|
||||
"any": "選択値のいずれかを含む",
|
||||
"all": "選択値のすべてを含む",
|
||||
"none": "選択値を一つも含まない",
|
||||
"eq": "等しい",
|
||||
"ne": "等しくない",
|
||||
"gt": "より大きい",
|
||||
@@ -205,6 +215,17 @@
|
||||
"like": "パターン",
|
||||
"range": "範囲"
|
||||
},
|
||||
"pattern": {
|
||||
"input": "画面出力タイプ",
|
||||
"single": "単一入力",
|
||||
"multiple": "複数入力",
|
||||
"list": "複数選択リスト",
|
||||
"pattern": "データパターン",
|
||||
"link": "データリンク",
|
||||
"columns": "表示列",
|
||||
"value": "適用値",
|
||||
"selection": "選択"
|
||||
},
|
||||
"events": {
|
||||
"create": "起票",
|
||||
"update": "異動",
|
||||
|
||||
新しいイシューから参照
ユーザーをブロックする