このコミットが含まれているのは:
@@ -84,6 +84,15 @@ class CountsVersionRepr
|
|||||||
def locale_names(values)
|
def locale_names(values)
|
||||||
@name_columns.to_h do |field|
|
@name_columns.to_h do |field|
|
||||||
locale = field.fetch(:name_locale)
|
locale = field.fetch(:name_locale)
|
||||||
|
if field[:multiple_values]
|
||||||
|
entries = values.select { _1[:language_code] == locale[:language_code] }
|
||||||
|
aliases = entries.flat_map do |entry|
|
||||||
|
entry[:aliases].select do |value|
|
||||||
|
value['script_code'].to_s.downcase == locale[:script_code].to_s.downcase
|
||||||
|
end.map { _1.merge('language_code' => entry[:language_code]) }
|
||||||
|
end
|
||||||
|
next [field.fetch(:key).to_sym, aliases]
|
||||||
|
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
|
value[:script_code].to_s.downcase == locale[:script_code].to_s.downcase
|
||||||
|
|||||||
@@ -30,10 +30,11 @@ class CountsCondition
|
|||||||
nullable = prefix.present? || (physical ? physical[:nullable] : key != 'latest')
|
nullable = prefix.present? || (physical ? physical[:nullable] : key != 'latest')
|
||||||
nullable = false if field[:children]
|
nullable = false if field[:children]
|
||||||
entry = { key:, type:, labels: path_labels, nullable:,
|
entry = { key:, type:, labels: path_labels, nullable:,
|
||||||
multiple: prefix.present? || field[:children].present?,
|
multiple: prefix.present? || field[:children].present? || field[:multiple_values],
|
||||||
collection: field[:children].present? || type == :interval,
|
collection: field[:children].present? || type == :interval,
|
||||||
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]
|
||||||
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)
|
||||||
@@ -344,6 +345,7 @@ class CountsCondition
|
|||||||
def source_for(key)
|
def source_for(key)
|
||||||
root, *parts = key.split('.')
|
root, *parts = key.split('.')
|
||||||
if @definition.resource == 'tags' && @definition.name_column(root)
|
if @definition.resource == 'tags' && @definition.name_column(root)
|
||||||
|
return @definition.alias_source(root) if root.start_with?('aliases_')
|
||||||
return [nil, @definition.name_expression(root)]
|
return [nil, @definition.name_expression(root)]
|
||||||
end
|
end
|
||||||
return [nil, "(#{ @population.latest_sql })"] if root == 'latest'
|
return [nil, "(#{ @population.latest_sql })"] if root == 'latest'
|
||||||
@@ -357,6 +359,10 @@ class CountsCondition
|
|||||||
if root == 'parents'
|
if root == 'parents'
|
||||||
source, id = parent_source
|
source, id = parent_source
|
||||||
if @definition.resource == 'tags' && @definition.name_column(parts.first)
|
if @definition.resource == 'tags' && @definition.name_column(parts.first)
|
||||||
|
if parts.first.start_with?('aliases_')
|
||||||
|
aliases, value = @definition.alias_source(parts.first, 'parent_version')
|
||||||
|
return ["#{ source } JOIN #{ aliases }", value]
|
||||||
|
end
|
||||||
return [source, @definition.name_expression(parts.first, 'parent_version')]
|
return [source, @definition.name_expression(parts.first, 'parent_version')]
|
||||||
end
|
end
|
||||||
if parts.first == 'names'
|
if parts.first == 'names'
|
||||||
@@ -402,6 +408,10 @@ class CountsCondition
|
|||||||
source = post_tag_source
|
source = post_tag_source
|
||||||
document = 'tag_snapshot.item'
|
document = 'tag_snapshot.item'
|
||||||
if @definition.name_column(parts.first)
|
if @definition.name_column(parts.first)
|
||||||
|
if parts.first.start_with?('aliases_')
|
||||||
|
aliases, value = @definition.alias_source(parts.first, 'referenced_tag')
|
||||||
|
return ["#{ source } JOIN #{ aliases }", value]
|
||||||
|
end
|
||||||
return [source, @definition.name_expression(parts.first, 'referenced_tag')]
|
return [source, @definition.name_expression(parts.first, 'referenced_tag')]
|
||||||
end
|
end
|
||||||
case parts.first
|
case parts.first
|
||||||
|
|||||||
@@ -14,12 +14,17 @@ class CountsDefaults
|
|||||||
details = detail_params(resource)
|
details = detail_params(resource)
|
||||||
if details
|
if details
|
||||||
name = CountsResourceDefinition.name_key(locale)
|
name = CountsResourceDefinition.name_key(locale)
|
||||||
|
aliases = CountsResourceDefinition.aliases_key(locale)
|
||||||
details = details.transform_values do |value|
|
details = details.transform_values do |value|
|
||||||
value.split(' ').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)
|
||||||
token == 'name' ? name : "parents.#{ name }"
|
token == 'name' ? name : ["parents.#{ name }", "parents.#{ aliases }"]
|
||||||
|
elsif resource == 'tags' && token == 'aliases'
|
||||||
|
aliases
|
||||||
|
elsif resource == 'tags' && token.start_with?('aliases.')
|
||||||
|
[]
|
||||||
elsif resource == 'posts' && token == 'tags.name'
|
elsif resource == 'posts' && token == 'tags.name'
|
||||||
"tags.#{ name }"
|
["tags.#{ name }", "tags.#{ aliases }"]
|
||||||
else token
|
else token
|
||||||
end
|
end
|
||||||
end.join(' ')
|
end.join(' ')
|
||||||
|
|||||||
@@ -139,6 +139,7 @@ class CountsExportProjection
|
|||||||
if @complete_names && ((!@summary && @resource == 'tags') || row.key?(:names))
|
if @complete_names && ((!@summary && @resource == 'tags') || row.key?(:names))
|
||||||
result['names'] = row[:names] || []
|
result['names'] = row[:names] || []
|
||||||
end
|
end
|
||||||
|
result['aliases'] = row[:aliases] if @complete_names && row.key?(:aliases)
|
||||||
result
|
result
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -155,6 +156,9 @@ class CountsExportProjection
|
|||||||
end
|
end
|
||||||
|
|
||||||
def csv_display(value, key)
|
def csv_display(value, key)
|
||||||
|
if key.start_with?('aliases_') && value.is_a?(Array)
|
||||||
|
return value.map { fetch(_1, 'name') }.compact.join("\n")
|
||||||
|
end
|
||||||
return @labels.fetch('bot', '') if key == 'created_by_user' && value.nil?
|
return @labels.fetch('bot', '') if key == 'created_by_user' && value.nil?
|
||||||
return @labels.fetch("event_type:#{ value }", value) if key == 'event_type'
|
return @labels.fetch("event_type:#{ value }", value) if key == 'event_type'
|
||||||
return value ? @labels.fetch('latest', 'true') : '' if key == 'latest'
|
return value ? @labels.fetch('latest', 'true') : '' if key == 'latest'
|
||||||
@@ -272,6 +276,8 @@ class CountsExportProjection
|
|||||||
end
|
end
|
||||||
if @complete_names && ['tags', 'parents'].include?(prefix)
|
if @complete_names && ['tags', 'parents'].include?(prefix)
|
||||||
result['names'] = fetch(row, 'names') || []
|
result['names'] = fetch(row, 'names') || []
|
||||||
|
aliases = fetch(row, 'aliases')
|
||||||
|
result['aliases'] = aliases if aliases
|
||||||
end
|
end
|
||||||
result
|
result
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -112,19 +112,29 @@ class CountsResourceDefinition
|
|||||||
def field_label(key) = LABELS.fetch(key, "counts:attributes.#{ key }")
|
def field_label(key) = LABELS.fetch(key, "counts:attributes.#{ key }")
|
||||||
|
|
||||||
def self.name_key(code) = "name_#{ code.to_s.downcase.tr('-', '_') }"
|
def self.name_key(code) = "name_#{ code.to_s.downcase.tr('-', '_') }"
|
||||||
|
def self.aliases_key(code) = "aliases_#{ code.to_s.downcase.tr('-', '_') }"
|
||||||
|
|
||||||
def name_columns
|
def name_columns
|
||||||
@name_columns ||= Locale.order(:code).map do |locale|
|
@name_columns ||= Locale.select(:code, :language_code, :script_code).order(:code)
|
||||||
{ key: self.class.name_key(locale.code), label: 'counts:localeName',
|
.flat_map do |locale|
|
||||||
label_values: { locale: locale.name },
|
codes = { code: locale.code, language_code: locale.language_code,
|
||||||
name_locale: { code: locale.code, language_code: locale.language_code,
|
script_code: locale.script_code }
|
||||||
script_code: locale.script_code } }
|
[{ key: self.class.name_key(locale.code), label: 'counts:localeName', name_locale: codes },
|
||||||
|
{ key: self.class.aliases_key(locale.code), label: 'counts:localeAliases',
|
||||||
|
name_locale: codes, multiple_values: true }]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def name_column(key) = name_columns.find { _1[:key] == key }
|
def name_column(key) = name_columns.find { _1[:key] == key }
|
||||||
|
|
||||||
def name_expression(key, row = 'v')
|
def name_expression(key, row = 'v')
|
||||||
|
if key.start_with?('aliases_')
|
||||||
|
source, value = alias_source(key, row)
|
||||||
|
return "CAST(COALESCE((SELECT JSON_ARRAYAGG(#{ value }) OVER (" +
|
||||||
|
'ORDER BY locale_alias.ordinal ROWS BETWEEN UNBOUNDED PRECEDING ' +
|
||||||
|
"AND UNBOUNDED FOLLOWING) FROM #{ source } WHERE #{ value } IS NOT NULL " +
|
||||||
|
'LIMIT 1), JSON_ARRAY()) AS CHAR) COLLATE utf8mb4_bin'
|
||||||
|
end
|
||||||
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 }")
|
||||||
@@ -135,6 +145,19 @@ class CountsResourceDefinition
|
|||||||
"CONCAT(#{ path }, '.name')), CAST('null' AS JSON))) END"
|
"CONCAT(#{ path }, '.name')), CAST('null' AS JSON))) END"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def alias_source(key, row = 'v')
|
||||||
|
locale = name_column(key).fetch(:name_locale)
|
||||||
|
connection = model.connection
|
||||||
|
path = connection.quote("$.#{ locale[:language_code].to_json }.aliases[*]")
|
||||||
|
script = connection.quote(locale[:script_code])
|
||||||
|
source = "JSON_TABLE(COALESCE(#{ row }.names, JSON_OBJECT()), #{ path } " +
|
||||||
|
"COLUMNS (ordinal FOR ORDINALITY, name TEXT PATH '$.name', " +
|
||||||
|
"script TEXT PATH '$.script_code')) locale_alias"
|
||||||
|
value = "CASE WHEN LOWER(locale_alias.script) = LOWER(#{ script }) " +
|
||||||
|
'THEN locale_alias.name END'
|
||||||
|
[source, value]
|
||||||
|
end
|
||||||
|
|
||||||
def output_fields
|
def output_fields
|
||||||
content = case resource
|
content = case resource
|
||||||
when 'posts'
|
when 'posts'
|
||||||
@@ -149,6 +172,7 @@ class CountsResourceDefinition
|
|||||||
keys.flat_map do |key|
|
keys.flat_map do |key|
|
||||||
next name_columns if key == 'name' && resource == 'tags'
|
next name_columns if key == 'name' && resource == 'tags'
|
||||||
next [] if key == 'names' && resource == 'tags'
|
next [] if key == 'names' && resource == 'tags'
|
||||||
|
next [] if key == 'aliases' && resource == 'tags'
|
||||||
label = if key == 'parents'
|
label = if key == 'parents'
|
||||||
resource == 'tags' ? 'tags:parents' : 'shared:labels.parentPosts'
|
resource == 'tags' ? 'tags:parents' : 'shared:labels.parentPosts'
|
||||||
else field_label(key)
|
else field_label(key)
|
||||||
@@ -174,6 +198,7 @@ class CountsResourceDefinition
|
|||||||
else []
|
else []
|
||||||
end
|
end
|
||||||
keys.flat_map do |child|
|
keys.flat_map do |child|
|
||||||
|
next [] if child == 'aliases' && key == 'tags'
|
||||||
if child == 'name' && ['tags', 'parents'].include?(key)
|
if child == 'name' && ['tags', 'parents'].include?(key)
|
||||||
next [{ key: child, label: CHILD_LABELS.fetch(child) }, *name_columns]
|
next [{ key: child, label: CHILD_LABELS.fetch(child) }, *name_columns]
|
||||||
end
|
end
|
||||||
@@ -194,14 +219,16 @@ class CountsResourceDefinition
|
|||||||
fields = output_fields
|
fields = output_fields
|
||||||
if resource == 'tags'
|
if resource == 'tags'
|
||||||
fields += [{ key: 'name', label: 'counts:columns.name' },
|
fields += [{ 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') }]
|
||||||
end
|
end
|
||||||
if resource == 'posts'
|
if resource == 'posts'
|
||||||
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] + [
|
||||||
{ 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') }])
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
fields
|
fields
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ const ConditionEditor = (
|
|||||||
controls.setActions ([{ label: label (initial == null ? 'add' : 'shared:actions.edit'),
|
controls.setActions ([{ label: label (initial == null ? 'add' : 'shared:actions.edit'),
|
||||||
onSelect: () => save (condition) }])
|
onSelect: () => save (condition) }])
|
||||||
}, [controls, condition, initial, label, save])
|
}, [controls, condition, initial, label, save])
|
||||||
return <div className="space-y-5 px-1 py-3 md:px-3">
|
return <div className="space-y-2">
|
||||||
<select value={field} aria-label={message ('field').text}
|
<select value={field} aria-label={message ('field').text}
|
||||||
className={inputClass (false, 'h-11 w-full')}
|
className={inputClass (false, 'h-11 w-full')}
|
||||||
onChange={event => {
|
onChange={event => {
|
||||||
@@ -115,7 +115,6 @@ const CountsAdditionalConditions = ({ params, update }: {
|
|||||||
else replace (index, condition)
|
else replace (index, condition)
|
||||||
}
|
}
|
||||||
void dialogue.form ({ title: label ('additionalConditions'), footerLayout: 'inline',
|
void dialogue.form ({ title: label ('additionalConditions'), footerLayout: 'inline',
|
||||||
size: 'large',
|
|
||||||
body: controls => <ConditionEditor definition={definition}
|
body: controls => <ConditionEditor definition={definition}
|
||||||
initial={initial} controls={controls} save={save}/> })
|
initial={initial} controls={controls} save={save}/> })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -180,9 +180,11 @@ const CountsControls = (
|
|||||||
</label>}
|
</label>}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<AnimatedCollapse id={id} open={open}>
|
<AnimatedCollapse id={id} open={open}>
|
||||||
<div className="grid gap-3 border-b py-2 dark:border-slate-700 md:grid-cols-2">
|
<div className="grid max-h-[40dvh] auto-rows-max gap-3 overflow-y-auto overscroll-contain
|
||||||
<div className="max-h-[20dvh] min-h-0 min-w-0 overflow-y-auto overscroll-contain
|
border-b py-2 [direction:ltr] dark:border-slate-700 md:max-h-none md:auto-rows-auto
|
||||||
md:max-h-[40dvh]" style={{ direction: 'rtl' }}>
|
md:grid-cols-2 md:overflow-visible">
|
||||||
|
<div className="min-h-0 min-w-0 md:max-h-[40dvh] md:overflow-y-auto
|
||||||
|
md:overscroll-contain md:[direction:rtl]">
|
||||||
<fieldset className="min-w-0" data-counts-output style={{ direction: 'ltr' }}>
|
<fieldset className="min-w-0" data-counts-output style={{ direction: 'ltr' }}>
|
||||||
{mode === 'summary' ? <CountsAxes params={draft} update={update}/>
|
{mode === 'summary' ? <CountsAxes params={draft} update={update}/>
|
||||||
: <>
|
: <>
|
||||||
@@ -194,8 +196,9 @@ const CountsControls = (
|
|||||||
</>}
|
</>}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</div>
|
</div>
|
||||||
<div className="max-h-[20dvh] min-h-0 min-w-0 overflow-y-auto overscroll-contain
|
<div className="min-h-0 min-w-0 border-t pt-2 dark:border-slate-700
|
||||||
md:max-h-[40dvh]" style={{ direction: 'ltr' }}>
|
md:max-h-[40dvh] md:overflow-y-auto md:overscroll-contain md:border-t-0 md:pt-0"
|
||||||
|
style={{ direction: 'ltr' }}>
|
||||||
<fieldset className="min-w-0 space-y-2" data-counts-filters>
|
<fieldset className="min-w-0 space-y-2" data-counts-filters>
|
||||||
<legend className="font-semibold">{label ('filters')}</legend>
|
<legend className="font-semibold">{label ('filters')}</legend>
|
||||||
{history && <div className="flex min-w-0 max-w-full items-center gap-1">
|
{history && <div className="flex min-w-0 max-w-full items-center gap-1">
|
||||||
|
|||||||
@@ -47,9 +47,15 @@ const CountsNestedTable = (
|
|||||||
{String (value)}</PrefetchLink>
|
{String (value)}</PrefetchLink>
|
||||||
}
|
}
|
||||||
const display = countsLogicalName (row, field, locale)
|
const display = countsLogicalName (row, field, locale)
|
||||||
const nameLocale = fields.find (item => item.key === field)?.nameLocale
|
const metadata = fields.find (item => item.key === field)
|
||||||
|
const nameLocale = metadata?.nameLocale
|
||||||
if (nameLocale != null)
|
if (nameLocale != null)
|
||||||
return <LocalisedText language={nameLocale.languageCode}>{display.text}</LocalisedText>
|
{
|
||||||
|
if (metadata?.multipleValues)
|
||||||
|
return <span lang={nameLocale.code} className="whitespace-pre-line">{display.text}</span>
|
||||||
|
return <LocalisedText language={nameLocale.languageCode}
|
||||||
|
className="whitespace-pre-line">{display.text}</LocalisedText>
|
||||||
|
}
|
||||||
if (typeof value === 'boolean')
|
if (typeof value === 'boolean')
|
||||||
return <span aria-label={display.text} lang={display.language}>{value ? '✓' : '✗'}</span>
|
return <span aria-label={display.text} lang={display.language}>{value ? '✓' : '✗'}</span>
|
||||||
if (display.language != null)
|
if (display.language != null)
|
||||||
@@ -59,7 +65,8 @@ const CountsNestedTable = (
|
|||||||
return display.text
|
return display.text
|
||||||
}
|
}
|
||||||
if (countsNestedIsScalar (fields, path, params))
|
if (countsNestedIsScalar (fields, path, params))
|
||||||
return <span>{countsNestedScalarText (rows, fields, path, params, locale)}</span>
|
return <span className="whitespace-pre-line">
|
||||||
|
{countsNestedScalarText (rows, fields, path, params, locale)}</span>
|
||||||
return (
|
return (
|
||||||
<div className="counts-nested w-full min-w-0 max-w-full">
|
<div className="counts-nested w-full min-w-0 max-w-full">
|
||||||
{params.get ('nested_json') === '1' ? (
|
{params.get ('nested_json') === '1' ? (
|
||||||
|
|||||||
@@ -48,7 +48,12 @@ const CountsResults = (
|
|||||||
title={message (field.label, field.labelValues)} params={params} update={update}/>
|
title={message (field.label, field.labelValues)} params={params} update={update}/>
|
||||||
}
|
}
|
||||||
if (field.nameLocale != null)
|
if (field.nameLocale != null)
|
||||||
return <LocalisedText language={field.nameLocale.languageCode}>{value}</LocalisedText>
|
{
|
||||||
|
if (field.multipleValues)
|
||||||
|
return <span lang={field.nameLocale.code} className="whitespace-pre-line">{value}</span>
|
||||||
|
return <LocalisedText language={field.nameLocale.languageCode}
|
||||||
|
className="whitespace-pre-line">{value}</LocalisedText>
|
||||||
|
}
|
||||||
switch (column)
|
switch (column)
|
||||||
{
|
{
|
||||||
case 'resource_id':
|
case 'resource_id':
|
||||||
|
|||||||
@@ -44,6 +44,44 @@ const cellAt = (table: HTMLTableElement, point: Point) =>
|
|||||||
cellGrid (table)[point.row]?.[point.column]
|
cellGrid (table)[point.row]?.[point.column]
|
||||||
|
|
||||||
|
|
||||||
|
const scrollToCell = (table: HTMLTableElement, point: Point, previous: Point) => {
|
||||||
|
const cell = cellAt (table, point)
|
||||||
|
const container = table.parentElement?.closest<HTMLElement> (
|
||||||
|
'[data-counts-scroll],[data-counts-results]')
|
||||||
|
if (cell == null || container == null) return
|
||||||
|
const viewport = container.getBoundingClientRect ()
|
||||||
|
const top = viewport.top + container.clientTop
|
||||||
|
const left = viewport.left + container.clientLeft
|
||||||
|
const bottom = top + container.clientHeight
|
||||||
|
const right = left + container.clientWidth
|
||||||
|
const header = table.tHead?.getBoundingClientRect ()
|
||||||
|
const visibleTop = header == null ? top : Math.max (top, Math.min (bottom, header.bottom))
|
||||||
|
const rect = cell.getBoundingClientRect ()
|
||||||
|
const lastRow = (table.tBodies[0]?.rows.length ?? 1) - 1
|
||||||
|
const verticalEdge = (point.row === 0 || point.row < previous.row) ? 'start'
|
||||||
|
: ((point.row === lastRow || point.row > previous.row) ? 'end'
|
||||||
|
: (Math.abs (rect.top - visibleTop) <= Math.abs (rect.bottom - bottom) ? 'start' : 'end'))
|
||||||
|
const offset = (
|
||||||
|
start: number, end: number, minimum: number, maximum: number,
|
||||||
|
edge?: 'start' | 'end',
|
||||||
|
) => {
|
||||||
|
if (maximum <= minimum) return 0
|
||||||
|
const oversized = end - start > maximum - minimum
|
||||||
|
// Vertical merged cells follow the destination and movement direction.
|
||||||
|
// Horizontal scrolling retains its existing nearest-edge behaviour.
|
||||||
|
if (oversized && edge != null) return edge === 'start' ? start - minimum : end - maximum
|
||||||
|
if (start < minimum && end > maximum) return 0
|
||||||
|
if (start < minimum) return oversized ? end - maximum : start - minimum
|
||||||
|
if (end > maximum) return oversized ? start - minimum : end - maximum
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
const vertical = offset (rect.top, rect.bottom, visibleTop, bottom, verticalEdge)
|
||||||
|
const horizontal = offset (rect.left, rect.right, left, right)
|
||||||
|
if (vertical !== 0) container.scrollTop += vertical
|
||||||
|
if (horizontal !== 0) container.scrollLeft += horizontal
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
const CountsSelectableTable = (
|
const CountsSelectableTable = (
|
||||||
{ family, columns, identity, children, className, name, copyTable, stretched = false }: {
|
{ family, columns, identity, children, className, name, copyTable, stretched = false }: {
|
||||||
family: string
|
family: string
|
||||||
@@ -74,6 +112,7 @@ const CountsSelectableTable = (
|
|||||||
const { label, message } = useUITranslation ('counts')
|
const { label, message } = useUITranslation ('counts')
|
||||||
const clearTextSelection = useCallback (() => {
|
const clearTextSelection = useCallback (() => {
|
||||||
setTextMode (null)
|
setTextMode (null)
|
||||||
|
setCaret (null)
|
||||||
}, [])
|
}, [])
|
||||||
useLayoutEffect (() => {
|
useLayoutEffect (() => {
|
||||||
if (textMode == null) return
|
if (textMode == null) return
|
||||||
@@ -488,12 +527,18 @@ const CountsSelectableTable = (
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (event.key === 'Escape')
|
if (event.key === 'Escape' || (event.key === 'Enter' && textCell != null))
|
||||||
{
|
{
|
||||||
const textModeFlg = textCell != null
|
const textModeFlg = textCell != null
|
||||||
clearTextSelection ()
|
clearTextSelection ()
|
||||||
if (!textModeFlg) setStored (null)
|
if (textModeFlg)
|
||||||
|
{
|
||||||
|
window.getSelection ()?.removeAllRanges ()
|
||||||
|
event.currentTarget.focus ({ preventScroll: true })
|
||||||
|
}
|
||||||
|
else setStored (null)
|
||||||
event.preventDefault ()
|
event.preventDefault ()
|
||||||
|
event.stopPropagation ()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (event.key === 'Enter' && textCell == null && selection != null)
|
if (event.key === 'Enter' && textCell == null && selection != null)
|
||||||
@@ -567,8 +612,7 @@ const CountsSelectableTable = (
|
|||||||
+ (key === 'ArrowRight' ? 1 : (key === 'ArrowLeft' ? -1 : 0)))) }
|
+ (key === 'ArrowRight' ? 1 : (key === 'ArrowLeft' ? -1 : 0)))) }
|
||||||
setStored ({ identity, anchor: event.shiftKey && selection != null
|
setStored ({ identity, anchor: event.shiftKey && selection != null
|
||||||
? selection.anchor : point, end: point })
|
? selection.anchor : point, end: point })
|
||||||
cellAt (event.currentTarget, point)?.scrollIntoView ({
|
scrollToCell (event.currentTarget, point, previous)
|
||||||
block: 'nearest', inline: 'nearest' })
|
|
||||||
}}>
|
}}>
|
||||||
<caption className="sr-only">
|
<caption className="sr-only">
|
||||||
<span id={`${id}-hint`} className="sr-only">{label ('selection.hint')}</span>
|
<span id={`${id}-hint`} className="sr-only">{label ('selection.hint')}</span>
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ export type CountsResult = {
|
|||||||
export type CountSort = { key: string; direction: 'asc' | 'desc' }
|
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
|
||||||
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>
|
||||||
|
|
||||||
@@ -335,12 +336,17 @@ export const normaliseCountsParams = (
|
|||||||
const params = new URLSearchParams (source)
|
const params = new URLSearchParams (source)
|
||||||
if (definition?.nameColumns != null)
|
if (definition?.nameColumns != null)
|
||||||
{
|
{
|
||||||
const names = definition.nameColumns.map (field => field.key)
|
const names = definition.nameColumns.filter (field => !field.multipleValues)
|
||||||
|
.map (field => field.key)
|
||||||
const current = definition.nameColumns.find (field =>
|
const current = definition.nameColumns.find (field =>
|
||||||
field.nameLocale?.code === currentViewingLocale ())?.key
|
!field.multipleValues && field.nameLocale?.code === currentViewingLocale ())?.key
|
||||||
|
const aliases = definition.nameColumns.find (field =>
|
||||||
|
field.multipleValues && field.nameLocale?.code === currentViewingLocale ())?.key
|
||||||
|
const rootKey = (key: string) => (key === 'name' && current != null) ? current
|
||||||
|
: ((key === 'aliases' && aliases != null) ? aliases : key)
|
||||||
if (resource === 'tags' && params.has ('columns'))
|
if (resource === 'tags' && params.has ('columns'))
|
||||||
params.set ('columns', params.get ('columns')!.split (' ').flatMap (key =>
|
params.set ('columns', params.get ('columns')!.split (' ').flatMap (key =>
|
||||||
key === 'names' ? names : [key === 'name' && current != null ? current : key])
|
key === 'names' ? names : [rootKey (key)])
|
||||||
.join (' '))
|
.join (' '))
|
||||||
if (params.has ('children'))
|
if (params.has ('children'))
|
||||||
{
|
{
|
||||||
@@ -348,12 +354,15 @@ export const normaliseCountsParams = (
|
|||||||
params.set ('children', selected.flatMap (key => {
|
params.set ('children', selected.flatMap (key => {
|
||||||
if (['tags.names', 'parents.names'].includes (key))
|
if (['tags.names', 'parents.names'].includes (key))
|
||||||
return names.map (name => `${key.slice (0, -6)}.${name}`)
|
return names.map (name => `${key.slice (0, -6)}.${name}`)
|
||||||
|
if (['tags.aliases', 'parents.aliases'].includes (key) && aliases != null)
|
||||||
|
return [`${key.slice (0, -8)}.${aliases}`]
|
||||||
|
if (key.startsWith ('tags.aliases.') || key.startsWith ('parents.aliases.')) return []
|
||||||
return key.startsWith ('tags.names.') || key.startsWith ('parents.names.') ? [] : [key]
|
return key.startsWith ('tags.names.') || key.startsWith ('parents.names.') ? [] : [key]
|
||||||
}).join (' '))
|
}).join (' '))
|
||||||
}
|
}
|
||||||
if (resource === 'tags' && params.has ('order'))
|
if (resource === 'tags' && params.has ('order'))
|
||||||
params.set ('order', params.get ('order')!.split (' ').flatMap (key =>
|
params.set ('order', params.get ('order')!.split (' ').flatMap (key =>
|
||||||
key === 'names' ? names : [key === 'name' && current != null ? current : key])
|
key === 'names' ? names : [rootKey (key)])
|
||||||
.join (' '))
|
.join (' '))
|
||||||
}
|
}
|
||||||
for (const [key, value] of params)
|
for (const [key, value] of params)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createContext, useContext } from 'react'
|
import { createContext, useContext } from 'react'
|
||||||
|
|
||||||
import { translateUIMessage } from '@/lib/i18n'
|
import { i18n, translateUIMessage } from '@/lib/i18n'
|
||||||
import { apiGet } from '@/lib/api'
|
import { apiGet } from '@/lib/api'
|
||||||
|
|
||||||
import type { LocaleCode } from '@/lib/locales'
|
import type { LocaleCode } from '@/lib/locales'
|
||||||
@@ -26,6 +26,7 @@ export type CountsFieldMetadata = {
|
|||||||
labels?: string[]; multiple?: boolean; candidateSource?: 'categories' | 'tags'
|
labels?: string[]; multiple?: boolean; candidateSource?: 'categories' | 'tags'
|
||||||
setType?: 'tag_reference'; inputTypes?: string[]
|
setType?: 'tag_reference'; inputTypes?: string[]
|
||||||
labelValues?: Record<string, string>
|
labelValues?: Record<string, string>
|
||||||
|
nameLocale?: CountField['nameLocale']
|
||||||
choices?: { value: string; label: string }[] }
|
choices?: { value: string; label: string }[] }
|
||||||
export type CountsDefinition = {
|
export type CountsDefinition = {
|
||||||
fields: CountsFieldMetadata[]
|
fields: CountsFieldMetadata[]
|
||||||
@@ -46,10 +47,48 @@ export const useCountsDefinition = (): CountsDefinition => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const localiseCountsDefinition = (
|
||||||
|
definition: CountsDefinition, locale: LocaleCode,
|
||||||
|
): CountsDefinition => {
|
||||||
|
const heading = (name: CountField['nameLocale'], aliasesFlg: boolean) => {
|
||||||
|
if (name == null) return null
|
||||||
|
const key = `languageNames.${name.languageCode}`
|
||||||
|
const language = i18n.exists (key, { ns: 'counts', lng: locale })
|
||||||
|
? translateUIMessage (key, 'counts', locale).text : name.languageCode
|
||||||
|
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'
|
||||||
|
return { label: scripts.size > 1 ? `${label}WithScript` : label,
|
||||||
|
labelValues: { language, script: name.scriptCode } }
|
||||||
|
}
|
||||||
|
const columns = (fields: CountField[]): CountField[] => fields.map (field => ({
|
||||||
|
...field, ...heading (field.nameLocale, field.multipleValues === true),
|
||||||
|
...(field.children == null ? { } : { children: columns (field.children) }) }))
|
||||||
|
const metadata = (fields: CountsFieldMetadata[]): CountsFieldMetadata[] =>
|
||||||
|
fields.map (field => {
|
||||||
|
const aliasesFlg = field.key.split ('.').pop()?.startsWith ('aliases_') === true
|
||||||
|
const name = heading (field.nameLocale, aliasesFlg)
|
||||||
|
return name == null ? field : { ...field, labelValues: name.labelValues,
|
||||||
|
labels: field.labels?.map (label =>
|
||||||
|
['counts:localeName', 'counts:localeAliases'].includes (label) ? name.label : label) }
|
||||||
|
})
|
||||||
|
return { ...definition,
|
||||||
|
fields: metadata (definition.fields),
|
||||||
|
...(definition.outputFields == null ? { }
|
||||||
|
: { outputFields: columns (definition.outputFields) }),
|
||||||
|
...(definition.nameColumns == null ? { }
|
||||||
|
: { nameColumns: columns (definition.nameColumns) }),
|
||||||
|
...(definition.filterFields == null ? { }
|
||||||
|
: { filterFields: metadata (definition.filterFields) }) }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export const fetchCountsDefinition = (
|
export const fetchCountsDefinition = (
|
||||||
resource: CountsResource, locale: LocaleCode = 'ja',
|
resource: CountsResource, locale: LocaleCode = 'ja',
|
||||||
): Promise<CountsDefinition> => apiGet (
|
): Promise<CountsDefinition> => apiGet<CountsDefinition> (
|
||||||
`/counts/definitions/${encodeURIComponent (resource)}`, { locale })
|
`/counts/definitions/${encodeURIComponent (resource)}`, { locale })
|
||||||
|
.then (definition => localiseCountsDefinition (definition, locale))
|
||||||
|
|
||||||
export type CountsCandidate = {
|
export type CountsCandidate = {
|
||||||
value: string; id?: number; name: string | null; category?: string | null
|
value: string; id?: number; name: string | null; category?: string | null
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ export const countsCellText = (
|
|||||||
case 'created_at': return date (row.createdAt)
|
case 'created_at': return date (row.createdAt)
|
||||||
default:
|
default:
|
||||||
{
|
{
|
||||||
|
if (column.startsWith ('aliases_')) return countsLogicalName (row, column, locale).text
|
||||||
const key = column.replace (/_([a-z])/g, (_, letter: string) => letter.toUpperCase ())
|
const key = column.replace (/_([a-z])/g, (_, letter: string) => letter.toUpperCase ())
|
||||||
const value = row[key]
|
const value = row[key]
|
||||||
if (['discarded_at', 'updated_at'].includes (column) && typeof value === 'string')
|
if (['discarded_at', 'updated_at'].includes (column) && typeof value === 'string')
|
||||||
@@ -123,6 +124,9 @@ export const countsLogicalName = (
|
|||||||
): { text: string; language?: string } => {
|
): { text: string; language?: string } => {
|
||||||
const value = countsFieldValue (row, field)
|
const value = countsFieldValue (row, field)
|
||||||
if (value == null) return { text: '' }
|
if (value == null) return { text: '' }
|
||||||
|
if (field.startsWith ('aliases_') && Array.isArray (value))
|
||||||
|
return { text: value.map (entry => typeof entry === 'string' ? entry
|
||||||
|
: String (entry?.name ?? '')).join ('\n') }
|
||||||
if (field === 'sections')
|
if (field === 'sections')
|
||||||
return { text: (countsSectionRanges (value, locale) ?? []).join (
|
return { text: (countsSectionRanges (value, locale) ?? []).join (
|
||||||
translateUIMessage ('sections.separator', 'counts', locale).text) }
|
translateUIMessage ('sections.separator', 'counts', locale).text) }
|
||||||
|
|||||||
@@ -8,7 +8,15 @@
|
|||||||
"materials": "素材の集計・明細"
|
"materials": "素材の集計・明細"
|
||||||
},
|
},
|
||||||
"target": "集計定義",
|
"target": "集計定義",
|
||||||
"localeName": "{{locale}}名",
|
"localeName": "名称({{language}})",
|
||||||
|
"localeNameWithScript": "名称({{language}}・{{script}})",
|
||||||
|
"localeAliases": "別名({{language}})",
|
||||||
|
"localeAliasesWithScript": "別名({{language}}・{{script}})",
|
||||||
|
"languageNames": {
|
||||||
|
"ja": "日本語",
|
||||||
|
"en": "英語",
|
||||||
|
"ain": "アイヌ語"
|
||||||
|
},
|
||||||
"run": "集計実行",
|
"run": "集計実行",
|
||||||
"conditions": "集計条件",
|
"conditions": "集計条件",
|
||||||
"output": "出力項目",
|
"output": "出力項目",
|
||||||
|
|||||||
新しいイシューから参照
ユーザーをブロックする