このコミットが含まれているのは:
@@ -21,7 +21,9 @@
|
|||||||
- Counts の取得対象の ID・集合・件数・並び順を UI ロカールに依存させない。
|
- Counts の取得対象の ID・集合・件数・並び順を UI ロカールに依存させない。
|
||||||
言語・文字体系を抽出条件に使う場合は明示的な項目・条件として扱う。
|
言語・文字体系を抽出条件に使う場合は明示的な項目・条件として扱う。
|
||||||
- API は記録済みの全言語名と別名を保持する。履歴の名前を現在の名前で置換しない。
|
- API は記録済みの全言語名と別名を保持する。履歴の名前を現在の名前で置換しない。
|
||||||
言語名列は既存 Locale の言語・文字体系の組から定義し、未登録値を別言語で補完しない。
|
名称・別名列は Language の言語コードから一言語一列で定義し、Locale ごとに増やさない。
|
||||||
|
同じ言語の全文字体系を含め、script_code 等の記録属性を保持する。
|
||||||
|
未登録値を別言語で補完しない。旧ロカール限定条件の互換処理では意味を無言で変えない。
|
||||||
初期表示列だけは UI ロカールに合わせてよい。明示的な列指定・空選択を優先する。
|
初期表示列だけは UI ロカールに合わせてよい。明示的な列指定・空選択を優先する。
|
||||||
- 明細の内表は行集合を保持し、表示列・列順・列幅だけを表示設定として操作する。
|
- 明細の内表は行集合を保持し、表示列・列順・列幅だけを表示設定として操作する。
|
||||||
内表行を抽出・除外する表示機能を追加しない。内表を参照する抽出条件は外表の
|
内表行を抽出・除外する表示機能を追加しない。内表を参照する抽出条件は外表の
|
||||||
|
|||||||
@@ -24,6 +24,8 @@
|
|||||||
UI 言語を名前の WHERE・ORDER BY へ暗黙に使わない。名前検索は記録済みの
|
UI 言語を名前の WHERE・ORDER BY へ暗黙に使わない。名前検索は記録済みの
|
||||||
全言語又は明示された言語・文字体系を使う。関連名にも HistoryAccess を適用する。
|
全言語又は明示された言語・文字体系を使う。関連名にも HistoryAccess を適用する。
|
||||||
JSON は全言語名を保持し、CSV の列選択を理由に元スナップショットを削らない。
|
JSON は全言語名を保持し、CSV の列選択を理由に元スナップショットを削らない。
|
||||||
|
名称・別名列は Language から生成し、Locale の script_code で同じ言語の値を落とさない。
|
||||||
|
旧ロカール列の条件互換は入力境界に限定し、旧キーの文字体系限定を維持する。
|
||||||
|
|
||||||
## Mandatory naming contract
|
## Mandatory naming contract
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ class CountsTablesController < ApplicationController
|
|||||||
filter_fields: definition.filter_metadata,
|
filter_fields: definition.filter_metadata,
|
||||||
axes: { latest: definition.axes(false), history: definition.axes(true) },
|
axes: { latest: definition.axes(false), history: definition.axes(true) },
|
||||||
name_columns: definition.name_columns,
|
name_columns: definition.name_columns,
|
||||||
|
column_aliases: definition.column_aliases,
|
||||||
|
condition_aliases: definition.condition_aliases,
|
||||||
grains: CountsAxes::GRAINS,
|
grains: CountsAxes::GRAINS,
|
||||||
defaults: CountsDefaults.metadata(params[:resource], locale: locale.code) }
|
defaults: CountsDefaults.metadata(params[:resource], locale: locale.code) }
|
||||||
rescue ArgumentError
|
rescue ArgumentError
|
||||||
|
|||||||
@@ -87,15 +87,12 @@ class CountsVersionRepr
|
|||||||
if field[:multiple_values]
|
if field[:multiple_values]
|
||||||
entries = values.select { _1[:language_code] == locale[:language_code] }
|
entries = values.select { _1[:language_code] == locale[:language_code] }
|
||||||
aliases = entries.flat_map do |entry|
|
aliases = entries.flat_map do |entry|
|
||||||
entry[:aliases].select do |value|
|
entry[:aliases].map { _1.merge('language_code' => entry[:language_code]) }
|
||||||
value['script_code'].to_s.downcase == locale[:script_code].to_s.downcase
|
|
||||||
end.map { _1.merge('language_code' => entry[:language_code]) }
|
|
||||||
end
|
end
|
||||||
next [field.fetch(:key).to_sym, aliases]
|
next [field.fetch(:key).to_sym, aliases]
|
||||||
end
|
end
|
||||||
entry = values.find do |value|
|
entry = values.find do |value|
|
||||||
value[:language_code] == locale[:language_code] &&
|
value[:language_code] == locale[:language_code]
|
||||||
value[:script_code].to_s.downcase == locale[:script_code].to_s.downcase
|
|
||||||
end
|
end
|
||||||
[field.fetch(:key).to_sym, entry && entry[:name]]
|
[field.fetch(:key).to_sym, entry && entry[:name]]
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ class CountsCondition
|
|||||||
operators: CountsResourceDefinition::OPERATORS.fetch(type) }
|
operators: CountsResourceDefinition::OPERATORS.fetch(type) }
|
||||||
entry[:label_values] = field[:label_values] if field[:label_values]
|
entry[:label_values] = field[:label_values] if field[:label_values]
|
||||||
entry[:name_locale] = field[:name_locale] if field[:name_locale]
|
entry[:name_locale] = field[:name_locale] if field[:name_locale]
|
||||||
|
entry[:legacy] = true if field[:legacy]
|
||||||
result.reject! { _1[:key] == key }
|
result.reject! { _1[:key] == key }
|
||||||
result << entry
|
result << entry
|
||||||
visit.call(field[:children] || [], key, path_labels)
|
visit.call(field[:children] || [], key, path_labels)
|
||||||
|
|||||||
@@ -13,8 +13,9 @@ class CountsDefaults
|
|||||||
'rows' => '', 'cols' => '', 'conditions' => '', 'tags' => '' } }
|
'rows' => '', 'cols' => '', 'conditions' => '', 'tags' => '' } }
|
||||||
details = detail_params(resource)
|
details = detail_params(resource)
|
||||||
if details
|
if details
|
||||||
name = CountsResourceDefinition.name_key(locale)
|
language = Locale.find_by(code: locale)&.language_code || locale
|
||||||
aliases = CountsResourceDefinition.aliases_key(locale)
|
name = CountsResourceDefinition.name_key(language)
|
||||||
|
aliases = CountsResourceDefinition.aliases_key(language)
|
||||||
details = details.transform_values do |value|
|
details = details.transform_values do |value|
|
||||||
value.split(' ').flat_map do |token|
|
value.split(' ').flat_map do |token|
|
||||||
if resource == 'tags' && ['name', 'parents.name'].include?(token)
|
if resource == 'tags' && ['name', 'parents.name'].include?(token)
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
class CountsResourceDefinition
|
class CountsResourceDefinition
|
||||||
# Compatibility belongs at the request boundary, not in field definitions.
|
# Compatibility belongs at the request boundary, not in field definitions.
|
||||||
def self.normalise_params(params)
|
def self.normalise_params(params)
|
||||||
|
aliases = nil
|
||||||
|
predicates = nil
|
||||||
|
scope = params['name_scope']
|
||||||
|
raise ArgumentError if scope && !['locale', 'language'].include?(scope)
|
||||||
params.each_with_object({}) do |(key, value), result|
|
params.each_with_object({}) do |(key, value), result|
|
||||||
result[key] = value
|
result[key] = value
|
||||||
next unless value.is_a?(String)
|
next unless value.is_a?(String)
|
||||||
@@ -14,6 +18,16 @@ class CountsResourceDefinition
|
|||||||
value == 'is_latest')
|
value == 'is_latest')
|
||||||
result[key] = 'latest'
|
result[key] = 'latest'
|
||||||
end
|
end
|
||||||
|
if (['columns', 'children', 'order', 'sort', 'child_sort'].include?(key) &&
|
||||||
|
value.match?(/(?:name|aliases)_[a-z]+_[a-z0-9_]+/))
|
||||||
|
aliases ||= new('tags').column_aliases.to_h { [_1[:key], _1[:value]] }
|
||||||
|
result[key] = result[key].gsub(/(?:name|aliases)_[a-z0-9_]+/) { aliases.fetch(_1, _1) }
|
||||||
|
end
|
||||||
|
if (scope != 'language' && key.match?(/\Aconditions\.[0-9]+\.field\z/) &&
|
||||||
|
value.match?(/(?:name|aliases)_[a-z0-9_]+/))
|
||||||
|
predicates ||= new('tags').condition_aliases.to_h { [_1[:key], _1[:value]] }
|
||||||
|
result[key] = value.gsub(/(?:name|aliases)_[a-z0-9_]+/) { predicates.fetch(_1, _1) }
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -116,12 +130,8 @@ class CountsResourceDefinition
|
|||||||
|
|
||||||
def name_columns
|
def name_columns
|
||||||
@name_columns ||= begin
|
@name_columns ||= begin
|
||||||
locales = Locale.joins(:language)
|
codes = Language.select(:code).order(:created_at, :code).map do |language|
|
||||||
.select('locales.code', 'locales.language_code', 'locales.script_code')
|
{ code: language.code, language_code: language.code }
|
||||||
.order('languages.created_at ASC', 'languages.code ASC', 'locales.code ASC')
|
|
||||||
codes = locales.map do |locale|
|
|
||||||
{ code: locale.code, language_code: locale.language_code,
|
|
||||||
script_code: locale.script_code }
|
|
||||||
end
|
end
|
||||||
names = codes.map do |locale|
|
names = codes.map do |locale|
|
||||||
{ key: self.class.name_key(locale[:code]), label: 'counts:localeName',
|
{ key: self.class.name_key(locale[:code]), label: 'counts:localeName',
|
||||||
@@ -135,7 +145,45 @@ class CountsResourceDefinition
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def name_column(key) = name_columns.find { _1[:key] == key }
|
# Old locale-qualified predicates keep their script restriction at the input boundary.
|
||||||
|
def legacy_name_columns
|
||||||
|
@legacy_name_columns ||= Locale.select(:code, :language_code, :script_code)
|
||||||
|
.order(:code).flat_map do |locale|
|
||||||
|
codes = { code: locale.code, language_code: locale.language_code,
|
||||||
|
script_code: locale.script_code }
|
||||||
|
code = locale.code == locale.language_code ? "#{ locale.code }_#{ locale.script_code }" :
|
||||||
|
locale.code
|
||||||
|
[{ key: self.class.name_key(code), label: 'counts:localeName',
|
||||||
|
name_locale: codes, legacy: true },
|
||||||
|
{ key: self.class.aliases_key(code), label: 'counts:localeAliases',
|
||||||
|
name_locale: codes, multiple_values: true, legacy: true }]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def column_aliases
|
||||||
|
legacy_name_columns.map do |field|
|
||||||
|
language = field[:name_locale][:language_code]
|
||||||
|
value = if field[:multiple_values]
|
||||||
|
self.class.aliases_key(language)
|
||||||
|
else self.class.name_key(language)
|
||||||
|
end
|
||||||
|
{ key: field[:key], value: }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def condition_aliases
|
||||||
|
legacy_name_columns.filter_map do |field|
|
||||||
|
code = field[:name_locale][:code]
|
||||||
|
key = field[:multiple_values] ? self.class.aliases_key(code) : self.class.name_key(code)
|
||||||
|
next if key == field[:key]
|
||||||
|
|
||||||
|
{ key:, value: field[:key] }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def name_column(key)
|
||||||
|
name_columns.find { _1[:key] == key } || legacy_name_columns.find { _1[:key] == key }
|
||||||
|
end
|
||||||
|
|
||||||
def name_expression(key, row = 'v')
|
def name_expression(key, row = 'v')
|
||||||
if key.start_with?('aliases_')
|
if key.start_with?('aliases_')
|
||||||
@@ -148,21 +196,26 @@ class CountsResourceDefinition
|
|||||||
locale = name_column(key).fetch(:name_locale)
|
locale = name_column(key).fetch(:name_locale)
|
||||||
connection = model.connection
|
connection = model.connection
|
||||||
path = connection.quote("$.#{ locale[:language_code].to_json }")
|
path = connection.quote("$.#{ locale[:language_code].to_json }")
|
||||||
|
value = "JSON_UNQUOTE(NULLIF(JSON_EXTRACT(#{ row }.names, " +
|
||||||
|
"CONCAT(#{ path }, '.name')), CAST('null' AS JSON)))"
|
||||||
|
return value unless locale[:script_code]
|
||||||
|
|
||||||
script = connection.quote(locale[:script_code])
|
script = connection.quote(locale[:script_code])
|
||||||
"CASE WHEN LOWER(JSON_UNQUOTE(JSON_EXTRACT(#{ row }.names, " +
|
"CASE WHEN LOWER(JSON_UNQUOTE(JSON_EXTRACT(#{ row }.names, " +
|
||||||
"CONCAT(#{ path }, '.script_code')))) = LOWER(#{ script }) " +
|
"CONCAT(#{ path }, '.script_code')))) = LOWER(#{ script }) " +
|
||||||
"THEN JSON_UNQUOTE(NULLIF(JSON_EXTRACT(#{ row }.names, " +
|
"THEN #{ value } END"
|
||||||
"CONCAT(#{ path }, '.name')), CAST('null' AS JSON))) END"
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def alias_source(key, row = 'v')
|
def alias_source(key, row = 'v')
|
||||||
locale = name_column(key).fetch(:name_locale)
|
locale = name_column(key).fetch(:name_locale)
|
||||||
connection = model.connection
|
connection = model.connection
|
||||||
path = connection.quote("$.#{ locale[:language_code].to_json }.aliases[*]")
|
path = connection.quote("$.#{ locale[:language_code].to_json }.aliases[*]")
|
||||||
script = connection.quote(locale[:script_code])
|
|
||||||
source = "JSON_TABLE(COALESCE(#{ row }.names, JSON_OBJECT()), #{ path } " +
|
source = "JSON_TABLE(COALESCE(#{ row }.names, JSON_OBJECT()), #{ path } " +
|
||||||
"COLUMNS (ordinal FOR ORDINALITY, name TEXT PATH '$.name', " +
|
"COLUMNS (ordinal FOR ORDINALITY, name TEXT PATH '$.name', " +
|
||||||
"script TEXT PATH '$.script_code')) locale_alias"
|
"script TEXT PATH '$.script_code')) locale_alias"
|
||||||
|
return [source, 'locale_alias.name'] unless locale[:script_code]
|
||||||
|
|
||||||
|
script = connection.quote(locale[:script_code])
|
||||||
value = "CASE WHEN LOWER(locale_alias.script) = LOWER(#{ script }) " +
|
value = "CASE WHEN LOWER(locale_alias.script) = LOWER(#{ script }) " +
|
||||||
'THEN locale_alias.name END'
|
'THEN locale_alias.name END'
|
||||||
[source, value]
|
[source, value]
|
||||||
@@ -231,11 +284,11 @@ class CountsResourceDefinition
|
|||||||
fields = fields.map do |field|
|
fields = fields.map do |field|
|
||||||
next field unless field[:key] == 'parents'
|
next field unless field[:key] == 'parents'
|
||||||
|
|
||||||
field.merge(children: field[:children] + [
|
field.merge(children: field[:children] + legacy_name_columns + [
|
||||||
{ key: 'name', label: 'history:name' },
|
{ key: 'name', label: 'history:name' },
|
||||||
{ key: 'names', label: 'counts:columns.names', children: child_fields('names') }])
|
{ key: 'names', label: 'counts:columns.names', children: child_fields('names') }])
|
||||||
end
|
end
|
||||||
fields += [{ key: 'name', label: 'counts:columns.name' },
|
fields += legacy_name_columns + [{ key: 'name', label: 'counts:columns.name' },
|
||||||
{ key: 'names', label: 'counts:columns.names', children: child_fields('names') },
|
{ key: 'names', label: 'counts:columns.names', children: child_fields('names') },
|
||||||
{ key: 'aliases', label: 'tags:aliases', children: child_fields('aliases') }]
|
{ key: 'aliases', label: 'tags:aliases', children: child_fields('aliases') }]
|
||||||
end
|
end
|
||||||
@@ -243,7 +296,7 @@ class CountsResourceDefinition
|
|||||||
fields = fields.map do |field|
|
fields = fields.map do |field|
|
||||||
next field unless field[:key] == 'tags'
|
next field unless field[:key] == 'tags'
|
||||||
|
|
||||||
field.merge(children: field[:children] + [
|
field.merge(children: field[:children] + legacy_name_columns + [
|
||||||
{ key: 'name', label: 'history:name' },
|
{ key: 'name', label: 'history:name' },
|
||||||
{ key: 'names', label: 'counts:columns.names', children: child_fields('names') },
|
{ key: 'names', label: 'counts:columns.names', children: child_fields('names') },
|
||||||
{ key: 'aliases', label: 'tags:aliases', children: child_fields('aliases') }])
|
{ key: 'aliases', label: 'tags:aliases', children: child_fields('aliases') }])
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ class CountsTableQuery
|
|||||||
KEYS = ['resource', 'definition', 'mode', 'at', 'execution_at', 'from', 'through',
|
KEYS = ['resource', 'definition', 'mode', 'at', 'execution_at', 'from', 'through',
|
||||||
'timezone', 'rows', 'cols', 'conditions', 'drill', 'tags', 'tag_refs', 'match',
|
'timezone', 'rows', 'cols', 'conditions', 'drill', 'tags', 'tag_refs', 'match',
|
||||||
'tag', 'kind', 'resource_id', 'user_id', 'page', 'limit', 'sort', 'locale',
|
'tag', 'kind', 'resource_id', 'user_id', 'page', 'limit', 'sort', 'locale',
|
||||||
'measures', 'column_order', 'condition_values'].freeze
|
'measures', 'column_order', 'condition_values', 'name_scope'].freeze
|
||||||
attr_reader :population, :axes, :page, :limit, :mode
|
attr_reader :population, :axes, :page, :limit, :mode
|
||||||
|
|
||||||
def initialize(params)
|
def initialize(params)
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Counts の言語別列
|
||||||
|
|
||||||
|
- 列は `languages.created_at ASC, languages.code ASC` の順に生成する。
|
||||||
|
全名称列の後に全別名列を置く。Locale の数によって列を重複させない。
|
||||||
|
- 識別子は `name_ja`、`name_ain`、`aliases_ja`、`aliases_ain` 等とする。
|
||||||
|
言語の名称は Frontend の common 辞書を使う。
|
||||||
|
- 名称は記録済みの言語の主名称、別名はその言語の全別名を使う。
|
||||||
|
特定の文字体系で絞らず、記録済みの script_code 等を保持する。
|
||||||
|
履歴の時点評価と HistoryAccess は従来どおり適用する。
|
||||||
|
- 初期選択には UI ロカールの言語コードを使う。明示的な列・空選択は上書きしない。
|
||||||
|
|
||||||
|
## 旧 URL・API
|
||||||
|
|
||||||
|
- 出力列、子列、列順、ソートの旧 `name_ain_kana` 等は、入力境界で
|
||||||
|
`name_ain` 等へ正規化する。表示・ソートは言語全体の値を対象とするため、
|
||||||
|
従来表示されなかった文字体系の値が表示され、並びが変わる場合がある。
|
||||||
|
- 抽出条件の旧ロカール付きキーは別扱いとし、従来の文字体系限定 SQL を維持する。
|
||||||
|
新規条件候補には出さない。既存条件の表示・編輯時だけ文字体系付きの項目名を示す。
|
||||||
|
利用者が新しい言語単位の項目を選択した場合にだけ、その条件を言語全体へ切り替える。
|
||||||
|
- 新しい条件の解釈を `name_scope=language` で明示する。
|
||||||
|
未指定又は `locale` の旧条件は旧仕様として扱い、例えば `name_ja` を
|
||||||
|
`name_ja_jpan` という入力互換専用キーへ変換する。これにより Jpan 限定を保つ。
|
||||||
|
正規化後の URL は `name_scope=language` と互換専用キーを保存し、再解釈を防ぐ。
|
||||||
|
- 新しい API 利用者が言語単位の条件を送る場合も `name_scope=language` を指定する。
|
||||||
|
指定のない既存 API の `name_ja` / `aliases_ja` 条件は従来の Jpan 限定として扱う。
|
||||||
|
未知の name_scope は 400 とする。JSON・Base64 をクエリに格納しない。
|
||||||
+1
-1
@@ -21,7 +21,7 @@
|
|||||||
## Mandatory Counts data contract
|
## Mandatory Counts data contract
|
||||||
|
|
||||||
- Counts のロカール非依存・内表行集合保持はルート AGENTS.md の必須契約に従う。
|
- Counts のロカール非依存・内表行集合保持はルート AGENTS.md の必須契約に従う。
|
||||||
内表行の表示フィルターを作らない。言語名列はサーバーの Locale 定義を使い、
|
内表行の表示フィルターを作らない。名称・別名列はサーバーの Language コードを使い、
|
||||||
未登録列に別言語名を代入しない。列を隠しても取得済みの全言語データは保持する。
|
未登録列に別言語名を代入しない。列を隠しても取得済みの全言語データは保持する。
|
||||||
ソート・コピー・JSON の投影には安定したキーと値を使い、UI 訳語を識別に使わない。
|
ソート・コピー・JSON の投影には安定したキーと値を使い、UI 訳語を識別に使わない。
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { SortableContext, arrayMove, horizontalListSortingStrategy,
|
|||||||
sortableKeyboardCoordinates } from '@dnd-kit/sortable'
|
sortableKeyboardCoordinates } from '@dnd-kit/sortable'
|
||||||
import { useRef, useState } from 'react'
|
import { useRef, useState } from 'react'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
|
import { CSS } from '@dnd-kit/utilities'
|
||||||
|
|
||||||
import { useClientMotion } from '@/lib/useClientMotion'
|
import { useClientMotion } from '@/lib/useClientMotion'
|
||||||
|
|
||||||
@@ -55,11 +56,16 @@ const SortableColumns = ({ ids, fixed = [], name, onChange, children }: {
|
|||||||
<SortableContext items={ids} strategy={horizontalListSortingStrategy}>
|
<SortableContext items={ids} strategy={horizontalListSortingStrategy}>
|
||||||
{children (ids)}
|
{children (ids)}
|
||||||
</SortableContext>
|
</SortableContext>
|
||||||
{typeof document !== 'undefined' && createPortal (<DragOverlay
|
{typeof document !== 'undefined' && createPortal (<DragOverlay adjustScale={false}
|
||||||
|
style={{ width: 'max-content', height: 'auto', maxWidth: 'calc(100vw - 2rem)' }}
|
||||||
dropAnimation={motion.reduced ? null : {
|
dropAnimation={motion.reduced ? null : {
|
||||||
duration: motion.duration ('drop'), easing: motion.easing }}>
|
duration: motion.duration ('drop'), easing: motion.easing,
|
||||||
|
keyframes: ({ transform }) => [
|
||||||
|
{ transform: CSS.Translate.toString (transform.initial) },
|
||||||
|
{ transform: CSS.Translate.toString (transform.final) }] }}>
|
||||||
{active != null && <div lang={name (active).language}
|
{active != null && <div lang={name (active).language}
|
||||||
className="rounded border border-blue-500 bg-white p-2 text-slate-900 shadow-lg
|
className="w-max max-w-[min(20rem,calc(100vw_-_2rem))] truncate rounded
|
||||||
|
border border-blue-500 bg-white px-2 py-1 text-sm text-slate-900 shadow-lg
|
||||||
dark:border-blue-400 dark:bg-slate-800 dark:text-slate-100">{name (active).text}</div>}
|
dark:border-blue-400 dark:bg-slate-800 dark:text-slate-100">{name (active).text}</div>}
|
||||||
</DragOverlay>, document.body)}
|
</DragOverlay>, document.body)}
|
||||||
</DndContext>
|
</DndContext>
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ const SortableItem = ({ id, name, fixed, children, moveGroup, handleId, renderSh
|
|||||||
}
|
}
|
||||||
else item.listeners?.onKeyDown?.(event)
|
else item.listeners?.onKeyDown?.(event)
|
||||||
}}><span aria-hidden>⠿</span></button>
|
}}><span aria-hidden>⠿</span></button>
|
||||||
const style = { transform: CSS.Transform.toString (item.transform), transition: item.transition }
|
const style = { transform: CSS.Translate.toString (item.transform), transition: item.transition }
|
||||||
if (renderShell != null)
|
if (renderShell != null)
|
||||||
return renderShell ({ ref: item.setNodeRef, style, handle, dragging: item.isDragging })
|
return renderShell ({ ref: item.setNodeRef, style, handle, dragging: item.isDragging })
|
||||||
return <div ref={item.setNodeRef} style={style}
|
return <div ref={item.setNodeRef} style={style}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { SortableContext, arrayMove, rectSortingStrategy,
|
|||||||
sortableKeyboardCoordinates } from '@dnd-kit/sortable'
|
sortableKeyboardCoordinates } from '@dnd-kit/sortable'
|
||||||
import { useId, useRef, useState } from 'react'
|
import { useId, useRef, useState } from 'react'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
|
import { CSS } from '@dnd-kit/utilities'
|
||||||
|
|
||||||
import SortableItem from '@/components/common/SortableItem'
|
import SortableItem from '@/components/common/SortableItem'
|
||||||
import { useClientMotion } from '@/lib/useClientMotion'
|
import { useClientMotion } from '@/lib/useClientMotion'
|
||||||
@@ -29,8 +30,10 @@ const Group = ({ id, children }: { id: string; children: ReactNode }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const SortableList = ({ groups, name, renderItem, renderGroup, onChange, fixed = [] }: {
|
const SortableList = (
|
||||||
|
{ groups, name, handleName, renderItem, renderGroup, onChange, fixed = [] }: {
|
||||||
groups: Groups; name: (id: string) => Name
|
groups: Groups; name: (id: string) => Name
|
||||||
|
handleName?: (id: string) => Name
|
||||||
renderItem: (id: string, group: string) => ReactNode
|
renderItem: (id: string, group: string) => ReactNode
|
||||||
renderGroup: (id: string, content: ReactNode) => ReactNode
|
renderGroup: (id: string, content: ReactNode) => ReactNode
|
||||||
onChange: (groups: Groups) => void; fixed?: string[] },
|
onChange: (groups: Groups) => void; fixed?: string[] },
|
||||||
@@ -39,6 +42,7 @@ const SortableList = ({ groups, name, renderItem, renderGroup, onChange, fixed =
|
|||||||
const scope = useId ()
|
const scope = useId ()
|
||||||
const [active, setActive] = useState<string | null> (null)
|
const [active, setActive] = useState<string | null> (null)
|
||||||
const motion = useClientMotion ()
|
const motion = useClientMotion ()
|
||||||
|
const accessibleName = handleName ?? name
|
||||||
const sensors = useSensors (
|
const sensors = useSensors (
|
||||||
useSensor (MouseSensor, { activationConstraint: { distance: 4 } }),
|
useSensor (MouseSensor, { activationConstraint: { distance: 4 } }),
|
||||||
useSensor (TouchSensor, { activationConstraint: { delay: 350, tolerance: 8 } }),
|
useSensor (TouchSensor, { activationConstraint: { delay: 350, tolerance: 8 } }),
|
||||||
@@ -84,13 +88,14 @@ const SortableList = ({ groups, name, renderItem, renderGroup, onChange, fixed =
|
|||||||
}
|
}
|
||||||
return <DndContext sensors={sensors} collisionDetection={closestCenter}
|
return <DndContext sensors={sensors} collisionDetection={closestCenter}
|
||||||
accessibility={{ announcements: {
|
accessibility={{ announcements: {
|
||||||
onDragStart: event => name (String (event.active.id)).text,
|
onDragStart: event => accessibleName (String (event.active.id)).text,
|
||||||
onDragOver: event => event.over == null ? undefined : (
|
onDragOver: event => event.over == null ? undefined : (
|
||||||
event.over.id.toString ().startsWith ('group:') ? undefined
|
event.over.id.toString ().startsWith ('group:') ? undefined
|
||||||
: name (String (event.over.id)).text),
|
: accessibleName (String (event.over.id)).text),
|
||||||
onDragMove: () => undefined, onDragEnd: () => undefined,
|
onDragMove: () => undefined, onDragEnd: () => undefined,
|
||||||
onDragCancel: () => undefined },
|
onDragCancel: () => undefined },
|
||||||
screenReaderInstructions: { draggable: active == null ? '' : name (active).text } }}
|
screenReaderInstructions: {
|
||||||
|
draggable: active == null ? '' : accessibleName (active).text } }}
|
||||||
onDragStart={event => {
|
onDragStart={event => {
|
||||||
const id = String (event.active.id)
|
const id = String (event.active.id)
|
||||||
drag.current = { id, groups }
|
drag.current = { id, groups }
|
||||||
@@ -108,17 +113,23 @@ const SortableList = ({ groups, name, renderItem, renderGroup, onChange, fixed =
|
|||||||
{Object.entries (groups).map (([group, ids]) => <div key={group}>
|
{Object.entries (groups).map (([group, ids]) => <div key={group}>
|
||||||
{renderGroup (group, <SortableContext items={ids} strategy={rectSortingStrategy}>
|
{renderGroup (group, <SortableContext items={ids} strategy={rectSortingStrategy}>
|
||||||
<Group id={group}>{ids.map (id => <SortableItem key={id} id={id}
|
<Group id={group}>{ids.map (id => <SortableItem key={id} id={id}
|
||||||
name={name (id)} fixed={fixed.includes (id)} handleId={`${scope}-${id}`}
|
name={accessibleName (id)} fixed={fixed.includes (id)} handleId={`${scope}-${id}`}
|
||||||
moveGroup={direction => moveGroup (id, direction)}>
|
moveGroup={direction => moveGroup (id, direction)}>
|
||||||
{renderItem (id, group)}
|
{renderItem (id, group)}
|
||||||
</SortableItem>)}</Group>
|
</SortableItem>)}</Group>
|
||||||
</SortableContext>)}
|
</SortableContext>)}
|
||||||
</div>)}
|
</div>)}
|
||||||
{typeof document !== 'undefined' && createPortal (
|
{typeof document !== 'undefined' && createPortal (
|
||||||
<DragOverlay dropAnimation={motion.reduced ? null : {
|
<DragOverlay adjustScale={false}
|
||||||
duration: motion.duration ('drop'), easing: motion.easing }}>
|
style={{ width: 'max-content', height: 'auto', maxWidth: 'calc(100vw - 2rem)' }}
|
||||||
|
dropAnimation={motion.reduced ? null : {
|
||||||
|
duration: motion.duration ('drop'), easing: motion.easing,
|
||||||
|
keyframes: ({ transform }) => [
|
||||||
|
{ transform: CSS.Translate.toString (transform.initial) },
|
||||||
|
{ transform: CSS.Translate.toString (transform.final) }] }}>
|
||||||
{active == null ? null : <div lang={name (active).language}
|
{active == null ? null : <div lang={name (active).language}
|
||||||
className="rounded border border-blue-500 bg-white px-3 py-2 text-sm
|
className="w-max max-w-[min(20rem,calc(100vw_-_2rem))] truncate rounded
|
||||||
|
border border-blue-500 bg-white px-2 py-1 text-sm
|
||||||
text-slate-900 shadow-lg dark:border-blue-400 dark:bg-slate-800 dark:text-slate-100">
|
text-slate-900 shadow-lg dark:border-blue-400 dark:bg-slate-800 dark:text-slate-100">
|
||||||
{name (active).text}</div>}
|
{name (active).text}</div>}
|
||||||
</DragOverlay>, document.body)}
|
</DragOverlay>, document.body)}
|
||||||
|
|||||||
@@ -48,7 +48,8 @@ const ConditionEditor = (
|
|||||||
const operator = 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 })
|
||||||
}}>
|
}}>
|
||||||
{fields.filter (field => field.operators.length > 0).map (field => (
|
{fields.filter (item => item.operators.length > 0
|
||||||
|
&& (!item.legacy || item.key === field)).map (field => (
|
||||||
<option key={field.key} value={field.key}>{fieldName (field.key)}</option>))}
|
<option key={field.key} value={field.key}>{fieldName (field.key)}</option>))}
|
||||||
</select>
|
</select>
|
||||||
<select value={operator} aria-label={message ('operator').text}
|
<select value={operator} aria-label={message ('operator').text}
|
||||||
@@ -98,7 +99,7 @@ const CountsAdditionalConditions = ({ params, update }: {
|
|||||||
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 metadata = definition.filterFields ?? definition.fields
|
||||||
const fields = metadata.filter (field => field.operators.length > 0)
|
const fields = metadata.filter (field => field.operators.length > 0 && !field.legacy)
|
||||||
const fieldName = (key: string) => (metadata.find (item => item.key === key)?.labels
|
const fieldName = (key: string) => (metadata.find (item => item.key === key)?.labels
|
||||||
?? [countsFieldLabel (key)]).map (label => message (
|
?? [countsFieldLabel (key)]).map (label => message (
|
||||||
label, metadata.find (item => item.key === key)?.labelValues).text).join (' › ')
|
label, metadata.find (item => item.key === key)?.labelValues).text).join (' › ')
|
||||||
|
|||||||
@@ -69,7 +69,9 @@ const FieldGroup = ({ fields, path, params, enabled, selected, select, update }:
|
|||||||
const prefix = path ? `${path}.` : ''
|
const prefix = path ? `${path}.` : ''
|
||||||
return <SortableList groups={{ items: ordered.map (field => field.key) }}
|
return <SortableList groups={{ items: ordered.map (field => field.key) }}
|
||||||
fixed={path === '' ? ['thumbnail'] : []}
|
fixed={path === '' ? ['thumbnail'] : []}
|
||||||
name={key => message ('reorder', {
|
name={key => message (fields.find (field => field.key === key)!.label,
|
||||||
|
fields.find (field => field.key === key)!.labelValues)}
|
||||||
|
handleName={key => message ('reorder', {
|
||||||
column: message (fields.find (field => field.key === key)!.label,
|
column: message (fields.find (field => field.key === key)!.label,
|
||||||
fields.find (field => field.key === key)!.labelValues).text })}
|
fields.find (field => field.key === key)!.labelValues).text })}
|
||||||
renderGroup={(_, content) => content}
|
renderGroup={(_, content) => content}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { countsFieldLabel } from '@/lib/countsDefinitions'
|
import { countsFieldLabel } from '@/lib/countsDefinitions'
|
||||||
import { apiDownload, apiGet } from '@/lib/api'
|
import { apiDownload, apiGet } from '@/lib/api'
|
||||||
import { currentViewingLocale } from '@/lib/locales'
|
import { currentViewingLocale, localeLanguage } from '@/lib/locales'
|
||||||
|
|
||||||
import { readCountState, encodeCountState } from '@/lib/queryState'
|
import { readCountState, encodeCountState } from '@/lib/queryState'
|
||||||
|
|
||||||
@@ -99,7 +99,7 @@ export type CountSort = { key: string; direction: 'asc' | 'desc' }
|
|||||||
export type CountField = { key: string; label: string; children?: CountField[]
|
export type CountField = { key: string; label: string; children?: CountField[]
|
||||||
labelValues?: Record<string, string>
|
labelValues?: Record<string, string>
|
||||||
multipleValues?: boolean
|
multipleValues?: boolean
|
||||||
nameLocale?: { code: string; languageCode: string; scriptCode: string } }
|
nameLocale?: { code: string; languageCode: string; scriptCode?: string } }
|
||||||
export type CountRecord = Record<string, unknown>
|
export type CountRecord = Record<string, unknown>
|
||||||
|
|
||||||
export const countColumns: Record<string, string> = {
|
export const countColumns: Record<string, string> = {
|
||||||
@@ -233,7 +233,8 @@ export const selectedCountColumns = (
|
|||||||
const defaults: Record<CountsResource, string[]> = {
|
const defaults: Record<CountsResource, string[]> = {
|
||||||
posts: ['title', 'url', 'tags', 'parents', 'video_ms'],
|
posts: ['title', 'url', 'tags', 'parents', 'video_ms'],
|
||||||
tags: [...(definition?.nameColumns ?? []).filter (field =>
|
tags: [...(definition?.nameColumns ?? []).filter (field =>
|
||||||
field.nameLocale?.code === currentViewingLocale ()).map (field => field.key),
|
field.nameLocale?.languageCode === localeLanguage (currentViewingLocale ()))
|
||||||
|
.map (field => field.key),
|
||||||
'category', 'parents'],
|
'category', 'parents'],
|
||||||
nico_tags: ['name', 'linked_tags'],
|
nico_tags: ['name', 'linked_tags'],
|
||||||
tag_descriptions: ['language_code', 'body', 'source_language_code'],
|
tag_descriptions: ['language_code', 'body', 'source_language_code'],
|
||||||
@@ -356,14 +357,31 @@ export const normaliseCountsParams = (
|
|||||||
definition?: CountsDefinition,
|
definition?: CountsDefinition,
|
||||||
): URLSearchParams => {
|
): URLSearchParams => {
|
||||||
const params = new URLSearchParams (source)
|
const params = new URLSearchParams (source)
|
||||||
|
const nameScope = params.get ('name_scope')
|
||||||
|
if (definition?.conditionAliases != null && (nameScope == null || nameScope === 'locale'))
|
||||||
|
{
|
||||||
|
const predicates = new Map ((definition.conditionAliases ?? [])
|
||||||
|
.map (entry => [entry.key, entry.value]))
|
||||||
|
for (const [key, value] of params)
|
||||||
|
if (/^conditions\.\d+\.field$/.test (key)) params.set (key, value.replace (
|
||||||
|
/(?:name|aliases)_[a-z0-9_]+/g, field => predicates.get (field) ?? field))
|
||||||
|
params.set ('name_scope', 'language')
|
||||||
|
}
|
||||||
|
const aliasesByKey = new Map ((definition?.columnAliases ?? [])
|
||||||
|
.map (entry => [entry.key, entry.value]))
|
||||||
|
for (const key of ['columns', 'children', 'order', 'sort', 'child_sort'])
|
||||||
|
if (params.has (key)) params.set (key, params.get (key)!.replace (
|
||||||
|
/(?:name|aliases)_[a-z0-9_]+/g, field => aliasesByKey.get (field) ?? field))
|
||||||
if (definition?.nameColumns != null)
|
if (definition?.nameColumns != null)
|
||||||
{
|
{
|
||||||
const names = definition.nameColumns.filter (field => !field.multipleValues)
|
const names = definition.nameColumns.filter (field => !field.multipleValues)
|
||||||
.map (field => field.key)
|
.map (field => field.key)
|
||||||
const current = definition.nameColumns.find (field =>
|
const current = definition.nameColumns.find (field =>
|
||||||
!field.multipleValues && field.nameLocale?.code === currentViewingLocale ())?.key
|
!field.multipleValues
|
||||||
|
&& field.nameLocale?.languageCode === localeLanguage (currentViewingLocale ()))?.key
|
||||||
const aliases = definition.nameColumns.find (field =>
|
const aliases = definition.nameColumns.find (field =>
|
||||||
field.multipleValues && field.nameLocale?.code === currentViewingLocale ())?.key
|
field.multipleValues
|
||||||
|
&& field.nameLocale?.languageCode === localeLanguage (currentViewingLocale ()))?.key
|
||||||
const rootKey = (key: string) => (key === 'name' && current != null) ? current
|
const rootKey = (key: string) => (key === 'name' && current != null) ? current
|
||||||
: ((key === 'aliases' && aliases != null) ? aliases : key)
|
: ((key === 'aliases' && aliases != null) ? aliases : key)
|
||||||
const childKeys = (key: string): string[] => {
|
const childKeys = (key: string): string[] => {
|
||||||
|
|||||||
@@ -27,11 +27,14 @@ export type CountsFieldMetadata = {
|
|||||||
setType?: 'tag_reference'; inputTypes?: string[]
|
setType?: 'tag_reference'; inputTypes?: string[]
|
||||||
labelValues?: Record<string, string>
|
labelValues?: Record<string, string>
|
||||||
nameLocale?: CountField['nameLocale']
|
nameLocale?: CountField['nameLocale']
|
||||||
|
legacy?: boolean
|
||||||
choices?: { value: string; label: string }[] }
|
choices?: { value: string; label: string }[] }
|
||||||
export type CountsDefinition = {
|
export type CountsDefinition = {
|
||||||
fields: CountsFieldMetadata[]
|
fields: CountsFieldMetadata[]
|
||||||
outputFields?: CountField[]
|
outputFields?: CountField[]
|
||||||
nameColumns?: CountField[]
|
nameColumns?: CountField[]
|
||||||
|
columnAliases?: { key: string; value: string }[]
|
||||||
|
conditionAliases?: { key: string; value: string }[]
|
||||||
filterFields?: CountsFieldMetadata[]
|
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[] }
|
||||||
@@ -53,12 +56,9 @@ const localiseCountsDefinition = (
|
|||||||
const heading = (name: CountField['nameLocale'], aliasesFlg: boolean) => {
|
const heading = (name: CountField['nameLocale'], aliasesFlg: boolean) => {
|
||||||
if (name == null) return null
|
if (name == null) return null
|
||||||
const language = languageUIMessage (name.languageCode, locale).text
|
const language = languageUIMessage (name.languageCode, locale).text
|
||||||
const scripts = new Set ((definition.nameColumns ?? [])
|
|
||||||
.filter (field => field.nameLocale?.languageCode === name.languageCode)
|
|
||||||
.map (field => field.nameLocale!.scriptCode.toLowerCase ()))
|
|
||||||
const label = aliasesFlg ? 'counts:localeAliases' : 'counts:localeName'
|
const label = aliasesFlg ? 'counts:localeAliases' : 'counts:localeName'
|
||||||
return { label: scripts.size > 1 ? `${label}WithScript` : label,
|
return { label: name.scriptCode == null ? label : `${label}WithScript`,
|
||||||
labelValues: { language, script: name.scriptCode } }
|
labelValues: { language, script: name.scriptCode ?? '' } }
|
||||||
}
|
}
|
||||||
const columns = (fields: CountField[]): CountField[] => fields.map (field => ({
|
const columns = (fields: CountField[]): CountField[] => fields.map (field => ({
|
||||||
...field, ...heading (field.nameLocale, field.multipleValues === true),
|
...field, ...heading (field.nameLocale, field.multipleValues === true),
|
||||||
|
|||||||
新しいイシューから参照
ユーザーをブロックする